feat(PBI-96/T-1065): extract MarkdownDocEditor as shared component
- components/shared/markdown-doc-editor.tsx: geëxtraheerd uit components/ideas/idea-md-editor.tsx zodat Ideas (grill/plan) en Product Docs dezelfde editor-stack delen (CLAUDE.md dialog-discipline: "twee keer kopieren = promote 'm meteen"). - Props: storageKey + initialValue + validate? + onSave + onSaved? + onCancel + rows? + placeholder? + saveLabel? + validationErrorsHeader? + debug-attrs. Component bevat geen entity-specifieke logica. - 14 nieuwe tests groen (rendering/dirty-state, localStorage persist+ restore+clear, Cmd+S/Ctrl+S save, success-clear+onSaved+onCancel, error-rendering, validation blocks submit, cancel-button). - T-1066 (volgende) refactort idea-md-editor naar wrapper rond deze. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4539de1fff
commit
7332420914
2 changed files with 485 additions and 0 deletions
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal file
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — rendering + dirty-state', () => {
|
||||
it('rendert textarea met initialValue', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-1"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('hello')
|
||||
})
|
||||
|
||||
it('save-knop is disabled wanneer niet dirty', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-2"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('save-knop is enabled na wijziging', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-3"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'hello world' } })
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — localStorage draft', () => {
|
||||
it('persisteert draft naar localStorage on change', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-draft-1"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'changed' },
|
||||
})
|
||||
expect(window.localStorage.getItem('test-draft-1')).toBe('changed')
|
||||
})
|
||||
|
||||
it('verwijdert draft als waarde terug op initialValue staat', () => {
|
||||
window.localStorage.setItem('test-draft-2', 'staleDraft')
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-draft-2"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Restore from draft → toast.info wordt aangeroepen
|
||||
// Reset naar initialValue
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } })
|
||||
expect(window.localStorage.getItem('test-draft-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('restored draft uit localStorage bij mount + toast.info', () => {
|
||||
window.localStorage.setItem('test-restore', 'restored content')
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-restore"
|
||||
initialValue="original"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('restored content')
|
||||
expect(toast.info).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — save flow', () => {
|
||||
it('Cmd+S triggert onSave', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-1"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'changed' } })
|
||||
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith('changed'))
|
||||
})
|
||||
|
||||
it('Ctrl+S triggert ook onSave', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-2"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 's', ctrlKey: true })
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('na success: localStorage clear + onSaved + onCancel + toast.success', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
const onSaved = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-3"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onSaved={onSaved}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(window.localStorage.getItem('test-save-3')).toBeNull()
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('na error: toast.error + submitErrors renderen', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({
|
||||
error: 'Server-fout',
|
||||
code: 422,
|
||||
details: [{ line: 5, message: 'bad yaml' }],
|
||||
})
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-error"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Server-fout')
|
||||
expect(screen.queryByText(/Regel 5/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/bad yaml/i)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — validation', () => {
|
||||
it('valide-errors blokkeren submit (save-knop disabled)', () => {
|
||||
const validate = vi.fn().mockReturnValue([{ message: 'yaml fout' }])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-1"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('valide-errors worden gerendered in error-box', () => {
|
||||
const validate = vi.fn().mockReturnValue([
|
||||
{ line: 3, message: 'yaml fout', hint: 'check de indenting' },
|
||||
])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-2"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
validationErrorsHeader="YAML-frontmatter fouten"
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
expect(screen.queryByText(/YAML-frontmatter fouten/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/Regel 3/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/yaml fout/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/check de indenting/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('validate wordt niet aangeroepen als waarde nog op initialValue staat', () => {
|
||||
const validate = vi.fn().mockReturnValue([])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-3"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(validate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — cancel', () => {
|
||||
it('Annuleer-knop roept onCancel', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-cancel"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /annuleer/i }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
222
components/shared/markdown-doc-editor.tsx
Normal file
222
components/shared/markdown-doc-editor.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
'use client'
|
||||
|
||||
// Generieke markdown-editor met state, draft-persistence en keyboard shortcut.
|
||||
// Geëxtraheerd uit components/ideas/idea-md-editor.tsx zodat zowel Ideas
|
||||
// (grill/plan) als Product Docs dezelfde editor-stack gebruiken (CLAUDE.md
|
||||
// dialog-discipline: "twee keer kopieren = promote 'm meteen").
|
||||
//
|
||||
// Plan: docs/plans/PBI-96-product-docs.md §D.1.
|
||||
//
|
||||
// Patronen die deze component levert:
|
||||
// - Cmd/Ctrl+S → onSave
|
||||
// - localStorage-backed draft per `storageKey`, restore bij heropening
|
||||
// - live validatie via optionele `validate`-callback (blokkeert submit)
|
||||
// - server-side error-details (uit ActionResult.details) renderen
|
||||
// - toast-feedback (sonner)
|
||||
// - dirty-state-indicator in footer
|
||||
//
|
||||
// Entity-specifieke logica (welke action, welke validator) wordt door de
|
||||
// caller geïnjecteerd; de component is volledig generic.
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { Save, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
export interface MarkdownDocEditorError {
|
||||
line?: number
|
||||
message: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface ActionSuccess {
|
||||
success: true
|
||||
}
|
||||
|
||||
interface ActionFailure {
|
||||
error: string
|
||||
code?: number
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type MarkdownDocEditorActionResult = ActionSuccess | ActionFailure
|
||||
|
||||
interface Props {
|
||||
/** Unieke key voor localStorage-draft (bv. `idea-md-${id}-${kind}` of `product-doc-${id}`). */
|
||||
storageKey: string
|
||||
/** Originele inhoud — wijzigingen worden ten opzichte hiervan als 'dirty' beschouwd. */
|
||||
initialValue: string
|
||||
/** Optionele live-validator. Returnt errors[] (leeg = OK). Submit wordt geblokkeerd bij errors. */
|
||||
validate?: (value: string) => MarkdownDocEditorError[]
|
||||
/** Server-action wrapper. Returnt {success:true} of {error, code, details}. */
|
||||
onSave: (value: string) => Promise<MarkdownDocEditorActionResult>
|
||||
/** Hook om na success extra werk te doen (bv. router.refresh()). Aangeroepen vóór onCancel. */
|
||||
onSaved?: () => void
|
||||
/** Sluit de editor (zowel bij annuleren als na succesvolle save). */
|
||||
onCancel: () => void
|
||||
rows?: number
|
||||
placeholder?: string
|
||||
saveLabel?: string
|
||||
validationErrorsHeader?: string
|
||||
/** Voor debug-attribuut op de root div. */
|
||||
debugId?: string
|
||||
debugComponentName?: string
|
||||
debugFile?: string
|
||||
}
|
||||
|
||||
function readSeed(storageKey: string, initialValue: string): {
|
||||
value: string
|
||||
restored: boolean
|
||||
} {
|
||||
if (typeof window === 'undefined') {
|
||||
return { value: initialValue, restored: false }
|
||||
}
|
||||
const draft = window.localStorage.getItem(storageKey)
|
||||
if (draft && draft !== initialValue) return { value: draft, restored: true }
|
||||
return { value: initialValue, restored: false }
|
||||
}
|
||||
|
||||
export function MarkdownDocEditor({
|
||||
storageKey,
|
||||
initialValue,
|
||||
validate,
|
||||
onSave,
|
||||
onSaved,
|
||||
onCancel,
|
||||
rows = 24,
|
||||
placeholder,
|
||||
saveLabel = 'Opslaan',
|
||||
validationErrorsHeader = 'Validatiefouten',
|
||||
debugId = 'markdown-doc-editor',
|
||||
debugComponentName = 'MarkdownDocEditor',
|
||||
debugFile = 'components/shared/markdown-doc-editor.tsx',
|
||||
}: Props) {
|
||||
const [seed] = useState(() => readSeed(storageKey, initialValue))
|
||||
const [value, setValue] = useState(seed.value)
|
||||
const [submitErrors, setSubmitErrors] = useState<MarkdownDocEditorError[]>([])
|
||||
const [submitting, startSubmit] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
if (seed.restored) {
|
||||
toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.')
|
||||
}
|
||||
}, [seed.restored])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (value === initialValue) {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} else {
|
||||
window.localStorage.setItem(storageKey, value)
|
||||
}
|
||||
}, [value, initialValue, storageKey])
|
||||
|
||||
const validationErrors = useMemo<MarkdownDocEditorError[]>(() => {
|
||||
if (!validate) return []
|
||||
if (value === '' || value === initialValue) return []
|
||||
return validate(value)
|
||||
}, [value, initialValue, validate])
|
||||
|
||||
const errors = submitErrors.length > 0 ? submitErrors : validationErrors
|
||||
const hasValidationErrors = validationErrors.length > 0
|
||||
const dirty = value !== initialValue
|
||||
|
||||
function save() {
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Inhoud heeft fouten — fix die eerst.')
|
||||
return
|
||||
}
|
||||
setSubmitErrors([])
|
||||
startSubmit(async () => {
|
||||
const r = await onSave(value)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
if ('details' in r && Array.isArray(r.details)) {
|
||||
setSubmitErrors(r.details as MarkdownDocEditorError[])
|
||||
}
|
||||
return
|
||||
}
|
||||
toast.success('Opgeslagen')
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
}
|
||||
onSaved?.()
|
||||
onCancel()
|
||||
})
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-3"
|
||||
{...debugProps(debugId, debugComponentName, debugFile)}
|
||||
>
|
||||
{errors.length > 0 && (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-status-blocked">
|
||||
{validationErrorsHeader}
|
||||
</p>
|
||||
<ul className="text-xs text-status-blocked space-y-0.5">
|
||||
{errors.map((err, i) => (
|
||||
<li key={i}>
|
||||
{err.line ? `Regel ${err.line}: ` : ''}
|
||||
{err.message}
|
||||
{err.hint && (
|
||||
<div className="mt-1 text-foreground/80">Tip: {err.hint}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={rows}
|
||||
className="font-mono text-sm leading-relaxed"
|
||||
data-debug-id={`${debugId}__textarea`}
|
||||
placeholder={placeholder}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty
|
||||
? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan'
|
||||
: 'Geen wijzigingen'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<X className="size-3.5 mr-1" />
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!dirty || submitting || hasValidationErrors}
|
||||
data-debug-id={`${debugId}__save`}
|
||||
>
|
||||
<Save className="size-3.5 mr-1" />
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue