/**
* cli:scaffold-component — render/detail.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, ComponentField } from '../types.js'
import { extensionsModuleId } from '../../../../../../lib/app-classification.js'
import { parseRelatedTabs, relatedAppOf, relatedExtensionsId, relatedPluralOf, relatedRouteFamilyOf, relatedTabPlacementOf, requiresAvailabilityGuard, type PageRelatedTab } from '../../../../../../lib/page-spec-related-tabs.js'
import { resolveSections, type ResolvedSection } from '../../../../../../lib/page-spec-sections.js'
import { lifecycleVisibleWhen, resolveLifecycle } from '../../../../../../lib/page-spec-lifecycle.js'
import { FormControl, GENERATED_MARKER, applyUiDesignOverlay, compileVisibleWhen, controlOf, coreNonStandardLookup, fieldToCamel, formDataTypeLiteral, humanize, isEditable, isFkField, lookupHookImportLines, renderFormSections, renderReadValueExpr, resolveFicheSectionKeys, toCamel } from './shared.js'
import { pickUiDesignOverlay, resolveEditMode } from '../../../../../../lib/ui-design-overlay.js'
import { resolveEditSurface } from '../../../../../../lib/edit-surface.js'
import type { RenderContext } from './context.js'
export function renderDetail(rc: RenderContext): GeneratedFile[] {
const files: GeneratedFile[] = []
const { spec, ctx, allCustomActions, dialogParamsExpr, e, eLower, entityViews, featurePath, formFields, formLayout, headerTitleExpr, hookCodeOf, iconForAction, initialFormObj, isApi, isNavigate, isPayloadAction, navEdit, offlineLevel, onlineStatusDecl, onlineStatusImport, outboxChipImport, outboxChipJsx, pathFor, permKey, renderHeaderActionsCluster, sectionCamel, sectionMeta, toPascal, uiOverlay, versioned } = rc
// ─── DetailPage ──────────────────────────────────────────────────────────
if (spec.views.includes('detail')) {
// Tab support: pageSpec.tabs[] groups the entity's OWN fields into panels;
// pageSpec.relatedTabs[] adds 360 tabs embedding entities IN RELATION
// (Client ⟷ Factures), each fetching through the FK filter
// (`?{relationFk}={id}`) and gated by its own PermissionGuard. Both
// collapse into ONE URL-synced strip (?tab=key). Otherwise render flat.
type TabMeta = { key: string; labelKey?: string; label?: string; fields?: string[] }
const fieldTabsRaw = (spec.pageSpec?.tabs ?? []) as TabMeta[]
const { tabs: relatedTabs, rejected: rejectedRelatedTabs } = parseRelatedTabs(spec.pageSpec?.relatedTabs)
if (rejectedRelatedTabs.length > 0) {
// Fail loud: a malformed related tab silently dropped is exactly the
// drift PRD-104 / DEV-UI-031 exist to prevent. ba-develop auto-heal
// routes this message back to the pagespec.
throw new Error(
`scaffold-component: entity '${e}' has invalid pageSpec.relatedTabs entries — `
+ rejectedRelatedTabs.map(r => `#${r.index}: ${r.issues.join('; ')}`).join(' | '),
)
}
// ── Band/strip partition (placement — lib relatedTabPlacementOf) ────────
// `summary` tabs default to the BAND: always-mounted count cartouches in a
// row above the strip, occupying NO tab trigger. An explicit
// `placement: 'tab'` opts a summary back into the strip (rare).
const bandTabs = relatedTabs.filter(t => relatedTabPlacementOf(t) === 'band')
const stripTabs = relatedTabs.filter(t => relatedTabPlacementOf(t) === 'tab')
const hasStripRelated = stripTabs.length > 0
// ── Unified fiche (« fiche unique, édition en place ») ──────────────────
// The view-set carries detail AND form without the 'direct' opt-out → this
// DetailPage IS the edit surface: the entity's own fields render as
// read-first section cards toggling to edit in place (the tabs[] grouping
// seeds the sections through resolveSections), the FormPage shrinks to
// create-only, and `/edit` routes here with every section opened.
const unified = resolveEditSurface(entityViews, resolveEditMode(spec.pageSpec, uiOverlay)) === 'unified'
// STRIP-placed related tabs without field tabs: the entity's own fields
// regroup under a synthetic "info" tab so the strip always has a home for
// the fiche itself. A band-only page gets NO synthetic tab and NO strip.
// Unified fiche: NO field tabs at all — the strip carries only the 360
// related tabs, the own fields live above it as section cards.
const detailTabs: TabMeta[] = unified
? []
: hasStripRelated && fieldTabsRaw.length === 0
? [{ key: 'info', labelKey: 'detail.tabs.info' }]
: fieldTabsRaw
// The collision guard scans ALL related tabs (band included): a band key
// still owns the i18n family and the child-component name, and flipping
// its placement back must never surface a new collision.
const dupTabKeys = relatedTabs.filter(rt => detailTabs.some(ft => ft.key === rt.key)).map(rt => rt.key)
if (dupTabKeys.length > 0) {
throw new Error(`scaffold-component: entity '${e}' declares tab keys used by BOTH tabs[] and relatedTabs[]: ${dupTabKeys.join(', ')}`)
}
const hasDetailTabs = detailTabs.length > 0 || hasStripRelated
// A tab's fields[] are pagespec keys (camelCase) while spec.fields carry
// the entité.md casing (PascalCase in the real pipeline) — membership is
// camel-insensitive on BOTH sides, like resolveSections. A strict compare
// here shipped every tabpanel with an empty
(77 declared fields, zero
// rendered) because the two casings never met.
const resolveTabFields = (tab: TabMeta): ComponentField[] => {
if (!tab.fields) return spec.fields
const wanted = new Set(tab.fields.map(fieldToCamel))
return spec.fields.filter(f => wanted.has(fieldToCamel(f.name)))
}
// OWN fields the detail body actually renders: the union of the tab panels
// when tabs[] partition the fiche, every field otherwise (sectioned and
// flat bodies render them all). The FK lookup hooks below are scoped to
// this set — a hook for an unrendered FK is a dead binding (TS6133 under
// noUnusedLocals) that pays a lookup request for nothing.
const detailRenderedNames: ReadonlySet = !unified && hasDetailTabs
? new Set(detailTabs.flatMap(t => resolveTabFields(t).map(f => f.name)))
: new Set(spec.fields.map(f => f.name))
const detailExtraImport = `${hasDetailTabs ? ', useSearchParams' : ''}${unified ? ', useLocation' : ''}`
// Data (columns / display field) for each related tab — supplied by
// ba-develop Phase 3a from the RELATED entity's list pagespec. A missing
// entry fails open on a single createdAt column (validate-page warns).
const relatedDataByKey = new Map((spec.relatedTabsData ?? []).map(d => [d.key, d]))
// The routes registry a tab navigates through. Same app + same module → the page's own
// `routes`; same app, another module → the historical `{module}Routes` (output stays
// byte-identical for every pre-existing pagespec); ANOTHER application → the app-scoped
// `{app}{Module}Routes`, because two applications may ship the same module code and a
// module-only name would collide on the second import.
const relatedRoutesExpr = (tab: PageRelatedTab) => {
const app = relatedAppOf(tab, spec.appCode)
if (app !== spec.appCode.toLowerCase()) return `${toCamel(relatedExtensionsId(tab, spec.appCode))}Routes`
return tab.relatedModule === spec.module ? 'routes' : `${toCamel(tab.relatedModule)}Routes`
}
// ── 360 related-tab ROUTING resolution (the sub-view mis-routing guard) ──
// The family a tab navigates through is DATA: pagespec `relatedRouteFamily`
// with the legacy `relatedSection` fallback (lib/relatedRouteFamilyOf). It
// is verified against the target module's generated *Routes.ts when
// readable (ctx.routesFamilies): an unknown family is a HARD error —
// emitting a navigation that happens to compile against another family
// (the porteur's) is exactly the silent mis-routing this gate closes.
// Create button and row-click are then emitted only when the create()/
// detail() helpers actually resolve (Routes.ts first, relatedTabsData
// signals as the pre-routes fallback, legacy `true` + warning when no
// signal exists at all).
interface RelatedTabRouting {
familyCamel: string
routesExpr: string
withCreate: boolean
withRowOpen: boolean
createPerm: string
}
const relatedRouting = new Map()
for (const tab of relatedTabs) {
const familyCamel = toCamel(relatedRouteFamilyOf(tab))
const modFamilies = ctx.routesFamilies?.[relatedExtensionsId(tab, spec.appCode)]
let helpers: string[] | undefined
if (modFamilies !== undefined) {
helpers = modFamilies[familyCamel]
if (helpers === undefined) {
throw new Error(
`scaffold-component: related tab '${tab.key}' navigates through route family '${familyCamel}' `
+ `but src/extensions/${relatedExtensionsId(tab, spec.appCode)}Routes.ts declares no such family `
+ `(available: ${Object.keys(modFamilies).join(', ') || '(none)'}). `
+ `Fix the pagespec (relatedRouteFamily must copy the target list pagespec's routeFamily) `
+ `or re-run scaffold-routes for module '${tab.relatedModule}'.`,
)
}
} else {
ctx.warnings?.push(
`related tab '${tab.key}': ${relatedExtensionsId(tab, spec.appCode)}Routes.ts not loaded — `
+ `family '${familyCamel}' unverified (run scaffold-routes, then re-scaffold to verify)`,
)
}
const tabData = relatedDataByKey.get(tab.key)
const canCreate = helpers !== undefined ? helpers.includes('create') : tabData?.hasCreateForm
const canOpen = helpers !== undefined ? helpers.includes('detail') : tabData?.hasDetail
let withCreate: boolean
if (tab.withCreate === false) {
withCreate = false
} else if (tab.withCreate === true) {
if (canCreate === false) {
throw new Error(
`scaffold-component: related tab '${tab.key}' declares withCreate: true but no create form resolves for ${tab.relatedEntity} `
+ `(${helpers !== undefined ? `family '${familyCamel}' has no create() helper` : 'relatedTabsData.hasCreateForm is false'}). `
+ `Author "withCreate": false in the pagespec (audit journal / business-flow satellite) or scaffold the create form first.`,
)
}
withCreate = true
} else if (canCreate !== undefined) {
withCreate = canCreate
} else {
withCreate = true // legacy default — no explicit value, no signal
if (tab.displayMode !== 'summary') {
ctx.warnings?.push(
`related tab '${tab.key}': withCreate unresolved (no Routes.ts family, no relatedTabsData.hasCreateForm) — legacy default true`,
)
}
}
let withRowOpen: boolean
if (tab.withRowOpen === false) {
withRowOpen = false
} else if (tab.withRowOpen === true) {
if (canOpen === false) {
throw new Error(
`scaffold-component: related tab '${tab.key}' declares withRowOpen: true but no detail surface resolves for ${tab.relatedEntity} `
+ `(${helpers !== undefined ? `family '${familyCamel}' has no detail() helper` : 'relatedTabsData.hasDetail is false'}). `
+ `Author "withRowOpen": false (inert rows) or scaffold the detail page first.`,
)
}
withRowOpen = true
} else if (canOpen !== undefined) {
withRowOpen = canOpen
} else {
withRowOpen = true // legacy default
}
// Create permission: REUSED from the target list page's own create
// action (pagespec tab, else Phase 3a's relatedTabsData copy) — the
// recomposition can't guess resource-scoped shapes like
// `portfolio.list.work-package.create`.
const createPerm = tab.createPermission
?? tabData?.createPermission
?? `${tab.relatedModule}.${tab.relatedSection}.create`
if (
withCreate && tab.displayMode !== 'summary'
&& tab.createPermission === undefined && tabData?.createPermission === undefined
&& tab.relatedRouteFamily !== undefined && tab.relatedRouteFamily !== tab.relatedSection
) {
ctx.warnings?.push(
`related tab '${tab.key}': create permission recomposed as '${createPerm}' on a SUB-VIEW target — likely wrong `
+ `(real shape is often '{module}.{section}.{resource}.create'). Author createPermission on the pagespec tab.`,
)
}
relatedRouting.set(tab.key, {
familyCamel,
routesExpr: relatedRoutesExpr(tab),
withCreate,
withRowOpen,
createPerm,
})
}
// Package datetime helpers actually used by the detail page (own fields, field
// tabs and related tabs) — drives the '@atlashub/smartstack' import line.
const detailDateFns = new Set()
// Lifecycle on the READ surface: an owned later-phase field's row renders
// only while the record's status is in the phase (guard compiled over
// `data`) — a draft invoice's detail shows no "Payée le" row, mirroring the
// form. Only status-anchored phases guard; a statuses-less phase renders as
// today (em dash on empty). Resolved over ALL fields (the detail shows
// computed fields too) so the effects cover every branch below.
const detailLifecycle = resolveLifecycle(spec.fields, spec.pageSpec)
const detailRowGuard = (f: ComponentField): string | null => {
const eff = detailLifecycle.effects.get(fieldToCamel(f.name))
if (!eff?.owned || !(eff.statuses?.length) || !detailLifecycle.statusFieldOnForm) return null
return compileVisibleWhen(lifecycleVisibleWhen(detailLifecycle.statusField!, eff.statuses), 'data')
}
function renderFieldDl(fields: typeof spec.fields, skipLifecycleGuards = false): string {
return fields.map(f => {
const camel = fieldToCamel(f.name)
// Value resolution shared with the read-first form grid
// (renderReadValueExpr): enum/multiselect → option label, dates →
// platform datetime module (collected into detailDateFns), FK →
// lookup displayName, never the raw Guid.
const valueExpr = renderReadValueExpr(f, 'data', eLower, detailDateFns)
const item = `
{t('${eLower}.detail.fields.${camel}')}
${valueExpr}
`
const guard = skipLifecycleGuards ? null : detailRowGuard(f)
return guard ? ` {(${guard}) && (\n${item}\n )}` : item
}).join('\n')
}
// First-order sections on the DETAIL: same membership/metadata resolution
// as the form, WITHOUT the tabs seed (on a detail page `tabs[]` are panels,
// not section groups). The uiDesign per-field overrides still win, and the
// i18n keys are SHARED with the form (`form.section.`) — one label
// per section, two renders.
const { fields: detailSeededFields, order: detailSectionMeta } = resolveSections(
spec.fields, { sections: spec.pageSpec?.sections }, uiOverlay)
const detailFields = applyUiDesignOverlay(detailSeededFields, uiOverlay)
const detailMetaByKey = new Map(detailSectionMeta.map(m => [m.key, m] as const))
const hasDetailSections = detailFields.some(f => (f.section ?? '').trim() !== '')
const detailResolvedByName = new Map(detailFields.map(f => [f.name, f] as const))
/** Group fields by resolved section — metadata-ordered keys first, then
* first appearance (same contract as renderFormSections). */
function groupDetailFields(fields: typeof spec.fields): Array<{ key: string; fields: typeof spec.fields }> {
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)
}
const order: string[] = []
const seen = new Set()
for (const m of detailSectionMeta) {
if (groups.has(m.key) && !seen.has(m.key)) { order.push(m.key); seen.add(m.key) }
}
for (const k of firstAppearance) {
if (!seen.has(k)) { order.push(k); seen.add(k) }
}
return order.map(key => ({ key, fields: groups.get(key)! }))
}
function detailSectionTitleExprs(key: string): { labelKeyExpr: string; fallbackLabel: string; description?: string } {
const meta = detailMetaByKey.get(key)
return {
labelKeyExpr: meta?.labelKey ? `${eLower}.${meta.labelKey}` : `${eLower}.form.section.${toCamel(key)}`,
fallbackLabel: (meta?.label ?? humanize(key)).replace(/'/g, "\\'"),
description: meta?.description,
}
}
// ── Tenant-catalogue availability (the cross-module 360 guard) ──────────
// A tab pointing INTO another module — a fortiori another application — can
// only be shown where that module was actually delivered to the current
// tenant. The signal is the nav catalogue the package already holds
// (tenant_TenantApplications / TenantModules), read through the generated
// useModuleAvailability primitive. A tab on the page's OWN module is never
// guarded: the page itself would be unreachable if its module were missing,
// so every pre-existing pagespec keeps its exact output.
const guardedTabs = relatedTabs.filter(t => requiresAvailabilityGuard(t, spec.appCode, spec.module))
const availabilityConst = (tab: PageRelatedTab) => `show${e}Related${toPascal(tab.key)}`
const availabilityDecls = guardedTabs.length === 0 ? '' : `
// Cross-module 360 surfaces: rendered only where the target module is part of
// what THIS tenant was given (nav catalogue, not the license).
const moduleAvailability = useModuleAvailability()
${guardedTabs.map(t => ` const ${availabilityConst(t)} = moduleAvailability.hasModule('${relatedAppOf(t, spec.appCode)}', '${t.relatedModule}')`).join('\n')}
`
const availabilityImport = guardedTabs.length === 0
? ''
: `import { useModuleAvailability } from '@/components/ui/useModuleAvailability'\n`
const guardedKeys = new Set(guardedTabs.map(t => t.key))
let detailTabHooks = ''
let detailBodyJsx: string
if (hasDetailTabs) {
// Unified fiche: no field tabs — the first STRIP-placed related tab is
// the default (band cartouches are not activatable).
const defaultTab = (detailTabs[0] ?? stripTabs[0]).key
const validTabKeys = [...detailTabs.map(t => t.key), ...stripTabs.map(t => t.key)]
const stripGuarded = stripTabs.filter(t => guardedKeys.has(t.key))
// Without a guarded trigger the key set is fixed at build time and the legacy
// literal is emitted verbatim. With one, the set is only known at render time:
// a hidden tab must neither be selectable through ?tab= nor be the fallback,
// or the strip would show a body with no trigger to go back to.
detailTabHooks = stripGuarded.length === 0
? `
const [searchParams, setSearchParams] = useSearchParams()
// Stale-URL guard: a ?tab= pointing at a key that no longer has a trigger
// (e.g. a bookmark to a tab since moved to the band row) falls back to the
// default instead of rendering a strip with an empty body.
const tabParam = searchParams.get('tab')
const activeTab = tabParam !== null && [${validTabKeys.map(k => `'${k}'`).join(', ')}].includes(tabParam) ? tabParam : '${defaultTab}'
const switchTab = (tab: string) => {
const p = new URLSearchParams(searchParams)
tab === '${defaultTab}' ? p.delete('tab') : p.set('tab', tab)
setSearchParams(p, { replace: true })
}
`
: `
const [searchParams, setSearchParams] = useSearchParams()
// Stale-URL guard, extended to the tenant catalogue: a ?tab= pointing at a key
// with no trigger — removed from the strip, or belonging to a module this tenant
// was not given — falls back to the first tab that IS shown.
const visibleTabKeys = [${validTabKeys.map(k => `'${k}'`).join(', ')}].filter(
(k) => ${stripGuarded.map(t => `(k !== '${t.key}' || ${availabilityConst(t)})`).join(' && ')}
)
const tabParam = searchParams.get('tab')
const defaultTabKey = visibleTabKeys[0] ?? '${defaultTab}'
const activeTab = tabParam !== null && visibleTabKeys.includes(tabParam) ? tabParam : defaultTabKey
const switchTab = (tab: string) => {
const p = new URLSearchParams(searchParams)
tab === defaultTabKey ? p.delete('tab') : p.set('tab', tab)
setSearchParams(p, { replace: true })
}
`
const tabButtonJsx = (key: string, labelExpr: string, indent: string) => `${indent}`
const fieldTabBarJsx = detailTabs.map(tab => {
const label = tab.labelKey ? `t('${eLower}.${tab.labelKey}')` : `'${tab.label ?? tab.key}'`
return tabButtonJsx(tab.key, label, ' ')
}).join('\n')
// Related-tab triggers are permission-gated: an actor who cannot read
// the entity in relation never sees the tab (nor fires its fetch).
// Only STRIP-placed tabs get a trigger — band cartouches render above.
const relatedTabBarJsx = stripTabs.map(tab => {
const guard = guardedKeys.has(tab.key) ? availabilityConst(tab) : null
const button = tabButtonJsx(tab.key, `t('${eLower}.${tab.labelKey}')`, guard ? ' ' : ' ')
const inner = guard ? ` {${guard} && (\n${button}\n )}` : button
return `
${inner}
`
}).join('\n')
const tabBarJsx = [fieldTabBarJsx, relatedTabBarJsx].filter(Boolean).join('\n')
const fieldTabPanelsJsx = detailTabs.map(tab => {
const tabFields = resolveTabFields(tab)
// Sub-group the tab's fields by their resolved section when at least one
// carries one. Headers only (no nested card chrome — the panel IS the
// card); the value markup stays renderFieldDl verbatim.
const tabFieldsResolved = tabFields.map(f => detailResolvedByName.get(f.name) ?? f)
const hasSubSections = tabFieldsResolved.some(f => (f.section ?? '').trim() !== '')
const panelInner = hasSubSections
? `
)}`
}).join('\n\n')
// Related panels mount their child component ONLY while active — hooks
// live inside the child, so an inactive tab never fetches its data.
const relatedTabPanelsJsx = stripTabs.map(tab => ` {activeTab === '${tab.key}'${guardedKeys.has(tab.key) ? ` && ${availabilityConst(tab)}` : ''} && (
`
}
// ─── Unified fiche assembly ─────────────────────────────────────────────
// The fiche's own-field body: read-first section cards (renderFormSections,
// fiche mode — permission-driven toggles) + the dirty-gated save bar, all
// wrapped in ONE