/** * cli:scaffold-component — render/shared.ts * Module-level helpers extracted VERBATIM from the historical generate.ts * (naming, i18n floor/translations, form-field control rendering, pills). * Pure move — no behaviour change (frozen by the 1.0 corpus hash). */ import type { ScaffoldComponentInput, ComponentField } from '../types.js' import { buildNavApiPath, pluralSegment } from '../../../../../../lib/url-conventions.js' import { pluralize as enPluralize } from '../../../../../../lib/string-utils.js' import { parseRelatedTabs, relatedPluralOf, relatedRouteFamilyOf, type PageRelatedTab } from '../../../../../../lib/page-spec-related-tabs.js' import { normalizeOffline } from '../../../../../../lib/pwa-meta.js' import { codedLabelOf } from '../../../../../../lib/page-spec-coded-entity.js' import { pickUiDesignOverlay, resolveEditMode, type UiDesignOverlay } from '../../../../../../lib/ui-design-overlay.js' import { resolveSections, type ResolvedSection } from '../../../../../../lib/page-spec-sections.js' export const LOCALES = ['fr', 'en', 'it', 'de'] as const export type Locale = (typeof LOCALES)[number] export const GENERATED_MARKER = '// @generated-by scaffold-component — DO NOT HAND-EDIT (re-run the CLI to update)\n' /** * Convert a flat i18n catalogue (e.g. `{ 'list.title': 'Employés' }`) to the * nested form i18next expects in JSON files (`{ list: { title: 'Employés' } }`). * Used in enriched mode when `spec.pageSpec.i18nKeys` is provided — the * canonical PRD shape stores flat keys (one entry per `t('key')` call) and the * generator materialises the nested tree at write time. */ export function flatToNested(flat: Record): Record { const root: Record = {} for (const [dottedKey, value] of Object.entries(flat)) { const parts = dottedKey.split('.') let cursor: Record = root let dropped = false for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]! const next = cursor[part] if (typeof next === 'object' && next !== null && !Array.isArray(next)) { cursor = next as Record } else if (typeof next === 'string') { // Leaf/parent conflict (e.g. `form.fields.x` label vs a stray // `form.fields.x.options.v`): the LEAF wins — labels are load-bearing, // while every nested extra carries a defaultValue in the TSX. Silently // replacing the label with a subtree shipped raw i18n keys on screen. dropped = true break } else { const fresh: Record = {} cursor[part] = fresh cursor = fresh } } if (!dropped) cursor[parts[parts.length - 1]!] = value } return root } /** * Inverse of flatToNested(): flatten a nested i18n catalogue * (`{ list: { title: 'Employés' } }`) back to flat dotted keys * (`{ 'list.title': 'Employés' }`). Used to seed the enriched-mode catalogue * from the COMPLETE buildTranslations() floor before the PRD overrides are * layered on top — guaranteeing every bare `t('…')` key the templates render * has a translation even when the PRD's thin i18nKeys block omits it. */ export function flattenCatalogue(nested: Record, prefix = ''): Record { const out: Record = {} for (const [key, value] of Object.entries(nested)) { const path = prefix ? `${prefix}.${key}` : key if (value !== null && typeof value === 'object' && !Array.isArray(value)) { Object.assign(out, flattenCatalogue(value as Record, path)) } else if (typeof value === 'string') { out[path] = value } } return out } export function translateLabel(label: string, locale: Locale): string { const dictionary: Record> = { Loading: { fr: 'Chargement…', en: 'Loading…', it: 'Caricamento…', de: 'Wird geladen…' }, NotFound: { fr: 'Introuvable', en: 'Not found', it: 'Non trovato', de: 'Nicht gefunden' }, Empty: { fr: 'Aucune donnée', en: 'No data', it: 'Nessun dato', de: 'Keine Daten' }, Create: { fr: 'Créer', en: 'Create', it: 'Crea', de: 'Erstellen' }, Edit: { fr: 'Modifier', en: 'Edit', it: 'Modifica', de: 'Bearbeiten' }, Delete: { fr: 'Supprimer', en: 'Delete', it: 'Elimina', de: 'Löschen' }, Update: { fr: 'Mettre à jour', en: 'Update', it: 'Aggiorna', de: 'Aktualisieren' }, Actions: { fr: 'Actions', en: 'Actions', it: 'Azioni', de: 'Aktionen' }, Save: { fr: 'Enregistrer', en: 'Save', it: 'Salva', de: 'Speichern' }, Cancel: { fr: 'Annuler', en: 'Cancel', it: 'Annulla', de: 'Abbrechen' }, Search: { fr: 'Rechercher…', en: 'Search…', it: 'Cerca…', de: 'Suchen…' }, Subtitle: { fr: 'Gérer les', en: 'Manage', it: 'Gestisci', de: 'Verwalten' }, Error: { fr: 'Erreur', en: 'Error', it: 'Errore', de: 'Fehler' }, Dashboard: { fr: 'Tableau de bord', en: 'Dashboard', it: 'Cruscotto', de: 'Dashboard' }, StartDate: { fr: 'Date de début', en: 'Start date', it: 'Data inizio', de: 'Startdatum' }, EndDate: { fr: 'Date de fin', en: 'End date', it: 'Data fine', de: 'Enddatum' }, Alerts: { fr: 'Alertes', en: 'Alerts', it: 'Avvisi', de: 'Warnungen' }, NoAlerts: { fr: 'Aucune alerte', en: 'No alerts', it: 'Nessun avviso', de: 'Keine Warnungen' }, SeverityInfo: { fr: 'Info', en: 'Info', it: 'Info', de: 'Info' }, SeverityWarning: { fr: 'Avertissement', en: 'Warning', it: 'Avviso', de: 'Warnung' }, SeverityCritical: { fr: 'Critique', en: 'Critical', it: 'Critico', de: 'Kritisch' }, Kpis: { fr: 'Indicateurs clés', en: 'Key indicators', it: 'Indicatori chiave', de: 'Kennzahlen' }, Navigation: { fr: 'Navigation', en: 'Navigation', it: 'Navigazione', de: 'Navigation' }, All: { fr: 'Tous', en: 'All', it: 'Tutti', de: 'Alle' }, Unassigned: { fr: 'Non assigné', en: 'Unassigned', it: 'Non assegnato', de: 'Nicht zugewiesen' }, KanbanTruncated: { fr: '{{shown}} cartes affichées sur {{total}} — affinez les filtres pour voir le reste', en: '{{shown}} of {{total}} cards shown — refine the filters to see the rest', it: '{{shown}} schede su {{total}} visualizzate — affina i filtri per vedere il resto', de: '{{shown}} von {{total}} Karten angezeigt — Filter verfeinern, um den Rest zu sehen', }, KanbanMoveError: { fr: 'Le déplacement a été refusé — la carte a été remise dans sa colonne', en: 'The move was refused — the card was put back in its column', it: 'Lo spostamento è stato rifiutato — la scheda è tornata nella sua colonna', de: 'Das Verschieben wurde abgelehnt — die Karte wurde zurückgelegt', }, KanbanMoveNotAllowed: { fr: 'Transition non autorisée par le workflow', en: 'Transition not allowed by the workflow', it: 'Transizione non consentita dal workflow', de: 'Übergang vom Workflow nicht erlaubt', }, KanbanReorderColumn: { fr: 'Glisser pour réordonner la colonne', en: 'Drag to reorder the column', it: 'Trascina per riordinare la colonna', de: 'Ziehen, um die Spalte neu anzuordnen', }, Refuse: { fr: 'Refuser', en: 'Refuse', it: 'Rifiuta', de: 'Ablehnen' }, SelectAll: { fr: 'Tout sélectionner', en: 'Select all', it: 'Seleziona tutto', de: 'Alle auswählen' }, Identifier: { fr: 'Identifiant', en: 'Identifier', it: 'Identificativo', de: 'Kennung' }, Required: { fr: 'Ce champ est obligatoire', en: 'This field is required', it: 'Questo campo è obbligatorio', de: 'Dieses Feld ist erforderlich' }, Essential: { fr: 'Essentiel', en: 'Essential', it: 'Essenziale', de: 'Wesentlich' }, Details: { fr: 'Détails', en: 'Details', it: 'Dettagli', de: 'Details' }, Me: { fr: 'Moi', en: 'Me', it: 'Io', de: 'Ich' }, Sections: { fr: 'Sections', en: 'Sections', it: 'Sezioni', de: 'Abschnitte' }, Yes: { fr: 'Oui', en: 'Yes', it: 'Sì', de: 'Ja' }, No: { fr: 'Non', en: 'No', it: 'No', de: 'Nein' }, Information: { fr: 'Informations', en: 'Information', it: 'Informazioni', de: 'Informationen' }, ViewAll: { fr: 'Voir tout', en: 'View all', it: 'Vedi tutto', de: 'Alle anzeigen' }, OfflineActionUnavailable: { fr: 'Indisponible hors ligne', en: 'Unavailable offline', it: 'Non disponibile offline', de: 'Offline nicht verfügbar' }, OfflineStaleData: { fr: 'Hors ligne — données éventuellement obsolètes', en: 'Offline — data may be stale', it: 'Offline — dati potenzialmente non aggiornati', de: 'Offline — Daten eventuell veraltet' }, OfflineFormUnavailable: { fr: 'Enregistrement indisponible hors ligne', en: 'Saving is unavailable offline', it: 'Salvataggio non disponibile offline', de: 'Speichern offline nicht verfügbar' }, OutboxPending: { fr: 'À synchroniser', en: 'Pending sync', it: 'Da sincronizzare', de: 'Zu synchronisieren' }, OutboxFailed: { fr: 'Échec de synchronisation', en: 'Sync failed', it: 'Sincronizzazione non riuscita', de: 'Synchronisierung fehlgeschlagen' }, OutboxConflict: { fr: 'Conflit (serveur prioritaire)', en: 'Conflict (server wins)', it: 'Conflitto (server prioritario)', de: 'Konflikt (Server gewinnt)' }, Previous: { fr: 'Précédent', en: 'Previous', it: 'Precedente', de: 'Zurück' }, Next: { fr: 'Suivant', en: 'Next', it: 'Successivo', de: 'Weiter' }, Total: { fr: 'Total', en: 'Total', it: 'Totale', de: 'Gesamt' }, Columns: { fr: 'Colonnes', en: 'Columns', it: 'Colonne', de: 'Spalten' }, ShowColumns: { fr: 'Afficher les colonnes', en: 'Show columns', it: 'Mostra colonne', de: 'Spalten anzeigen' }, Reset: { fr: 'Réinitialiser', en: 'Reset', it: 'Reimposta', de: 'Zurücksetzen' }, MoreFilters: { fr: 'Plus de filtres', en: 'More filters', it: 'Altri filtri', de: 'Weitere Filter' }, ClearFilter: { fr: 'Effacer le filtre', en: 'Clear filter', it: 'Cancella il filtro', de: 'Filter löschen' }, Done: { fr: 'Terminer', en: 'Done', it: 'Fatto', de: 'Fertig' }, UnsavedChanges: { fr: 'Modifications non enregistrées', en: 'Unsaved changes', it: 'Modifiche non salvate', de: 'Nicht gespeicherte Änderungen' }, SavedAt: { fr: 'Enregistré à', en: 'Saved at', it: 'Salvato alle', de: 'Gespeichert um' }, ConfirmLeave: { fr: 'Des modifications ne sont pas enregistrées. Quitter la page ?', en: 'You have unsaved changes. Leave the page?', it: 'Ci sono modifiche non salvate. Uscire dalla pagina?', de: 'Es gibt nicht gespeicherte Änderungen. Seite verlassen?' }, ViewTable: { fr: 'Tableau', en: 'Table', it: 'Tabella', de: 'Tabelle' }, ViewCards: { fr: 'Cartes', en: 'Cards', it: 'Schede', de: 'Karten' }, PrevPage: { fr: 'Page précédente', en: 'Previous page', it: 'Pagina precedente', de: 'Vorherige Seite' }, NextPage: { fr: 'Page suivante', en: 'Next page', it: 'Pagina successiva', de: 'Nächste Seite' }, ViewsMenu: { fr: 'Vues enregistrées', en: 'Saved views', it: 'Viste salvate', de: 'Gespeicherte Ansichten' }, SaveView: { fr: 'Enregistrer la vue actuelle', en: 'Save current view', it: 'Salva la vista corrente', de: 'Aktuelle Ansicht speichern' }, SaveViewPrompt: { fr: 'Nom de la vue', en: 'View name', it: 'Nome della vista', de: 'Name der Ansicht' }, NoSavedViews: { fr: 'Aucune vue enregistrée', en: 'No saved views', it: 'Nessuna vista salvata', de: 'Keine gespeicherten Ansichten' }, RemoveView: { fr: 'Supprimer la vue', en: 'Remove view', it: 'Elimina la vista', de: 'Ansicht löschen' }, } const entry = dictionary[label] if (!entry) throw new Error(`Missing translation for UI label '${label}' — update CLI dictionary`) return entry[locale] } /** * Per-locale floor for UBIQUITOUS field names. Without this, a field the PRD's * i18nKeys doesn't override ships `humanize()` IDENTICALLY in * fr/en/it/de — an English "Name" label on a French form. This dictionary is * the zero-PRD repair path: a plain re-scaffold localises the common vocabulary. * It does NOT excuse a thin PRD — PRD-106 (authoring gate) and DEV-UI-037 * (shipped-bundle net) keep watching; business wording still belongs upstream. * Keyed by the LOWERCASED field name after the FK `Id`-suffix strip. */ export const FIELD_NAME_FLOOR: Record> = { name: { fr: 'Nom', en: 'Name', it: 'Nome', de: 'Name' }, description: { fr: 'Description', en: 'Description', it: 'Descrizione', de: 'Beschreibung' }, code: { fr: 'Code', en: 'Code', it: 'Codice', de: 'Code' }, status: { fr: 'Statut', en: 'Status', it: 'Stato', de: 'Status' }, active: { fr: 'Actif', en: 'Active', it: 'Attivo', de: 'Aktiv' }, email: { fr: 'E-mail', en: 'Email', it: 'E-mail', de: 'E-Mail' }, phone: { fr: 'Téléphone', en: 'Phone', it: 'Telefono', de: 'Telefon' }, address: { fr: 'Adresse', en: 'Address', it: 'Indirizzo', de: 'Adresse' }, startdate: { fr: 'Date de début', en: 'Start date', it: 'Data inizio', de: 'Startdatum' }, enddate: { fr: 'Date de fin', en: 'End date', it: 'Data fine', de: 'Enddatum' }, duedate: { fr: "Date d'échéance", en: 'Due date', it: 'Data di scadenza', de: 'Fälligkeitsdatum' }, notes: { fr: 'Notes', en: 'Notes', it: 'Note', de: 'Notizen' }, title: { fr: 'Titre', en: 'Title', it: 'Titolo', de: 'Titel' }, type: { fr: 'Type', en: 'Type', it: 'Tipo', de: 'Typ' }, comment: { fr: 'Commentaire', en: 'Comment', it: 'Commento', de: 'Kommentar' }, firstname: { fr: 'Prénom', en: 'First name', it: 'Nome', de: 'Vorname' }, lastname: { fr: 'Nom', en: 'Last name', it: 'Cognome', de: 'Nachname' }, amount: { fr: 'Montant', en: 'Amount', it: 'Importo', de: 'Betrag' }, quantity: { fr: 'Quantité', en: 'Quantity', it: 'Quantità', de: 'Menge' }, organisation: { fr: 'Organisation', en: 'Organisation', it: 'Organizzazione', de: 'Organisation' }, city: { fr: 'Ville', en: 'City', it: 'Città', de: 'Stadt' }, country: { fr: 'Pays', en: 'Country', it: 'Paese', de: 'Land' }, createdat: { fr: 'Créé le', en: 'Created at', it: 'Creato il', de: 'Erstellt am' }, updatedat: { fr: 'Modifié le', en: 'Updated at', it: 'Modificato il', de: 'Geändert am' }, } /** Search placeholder of an FK lookup, built from the field's resolved floor * label. Article-free patterns so no gender agreement is needed. */ export function lookupPlaceholder(label: string, locale: Locale): string { switch (locale) { case 'fr': return `Rechercher : ${label}…` case 'it': return `Cerca ${label}…` case 'de': return `${label} suchen…` default: return `Search ${label}…` } } /** Map widget type to a sensible default Lucide icon. */ export const WIDGET_TYPE_ICONS: Record = { kpi: 'TrendingUp', counter: 'Hash', 'chart-pie': 'PieChart', 'chart-bar': 'BarChart3', 'chart-line': 'LineChart', list: 'List', } /** Convert kebab-case icon name to PascalCase Lucide import (e.g. `file-text` → `FileText`). */ export function kebabToPascal(kebab: string): string { return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('') } export function humanize(name: string): string { return name .replace(/([A-Z])/g, ' $1') .replace(/[_-]+/g, ' ') .trim() .replace(/^./, c => c.toUpperCase()) } /** kebab → camelCase (`bulk-archive` → `bulkArchive`). Used for mutation * variable names that match the api-client service member naming. */ export function toCamel(code: string): string { const parts = code.split('-') return parts[0]! + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') } export function pluralize(singular: string, locale: Locale): string { const lowercaseLast = singular.toLowerCase() if (locale === 'it') return lowercaseLast.endsWith('a') ? singular.slice(0, -1) + 'e' : singular + 's' return singular + 's' } /** PascalCase → camelCase (e.g. `FirstName` → `firstName`). */ export function fieldToCamel(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } // Parents whose LEAF is a field name (camelCased on normalisation). `options`, // `actionParams` and `actionParamOptions` are deliberately NOT in this set — // their leaves (enum values, camelCase param names, option codes) must stay // verbatim so the TSX `t('…')` key and the JSON key agree byte-for-byte. export const I18N_FIELD_PARENTS = new Set(['fields', 'columns', 'filters', 'help', 'placeholders']) /** * The ONE structural rule of the emitted catalogue: labels are LEAVES; every * secondary channel lives in a SIBLING branch (a nested JSON cannot hold * `"x": "label"` AND `"x": { child }` — flatToNested drops the child, leaf * wins). Derived channels for a custom action's payload dialog: * button label `list.actions.transferer` (leaf, from the PRD) * param labels `list.actionParams.transferer.

` (leaves) * option labels `list.actionParamOptions.transferer.

.` (leaves) * TSX emitters, the buildTranslations floor and the PRD-key normalizer ALL * derive these bases through this function so they can never disagree. */ export function actionParamBases(labelKey: string): { params: string; options: string } { const m = labelKey.match(/^(.*)\.actions\.([^.]+)$/) if (m) return { params: `${m[1]}.actionParams.${m[2]}`, options: `${m[1]}.actionParamOptions.${m[2]}` } // Odd explicit labelKey (no `.actions.` tail): suffix-derive — still sibling // paths, never nested under the label leaf. return { params: `${labelKey}Params`, options: `${labelKey}ParamOptions` } } /** * Remap ONE dotted key from any LEGACY collision-prone scheme onto the * collision-free sibling-branch scheme. Longest tail first (a params+options * key must not be half-remapped by the params-only rule). Idempotent: a key * already in the new scheme matches no pattern and passes through verbatim. * Serves the installed base of already-authored pagespecs — the documented * authoring convention (create-prd) is the NEW scheme. */ export function remapLegacyI18nKey(key: string): string { const parts = key.split('.') const len = parts.length // `…actions..params.

.options.` → `…actionParamOptions..

.` if (len >= 6 && parts[len - 6] === 'actions' && parts[len - 4] === 'params' && parts[len - 2] === 'options') { parts.splice(len - 6, 6, 'actionParamOptions', parts[len - 5]!, parts[len - 3]!, parts[len - 1]!) return parts.join('.') } // `…actions..params.

` → `…actionParams..

` if (len >= 4 && parts[len - 4] === 'actions' && parts[len - 2] === 'params') { parts.splice(len - 4, 4, 'actionParams', parts[len - 3]!, parts[len - 1]!) return parts.join('.') } // `…fields..placeholder` → `…placeholders.` (the legacy key can never // ship: nested under the label leaf, flatToNested drops it — remapping keeps // the authored value alive instead of silently losing it). if (len >= 3 && parts[len - 1] === 'placeholder' && parts[len - 3] === 'fields') { parts.splice(len - 3, 3, 'placeholders', parts[len - 2]!) return parts.join('.') } // `…fields..options.` → `…options..` (enum option labels // used to nest under the field-label leaf and were ALWAYS dropped). if (len >= 4 && parts[len - 4] === 'fields' && parts[len - 2] === 'options') { parts.splice(len - 4, 4, 'options', fieldToCamel(parts[len - 3]!), parts[len - 1]!) return parts.join('.') } return key } /** camelCase the FIELD segment of a new-scheme options key (`form.options..` * authored PascalCase) — the option VALUE leaf stays verbatim. */ export function camelizeOptionsField(parts: string[]): void { if (parts.length >= 4 && parts[parts.length - 3] === 'options') { parts[parts.length - 2] = fieldToCamel(parts[parts.length - 2]!) } } export function normalizeI18nFieldKeys(flat: Record): Record { const result: Record = {} for (const [key, value] of Object.entries(flat)) { const parts = remapLegacyI18nKey(key).split('.') if (parts.length >= 3) { const parentSegment = parts[parts.length - 2]! if (I18N_FIELD_PARENTS.has(parentSegment)) { parts[parts.length - 1] = fieldToCamel(parts[parts.length - 1]!) } } camelizeOptionsField(parts) result[parts.join('.')] = value } return result } /** * Legacy `"[en] "` / `"[it] …"` / `"[de] …"` placeholder values are contract * violations: they would ship VERBATIM into the generated app's locale JSON and * land bracket-tagged on screen (the abandoned "author FR, defer the rest" * convention — `/ba-translate-prd` backfills the real translations, PRD-089 * gates them in `/ba-audit-prd`). Self-heal at the scaffolder input edge: * DROP the placeholder so the complete buildTranslations() floor (real * per-locale chrome + humanised labels) wins instead — degraded but * functional, never a `[en] …` label on screen. Complements PRD-089 (the * audit gate stays authoritative); mirrors the Studio's assembler-side heal. */ export const I18N_PLACEHOLDER_RE = /^\[(en|it|de)\]\s/ export function dropI18nPlaceholders(flat: Record): Record { const result: Record = {} for (const [key, value] of Object.entries(flat)) { if (!I18N_PLACEHOLDER_RE.test(value)) result[key] = value } return result } /** * Symmetric to normalizeI18nFieldKeys() — applies the same camelCase * normalisation rule on a single labelKey BEFORE it lands in the TSX * `t('...')` call. Ensures the key the component requests matches the * key the JSON catalogue ships (which goes through normalizeI18nFieldKeys * at line ~1400). Without this symmetry, a PRD-emitted `list.columns.Code` * (PascalCase) becomes `list.columns.code` in JSON but stays * `list.columns.Code` in TSX → untranslated raw key on screen. * * Path is preserved verbatim — only the leaf is camelCased, and only when * its parent is in I18N_FIELD_PARENTS. Idempotent: re-camelCasing a * camelCase leaf is a no-op, so legacy pagespecs already in camelCase * are unaffected. Action labelKeys (e.g. `list.create`, `form.actions.toggle-actif`) * stay verbatim because their parent (`list`, `actions`) is not a field-parent — * symmetric with normalizeI18nFieldKeys which leaves them alone too. * * Runs remapLegacyI18nKey FIRST: an explicit legacy labelKey override (e.g. a * pagespec's `p.labelKey: 'detail.actions.x.params.y'`) lands on the same * remapped path in the TSX `t('…')` call as in the JSON catalogue. */ export function normalizeI18nKey(key: string): string { const parts = remapLegacyI18nKey(key).split('.') if (parts.length >= 3) { const parentSegment = parts[parts.length - 2]! if (I18N_FIELD_PARENTS.has(parentSegment)) { parts[parts.length - 1] = fieldToCamel(parts[parts.length - 1]!) } } camelizeOptionsField(parts) return parts.join('.') } /** * i18n floor of the 360 related tabs (see buildTranslations detail.related). * One sub-tree per declared tab: label/empty/loading/error/create/viewAll/ * count/previous/next + one label per embedded column. */ export function buildRelatedTabsFloor(spec: ScaffoldComponentInput, locale: Locale): Record { const { tabs } = parseRelatedTabs(spec.pageSpec?.relatedTabs) if (tabs.length === 0) return {} const dataByKey = new Map((spec.relatedTabsData ?? []).map(d => [d.key, d])) const floor: Record = {} for (const tab of tabs) { const columns: Record = {} for (const col of dataByKey.get(tab.key)?.columns ?? [{ key: 'createdAt' }]) { // Same placeholder heal as the pageSpec.i18nKeys edge: a `[en] …` label // resolved from the related list pagespec falls back to the humanised key. const label = (col as { labels?: Partial> }).labels?.[locale] columns[col.key] = label !== undefined && !I18N_PLACEHOLDER_RE.test(label) ? label : humanize(col.key) } floor[tab.key] = { label: humanize(tab.key), empty: translateLabel('Empty', locale), loading: translateLabel('Loading', locale), error: translateLabel('Error', locale), create: translateLabel('Create', locale), viewAll: translateLabel('ViewAll', locale), count: translateLabel('Total', locale), previous: translateLabel('Previous', locale), next: translateLabel('Next', locale), columns, } } return floor } // ─── Custom-action payload dialog floor ────────────────────────────────────── // Deterministic per-locale floor for action PARAM labels + select-option labels // (`{root}.actionParams..

` / `{root}.actionParamOptions..

.`, // same bases as actionParamBases derives for the TSX side). Without it the // dialog falls back to `defaultValue: p.name` — raw camelCase (`targetId`, // `quantity`) on screen whenever the PRD's i18nKeys are thin. PRD keys // override per locale as usual (they land on the same paths via the remap). export const FLOOR_CRUD_CODES = new Set(['create', 'edit', 'update', 'delete', 'list', 'detail', 'read']) export type FloorActionParam = { name: string; type?: string; options?: Array<{ value: string }> } export type FloorAction = { code: string; labelKey?: string; payloadParameters?: FloorActionParam[] } export type ActionFloorBranch = { actionParams?: Record> actionParamOptions?: Record>> } export function buildActionParamsFloor( spec: ScaffoldComponentInput, locale: Locale, ): Partial> { const out: Partial> = {} const actions = (spec.pageSpec?.actions ?? []) as FloorAction[] for (const a of actions) { if (FLOOR_CRUD_CODES.has(a.code.toLowerCase())) continue const params = a.payloadParameters ?? [] if (params.length === 0 || !a.labelKey) continue const m = a.labelKey.match(/^(list|detail|form)\.actions\.([^.]+)$/) if (!m) continue // odd labelKey → no floor; the TSX defaultValue covers const root = m[1] as 'list' | 'detail' | 'form' const code = m[2]! const branch = (out[root] ??= {}) for (const p of params) { // A lookup param's floor label is the TARGET's name (`targetId` → "Target"), // mirroring the FK field-label rule at buildTranslations. const floorName = p.type === 'lookup' ? p.name.replace(/Id$/, '') : p.name ;((branch.actionParams ??= {})[code] ??= {})[p.name] = FIELD_NAME_FLOOR[floorName.toLowerCase()]?.[locale] ?? humanize(floorName) for (const o of p.options ?? []) { // Identifier-like codes humanise (`inProgress` → "In Progress"); // anything else (ALL-CAPS codes, values with spaces) ships verbatim — // humanize() would space every capital of `MUTATION`. ;(((branch.actionParamOptions ??= {})[code] ??= {})[p.name] ??= {})[o.value] = /^[a-z][a-zA-Z0-9]*$/.test(o.value) ? humanize(o.value) : o.value } } } return out } export function buildTranslations(spec: ScaffoldComponentInput, locale: Locale): Record { // The BUSINESS label of an allocated code (« Référence ») — the `libellé` // facet of the entité.md **Code pattern** line, carried by the enriched // pagespec flag. Beats the generic floor for the `code` field only; the // PRD's i18nKeys still override it per locale (floor < existing < PRD). const codedLabel = codedLabelOf(spec.codedEntity ?? spec.pageSpec?.codedEntity, locale) const fieldLabels: Record = {} for (const f of spec.fields) { // A FK field's floor label is the TARGET's name, never the column name: // `ClientId` labels as "Client", not "Client Id" (the Id suffix is a storage // detail the user must never see — test-RH shipped "Client Id / Statut Id"). // Only applied when the FK is proven (`fkTo`) so a genuine identifier // column (`externalId`, noLookup) keeps its full name. The PRD's i18nKeys // still override this floor per locale. Ubiquitous names resolve through // FIELD_NAME_FLOOR so the floor is per-locale ("Nom", not "Name" on a // French form); anything business-specific keeps the humanised fallback. const floorName = f.fkTo ? f.name.replace(/Id$/, '') : f.name fieldLabels[fieldToCamel(f.name)] = f.label ?? (/^code$/i.test(f.name) ? codedLabel : null) ?? FIELD_NAME_FLOOR[floorName.toLowerCase()]?.[locale] ?? humanize(floorName) } // Contextual search placeholder per FK lookup (form.placeholders.) — // a SIBLING channel of form.fields, never form.fields..placeholder: a // nested key under a leaf cannot ship in the JSON catalogue (leaf wins, see // flatToNested), so placeholders get their own parent. const fieldPlaceholders: Record = {} for (const f of spec.fields) { if (!f.fkTo) continue fieldPlaceholders[fieldToCamel(f.name)] = lookupPlaceholder(fieldLabels[fieldToCamel(f.name)]!, locale) } const entityName = humanize(spec.entity) const pluralName = pluralize(entityName, locale) // Emitted conditionally (only when non-empty) so actionless entities' // bundles stay byte-identical — same contract as the offline/outbox floor. const actionFloor = buildActionParamsFloor(spec, locale) return { // Shared leaf keys reused across list/detail/form (e.g. boolean pills render // t('.common.yes'|'.no')). `common` is not an I18N_FIELD_PARENT so the // leaves stay verbatim through normalize + flatten. common: { yes: translateLabel('Yes', locale), no: translateLabel('No', locale), }, list: { title: pluralName, subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`, search: translateLabel('Search', locale), loading: translateLabel('Loading', locale), empty: translateLabel('Empty', locale), create: translateLabel('Create', locale), edit: translateLabel('Edit', locale), delete: translateLabel('Delete', locale), actionsColumn: translateLabel('Actions', locale), viewTable: translateLabel('ViewTable', locale), viewCards: translateLabel('ViewCards', locale), prevPage: translateLabel('PrevPage', locale), nextPage: translateLabel('NextPage', locale), views: { menu: translateLabel('ViewsMenu', locale), save: translateLabel('SaveView', locale), savePrompt: translateLabel('SaveViewPrompt', locale), empty: translateLabel('NoSavedViews', locale), remove: translateLabel('RemoveView', locale), }, error: translateLabel('Error', locale), columns: fieldLabels, // 'All' option of every select filter (t('list.filters.all')) + the // FilterBar chrome (more/reset/clear). The per-field filter labels // (list.filters.{field}) come from the PRD. filters: { all: translateLabel('All', locale), more: translateLabel('MoreFilters', locale), reset: translateLabel('Reset', locale), clear: translateLabel('ClearFilter', locale), }, // Column picker (view options) chrome — floor for every list page. columnsPicker: { button: translateLabel('Columns', locale), title: translateLabel('ShowColumns', locale), reset: translateLabel('Reset', locale), }, ...(actionFloor.list ?? {}), }, detail: { loading: translateLabel('Loading', locale), notFound: translateLabel('NotFound', locale), edit: translateLabel('Edit', locale), delete: translateLabel('Delete', locale), fields: fieldLabels, // aria-label of the tab strip (t('detail.tabs.label')) + the synthetic // "info" tab holding the entity's own fields when only related tabs are // declared. The per-tab labels (detail.tabs.{key}) come from the PRD. tabs: { label: translateLabel('Sections', locale), info: translateLabel('Information', locale) }, // 360 related-tabs floor: every t() key each related tab renders exists // in all 4 locales even when the PRD's i18nKeys are thin. Tab labels // default to the humanised key; the PRD overrides with the real 4-locale // label (create-prd authors detail.related.{key}.label). Column labels // come from relatedTabsData (resolved from the related list pagespec). related: buildRelatedTabsFloor(spec, locale), ...(actionFloor.detail ?? {}), }, form: { createTitle: `${translateLabel('Create', locale)} ${entityName}`, editTitle: `${translateLabel('Edit', locale)} ${entityName}`, submitCreate: translateLabel('Create', locale), submitUpdate: translateLabel('Update', locale), save: translateLabel('Save', locale), cancel: translateLabel('Cancel', locale), required: translateLabel('Required', locale), me: translateLabel('Me', locale), // Read-first fiche chrome (per-section toggle + dirty state) — floored in // all 4 locales so the buttons/chip never render a raw key. The TSX also // carries defaultValue (belt & braces, same idiom as form.section.*). editSection: translateLabel('Edit', locale), doneSection: translateLabel('Done', locale), unsavedChanges: translateLabel('UnsavedChanges', locale), reset: translateLabel('Reset', locale), savedAt: translateLabel('SavedAt', locale), confirmLeave: translateLabel('ConfirmLeave', locale), section: { essential: translateLabel('Essential', locale), details: translateLabel('Details', locale), // Floor keys for every explicit section (from `field.section` AND the // pageSpec.uiDesign overlay), so a sectioned form never renders a raw // `form.section.` key. Humanised default; pageSpec.i18nKeys overrides per locale. ...Object.fromEntries( Array.from(new Set([ ...(spec.fields ?? []).map(f => (f.section ?? '').trim()), ...Object.values(pickUiDesignOverlay(spec.pageSpec)?.fields ?? {}).map(d => (d.section ?? '').trim()), // Tab-seeded sections (pageSpec.tabs on a form view) need the same // floor so their

never renders a raw form.section. key. ...(spec.pageSpec?.tabs ?? []).map(t => (t.key ?? '').trim()), // First-order sections[] (and their uiDesign metadata twin) get the // same floor — shared by the form cards AND the sectioned detail. ...(spec.pageSpec?.sections ?? []).map(s => (s.key ?? '').trim()), ...(pickUiDesignOverlay(spec.pageSpec)?.sections ?? []).map(s => (s.key ?? '').trim()), ].filter(s => s !== '' && s !== 'essential' && s !== 'details'))) .map(s => [toCamel(s), humanize(s)]), ), }, fields: fieldLabels, placeholders: fieldPlaceholders, ...(actionFloor.form ?? {}), }, dashboard: { title: `${translateLabel('Dashboard', locale)} ${pluralName.toLowerCase()}`, subtitle: pluralName, loading: translateLabel('Loading', locale), error: translateLabel('Error', locale), startDate: translateLabel('StartDate', locale), endDate: translateLabel('EndDate', locale), // Per-widget labels keyed by widget.key — populated by create-prd from the // SmartDashboard widgets (or edit here). Read as dashboard.widget.. widget: {} as Record, }, home: { title: pluralName, subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`, kpis: translateLabel('Kpis', locale), navigation: translateLabel('Navigation', locale), }, kanban: { title: pluralName, subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`, loading: translateLabel('Loading', locale), error: translateLabel('Error', locale), unassigned: translateLabel('Unassigned', locale), // Board-branch floor (the kanban viewMode of the list page). Column // labels are NOT floored here — they arrive from the pagespec i18nKeys // (`kanban.columns.`, seeded by derive-kanban-spec). truncated: translateLabel('KanbanTruncated', locale), moveError: translateLabel('KanbanMoveError', locale), moveNotAllowed: translateLabel('KanbanMoveNotAllowed', locale), reorderColumn: translateLabel('KanbanReorderColumn', locale), }, reconduction: { title: pluralName, subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`, loading: translateLabel('Loading', locale), error: translateLabel('Error', locale), empty: translateLabel('Empty', locale), selectAll: translateLabel('SelectAll', locale), action: { refuse: translateLabel('Refuse', locale) }, columns: { identifier: translateLabel('Identifier', locale) }, }, breadcrumb: { section: humanize(spec.section), }, // Offline/outbox floor keys — emitted ONLY when the pagespec opts into // offline, so a non-PWA spec's locale bundles stay byte-identical. ...(normalizeOffline((spec.pageSpec?.pwa ?? spec.pwa)?.offline) !== 'none' ? { offline: { actionUnavailable: translateLabel('OfflineActionUnavailable', locale), staleData: translateLabel('OfflineStaleData', locale), formUnavailable: translateLabel('OfflineFormUnavailable', locale), }, } : {}), ...(normalizeOffline((spec.pageSpec?.pwa ?? spec.pwa)?.offline) === 'write' ? { outbox: { pending: translateLabel('OutboxPending', locale), failed: translateLabel('OutboxFailed', locale), conflict: translateLabel('OutboxConflict', locale), }, } : {}), } } export function defaultForField(f: ComponentField): string { if (f.defaultValue !== undefined && f.defaultValue !== null) { if (typeof f.defaultValue === 'string') return `'${f.defaultValue}'` return String(f.defaultValue) } // Multi-valued enum → empty array initial value (typed string[]). if (f.multiple && Array.isArray(f.options) && f.options.length > 0) return `[]` // A select locked at creation must show a concrete value, not a blank — seed // the first option so the disabled control isn't empty on the create form. if (f.readonlyOn === 'create' && Array.isArray(f.options) && f.options.length > 0) { return `'${f.options[0]!.value}'` } switch (f.type.toLowerCase()) { case 'string': return `''` case 'number': case 'int': case 'integer': case 'decimal': return `0` case 'bool': case 'boolean': return `false` case 'datetime': case 'date': return `''` default: return `''` } } export function formDataTypeLiteral(fields: ComponentField[]): string { if (fields.length === 0) return `Record` const lines = fields.map(f => ` ${fieldToCamel(f.name)}${f.required ? '' : '?'}: ${tsTypeForField(f)}`) return `{\n${lines.join('\n')}\n}` } /** Field-aware TS type: multi-valued enums become `string[]`; everything else * follows the scalar `toTsType` mapping. */ export function tsTypeForField(f: ComponentField): string { if (f.multiple && Array.isArray(f.options) && f.options.length > 0) return 'string[]' return toTsType(f.type) } export function toTsType(type: string): string { switch (type.toLowerCase()) { case 'string': case 'guid': case 'datetime': case 'date': return 'string' case 'number': case 'int': case 'integer': case 'decimal': return 'number' case 'bool': case 'boolean': return 'boolean' default: return 'string' } } export function toHtmlInputType(type: string): string { switch (type.toLowerCase()) { case 'number': case 'int': case 'integer': case 'decimal': return 'number' case 'bool': case 'boolean': return 'checkbox' case 'datetime': return 'datetime-local' case 'date': return 'date' default: return 'text' } } export function fromInputValue(type: string, expr: string): string { switch (type.toLowerCase()) { case 'number': case 'int': case 'integer': case 'decimal': return `Number(${expr})` case 'bool': case 'boolean': return `(event.target as HTMLInputElement).checked` default: return expr } } // ─── FK lookup helpers — power emission on Form pages ────── /** * A field is a FK when the orchestrator (ba-develop Phase 3a) has resolved * `fkTo` for it. The shape check is conservative: even if scaffold-component * is called legacy-mode without `fkTo` populated, the field still falls back * to a plain `` and audit DEV-UI-022 catches the regression. */ export function isFkField(f: ComponentField): boolean { return Boolean(f.fkTo) } // ─── Field-state helpers (form readonly / computed / conditional) ──────────── /** System-managed columns never editable on a form. Mirror of scaffold-controller's * reserved set so the form's exclusions line up with the Create/Update DTOs. */ export function isSystemFieldName(name: string): boolean { return /^(id|createdAt|updatedAt|createdBy|updatedBy|rowVersion)$/i.test(name) } /** A field shown read-only on the form: computed, explicitly read-only, or a * system column. Rendered as a display value, never an editable input, and * excluded from the create payload. */ export function isDisplayOnly(f: ComponentField): boolean { return f.readonly === true || f.isComputed === true || isSystemFieldName(f.name) } export function isEditable(f: ComponentField): boolean { return !isDisplayOnly(f) } /** A later-phase field (lifecycle pivot `phase`, seeded by resolveLifecycle * from `pageSpec.lifecycle` — lib/page-spec-lifecycle.ts): hidden on the * CREATE form, excluded from the create payload, edit-gated by its status * guard. Absent on every legacy spec → all call sites behave as before. */ export function isLaterPhase(f: ComponentField): boolean { return (f.phase ?? '').trim() !== '' } /** The `disabled={…}` expression for an editable field. `readonlyOn` locks the * field in one mode (disabled + pinned to its default); otherwise it follows the * pending state. Returns `isPending` verbatim when no flag → byte-identical to * the legacy output. */ export function disabledExpr(f: ComponentField): string { if (f.readonlyOn === 'create') return 'isPending || !isEdit' if (f.readonlyOn === 'edit') return 'isPending || isEdit' return 'isPending' } /** * Compile a `visibleWhen` predicate into a JS boolean expression over the given * accessor (`formData` on the form, `data` on the detail page). Deliberately * constrained — ONE comparison ` (===|!==) ` or ONE membership * ` [NOT] IN ('a', 'b', …)` (string literals only; the lifecycle status * gates compile through it) — so we never eval arbitrary JS in generated code. * Returns null for anything outside the grammar; the caller then renders the * field always-visible with a comment. * "status === 'Done'" → formData.status === 'Done' * "isUrgent === true" → formData.isUrgent === true * "status IN ('a', 'b')" → (formData.status === 'a' || formData.status === 'b') * "status NOT IN ('a')" → formData.status !== 'a' */ export function compileVisibleWhen(expr: string, accessor = 'formData'): string | null { const inM = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+(NOT\s+)?IN\s*\(\s*('(?:[^'\\]|\\.)*'(?:\s*,\s*'(?:[^'\\]|\\.)*')*)\s*\)\s*$/i.exec(expr) if (inM) { const v = `${accessor}.${fieldToCamel(inM[1]!)}` const lits = inM[3]!.match(/'(?:[^'\\]|\\.)*'/g) ?? [] if (lits.length === 0) return null const neg = inM[2] !== undefined const parts = lits.map(l => `${v} ${neg ? '!==' : '==='} ${l}`) return parts.length === 1 ? parts[0]! : `(${parts.join(neg ? ' && ' : ' || ')})` } const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(===|!==)\s*('(?:[^'\\]|\\.)*'|true|false|-?\d+(?:\.\d+)?)\s*$/.exec(expr) if (!m) return null return `${accessor}.${fieldToCamel(m[1]!)} ${m[2]} ${m[3]}` } /** Target plural → kebab segment for the lookup URL. Honours an explicit * `fkTo.plural` (irregular plurals like `Category` → `Categories`), else a * heuristic English pluralisation — then the shared kebab-caser so the URL * matches the route scaffold-controller emits for the same entity. */ export function lookupPluralKebab(fkTo: NonNullable): string { const plural = fkTo.plural ?? enPluralize(fkTo.entity) return pluralSegment(plural) } /** * Core entities whose FK picker is NOT served by `/api/core/{plural}/lookup`: the * platform exposes them through a non-standard endpoint returning a COMBINED * references DTO. The picker reads a named sub-list and maps it to {id,displayName}. * `selectExpr` is the `` adapter (the form fetches the raw * DTO + filters client-side). These targets have NO generated `useLookup` hook, * so the LIST page must skip the hook path for them (no dangling import). */ export const CORE_NONSTANDARD_LOOKUP: Record = { // Company → Organisation unification: `ref_Companies` (and its `/lookup`) was dropped. // The org picker reads `.companies` from the users org-references endpoint, which the // platform's own user form uses (GetOrganizationReferencesQuery). TenantOrganisation: { endpoint: '/api/administration/users/organization-references', selectExpr: `(r) => ((r as { companies?: Array<{ id: string; name: string }> }).companies ?? []).map((c) => ({ id: c.id, displayName: c.name }))`, }, } /** The non-standard lookup adapter for a Core FK target, or undefined for the standard path. */ export function coreNonStandardLookup(fkTo: NonNullable): { endpoint: string; selectExpr: string } | undefined { return fkTo.module === 'core' ? CORE_NONSTANDARD_LOOKUP[fkTo.entity] : undefined } /** * Endpoint for a FK field's ``. Resolution order (so it can never * diverge from the backend): * 1. explicit `fkTo.apiEndpoint` (ba-develop sets it for most FKs) — wins. * 2. Core targets → `/api/core/{plural}/lookup` (platform-served). * 3. `fkTo.navRoute` → `/api/{module}/{section}/lookup` — the NavRoute-resolved * route the target's integration controller serves at runtime (buildNavApiPath). * 4. best-effort `{module}.{entity-plural}` key — only when the orchestrator * passed neither apiEndpoint nor navRoute; audit-dev-wire flags it if it does * not match a real backend lookup route. * The legacy `/api/v1/integration/{plural}/lookup` literal is never emitted — the * platform's NavigationRouteModelProvider rewrote it away (→ 404). */ export function defaultLookupEndpoint(fkTo: NonNullable): string { if (fkTo.apiEndpoint) return fkTo.apiEndpoint if (fkTo.module === 'core') return `/api/core/${lookupPluralKebab(fkTo)}/lookup` if (fkTo.navRoute) return `${buildNavApiPath(fkTo.navRoute)}/lookup` return `${buildNavApiPath(`${fkTo.module}.${lookupPluralKebab(fkTo)}`)}/lookup` } /** * Build the conditional `import { useXxxLookup } from '@/features/.../...'` * lines for the Form/Detail pages. One line per unique target entity. * Empty array → emit nothing. */ export function lookupHookImportLines(fkFields: ComponentField[], appCode: string): string[] { const seen = new Map() for (const f of fkFields) { if (!f.fkTo) continue const key = `${f.fkTo.app ?? ''}/${f.fkTo.module}/${f.fkTo.entity}` if (seen.has(key)) continue const targetLower = f.fkTo.entity.charAt(0).toLowerCase() + f.fkTo.entity.slice(1) // One web project hosts every business app's features, so a cross-app FK // (Projet → Client) must import from the TARGET's app segment // (`@/features/client/annuaire/…`) — `fkTo.app` carries it (derive-fk-specs // sets it); absent → same-app FK, the importing page's appCode. const targetApp = (f.fkTo.app ?? appCode).toLowerCase() seen.set( key, `import { use${f.fkTo.entity}Lookup } from '@/features/${targetApp}/${f.fkTo.module}/${targetLower}/hooks/use${f.fkTo.entity}'`, ) } return [...seen.values()] } // ─── Control resolution + modern-form layout ───────────────────────────────── /** Enum options at or below this count render as a 1-click ; * above it, a 2-click dropdown. */ export const SEGMENTED_MAX = 4 /** The effective FORM control for a field. An explicit pageSpec `control` wins * where it makes sense; otherwise it's inferred from fkTo / options / type. * Returns a stable token consumed by renderControl() + the layout partitioner. */ export type FormControl = 'lookup' | 'multiselect' | 'segmented' | 'select' | 'date' | 'switch' | 'textarea' | 'number' | 'text' export function controlOf(f: ComponentField): FormControl { if (isFkField(f)) return 'lookup' if (isMultiEnumField(f)) return 'multiselect' if (isEnumField(f)) { const c = (f.control ?? '').toLowerCase() if (c === 'select' || c === 'dropdown' || c === 'combobox') return 'select' if (c === 'segmented' || c === 'radio') return 'segmented' return (f.options?.length ?? 0) <= SEGMENTED_MAX ? 'segmented' : 'select' } const c = (f.control ?? '').toLowerCase() if (c === 'switch' || c === 'toggle' || c === 'checkbox') return 'switch' if (/^bool(ean)?$/i.test(f.type)) return 'switch' if (c === 'date' || c === 'datetime' || c === 'date-popover' || c === 'date-inline') return 'date' if (isDateField(f)) return 'date' if (c === 'textarea' || c === 'multiline') return 'textarea' if (isLongTextName(f)) return 'textarea' if (c === 'number') return 'number' if (/^(number|int|integer|long|decimal|double|float|money)$/i.test(f.type)) return 'number' return 'text' } /** Long-text heuristic used when the pageSpec carries no explicit * `control: "textarea"` — a string field whose name reads like free text. */ export function isLongTextName(f: ComponentField): boolean { return /^string$/i.test(f.type) && /(description|notes?|comments?|remar(k|que)|body|content|message|summary|details?|biograph|adresse|address|motif|justification|observation)/i.test(f.name) } /** The persisted /ui-design judgment overlay (architecture C), read from the pagespec * that Phase 3a passes through verbatim as `pageSpec`. Optional + loosely typed * (pageSpec is passthrough) — absent → the per-field defaults stand. */ // The `uiDesign` overlay shape + picker and the sections[] membership seeding // moved to the shared libs (`lib/ui-design-overlay.ts`, `lib/page-spec-sections.ts`) // — the drift-lock test `lib/__tests__/ui-design-overlay-drift.test.ts` fails if // a local re-declaration reappears here. /** Apply the overlay to the form fields: annotate currentUserFk / section / control * (matched camelCase key ↔ field name), then reorder by `order`. Fields the overlay * doesn't mention are unchanged and keep their relative position (stable). The * overlay always WINS over an inferred default (it is the explicit judgment). */ export function applyUiDesignOverlay(fields: ComponentField[], overlay: UiDesignOverlay | undefined): ComponentField[] { if (!overlay) return fields const byKey = overlay.fields ?? {} const keyOf = (name: string) => Object.keys(byKey).find(k => fieldToCamel(k) === fieldToCamel(name)) let out = fields.map(f => { const k = keyOf(f.name) if (!k) return f const d = byKey[k]! return { ...f, ...(d.currentUserFk !== undefined ? { currentUserFk: d.currentUserFk } : {}), ...(d.section !== undefined ? { section: d.section } : {}), ...(d.control !== undefined ? { control: d.control } : {}), ...(d.dateBounds !== undefined ? { dateBounds: d.dateBounds } : {}), ...(d.fullWidth !== undefined ? { fullWidth: d.fullWidth } : {}), } }) if (overlay.order && overlay.order.length > 0) { const rank = new Map(overlay.order.map((k, i) => [fieldToCamel(k), i])) const rankOf = (f: ComponentField) => rank.has(fieldToCamel(f.name)) ? rank.get(fieldToCamel(f.name))! : Number.MAX_SAFE_INTEGER // Decorate-sort-undecorate so equal ranks keep declaration order on every engine. out = out.map((f, i) => ({ f, i })).sort((a, b) => (rankOf(a.f) - rankOf(b.f)) || (a.i - b.i)).map(x => x.f) } return out } /** A full-width form control (spans both grid columns): a textarea, an opted-in * always-open inline calendar, or an explicit `fullWidth: true` directive. * Everything else is a compact half-width cell; display-only fields render * compact text, so they're never full-width. */ export function isFullWidthControl(f: ComponentField): boolean { if (!isEditable(f)) return false if (f.fullWidth === true) return true const c = controlOf(f) return c === 'textarea' || (c === 'date' && f.control === 'date-inline') } /** Render ONE form field as a grid cell. Self-guarding fields (display-only / * visibleWhen) are emitted directly so a hidden field leaves NO empty cell; * full-width controls — and cells the odd-run balancing promotes — get * `md:col-span-2`; everything else is a half-width cell. */ export function renderFieldCell(f: ComponentField, eLower: string, e: string, promote = false): string { const rendered = renderFormField(f, eLower, e) if (isDisplayOnly(f) || f.visibleWhen || isLaterPhase(f)) return rendered if (isFullWidthControl(f) || promote) { return `
\n${rendered}\n
` } return rendered } /** * Resolve ONE field's READ display expression — the human label, never the raw * stored value: enum/multiselect resolve their option labels, dates go through * the platform datetime module (collected into `dateFns` — drives the * '@atlashub/smartstack' import line of the page), booleans render a pill, a * FK resolves the target's displayName via its `useLookup` hook data * (an em dash while in flight — the raw Guid is NEVER user-facing; a Core * target without a lookup hook masks the Guid). Shared by the DetailPage * `
` (accessor `data`) and the read-first FormPage grid (accessor * `formData`) so the two read surfaces cannot fork. */ export function renderReadValueExpr(f: ComponentField, accessor: string, eLower: string, dateFns: Set): string { const camel = fieldToCamel(f.name) const v = `${accessor}.${camel}` const isStatus = isStatusFieldName(f.name) if (isBoolField(f)) return `{${boolPillExpr(v, eLower)}}` if (isDateField(f)) { const fmt = /^datetime$/i.test(f.type) ? 'formatDateTime' : 'formatDate' dateFns.add(fmt) return `{${fmt}(${v})}` } if (isMultiEnumField(f)) { return `{((${v} as string[] | undefined) ?? []).map((v) => ${optionsArrayLiteral(f.options ?? [])}.find((o) => o.value === v)?.label ?? v).join(', ')}` } if (isEnumField(f)) { const el = `${optionsArrayLiteral(f.options ?? [])}.find((o) => o.value === ${v})?.label ?? String(${v} ?? '')` return isStatus ? `{${statusPillExpr(`{${el}}`, v)}}` : `{${el}}` } if (isFkField(f) && f.fkTo && coreNonStandardLookup(f.fkTo)) { return `{${v} ? '—' : ''}` } if (isFkField(f)) { return `{(${camel}LookupData?.items ?? []).find((it) => it.id === ${v})?.displayName ?? (${v} ? '—' : '')}` } const sv = `String(${v} ?? '')` return isStatus ? `{${statusPillExpr(`{${sv}}`, v)}}` : `{${sv}}` } /** * ONE read-mode item of the read-first form grid: dt/dd over `formData`, label * from `form.fields.*` (the form's own label namespace). The value expression * stays ALONE ON ITS OWN LINE — audit DEV-UI-033's detail-raw regex is line- * anchored, an inlined value would blind it. `visibleWhen` fields keep their * guard so a hidden field leaves no empty cell. */ export function renderReadFieldItem(f: ComponentField, eLower: string, dateFns: Set): string { const camel = fieldToCamel(f.name) const valueExpr = renderReadValueExpr(f, 'formData', eLower, dateFns) const item = `
{t('${eLower}.form.fields.${camel}')}
${valueExpr}
` if (f.visibleWhen) { const compiled = compileVisibleWhen(f.visibleWhen) if (compiled) return ` {(${compiled}) && (\n${item}\n )}` } return item } /** * Render the form body as titled SectionCards (architecture C). Fields are * grouped by their `section` directive; the card order follows the resolved * `sectionMeta` (uiDesign.sections > pageSpec.sections[]) with first-appearance * order as the legacy fallback for keys the metadata does not mention. An * un-sectioned group renders a sober card WITHOUT a header (no redundant * "Details"). Within a section the fields flow in a responsive grid — two * columns by default, one when `formLayout === 'single-column'` — with * textareas / inline calendars / `fullWidth` fields spanning the full width. * An ODD run of half-width cells promotes its LAST cell to `md:col-span-2` so * no field sits orphaned beside dead space. Deterministic and total: every * field lands in exactly one group. * * `readFirst` (the edit-mode default since the read-first uplift): each card * becomes editable — in EDIT mode it renders a read-only dt/dd grid (3 columns, * per-section `columns` override) until its "Modifier" toggle opens the edit * grid; CREATE mode always shows the edit grid (`editable={isEdit}` keeps the * toggle off). The read grid emits NO `md:col-span-2` (the odd-run balancing * stays an edit-grid concern). */ /** Toggle-state section keys of a read-first body, in group order — the keys * `editingSections` / the `section-edit-` testids use. The fiche's * « Modifier » header button and the `/edit` arrival open ALL of them. */ export function resolveFicheSectionKeys(fields: ComponentField[]): string[] { const keys: string[] = [] const seen = new Set() for (const f of fields) { const key = (f.section ?? '').trim() const skey = key ? toCamel(key) : 'default' if (!seen.has(skey)) { seen.add(skey); keys.push(skey) } } return keys } export function renderFormSections(fields: ComponentField[], eLower: string, e: string, layout?: string, sectionMeta: ResolvedSection[] = [], readFirst = false, readDateFns: Set = new Set(), fiche = false): string { const firstAppearance: string[] = [] const groups = new Map() for (const f of fields) { const key = (f.section ?? '').trim() if (!groups.has(key)) { groups.set(key, []); firstAppearance.push(key) } groups.get(key)!.push(f) } // Card order: metadata-ordered keys first (only those that actually hold // fields), then the remaining groups in first-appearance order. const order: string[] = [] const seenOrder = new Set() for (const m of sectionMeta) { if (groups.has(m.key) && !seenOrder.has(m.key)) { order.push(m.key); seenOrder.add(m.key) } } for (const key of firstAppearance) { if (!seenOrder.has(key)) { order.push(key); seenOrder.add(key) } } const metaByKey = new Map(sectionMeta.map(m => [m.key, m] as const)) const gridClass = layout === 'single-column' ? 'grid grid-cols-1 gap-x-6 gap-y-4' : 'grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4' // Odd-run balancing (two-column only). Only statically-placed cells count: // display-only / visibleWhen cells are self-guarding (may not render at all), // so they neither count toward a run nor get promoted. Full-width cells // delimit the runs — a half-cell run interrupted by a textarea must close // flush, not leak its hole under the textarea. const promoted = new Set() if (layout !== 'single-column') { for (const key of order) { let run: ComponentField[] = [] const closeRun = () => { if (run.length % 2 === 1) { const last = run[run.length - 1]! if (last.fullWidth !== false) promoted.add(last) } run = [] } for (const f of groups.get(key)!) { if (isDisplayOnly(f) || f.visibleWhen || isLaterPhase(f)) continue if (isFullWidthControl(f)) closeRun() else run.push(f) } closeRun() } } return order.map(key => { const group = groups.get(key)! const cells = group.map(f => renderFieldCell(f, eLower, e, promoted.has(f))).join('\n') const meta = metaByKey.get(key) // Lifecycle: a section whose EVERY field is later-phase disappears entirely // at create — the whole card renders under isEdit (no empty titled shell at // check-in for a « Sortie » card). Finer per-field status guards remain // inside; the phase marker doubles as the DEV-UI-044 anchor. const allPhased = group.every(isLaterPhase) const phaseKeys = Array.from(new Set(group.map(f => (f.phase ?? '').trim()).filter(k => k !== ''))) // Fiche mode: the page is ALWAYS the edit surface (there is no create // branch), so `isEdit` cannot gate the all-phased card. It gates on the // group's SHARED status predicate instead when every field carries the // same compiled guard (no empty titled shell for a draft's « Paiement » // card); heterogeneous guards fall back to the self-guarding cells. const fichePhasedGuard = (() => { if (!fiche || !allPhased) return null const preds = group.map(f => (f.visibleWhen ? compileVisibleWhen(f.visibleWhen) : null)) return preds[0] !== null && preds.every(p => p === preds[0]) ? preds[0] : null })() const wrapPhased = (card: string): string => allPhased ? (fiche ? (fichePhasedGuard ? `\n {/* phase:${phaseKeys.join(',')} */}\n {(${fichePhasedGuard}) && (${card}\n )}` : `\n {/* phase:${phaseKeys.join(',')} */}${card}`) : `\n {/* phase:${phaseKeys.join(',')} */}\n {isEdit && (${card}\n )}`) : card // Explicit sections[] metadata wins: labelKey overrides the conventional // form.section. key, the authored label becomes the defaultValue. const labelKeyExpr = meta?.labelKey ? `${eLower}.${meta.labelKey}` : `${eLower}.form.section.${toCamel(key)}` const fallbackLabel = (meta?.label ?? humanize(key)).replace(/'/g, "\\'") const titleProp = key ? `\n title={t('${labelKeyExpr}', { defaultValue: '${fallbackLabel}' })}` : '' const descProp = meta?.description ? `\n description={t('${eLower}.form.sectionDescription.${toCamel(key)}', { defaultValue: '${meta.description.replace(/'/g, "\\'")}' })}` : '' if (!readFirst) { return wrapPhased(`
${cells}
`) } // Read-first: the section key drives the toggle state + the driver testids // (section-edit-); the un-sectioned default group uses 'default'. const skey = key ? toCamel(key) : 'default' const readCols = meta?.columns ?? 3 const readGridClass = readCols === 1 ? 'grid grid-cols-1 gap-y-4' : readCols === 2 ? 'grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4' : 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4' const readItems = group.map(f => renderReadFieldItem(f, eLower, readDateFns)).join('\n') if (fiche) { // Unified fiche: the page has no create branch, so the toggles key on // the caller's `canUpdate` (permission-driven) instead of `isEdit` — a // read-only actor sees plain read cards, never an edit affordance. return wrapPhased(` toggleSection('${skey}')} editLabel={t('${eLower}.form.editSection', { defaultValue: 'Edit' })} doneLabel={t('${eLower}.form.doneSection', { defaultValue: 'Done' })} sectionKey="${skey}" > {canUpdate && editingSections.has('${skey}') ? (
${cells}
) : (
${readItems}
)} `) } return wrapPhased(` toggleSection('${skey}')} editLabel={t('${eLower}.form.editSection', { defaultValue: 'Edit' })} doneLabel={t('${eLower}.form.doneSection', { defaultValue: 'Done' })} sectionKey="${skey}" > {isEdit && !editingSections.has('${skey}') ? (
${readItems}
) : (
${cells}
)} `) }).join('') } /** Single-quote-safe `[{ value, label: t(...) }, …]` literal for an enum prop — * every option label goes through i18n (BA label as the fallback) so no choice * text is ever hardcoded (a status "To do" stays translatable). Option labels * live in the `form.options..` SIBLING branch: nested under the * `form.fields.` label leaf they could never ship (flatToNested drops * the child, leaf wins) — PRD-authored option labels were silently lost. */ export function optionsArrayLiteralI18n(f: ComponentField, eLower: string): string { const camel = fieldToCamel(f.name) const esc = (s: string) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") const items = (f.options ?? []).map(o => `{ value: '${esc(o.value)}', label: t('${eLower}.form.options.${camel}.${esc(o.value)}', { defaultValue: '${esc(o.label)}' }) }`, ) return `[${items.join(', ')}]` } /** Runtime JS condition (over `formData`) true when a display-only field has a * value — system/computed read-only fields render only then (an empty * completedAt shows nothing, per the modern-form contract). */ export function displayOnlyValueGuard(f: ComponentField): string { const camel = fieldToCamel(f.name) if (isMultiEnumField(f)) return `((formData.${camel} as string[] | undefined)?.length ?? 0) > 0` return `formData.${camel} !== null && formData.${camel} !== undefined && formData.${camel} !== ''` } /** Dispatch ONE editable field to the right control renderer. */ export function renderControl(f: ComponentField, eLower: string, e: string): string { switch (controlOf(f)) { case 'lookup': return renderFkLookupField(f, eLower, e) case 'multiselect': return renderMultiSelectField(f, eLower, e) case 'segmented': return renderSegmentedField(f, eLower, e) case 'select': return renderEnumSelectField(f, eLower, e) case 'date': return renderDateField(f, eLower, e) case 'switch': return renderSwitchField(f, eLower, e) case 'textarea': return renderTextareaField(f, eLower, e) default: return renderPlainFormField(f, eLower, e) } } /** Render ONE form field with its display-only / visibleWhen / lifecycle * guards applied. A later-phase field never renders at create (`isEdit &&`) * and carries the `phase:` marker comment — the DEV-UI-044 parity * anchor; its status predicate (usually the synthetic guard resolveLifecycle * seeded into `visibleWhen`) gates the edit surface. */ export function renderFormField(f: ComponentField, eLower: string, e: string): string { const inner = isDisplayOnly(f) ? renderDisplayOnlyField(f, eLower) : renderControl(f, eLower, e) const guarded = isDisplayOnly(f) ? ` {(${displayOnlyValueGuard(f)}) && (\n${inner}\n )}` : inner const phaseKey = (f.phase ?? '').trim() const phaseMarker = phaseKey ? ` {/* phase:${phaseKey} */}\n` : '' if (!f.visibleWhen) { if (!phaseKey) return guarded return `${phaseMarker} {isEdit && (\n${guarded}\n )}` } const compiled = compileVisibleWhen(f.visibleWhen) if (!compiled) { const safe = f.visibleWhen.replace(/\*\//g, '* /') if (phaseKey) { return `${phaseMarker} {/* visibleWhen "${safe}" is not a supported predicate */}\n {isEdit && (\n${guarded}\n )}` } return ` {/* visibleWhen "${safe}" is not a supported predicate — field always shown */}\n${guarded}` } if (phaseKey) return `${phaseMarker} {isEdit && (${compiled}) && (\n${guarded}\n )}` return ` {(${compiled}) && (\n${guarded}\n )}` } /** — 1-click inline radio group for a low-cardinality enum. */ export function renderSegmentedField(f: ComponentField, eLower: string, e: string): string { const camelName = fieldToCamel(f.name) return ` onChange('${camelName}', v as ${e}FormData['${camelName}'])} label={t('${eLower}.form.fields.${camelName}')} ${f.required ? 'required' : ''} disabled={${disabledExpr(f)}} error={fieldErrors.${camelName}} />` } /**