Scrum4Me/__tests__/components/shared/markdown-doc-editor.test.tsx
Madhura68 7332420914 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>
2026-05-16 11:50:50 +02:00

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