/** * cli:scaffold-component — render/form.ts * Extracted VERBATIM from the historical generate.ts (pure move — frozen by * the 1.0 corpus hash). Renders nothing when the view is absent from * spec.views. */ import type { GeneratedFile } from '../types.js' import { extensionsModuleId } from '../../../../../../lib/app-classification.js' import { pickUiDesignOverlay, resolveEditMode, type UiDesignOverlay } from '../../../../../../lib/ui-design-overlay.js' import { FormControl, GENERATED_MARKER, compileVisibleWhen, controlOf, coreNonStandardLookup, fieldToCamel, formDataTypeLiteral, isEditable, isFkField, isLaterPhase, lookupHookImportLines, renderFormSections, toCamel } from './shared.js' import { isSupplied } from '../../../../../../lib/page-spec-coded-entity.js' import { resolveEditSurface } from '../../../../../../lib/edit-surface.js' import type { RenderContext } from './context.js' export function renderForm(rc: RenderContext): GeneratedFile[] { const files: GeneratedFile[] = [] const { spec, allCustomActions, dialogParamsExpr, e, eLower, featurePath, formFields, formLayout, hookCodeOf, iconForAction, initialFormObj, isApi, isNavigate, isPayloadAction, offlineFormBanner, offlineLevel, onlineStatusDecl, onlineStatusImport, pathFor, permKey, renderHeaderActionsCluster, sectionCamel, sectionMeta, toPascal, uiOverlay, versioned } = rc // ─── FormPage ──────────────────────────────────────────────────────────── if (spec.views.includes('form')) { // Form body is grouped into sections (architecture C): `field.section` is an // explicit judgment directive; fields without one fall into a single sober // default section (no redundant header). Assembled below as `formBody`. // Edit experience: `read-first` (default) renders every section as a read // card in EDIT mode, opened per-section by its "Modifier" toggle, with a // dirty-gated Save. `direct` (overlay/pagespec opt-out) keeps the legacy // always-editable behaviour. CREATE mode is always direct. const editExperience = resolveEditMode(spec.pageSpec, uiOverlay) // Unified fiche (lib/edit-surface): the DetailPage owns the edit surface // (read-first sections in place), so THIS page degrades to the DIRECT // shape — it serves `/create`; its dormant edit half is unreachable // (scaffold-routes sends `/edit` to the DetailPage). const unifiedFiche = resolveEditSurface(rc.entityViews, editExperience) === 'unified' const readFirst = editExperience === 'read-first' && !unifiedFiche // 202-optimistic offline-write pages queue through the outbox — its chip is // the truthful save signal, so the "Enregistré à HH:MM" stamp is not emitted. const emitSavedAt = readFirst && offlineLevel !== 'write' // Datetime helpers used by the read grid — drives the '@atlashub/smartstack' // import line of the form page (same collector pattern as the detail page). const formDateFns = new Set() // FK fields of the read grid resolve the target's displayName through its // useLookup hook (same contract as the detail page — the raw Guid // is never user-facing). Core non-standard targets mask the Guid instead. const formFkReadFields = readFirst ? formFields.filter(f => isFkField(f) && f.fkTo && !coreNonStandardLookup(f.fkTo)) : [] const formFkHookImports = lookupHookImportLines(formFkReadFields, spec.appCode).map(l => `\n${l}`).join('') const formFkHookDecls = formFkReadFields .map(f => { const camel = fieldToCamel(f.name) return ` const { data: ${camel}LookupData } = use${f.fkTo!.entity}Lookup({ search: (formData.${camel} as string | undefined) || undefined, pageSize: 1 })` }) .join('\n') // FK fields drive the EntityLookup import. Empty → no , and // audit DEV-UI-022 passes because there's no FK to render in the first place. const usesLookup = formFields.some(isFkField) // FK pre-fill from the query string: a 360 related tab's «Créer» button // navigates to `create()?{relationFk}={currentId}` — the create form seeds // every FK field present in the URL so the relation is wired on arrival. // (Edit mode is unaffected: `existing` overwrites via the useEffect below.) const fkFormFields = formFields.filter(f => isEditable(f) && isFkField(f)) const hasFkPrefill = fkFormFields.length > 0 const formSearchParamsImport = hasFkPrefill ? ', useSearchParams' : '' const fkPrefillDecl = hasFkPrefill ? ` const [searchParams] = useSearchParams()\n` : '' const formDataInitExpr = hasFkPrefill ? `() => ({\n ...initial${e}FormData,\n${fkFormFields.map(f => { const c = fieldToCamel(f.name) return ` ${c}: searchParams.get('${c}') ?? initial${e}FormData.${c},` }).join('\n')}\n })` : `initial${e}FormData` // Only an EDITABLE user FK gets the "Me" button (which references `user`); // a display-only one renders a disabled lookup, so it must NOT pull in // useAuth (that would leave `user` an unused local in the generated app). const usesUserFk = formFields.some(f => isEditable(f) && f.currentUserFk === true) const fkLookupImport = usesLookup ? `\nimport { EntityLookup } from '@/components/ui/EntityLookup'` : '' // Theme-compliant form primitives (scaffold-ui-primitives) — imported only // when an EDITABLE field actually renders the matching control (display-only // fields render as compact text, never a live control). const usesControl = (c: FormControl) => formFields.some(f => isEditable(f) && controlOf(f) === c) const primitiveImports = [ usesControl('date') ? `\nimport { DateInput } from '@/components/ui/DateInput'` : '', usesControl('select') ? `\nimport { EnumSelect } from '@/components/ui/EnumSelect'` : '', usesControl('segmented') ? `\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'` : '', usesControl('multiselect') ? `\nimport { MultiSelect } from '@/components/ui/MultiSelect'` : '', usesControl('textarea') ? `\nimport { Textarea } from '@/components/ui/Textarea'` : '', usesControl('switch') ? `\nimport { Switch } from '@/components/ui/Switch'` : '', ].join('') const authImport = usesUserFk ? `\nimport { useAuth } from '@/business/auth/useAuth'` : '' const authDecl = usesUserFk ? ` const { user } = useAuth()\n` : '' // ── Supplied-on-create code field (coded entities) ───────────────────── // Injected FROM THE FLAG, never a fields[] entry (validate rejects those): // the entité.md line declares « surchargeable à la création », derive-code- // specs stamps `codedEntity: { supplied, codeKey, codeInputFields }` on the // pagespec, and the CREATE mode mounts the SmartCodeField primitive whose // value rides the create payload as `code`. NEVER on edit — codes are // immutable after creation (the backend Update DTO has no Code anyway). const codedFlag = spec.codedEntity ?? spec.pageSpec?.codedEntity const codedFlagObj = typeof codedFlag === 'object' && codedFlag !== null ? (codedFlag as Record) : null const suppliedCodeKey = isSupplied(codedFlag) && typeof codedFlagObj?.['codeKey'] === 'string' ? (codedFlagObj['codeKey'] as string) : null const codedSupplied = suppliedCodeKey !== null const suppliedInputFields = ( Array.isArray(codedFlagObj?.['codeInputFields']) ? (codedFlagObj['codeInputFields'] as string[]) : [] ).filter((n) => formFields.some((f) => f.name === n)) const smartCodeImport = codedSupplied ? `\nimport { SmartCodeField } from '@/components/ui/SmartCodeField'` : '' const suppliedStateDecl = codedSupplied ? ` const [suppliedCode, setSuppliedCode] = useState('')\n` : '' const suppliedInputsExpr = suppliedInputFields.length > 0 ? `{ ${suppliedInputFields.map((n) => `'${n}': String(formData.${fieldToCamel(n)} ?? '') || null`).join(', ')} }` : '{}' const smartCodeJsx = codedSupplied ? ` {!isEdit && ( )}\n` : '' // Display-only fields (computed / readonly / system) render a value but have // no editable control, so they must NOT enter the CREATE payload. Later-phase // fields (lifecycle) are excluded too — the wire-level half of the guarantee // (the backend Create DTO omits them as well). Legacy specs (no phase) keep // the exact display-only-driven emission. const editableFields = formFields.filter(isEditable) const createFields = editableFields.filter(f => !isLaterPhase(f)) const needsCreatePayload = createFields.length !== formFields.length const toCreatePayloadDecl = needsCreatePayload ? `\n\n const toCreatePayload = (d: ${e}FormData) => ({\n${createFields.map(f => ` ${fieldToCamel(f.name)}: d.${fieldToCamel(f.name)},`).join('\n')}\n })` : '' const basePayloadExpr = needsCreatePayload ? 'toCreatePayload(formData)' : 'formData' // Supplied code: appended to the CREATE payload only when non-blank — a // blank field sends NO code, the engine allocates (HasCode contract). const createPayloadExpr = codedSupplied ? `{ ...${basePayloadExpr}, ...(suppliedCode.trim() !== '' ? { code: suppliedCode.trim() } : {}) }` : basePayloadExpr // Inline required-field validation: a message under each empty required // field (visibleWhen-hidden fields are skipped). Only emitted when there is // at least one required editable field, so `isBlank`/`requiredMsg` are never // dangling unused locals. const requiredEditable = formFields.filter(f => isEditable(f) && f.required) // Phase-required fields (lifecycle requiredFields): optional until the // phase's statuses are reached, then mandatory — the blank-check is guarded // by the compiled `requiredWhen` predicate (plus isEdit for an owned field, // which is not even rendered at create). Disjoint from requiredEditable: // a phase field is never `required: true` (validate.ts rejects it). const phaseRequired = formFields.filter(f => isEditable(f) && !f.required && (f.requiredInPhase === true || f.requiredWhen !== undefined)) const hasRequired = requiredEditable.length > 0 || phaseRequired.length > 0 const isBlankDecl = hasRequired ? ` const isBlank = (v: unknown) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)\n` : '' const requiredMsgDecl = hasRequired ? ` const requiredMsg = t('${eLower}.form.required', { defaultValue: 'This field is required' })\n` : '' const requiredChecks = requiredEditable.map(f => { const camel = fieldToCamel(f.name) const blank = `isBlank(formData.${camel})` if (f.visibleWhen) { const g = compileVisibleWhen(f.visibleWhen) if (g) return ` if ((${g}) && ${blank}) errs.${camel} = requiredMsg` } return ` if (${blank}) errs.${camel} = requiredMsg` }).join('\n') const phaseChecks = phaseRequired.map(f => { const camel = fieldToCamel(f.name) const blank = `isBlank(formData.${camel})` const guards: string[] = [] if (isLaterPhase(f)) guards.push('isEdit') const pred = f.requiredWhen ? compileVisibleWhen(f.requiredWhen) : (f.visibleWhen ? compileVisibleWhen(f.visibleWhen) : null) if (pred) guards.push(pred) if (guards.length === 0) return ` if (${blank}) errs.${camel} = requiredMsg` return ` if ((${guards.join(' && ')}) && ${blank}) errs.${camel} = requiredMsg` }).join('\n') const validateChecks = [requiredChecks, phaseChecks].filter(s => s !== '').join('\n') // Custom header actions for the form — forms have no rows, only header scope. // Guarded by isEdit: you can't duplicate/archive an entity that doesn't exist yet. const formHeaderActions = allCustomActions.filter(a => a.scope === 'header') const formApiActions = formHeaderActions.filter(isApi) const hasFormActions = formHeaderActions.length > 0 const formCustomHookImports = formApiActions .map(a => `, use${toPascal(hookCodeOf(a))}${e}`) .join('') const formCustomIconNames = new Set(formHeaderActions.map(a => iconForAction(a))) const formCustomIconImports = Array.from(formCustomIconNames).map(name => `, ${name}`).join('') const formCustomMutationDecls = formApiActions .map(a => ` const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`) .join('\n') // Payload actions open a on the form page too (header-scoped, // firing mutateAsync(payload)); non-payload actions call the header hook with no arg // (matches the api-client () signature — passing `id` broke the arity). const formPayloadActions = formApiActions.filter(isPayloadAction) const formDialogStateDecls = formPayloadActions .map(a => ` const [${a.code}DialogOpen, set${toPascal(a.code)}DialogOpen] = useState(false)`) .join('\n') const formDialogsJsx = formPayloadActions.map(a => { const P = toPascal(a.code) const hookVar = `${toCamel(hookCodeOf(a))}Mutation` const title = `t('${eLower}.${a.labelKey}')` const common = ` title={${title}}\n submitLabel={${title}}\n params={${dialogParamsExpr(a)}}` return ` { await ${hookVar}.mutateAsync(payload as never); set${P}DialogOpen(false) }}\n onClose={() => set${P}DialogOpen(false)}\n />` }).join('\n') const formDialogImport = formPayloadActions.length > 0 ? `import { CustomActionDialog } from '@/components/ui/CustomActionDialog'\n` : '' const formCustomHandlerDecls = formHeaderActions .map(a => { const name = `handle${toPascal(a.code)}` if (isNavigate(a)) { const target = a.targetRoute ?? `routes.${sectionCamel}.list()` return ` const ${name} = () => {\n navigate(${target})\n }` // form actions are always header-scoped } // Payload action → open its dialog; the dialog fires mutateAsync(payload). if (isPayloadAction(a)) { return ` const ${name} = () => { set${toPascal(a.code)}DialogOpen(true) }` } const hookVar = `${toCamel(hookCodeOf(a))}Mutation` return ` const ${name} = async () => {\n await ${hookVar}.mutateAsync()\n }` }) .join('\n\n') // Priority+ cluster (shared with list/detail) — flex-wrap harmonised with // the other two headers so a promoted button can never crush the title. const formActionsJsx = hasFormActions ? ` actions={isEdit ? (
${renderHeaderActionsCluster(formHeaderActions)}
) : undefined}` : '' const formHeaderMenuImport = hasFormActions ? `import { HeaderActionsMenu } from '@/components/ui/HeaderActionsMenu'\n` : '' // Section JSX (architecture C). Fields are grouped by `field.section`; a // single un-sectioned group renders a sober card with no redundant header. // Each field carries its own display-only / visibleWhen guards. const formBody = renderFormSections(formFields, eLower, e, formLayout, sectionMeta, readFirst, formDateFns) // Computed AFTER formBody — the read grid populates formDateFns. const formDateImport = formDateFns.size ? `, ${Array.from(formDateFns).sort().join(', ')}` : '' const sectionCardImport = `\nimport { SectionCard } from '@/components/ui/SectionCard'` // Read-first state machinery — emitted ONLY when the page is read-first so // a direct-mode page carries zero dead code. const readFirstStateDecls = readFirst ? ` const [editingSections, setEditingSections] = useState>(new Set()) const [baseline, setBaseline] = useState<${e}FormData | null>(null)${emitSavedAt ? ` const [savedAt, setSavedAt] = useState(null)` : ''} const toggleSection = (key: string) => { setEditingSections(prev => { const next = new Set(prev) if (next.has(key)) { next.delete(key) } else { next.add(key) } return next }) } // Field-level dirty check: arrays compare element-wise (MultiSelect) and // null / undefined / '' collapse to one "empty" state, so a pristine form // never reads dirty (the API echoes null where the form holds ''). const isEqualValue = (a: unknown, b: unknown): boolean => { if (Array.isArray(a) || Array.isArray(b)) { const aa = Array.isArray(a) ? a : [] const bb = Array.isArray(b) ? b : [] return aa.length === bb.length && aa.every((x, i) => x === bb[i]) } return (a ?? '') === (b ?? '') } const formKeys = Object.keys(initial${e}FormData) as (keyof ${e}FormData)[] const isDirty = baseline !== null && formKeys.some(k => !isEqualValue(formData[k], baseline[k])) const handleReset = () => { if (baseline) setFormData(baseline) setFieldErrors({}) setError(null) setEditingSections(new Set()) } // Dirty navigation guard — a tab close / refresh with unsaved edits prompts // the browser; the in-app Cancel button confirms through the same wording. useEffect(() => { if (!isDirty) return const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault() } window.addEventListener('beforeunload', onBeforeUnload) return () => window.removeEventListener('beforeunload', onBeforeUnload) }, [isDirty]) ` : '' // The Cancel exit is dirty-gated on read-first pages (the only mode that // tracks a baseline); a direct-mode form keeps the plain navigate. const cancelOnClick = readFirst ? `() => { if (!isDirty || window.confirm(t('${eLower}.form.confirmLeave', { defaultValue: 'You have unsaved changes. Leave the page?' }))) navigate(-1) }` : `() => navigate(-1)` const statusChipJsx = !readFirst ? '' : emitSavedAt ? ` {isEdit && (isDirty || savedAt) && ( {isDirty ? t('${eLower}.form.unsavedChanges', { defaultValue: 'Unsaved changes' }) : \`\${t('${eLower}.form.savedAt', { defaultValue: 'Saved at' })} \${savedAt}\`} )}` : ` {isEdit && isDirty && ( {t('${eLower}.form.unsavedChanges', { defaultValue: 'Unsaved changes' })} )}` const resetBtnJsx = readFirst ? ` {isEdit && isDirty && ( )}` : '' files.push({ path: pathFor('form', `${e}FormPage.tsx`), content: `${GENERATED_MARKER}import { useState, useEffect, useRef } from 'react' import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from 'react' import { useParams, useNavigate${formSearchParamsImport} } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Loader2, AlertTriangle, FilePen${formCustomIconImports} } from 'lucide-react' import { Slot${onlineStatusImport}${formDateImport} } from '@atlashub/smartstack' import { PermissionGuard } from '@/components/auth/PermissionGuard' import { PageTemplate } from '@/components/ui/PageTemplate' ${formDialogImport}${formHeaderMenuImport}import { routes } from '@/extensions/${extensionsModuleId(spec.appCode, spec.module)}Routes' import { use${e}, useCreate${e}, useUpdate${e}${formCustomHookImports} } from '${featurePath}/hooks/use${e}'${fkLookupImport}${primitiveImports}${smartCodeImport}${sectionCardImport}${authImport}${formFkHookImports} type ${e}FormData = ${formDataTypeLiteral(formFields)} const initial${e}FormData: ${e}FormData = ${initialFormObj} export function ${e}FormPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const { t } = useTranslation('${spec.module}') const isEdit = !!id && id !== 'new' ${fkPrefillDecl} const { data: existing } = use${e}(isEdit ? id! : '') const createMutation = useCreate${e}() const updateMutation = useUpdate${e}()${onlineStatusDecl} ${authDecl}${formCustomMutationDecls ? formCustomMutationDecls + '\n' : ''}${formDialogStateDecls ? formDialogStateDecls + '\n' : ''} const [formData, setFormData] = useState<${e}FormData>(${formDataInitExpr})${versioned ? ` const [rowVersion, setRowVersion] = useState(undefined)` : ''} ${suppliedStateDecl} const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) const formRef = useRef(null) ${formFkHookDecls ? formFkHookDecls + '\n' : ''}${readFirstStateDecls} ${readFirst ? ` // Resync from the server ONLY while pristine — a background refetch must // never clobber in-progress edits (the dirty guard closes that hole). useEffect(() => { if (existing && !isDirty) { setFormData(existing as ${e}FormData) setBaseline(existing as ${e}FormData)${versioned ? ` setRowVersion((existing as { rowVersion?: string }).rowVersion)` : ''} } }, [existing, isDirty])` : ` useEffect(() => { if (existing) { setFormData(existing as ${e}FormData)${versioned ? ` setRowVersion((existing as { rowVersion?: string }).rowVersion)` : ''} } }, [existing])`} // Autofocus the first editable control on mount (zero-click start). useEffect(() => { const first = formRef.current?.querySelector('input:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled]), button[role="switch"]:not([disabled]), button[role="radio"]:not([disabled])') first?.focus() }, []) const onChange = (field: K, value: ${e}FormData[K]) => { setFormData(prev => ({ ...prev, [field]: value })) }${toCreatePayloadDecl} ${isBlankDecl}${requiredMsgDecl} const validate = (): boolean => { const errs: Record = {} ${validateChecks ? validateChecks + '\n' : ''} setFieldErrors(errs) return Object.keys(errs).length === 0 } const handleSubmit = async (event: FormEvent) => { event.preventDefault() setError(null)${readFirst ? ` if (isEdit && !isDirty) return` : ''} if (!validate()) return try { if (isEdit && id) { await updateMutation.mutateAsync({ id, data: ${versioned ? '{ ...formData, rowVersion }' : 'formData'} })${readFirst ? ` setBaseline(formData) setEditingSections(new Set())${emitSavedAt ? ` setSavedAt(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))` : ''} } else { await createMutation.mutateAsync(${createPayloadExpr}) navigate(-1) }` : ` } else { await createMutation.mutateAsync(${createPayloadExpr}) } navigate(-1)`} } catch (err) { setError(err instanceof Error ? err.message : String(err)) } } // ⌘/Ctrl+Enter submits from any field. const onFormKeyDown = (event: ReactKeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() formRef.current?.requestSubmit() } } ${formCustomHandlerDecls ? '\n' + formCustomHandlerDecls + '\n' : ''} const permission = isEdit ? '${permKey}.update' : '${permKey}.create' const isPending = createMutation.isPending || updateMutation.isPending return ( } breadcrumbs={[ { label: t('${eLower}.breadcrumb.section'), href: routes.${sectionCamel}.list() }, { label: isEdit ? t('${eLower}.form.editTitle') : t('${eLower}.form.createTitle') }, ]}${formActionsJsx} > ${offlineFormBanner} {error && (
{error}
)}
${smartCodeJsx}${formBody}
${statusChipJsx}${resetBtnJsx}
${formDialogsJsx ? '\n' + formDialogsJsx : ''}
) } export default ${e}FormPage `, }) } return files }