/** * cli:scaffold-component — render/context.ts * The shared per-invocation prelude of generate(): naming, paths, permission * key, cross-view navigation gate, PWA/mobile snippets, custom-action * infrastructure. Extracted VERBATIM; every view renderer destructures what it * needs from the returned RenderContext. */ import type { ScaffoldComponentInput, GenerateContext } from '../types.js' import { normalizeOffline } from '../../../../../../lib/pwa-meta.js' import { pickUiDesignOverlay, resolveEditMode, type UiDesignOverlay } from '../../../../../../lib/ui-design-overlay.js' import { resolveSections, type ResolvedSection } from '../../../../../../lib/page-spec-sections.js' import { lifecycleVisibleWhen, resolveLifecycle } from '../../../../../../lib/page-spec-lifecycle.js' import { actionParamBases, applyUiDesignOverlay, buildActionParamsFloor, coreNonStandardLookup, defaultForField, defaultLookupEndpoint, fieldToCamel, normalizeI18nKey, toCamel } from './shared.js' import { coreLookupEndpointFor } from '../../../../../../lib/core-catalog.js' import { DISPLAYISH_RE } from '../../../../../../lib/display-field.js' import { pluralize } from '../../../../../../lib/string-utils.js' import { extensionsModuleId } from '../../../../../../lib/app-classification.js' export function buildRenderContext(spec: ScaffoldComponentInput, ctx: GenerateContext) { const e = spec.entity const eLower = e.charAt(0).toLowerCase() + e.slice(1) // Shared pluralizer (Category → Categories) — MUST agree with scaffold-routes, // which derives the same `${plural}ListPage` file names for its imports. const plural = spec.pluralName ?? pluralize(e) // Pages location. When `pageSpec.filePath` is provided, IT is the source // of truth — derive the directory from it. This handles legacy folder // naming (e.g. `pages/budgeting/budgets/` instead of the canonical // `pages/budgets/budgets/`). Without this override the scaffold would // write to a fresh directory and abandon the existing pages, leaving // the user with two copies. Falls back to the canonical // `src/pages/{appCode}/{module}/{section}` when no pageSpec provided. const canonicalBase = `src/pages/${spec.appCode.toLowerCase()}/${spec.module}/${spec.section}` let base = canonicalBase if (spec.pageSpec?.filePath) { const norm = spec.pageSpec.filePath.replace(/\\/g, '/') // A bare 'Foo.tsx' (no directory) must NOT become the directory itself — // sibling views would land under 'Foo.tsx/…'. Fall back to the canonical dir. base = norm.includes('/') ? norm.replace(/\/[^/]+\.tsx$/i, '') : canonicalBase } // When `pageSpec.filePath` matches the current view we want to honour the // exact file name from the PRD (e.g. `BudgetsModuleHomePage.tsx`) rather // than the entity-derived default. The single-view-per-call invariant // (Phase 3a passes `views: [pageSpec.view]`) guarantees the match logic // is unambiguous: at most one view in the loop can claim the pageSpec. function pathFor(view: string, defaultName: string): string { if (spec.pageSpec && spec.pageSpec.view === view && spec.pageSpec.filePath) { return spec.pageSpec.filePath.replace(/\\/g, '/') } return `${base}/${defaultName}` } const featurePath = `@/features/${spec.appCode.toLowerCase()}/${spec.module}/${eLower}` // Guard-permission PREFIX the templates suffix with an action (`.read`, // `.create`, …). Guard keys are APP-LESS (`{module}.{section}[.{resource}]`): // the stored `ba_permissions.path` is 4-seg app-prefixed, and the useAuth // ADAPTER (scaffold-frontend-auth) matches by stripping the leading appCode // — the emitted key itself never carries the app prefix. (The previous // comment here claimed the opposite of what the next line did — H11.) // SOURCE OF TRUTH: the pagespec's own `permission` minus its action // segment — so a page bound to a narrower (resource-grain) or explicitly // authored prefix guards on THAT, instead of a silently recomputed // `{module}.{section}` (the drift DEV-UI-011 now errs on). Legacy mode // (no pageSpec) keeps the `{module}.{section}` derivation. A ≥3-segment // spec permission whose prefix roots elsewhere is honoured verbatim (the // pagespec is the SSOT) with a loud warning — the authoring gates that own // the judgment are PRD-071 (permission shape, err) and PRD-043 (permission // resolves in rbac.md, err); PRD-128 covers ACTION permissions only. const specPermParts = spec.pageSpec?.permission?.split('.') ?? [] const permKey = specPermParts.length >= 3 ? specPermParts.slice(0, -1).join('.') : `${spec.module}.${spec.section}` if (specPermParts.length >= 3 && permKey !== `${spec.module}.${spec.section}` && !permKey.startsWith(`${spec.module}.${spec.section}.`)) { ctx.warnings?.push( `scaffold-component: ${spec.entity} pagespec permission "${spec.pageSpec!.permission}" roots outside ` + `${spec.module}.${spec.section} — guards emitted on "${permKey}.*" as authored. Verify the pagespec ` + `(PRD-055) if this is not deliberate.`, ) } // `spec.section` is kebab-case (URL segment), but `scaffold-routes` emits the // `routes` object with camelCase property keys (`typesAffaire`, not `types-affaire`) // — so any `routes.
` reference in generated TSX must use the same // transform. Without it, `routes.types-affaire.detail(item.id)` is parsed by // JS as `routes.types - affaire.detail(item.id)` (subtraction), throwing // `ReferenceError: affaire is not defined` at the first interaction. const sectionCamel = toCamel(spec.section) // ─── Cross-view navigation gate ────────────────────────────────────────── // Which sibling views EXIST for this entity. Declared source: // spec.entityViews (the entity's full pagespec view set — the 3a // orchestrator fans out per single view, so spec.views alone cannot say), // falling back to spec.views (batch callers pass the full set). When the // module's OWN Routes.ts is readable (regen — ctx.routesFamilies), its // helper list is intersected in: a nav emitted toward a helper // scaffold-routes did not declare is TS2339 at build — the list-only-entity // break (routes.X.edit does not exist on type '{ readonly list }'). const entityViews = new Set(spec.entityViews ?? spec.views) const ownFamily = ctx.routesFamilies?.[extensionsModuleId(spec.appCode, spec.module)]?.[sectionCamel] const canNav = (helper: 'detail' | 'edit' | 'create', declaringView: 'detail' | 'form'): boolean => { if (!entityViews.has(declaringView)) return false if (ownFamily !== undefined && !ownFamily.includes(helper)) { ctx.warnings?.push( `scaffold-component: ${spec.entity} declares view '${declaringView}' but the emitted ` + `${spec.module} Routes.ts family '${sectionCamel}' has no '${helper}()' helper — ` + `navigation suppressed. Re-run scaffold-routes with the entity's full views ` + `('form' expands to create+edit).`, ) return false } return true } const navDetail = canNav('detail', 'detail') const navEdit = canNav('edit', 'form') const navCreate = canNav('create', 'form') // ─── Mobile/offline behaviour (SSOT lib/pwa-meta.ts) ───────────────────── // pageSpec.pwa wins over the top-level mirror. 'read' → mutation controls // disabled offline (useOnlineStatus) + stale-data hint; 'write' → controls // stay LIVE (the apiClient outbox captures the mutation with an optimistic // 202) and the page header shows the outbox queue chip instead. 'none' → // every snippet below is empty and the output stays byte-identical. const pwaMeta = spec.pageSpec?.pwa ?? spec.pwa const offlineLevel = normalizeOffline(pwaMeta?.offline) // ─── Mobile kit (package components, safe-rendered on desktop) ─────────── // Only a page the mobile shell actually resolves gets the kit: 'adapted' is // the sole generatable support level ('desktop-only' is never rendered // there, 'full' is rejected by validatePwaMetaV1). The LIST view gains two // affordances: MobileEmptyState in place of the table's plain empty row, and // a MobileFab doubling the primary create button in the thumb zone. // The FAB is `position: fixed`, so it is render-gated on useViewportMode() // === 'mobile' — it must never float over the desktop layout. On an // offline-READ page the gate also requires isOnline: MobileFab exposes no // `disabled` prop, and hiding is the stricter reading of DEV-PWA-006. On an // offline-WRITE page it stays live — DEV-PWA-010 forbids degrading writes. const mobileKit = pwaMeta?.support === 'adapted' const mobileKitImport = mobileKit ? ', MobileEmptyState, MobileFab, useViewportMode' : '' const viewportModeDecl = mobileKit ? '\n const viewportMode = useViewportMode()' : '' // Prefix of the list's table expression: on a mobile-adapted page an EMPTY // (settled) list renders the package MobileEmptyState instead of the table; // loading now flows INTO the table (skeleton rows), never a spinner branch. const mobileEmptyBranch = mobileKit ? `!isLoading && filtered.length === 0 ? ( ) : ` : '' const mobileFabGate = offlineLevel === 'read' ? `viewportMode === 'mobile' && isOnline` : `viewportMode === 'mobile'` const mobileFabJsx = mobileKit && navCreate ? ` {${mobileFabGate} && ( navigate(routes.${sectionCamel}.create())} /> )}` : '' // Resource key = the componentKey root — the SAME key the entity's outbox // specs (scaffold-api-client) and useOutboxOverlay fold by. const outboxResource = `${spec.appCode.toLowerCase()}.${spec.module}.${spec.section}` const onlineStatusImport = offlineLevel === 'read' ? ', useOnlineStatus' : '' const onlineStatusDecl = offlineLevel === 'read' ? '\n const isOnline = useOnlineStatus()' : '' const outboxChipImport = offlineLevel === 'write' ? `import { OutboxStatusChip } from '@/components/pwa/OutboxStatusChip'\n` : '' const outboxChipJsx = offlineLevel === 'write' ? ` ` : '' const offlineStaleBanner = offlineLevel === 'read' ? ` {!isOnline && (
{t('${eLower}.offline.staleData')}
)} ` : '' const offlineFormBanner = offlineLevel === 'read' ? ` {!isOnline && (
{t('${eLower}.offline.formUnavailable')}
)} ` : '' // Backend entity carries an IVersionedEntity rowversion (offline-write 409 // path) — the form echoes the loaded rowVersion in the update payload so the // server can detect a stale concurrent edit. Independent of offlineLevel: // a versioned entity echoes its token even on online-only forms. const versioned = spec.versioned === true // List file uses the plural entity name (BudgetsListPage.tsx) to match the // existing project convention; detail/form/dashboard stay singular. const listFileName = `${plural}ListPage.tsx` // Form fields exclude computed attributes — they are read-only, projected // by the backend, and must NEVER appear as inputs. // UI-design judgment overlay (architecture C): a /ui-design pass persists its // decisions in pageSpec.uiDesign (passed through verbatim by ba-develop Phase 3a). // Apply them OVER the per-field defaults here so the SAME judgment renders on every // regen — deterministic, no LLM in the loop. Absent → plain defaults stand. // First-order pageSpec.sections[] membership, then BA tab groups // (pageSpec.tabs on a form view), seed each field's `section` FIRST — the // overlay (the explicit judgment) still has the last word. `sectionMeta` // carries the resolved card order + labels (uiDesign.sections > sections[]). const uiOverlay = pickUiDesignOverlay(spec.pageSpec) const { fields: seededFields, order: sectionMeta } = resolveSections( spec.fields.filter(f => !f.formula), spec.pageSpec, uiOverlay) const overlaidFields = applyUiDesignOverlay(seededFields, uiOverlay) // First-order lifecycle block (lib/page-spec-lifecycle.ts): seed the phase / // requiredInPhase pivots AFTER the overlay (membership final), then // synthesize the status guards — an owned field gets its phase's statuses // compiled into `visibleWhen` (an authored predicate always wins, never // stacked) and a phase-required field gets the same predicate as the // validation-only `requiredWhen`. When the statusField is not on the form // the guards cannot read formData → no synthesis, owned fields degrade to // bare edit-mode gates (validate.ts warns; PRD-120 errs upstream). // Absent block → identity, byte-identical legacy render. const lifecycle = resolveLifecycle(overlaidFields, spec.pageSpec) const formFields = lifecycle.fields.map(f => { const eff = lifecycle.effects.get(fieldToCamel(f.name)) if (!eff || !lifecycle.statusFieldOnForm || !(eff.statuses?.length)) return f const pred = lifecycleVisibleWhen(lifecycle.statusField!, eff.statuses) return { ...f, ...(eff.owned && !f.visibleWhen ? { visibleWhen: pred } : {}), ...(eff.requiredInPhase && !f.requiredWhen ? { requiredWhen: pred } : {}), } }) const formLayout = uiOverlay?.formLayout ?? spec.formLayout const headerField = spec.fields.find(f => !f.formula) ?? spec.fields[0] // ─── Detail header title (the fiche's identity) ────────────────────────── // Cascade — the historical single rule ("first non-formula field in spec // order") titled a driver's fiche with their MOBILE NUMBER because entité.md // listed Mobile first. The identity the BA already authored wins instead: // 1. person.identityFields (composed: name-ish fields joined, the rest — // email — as the fallback chain) — the pagespec's own statement of what // identifies the record; // 2. summary.titleField (pagespec / uiDesign overlay) — the summary band's // title is the fiche's title; // 3. the FIRST field of the FIRST tab — the identifier the BA put first; // 4. a name-shaped field (code/label/name/libelle/title/titre); // 5. the legacy fallback (first non-formula field). // Entries resolve camel-insensitively on fields[]; an unresolvable arm falls // through. The result is the FULL initializer expression over `data`. const fieldCamelSet = new Set(spec.fields.map(f => fieldToCamel(f.name))) const resolveCamel = (name: unknown): string | undefined => { if (typeof name !== 'string' || name.length === 0) return undefined const camel = fieldToCamel(name) return fieldCamelSet.has(camel) ? camel : undefined } const headerTitleExpr = ((): string => { const ps = spec.pageSpec as undefined | { person?: { identityFields?: unknown[] } summary?: { titleField?: unknown } tabs?: Array<{ fields?: unknown[] }> } const identity = (ps?.person?.identityFields ?? []).map(resolveCamel).filter((c): c is string => c !== undefined) if (identity.length > 0) { const nameParts = identity.filter(c => !/mail/i.test(c)) const rest = identity.filter(c => /mail/i.test(c)) const joined = nameParts.length > 0 ? `[${nameParts.map(c => `data.${c}`).join(', ')}].filter(Boolean).join(' ')` : '' const chain = [joined, ...rest.map(c => `data.${c}`), `'—'`].filter(Boolean).join(' || ') return `String(${chain})` } const summaryTitle = resolveCamel((uiOverlay?.detail?.summary as { titleField?: unknown } | undefined)?.titleField) ?? resolveCamel(ps?.summary?.titleField) if (summaryTitle !== undefined) return `String(data.${summaryTitle} ?? '')` const firstTabField = resolveCamel(ps?.tabs?.[0]?.fields?.[0]) if (firstTabField !== undefined) return `String(data.${firstTabField} ?? '')` // Same "which field NAMES a row" cascade as the backend's RefDto display // (lib/display-field, SSOT): a local list here would drift from it — the // detail title and the lookup label would designate the same row by two // different fields (§26). const displayish = spec.fields.find( f => !f.formula && DISPLAYISH_RE.test(fieldToCamel(f.name)), ) if (displayish) return `String(data.${fieldToCamel(displayish.name)} ?? '')` return `String(data.${fieldToCamel(headerField?.name ?? 'id')} ?? '')` })() const initialFormObj = `{\n${formFields.map(f => ` ${fieldToCamel(f.name)}: ${defaultForField(f)},`).join('\n')}\n }` // ─── Shared custom-action infrastructure ───────────────────────────────── // Hoisted here so list, detail, AND form blocks can all access it. type PageActionMeta = { code: string kind?: 'api' | 'navigate' scope: 'header' | 'row' | 'bulk' labelKey: string permission: string variant?: string targetRoute?: string targetScreen?: string endpoint?: string httpMethod?: string ucReference?: string } const STANDARD_CRUD_CODES = new Set(['create', 'edit', 'update', 'delete', 'list', 'detail', 'read']) const isNavigate = (a: PageActionMeta): boolean => { if (a.kind === 'navigate') return true if (a.kind === 'api') return false return a.code === 'open' && a.scope === 'row' } const isApi = (a: PageActionMeta): boolean => !isNavigate(a) // A row action that navigates to THIS section's detail is redundant with the // table's onRowClick (which already opens the detail row). Drop it so the row // keeps only Edit/Delete + genuine actions. A navigate action with an explicit // targetRoute that is NOT the detail (e.g. a related entity) is kept. const isRedundantRowNav = (a: PageActionMeta): boolean => a.scope === 'row' && isNavigate(a) && (!a.targetRoute || /\.detail\b/.test(a.targetRoute)) const hookCodeOf = (a: PageActionMeta): string => a.endpoint ?? a.code const toPascal = (code: string): string => code.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') const ICON_FOR_CODE: Record = { open: 'ExternalLink', close: 'XCircle', archive: 'Archive', restore: 'ArchiveRestore', duplicate: 'Copy', approve: 'Check', reject: 'X', validate: 'CheckCircle', cancel: 'XCircle', submit: 'Send', 'submit-for-review': 'Send', activate: 'Power', deactivate: 'PowerOff', assign: 'UserPlus', unassign: 'UserMinus', export: 'Download', import: 'Upload', print: 'Printer', share: 'Share2', lock: 'Lock', unlock: 'Unlock', 'toggle-actif': 'ToggleRight', 'set-availability': 'CalendarCheck', 'map-to-pce': 'ArrowRightLeft', 'core-alignment': 'GitMerge', 'analyze-impact': 'Search', 'sync-from-pce': 'RefreshCw', 'sync-from-swisstopo': 'MapPin', 'open-year': 'CalendarPlus', 'close-year': 'CalendarMinus', 'publish-to-pce': 'Upload', 'detect-anomalies': 'AlertTriangle', 'bulk-map': 'Layers', } // PRD/pagespec action codes are camelCase (`toggleActif`, `mapToPce`) but the // ICON_FOR_CODE keys are kebab-case (`toggle-actif`, `map-to-pce`). Normalise // before lookup so multi-word codes resolve to their intended icon instead of // silently falling back. const camelToKebab = (s: string): string => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2').toLowerCase() // Keyword net behind the exact table: business action codes are free-form // (`constaterEcheances`, `pipelineDesPropositions`), so an exact table can // never be complete. Tokens are diacritic-stripped kebab fragments matched by // PREFIX (FR + EN stems, verbs before nouns); the first dictionary hit wins. // Every icon name here must exist in lucide-react — it lands in the page's // import line verbatim. const KEYWORD_ICONS: ReadonlyArray = [ ['export', 'Download'], ['import', 'Upload'], ['imprim', 'Printer'], ['print', 'Printer'], ['sync', 'RefreshCw'], ['refresh', 'RefreshCw'], ['actualis', 'RefreshCw'], ['envoi', 'Send'], ['send', 'Send'], ['relanc', 'Send'], ['valid', 'CheckCircle'], ['approuv', 'Check'], ['constat', 'ClipboardCheck'], ['clotur', 'CheckCircle2'], ['duplic', 'Copy'], ['copi', 'Copy'], ['archiv', 'Archive'], ['assign', 'UserPlus'], ['affect', 'UserPlus'], ['calcul', 'Calculator'], ['recalc', 'Calculator'], ['compute', 'Calculator'], ['gener', 'Sparkles'], ['echeance', 'CalendarClock'], ['deadline', 'CalendarClock'], ['planif', 'CalendarDays'], ['schedul', 'CalendarDays'], ['pipeline', 'Workflow'], ['kanban', 'Workflow'], ['cout', 'Coins'], ['cost', 'Coins'], ['tarif', 'Coins'], ['prix', 'Coins'], ['price', 'Coins'], ['factur', 'Receipt'], ['invoice', 'Receipt'], ['rappel', 'Bell'], ['remind', 'Bell'], ['notif', 'Bell'], ['histor', 'History'], ['journal', 'History'], ['referen', 'BookOpen'], ['rapport', 'FileText'], ['report', 'FileText'], ] const stripDiacritics = (s: string): string => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '') const keywordIconFor = (code: string): string | undefined => { const tokens = camelToKebab(stripDiacritics(code)).split(/[-_.]/).filter(Boolean) for (const [stem, icon] of KEYWORD_ICONS) { if (tokens.some(tk => tk.startsWith(stem))) return icon } return undefined } /** * Icon for a custom action: exact table → keyword net → KIND-aware fallback. * The old fallback was a meaningless `Circle` — the bare ring every unmapped * business action shipped with. `ArrowUpRight` reads as "takes you somewhere" * (navigate), `Zap` as "does something" (api). Neither is `ChevronRight`, * which reads as row navigation (the historical bogus `>` buttons). */ const iconForAction = (a: PageActionMeta): string => ICON_FOR_CODE[a.code] ?? ICON_FOR_CODE[camelToKebab(a.code)] ?? ICON_FOR_CODE[a.code.toLowerCase()] ?? keywordIconFor(a.code) ?? (isNavigate(a) ? 'ArrowUpRight' : 'Zap') const allCustomActions = ((spec.pageSpec?.actions ?? []) as PageActionMeta[]) .filter(a => !STANDARD_CRUD_CODES.has(a.code.toLowerCase())) /** * Render a header action button JSX string (used by list, detail and form — * the bulk toolbar still maps it directly; header-scope actions go through * renderHeaderActionsCluster below). `testId` anchors the button * (`detail-action-`, DEV-UI-032); `visibilityClass` is the Priority+ * lever (`hidden lg:inline-flex` on promoted buttons). Both guards are * `typeof === 'string'` on purpose: `.map(renderHeaderActionButton)` passes * the array index / array as extra args — filtered out here so the bulk * output stays byte-identical. */ const renderHeaderActionButton = (a: PageActionMeta, testId?: string, visibilityClass?: string): string => { const Icon = iconForAction(a) const isPrimary = a.variant === 'primary' const base = isPrimary ? 'btn btn-primary' : 'btn btn-secondary' const className = typeof visibilityClass === 'string' ? `${base} ${visibilityClass}` : base const testIdAttr = typeof testId === 'string' ? `\n data-testid="${testId}"` : '' return ` ` } /** * Priority+ header cluster — THE shared rendering of header-scope custom * actions (list, detail and form). The mainstream page-header contract * (PatternFly / Polaris): at most HEADER_VISIBLE_BUDGET actions stay visible * text buttons at >= lg (primary variants first, authored order otherwise); * EVERY action also rides a item, the promoted ones * flagged so the menu only carries them below lg where the buttons are * `hidden`. The menu unmounts itself at desktop width when nothing overflows * (<= budget actions) — no "…" next to two visible buttons. * `testIdFor` (detail path) anchors a PROMOTED action on its button and an * OVERFLOWED one on its menu item — exactly one node per testid at any width. * Callers must import HeaderActionsMenu when this returns a non-empty string. */ const HEADER_VISIBLE_BUDGET = 2 const renderHeaderActionsCluster = (actions: PageActionMeta[], testIdFor?: (a: PageActionMeta) => string): string => { if (actions.length === 0) return '' const ordered = [...actions].sort((x, y) => Number(y.variant === 'primary') - Number(x.variant === 'primary')) const buttons = ordered.slice(0, HEADER_VISIBLE_BUDGET) .map(a => renderHeaderActionButton(a, testIdFor?.(a), 'hidden lg:inline-flex')) .join('\n') const items = ordered.map((a, i) => { const Icon = iconForAction(a) const promoted = i < HEADER_VISIBLE_BUDGET const parts = [ `key: '${a.code}'`, `label: t('${eLower}.${a.labelKey}')`, `icon: <${Icon} className="w-4 h-4" />`, `permission: '${a.permission}'`, ] if (promoted) parts.push('promoted: true') if (a.variant === 'danger') parts.push('danger: true') if (!promoted && testIdFor) parts.push(`testId: '${testIdFor(a)}'`) parts.push(`onClick: () => { void handle${toPascal(a.code)}() }`) return ` { ${parts.join(', ')} },` }).join('\n') return `${buttons} ` } // ─── Custom-action payload dialogs (shared by list / detail / form) ─────── // An action carrying payloadParameters[] opens a that // collects the inputs, then fires the mutation with the assembled payload — // instead of a no-op button (transferer/fusionner target picker, joindreDocument // file upload). Lifted to generate() scope so the list, detail AND form emitters // render the same dialog (payload actions used to work on the list view only). type PayloadParam = { name: string; type: string; labelKey?: string; required?: boolean; field?: string; entity?: string; module?: string; apiEndpoint?: string; navRoute?: string; accept?: string; options?: Array<{ value: string; labelKey?: string }> } const paramsOf = (a: PageActionMeta): PayloadParam[] => ((a as { payloadParameters?: PayloadParam[] }).payloadParameters ?? []) const isPayloadAction = (a: PageActionMeta): boolean => paramsOf(a).length > 0 // Param label + option keys live in SIBLING branches of the button label // (`actionParams` / `actionParamOptions`, derived by actionParamBases — the // same bases buildActionParamsFloor floors), never nested UNDER the label // leaf: `.params.

