/** * Read-first FormPage (the fiche « lecture d'abord » uplift). * * EDIT mode default: every section renders as a read-only dt/dd card; a * per-section "Modifier"/"Terminer" toggle (SectionCard) opens the edit grid; * Save is dirty-gated; Réinitialiser restores the baseline; saving stays on * the page. CREATE mode keeps the direct edit grid. `editMode: 'direct'` * (uiDesign overlay) or `editExperience: 'direct'` (pagespec) opts a page out * back to the LEGACY output (zero read-first machinery emitted). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldComponentInput, PageSpecMin } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldComponentInput { return { module: 'hr', appCode: 'TestV2', entity: 'Employee', section: 'employees', views: ['form'], fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'hireDate', type: 'date', required: false }, { name: 'departmentId', type: 'guid', required: true, fkTo: { entity: 'Department', module: 'hr' } }, ], projectPath: '/web', ...overrides, } } function pageSpec(overrides: Partial = {}): PageSpecMin { return { screenCode: 'SCR-HR-EMPLOYEES-FORM', module: 'hr', appCode: 'testv2', section: 'employees', entity: 'Employee', view: 'form', filePath: 'src/pages/testv2/hr/employees/EmployeeFormPage.tsx', permission: 'hr.employees.update', actions: [], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, needsRefinement: false, specHash: 'x', ...overrides, } as PageSpecMin } const formOf = (overrides: Partial = {}) => generate(fixture(overrides)).find((f) => /FormPage\.tsx$/.test(f.path))!.content describe('scaffold-component / read-first — default machinery', () => { it('emits the per-section toggle state + dirty machinery + reset', () => { const c = formOf() expect(c).toMatch(/const \[editingSections, setEditingSections\] = useState>\(new Set\(\)\)/) expect(c).toMatch(/const \[baseline, setBaseline\] = useState\(null\)/) expect(c).toMatch(/const toggleSection = \(key: string\)/) expect(c).toMatch(/const isEqualValue = \(a: unknown, b: unknown\)/) expect(c).toMatch(/const isDirty = baseline !== null && formKeys\.some/) expect(c).toMatch(/const handleReset = \(\)/) }) it('mounts SectionCard in editable mode with the toggle contract (labels + testid key)', () => { const c = formOf() expect(c).toMatch(/editable=\{isEdit\}/) expect(c).toMatch(/editing=\{!isEdit \|\| editingSections\.has\('default'\)\}/) expect(c).toMatch(/onToggleEdit=\{\(\) => toggleSection\('default'\)\}/) expect(c).toMatch(/editLabel=\{t\('employee\.form\.editSection', \{ defaultValue: 'Edit' \}\)\}/) expect(c).toMatch(/doneLabel=\{t\('employee\.form\.doneSection', \{ defaultValue: 'Done' \}\)\}/) expect(c).toMatch(/sectionKey="default"/) }) it('renders the READ grid from formData (labels + own-line values, never `existing` derefs)', () => { const c = formOf() // dt label from the form namespace; dd value alone on its line. expect(c).toMatch(/t\('employee\.form\.fields\.firstName'\)/) expect(c).toMatch(/^\s*\{String\(formData\.firstName \?\? ''\)\}\s*$/m) // A date goes through the platform datetime module — imported. expect(c).toMatch(/^\s*\{formatDate\(formData\.hireDate\)\}\s*$/m) expect(c).toMatch(/import \{ Slot, formatDate \} from '@atlashub\/smartstack'/) // The sentinel-GUID smoke probe loads edit with existing === undefined: // the read grid must never dereference `existing` (formData only). expect(c).not.toMatch(/existing\.\w/) }) it('resolves a FK read value via the per-target lookup hook (no raw Guid) and keeps values as text', () => { const c = formOf() expect(c).toMatch(/const \{ data: departmentIdLookupData \} = useDepartmentLookup\(\{ search: \(formData\.departmentId as string \| undefined\) \|\| undefined, pageSize: 1 \}\)/) expect(c).toMatch(/departmentIdLookupData\?\.items \?\? \[\]\)\.find\(\(it\) => it\.id === formData\.departmentId\)\?\.displayName \?\? \(formData\.departmentId \? '—' : ''\)/) // Read values render as text — never a disabled native input (DEV-UI-023/R26). expect(c).not.toMatch(/]*disabled[^>]*type="date"/) }) it('gates Save on dirty (inline expression) and keeps the form-submit testid', () => { const c = formOf() expect(c).toContain('data-testid="form-submit"') expect(c).toContain('disabled={isPending || (isEdit && !isDirty)}') }) it('saving in EDIT stays on the page (baseline reset + sections closed + savedAt); CREATE still navigates', () => { const c = formOf() const updateBranch = c.split('await updateMutation.mutateAsync')[1]!.split('} else {')[0]! expect(updateBranch).toMatch(/setBaseline\(formData\)/) expect(updateBranch).toMatch(/setEditingSections\(new Set\(\)\)/) expect(updateBranch).toMatch(/setSavedAt\(new Date\(\)\.toLocaleTimeString/) expect(updateBranch).not.toMatch(/navigate\(-1\)/) const createBranch = c.split('await createMutation.mutateAsync')[1]!.split('catch')[0]! expect(createBranch).toMatch(/navigate\(-1\)/) // Not-dirty submits are inert (⌘+Enter can bypass the disabled button). expect(c).toMatch(/if \(isEdit && !isDirty\) return/) }) it('emits the unsaved-changes chip + the Réinitialiser button (theme helper classes)', () => { const c = formOf() expect(c).toMatch(/t\('employee\.form\.unsavedChanges', \{ defaultValue: 'Unsaved changes' \}\)/) expect(c).toMatch(/t\('employee\.form\.savedAt', \{ defaultValue: 'Saved at' \}\)/) expect(c).toMatch(/\{isEdit && isDirty && \(\s*