- 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>
263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
// @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()
|
|
})
|
|
})
|