` could not coexist with the label in the // JSON, so dialogs showed raw camelCase param names. An explicit legacy // labelKey override goes through normalizeI18nKey → same remapped path on // both the TSX and the catalogue side. const dialogParamsExpr = (a: PageActionMeta): string => { const bases = actionParamBases(a.labelKey) return `[${paramsOf(a).map(p => { // A param bound to an entity attribute (`field` — the lifecycle // action↔field binding) reuses the SHARED `form.fields.` label // (authored per PRD-106) instead of minting a second actionParams.* key // for the same business datum. An explicit labelKey still wins. const labelKey = p.labelKey ? normalizeI18nKey(p.labelKey) : (p.field ? `form.fields.${p.field}` : `${bases.params}.${p.name}`) const parts = [`name: '${p.name}'`, `type: '${p.type}'`, `label: t('${eLower}.${labelKey}', { defaultValue: '${p.name}' })`] if (p.required) parts.push('required: true') if (p.type === 'lookup' && p.entity) { // Resolution order: explicit pagespec apiEndpoint > the non-standard // Core adapter (TenantOrganisation) > the Core SSOT endpoint // (lib/core-catalog.coreLookupEndpointFor — the SAME derivation the FK // channel uses) > the RESOLVED navRoute (spliced by the orchestrator // from derive-action-specs' dialogLookupParams — the [NavRoute] // mirror, §28) > the target module's conventional lookup (LAST resort // — a reconstructed `{module}/{english-plural}` was wrong 9/9 on one // project; audit-dev-wire flags any literal matching no backend // route). The historical fallback used the PAGE's module for a Core // target: the list filter called /api/core/offices/lookup while the // « transférer » action invented /api/parc/offices/lookup — two // routes, one catalogue. An EXPLICIT non-core module keeps its say (a // module may legitimately own an entity shadowing no Core name). const fk = { entity: p.entity, module: p.module ?? spec.module, ...(p.navRoute ? { navRoute: p.navRoute } : {}) } const adapter = coreNonStandardLookup(fk) const coreEndpoint = (p.module === undefined || p.module === 'core') ? coreLookupEndpointFor(p.entity) : null const endpoint = p.apiEndpoint ?? (adapter ? adapter.endpoint : null) ?? coreEndpoint ?? defaultLookupEndpoint(fk) parts.push(`apiEndpoint: '${endpoint}'`) } if (p.type === 'file' && p.accept) parts.push(`accept: '${p.accept}'`) if (p.type === 'select' && p.options) { parts.push(`options: [${p.options.map(o => `{ value: '${o.value}', label: t('${eLower}.${o.labelKey ? normalizeI18nKey(o.labelKey) : `${bases.options}.${p.name}.${o.value}`}', { defaultValue: '${o.value}' }) }`).join(', ')}]`) } return `{ ${parts.join(', ')} }` }).join(', ')}]` } return { spec, ctx, ICON_FOR_CODE, STANDARD_CRUD_CODES, allCustomActions, base, camelToKebab, canNav, dialogParamsExpr, e, eLower, entityViews, featurePath, formFields, formLayout, headerField, headerTitleExpr, hookCodeOf, iconForAction, initialFormObj, isApi, isNavigate, isPayloadAction, isRedundantRowNav, listFileName, mobileEmptyBranch, mobileFabGate, mobileFabJsx, mobileKit, mobileKitImport, navCreate, navDetail, navEdit, offlineFormBanner, offlineLevel, offlineStaleBanner, onlineStatusDecl, onlineStatusImport, outboxChipImport, outboxChipJsx, outboxResource, ownFamily, paramsOf, pathFor, permKey, plural, pwaMeta, renderHeaderActionButton, renderHeaderActionsCluster, sectionCamel, sectionMeta, seededFields, toPascal, uiOverlay, versioned, viewportModeDecl, } } export type RenderContext = ReturnType