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:
Janpeter Visser 2026-05-16 11:50:50 +02:00
parent 4539de1fff
commit 7332420914
2 changed files with 485 additions and 0 deletions

View 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()
})
})

View 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>
)
}