/** * lib/page-spec-related-tabs.ts — Canonical Zod schema for the 360-view * related tabs of a detail page. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` (Onglet lié bullets) → * `pagespec.relatedTabs[]` → scaffold-component / scaffold-api-client / * scaffold-controller / scaffold-business, and for the audits that verify it * (SCR-009/014, PRD-103..105, DEV-UI-031, DEV-API-019). * * A related tab embeds, inside an entity's detail page, the records of ANOTHER * entity that is IN RELATION with it (Client ⟷ Factures, Contacts, Adresses…). * This is a relational concept — NOT a parent/child hierarchy: the related * entity keeps its own top-level list/routes and merely gains a filter on the * FK it carries toward the current entity. * * The fundamental invariant (mirrors page-spec-actions' endpoint invariant): * * relatedTab.relationFk === the wire query param on the list endpoint * === [FromQuery] Guid? on the controller * === Where(x => x. == …) in the handler * === use({ : id }) in the page * * The same camelCase name is propagated VERBATIM at every layer, so the tab's * fetch can never drift from the backend filter. * * @see business-analyse/create-screen/SKILL.md (authors the Onglet lié bullets) * @see business-analyse/create-screen/cli/derive-related-tabs (derives candidates + validates prerequisites) * @see business-analyse/create-prd/SKILL.md (propagates screen.md → pagespec.relatedTabs) * @see development/frontend/component/cli/scaffold-component (renders the tabs) * @see development/audit-dev-frontend/SKILL.md (DEV-UI-031 — pageSpec ↔ DetailPage) * @see development/audit-dev-api/SKILL.md (DEV-API-019 — FK filter triplet) * * Deliberately DEFERRED from v1 (do not add without the rendering surface): * - displayMode 'report' — there is no reporting surface to embed yet. * - summary aggregates (sum/avg) — v1 summary is count-only via totalCount. * - direct N:N tabs — model the junction entity in entité.md and tab on it. */ import { z } from 'zod' import { pluralize, toPascalCase } from './string-utils.js' import { extensionsModuleId } from './app-classification.js' /** * How a related tab renders its records. The BA skill (intelligence) picks the * mode per relation — « ça dépend : tableau (factures), cards (adresses), * cartouche de synthèse » — and the generator renders it deterministically: * - `table` → embedded ResponsiveDataTable (serverMode) filtered by the FK * - `cards` → card grid of the related records * - `summary` → count cartouche (KpiCard) + "view all" link */ export const RELATED_TAB_DISPLAY_MODES = ['table', 'cards', 'summary'] as const export type RelatedTabDisplayMode = (typeof RELATED_TAB_DISPLAY_MODES)[number] /** * Where a related tab renders on the detail page: * - `tab` → a trigger in the TabStrip + a lazily-mounted panel (historical). * - `band` → an always-mounted count cartouche in the 360 band row, between * the detail summary band and the strip — it occupies NO tab. * The default is DERIVED, never stored: `summary` → 'band', anything else → * 'tab'. Resolve ONLY through `relatedTabPlacementOf` / `placementForDisplayMode` * — never re-encode the default. */ export const RELATED_TAB_PLACEMENTS = ['tab', 'band'] as const export type RelatedTabPlacement = (typeof RELATED_TAB_PLACEMENTS)[number] /** * Fields of the current entity that are FK filter candidates: Guid-typed, * `Id`-suffixed, and not a system column. This is the SSOT detection used by * scaffold-business / scaffold-controller / scaffold-api-client to decide * which `[FromQuery] Guid? ` params the list endpoints expose — every FK * gets one, deterministically, whether or not a related tab consumes it yet. */ export const FK_FILTER_EXCLUDED_FIELDS = new Set([ 'id', 'tenantId', 'createdBy', 'updatedBy', 'deletedBy', ]) export function isFkFilterField(field: { name: string; type: string; fkTo?: unknown }): boolean { const type = field.type.toLowerCase() // A FK column is a Guid in the database. FRONTEND specs routinely declare it // `string` instead (the TS view of a Guid — see scaffold-component's own // `isFkShaped`, which accepts string, and the shipped fixture // `{ name: 'statusId', type: 'string', fkTo: {…} }`). Those fields used to // fall out of the FK channel entirely: no `[FromQuery] Guid?` param, no // api-client member — while the generated page still posted the value, so // the filter silently did nothing. Accept them, but ONLY when the field // carries a resolved `fkTo`: the reference is then PROVEN, never inferred // from the `…Id` suffix (a backend `string ExternalId` must stay out). const isGuid = type === 'guid' || type === 'uuid' const isResolvedStringFk = type === 'string' && field.fkTo != null if (!isGuid && !isResolvedStringFk) return false if (!/Id$/.test(field.name) || field.name.length <= 2) return false return !FK_FILTER_EXCLUDED_FIELDS.has(toCamelFirst(field.name)) } /** Filter + normalise an entity's fields down to its FK filter param names (camelCase). */ export function fkFilterFields(fields: Array<{ name: string; type: string; fkTo?: unknown }>): string[] { return fields.filter(isFkFilterField).map(f => toCamelFirst(f.name)) } function toCamelFirst(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) } /** * The wire name of the FK filter param for a given relationFk. Identity today — * this function exists so there is exactly ONE documented place stating that * the query-string key IS the camelCase FK field name (`?clientId=`), * mirrored by the C# `[FromQuery] Guid? clientId` parameter. */ export function fkQueryParam(relationFk: string): string { return relationFk } /** BA screen.md vocabulary → canonical pagespec vocabulary. */ function coerceRelatedTabAliases(raw: unknown): unknown { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return raw const tab = { ...(raw as Record) } if (tab.relationFk === undefined && typeof tab.relationship === 'string') { tab.relationFk = tab.relationship } if (tab.targetScreen === undefined && typeof tab.screenTarget === 'string') { tab.targetScreen = tab.screenTarget } return tab } const RelatedTabBaseSchema = z.object({ /** * Stable tab identifier in lower-camel/kebab (e.g. `invoices`, `billing-addresses`). * Drives the DOM id (`tab-{key}`), the i18n family (`detail.related.{key}.*`) * and the generated child-component name. */ key: z .string() .min(1) .regex(/^[a-z][a-zA-Z0-9-]*$/, 'key must be lower-camel or kebab-case'), /** Rendering mode — see RELATED_TAB_DISPLAY_MODES. Default `table`. */ displayMode: z.enum(RELATED_TAB_DISPLAY_MODES).default('table'), /** PascalCase name of the entity IN RELATION (e.g. `Invoice`). */ relatedEntity: z .string() .regex(/^[A-Z][A-Za-z0-9]*$/, 'relatedEntity must be PascalCase'), /** * camelCase FK field ON the related entity pointing toward the current * entity (e.g. `clientId` on Invoice). This exact string is the wire query * param and the C# `[FromQuery]` parameter — see the invariant above. */ relationFk: z .string() .regex(/^[a-z][a-zA-Z0-9]*$/, 'relationFk must be camelCase'), /** * Module + section of the RELATED entity, resolved by ba-create-prd from the * target list screen. Denormalized here so scaffold-component can build the * hook import (`@/features/{app}/{relatedModule}/{…}`) and the navigation * routes without any cross-pagespec read. */ relatedModule: z.string().min(1), relatedSection: z.string().min(1), /** * Kebab code of the business APPLICATION the related entity lives in * (`facturation`). ABSENT ⇒ the current pagespec's own `appCode` — so every * pre-existing pagespec keeps its exact meaning and its exact generated output. * * A generated project holds ONE React app hosting EVERY business application, so * an application is a path/URL segment, never a bundle: the hook import * (`@/features/{relatedApp}/{relatedModule}/…`) and the routes registry * (`@/extensions/{relatedApp}-{relatedModule}Routes`) both need this code, and * before it existed they were built from the CURRENT page's app — a tab pointing * at another application produced paths that do not exist and broke the build. * Resolve ONLY through `relatedAppOf` — never re-encode the fallback. */ relatedApp: z .string() .regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, 'relatedApp must be kebab-case') .optional(), /** * Escape hatch: `false` suppresses the runtime availability guard the generator * otherwise wraps a cross-module / cross-application tab in (see * `requiresAvailabilityGuard`). ABSENT ⇒ the guard is emitted whenever the tab * leaves its page's own `(app, module)`. * * Author `false` only when the target module is known to ship with the current * one in every deployment AND the tab must survive a menu the tenant catalogue * does not carry. It does NOT disable the permission gate. */ availabilityCheck: z.boolean().optional(), /** * The ROUTE FAMILY of the related entity in its module's generated * `*Routes.ts` — the kebab slug scaffold-routes keys the family with * (`routes.{camel(relatedRouteFamily)}.*`). Equal to `relatedSection` for a * normal one-entity section, and REQUIRED to differ on the sub-view pattern * (several list entities under one menu section, e.g. section `list` hosting * `work-packages`, `phases`, `tasks`): there `relatedSection` is the MENU * section while the navigation targets the satellite's own family. Copied by * ba-create-prd from the target list pagespec's `routeFamily` (see * `relatedRouteFamilyOf`). Absent ⇒ falls back to `relatedSection` (legacy * behaviour, correct only when section === family — the AtlasHub sub-view * mis-routing this field exists to prevent). */ relatedRouteFamily: z .string() .regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, 'relatedRouteFamily must be kebab-case') .optional(), /** * Exact PascalCase plural of the related entity (drives `use{RelatedPlural}` * and the service import). Optional — `relatedPluralOf()` falls back to the * english pluralizer, override when the domain plural is irregular. */ relatedPlural: z.string().regex(/^[A-Z][A-Za-z0-9]*$/).optional(), /** The related SmartListView screen code (row-click / view-all target). */ targetScreen: z .string() .regex(/^SCR-[A-Z0-9_-]+$/, 'targetScreen must be SCR-...') .optional(), /** * Permission gating the WHOLE tab (trigger + panel + fetch). Optional in the * schema; `normalizePageRelatedTab` defaults it to * `{relatedModule}.{relatedSection}.read` — a tab must never fetch data its * viewer cannot read. 3 segments (module.section.action) or 4 when * app-scoped (app.module.section.action). */ permission: z .string() .regex( /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,3}$/, 'permission must be `[app.]module.section.action` kebab-case', ) .optional(), /** * Permission gating the tab's "Créer" button — the EXACT permission the * target LIST page's own create action carries (copied verbatim by * ba-create-prd from that pagespec's `actions[]` entry `code:"create"`). * NEVER recomposed: the real-world shapes diverge (`portfolio.list. * work-package.create` — 4 segments with a resource axis — vs * `configuration.phases.create`), so recomposition from * `{relatedModule}.{relatedSection}.create` cannot guess it. Absent ⇒ the * generator falls back to `relatedTabsData[].createPermission` (Phase 3a * reads the target list pagespec), then to the legacy recomposition. */ createPermission: z .string() .regex( /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,3}$/, 'createPermission must be `[app.]module.section[.resource].action` kebab-case', ) .optional(), /** i18n key of the tab label. Defaults to `detail.related.{key}.label`. */ labelKey: z.string().min(1).optional(), /** * Emit a "Créer" button on the tab (table/cards only), navigating to the * related entity's create form with `?{relationFk}={currentId}` pre-filled. * NO implicit default any more (it used to be `true`): absent means "resolve * from reality" — the generator emits the button only when a create form is * actually resolvable for the related entity (a `create()` helper in the * target route family, else `relatedTabsData[].hasCreateForm`). Author an * explicit `false` for audit journals / satellites created by business flows * (versions, arbitrages, grid steps…); an explicit `true` with no resolvable * create form is a generation ERROR, never a broken button. */ withCreate: z.boolean().optional(), /** * Emit the row-click navigation to the related entity's detail page * (table rows / cards). Same resolution contract as `withCreate`: absent ⇒ * the generator keeps it only when a real detail surface exists (a * `detail()` helper in the target route family, else * `relatedTabsData[].hasDetail`); explicit `false` ⇒ inert rows (and no * orphan `useNavigate()` — TS6133 breaks the strict typecheck). */ withRowOpen: z.boolean().optional(), /** * True when the related entity lives in ANOTHER module's PRD. The generator * imports that module's routes registry; audit-prd (PRD-105) relaxes the * same-PRD entity check for these tabs. */ crossModule: z.boolean().default(false), /** * Where the tab renders — see RELATED_TAB_PLACEMENTS. ABSENT means "derive": * `summary` lands in the band row, `table`/`cards` in the strip. Author an * explicit `'tab'` to force a summary cartouche back into the strip (rare). * `'band'` with a non-summary displayMode is REJECTED at the schema (below): * a band cartouche is always mounted, so a table/cards surface there would * fetch row payloads on every detail-page load. */ placement: z.enum(RELATED_TAB_PLACEMENTS).optional(), }).passthrough().superRefine((tab, ctx) => { if (tab.placement === 'band' && tab.displayMode !== 'summary') { ctx.addIssue({ code: 'custom', path: ['placement'], message: "placement 'band' requires displayMode 'summary' — a band cartouche is always mounted; table/cards would fetch row payloads on every detail-page load", }) } }) /** * One related tab of a detail page. Accepts the BA aliases * (`relationship` → `relationFk`, `screenTarget` → `targetScreen`) so a * pagespec hand-copied from screen.md vocabulary still parses. */ export const PageRelatedTabSchema = z.preprocess( coerceRelatedTabAliases, RelatedTabBaseSchema, ) export type PageRelatedTab = z.infer /** * Fill the normalisation defaults so every downstream consumer (generator, * audits) relies on one stable shape: * - `labelKey` → `detail.related.{key}.label` * - `permission` → `{relatedModule}.{relatedSection}.read` */ export function normalizePageRelatedTab(tab: PageRelatedTab): PageRelatedTab { return { ...tab, labelKey: tab.labelKey ?? `detail.related.${tab.key}.label`, permission: tab.permission ?? `${tab.relatedModule}.${tab.relatedSection}.read`, } } /** * The PascalCase plural used for the related hook/service names * (`use{RelatedPlural}`). Explicit `relatedPlural` wins; otherwise the english * pluralizer over `relatedEntity`. */ export function relatedPluralOf(tab: PageRelatedTab): string { return tab.relatedPlural ?? toPascalCase(pluralize(tab.relatedEntity)) } /** * The business application the related entity lives in — THE single place stating * the fallback: an explicit `relatedApp` wins, otherwise the tab is understood to * point inside the CURRENT page's own application (the shape every pagespec had * before cross-application tabs existed). Always lower-cased: the BA tree names * applications in UPPERCASE while every generated path segment is kebab. */ export function relatedAppOf(tab: PageRelatedTab, ownApp: string): string { return (tab.relatedApp ?? ownApp).toLowerCase() } /** * True when the tab points at ANOTHER application than the page it renders on. * Distinct from the authored `crossModule` flag, which only ever spoke about * modules and says nothing about applications. */ export function isCrossAppTab(tab: PageRelatedTab, ownApp: string): boolean { return relatedAppOf(tab, ownApp) !== ownApp.toLowerCase() } /** * True when the tab leaves its page's own `(application, module)` — the exact * condition under which the target may be absent from a given deployment or from * a given tenant's catalogue. Same-module tabs are never in that position: the * page itself could not be reached if its own module were missing. */ export function isCrossSurfaceTab(tab: PageRelatedTab, ownApp: string, ownModule: string): boolean { return ( isCrossAppTab(tab, ownApp) || tab.relatedModule.toLowerCase() !== ownModule.toLowerCase() ) } /** * Whether the generator must wrap this tab in the runtime availability guard * (`useModuleAvailability().hasModule(app, module)`): it leaves its own surface AND * the author did not opt out. THE single place stating that rule — scaffold-component * emits on it, DEV-UI-049 audits on it, so the two cannot disagree about which tabs * are supposed to carry a guard. */ export function requiresAvailabilityGuard( tab: PageRelatedTab, ownApp: string, ownModule: string, ): boolean { return tab.availabilityCheck !== false && isCrossSurfaceTab(tab, ownApp, ownModule) } /** * Identity of the generated front-end extension files holding the related entity's * routes (`src/extensions/-Routes.ts`). App-scoped through * `extensionsModuleId`, because two applications may legitimately reuse a module code. */ export function relatedExtensionsId(tab: PageRelatedTab, ownApp: string): string { return extensionsModuleId(relatedAppOf(tab, ownApp), tab.relatedModule) } /** * The kebab route family a tab navigates through — THE single place stating * the fallback order (`relatedRouteFamily` wins, `relatedSection` is the * legacy fallback). Every consumer (scaffold-component, DEV-UI-031) resolves * the family through this helper so the invariant cannot fork. */ export function relatedRouteFamilyOf(tab: PageRelatedTab): string { return tab.relatedRouteFamily ?? tab.relatedSection } /** * Resolved placement from the RAW pieces — the low-level half for consumers * that only hold the bullet vocabulary (screen.md's `affichage ` string, * derive-related-tabs check.ts). THE single place stating the derived default: * explicit `placement` wins; otherwise `summary` renders in the band, every * other mode in the strip. */ export function placementForDisplayMode( displayMode: string, placement?: RelatedTabPlacement, ): RelatedTabPlacement { return placement ?? (displayMode === 'summary' ? 'band' : 'tab') } /** * Resolved placement of a parsed tab. scaffold-component (band/strip * partition) and DEV-UI-031 (anchor choice) both resolve through this helper * so the derived default can never fork. */ export function relatedTabPlacementOf(tab: PageRelatedTab): RelatedTabPlacement { return placementForDisplayMode(tab.displayMode, tab.placement) } /** * The i18n floor keys scaffold-component seeds (in all 4 locales, with real * words) for one related tab. PRD `i18nKeys` overrides win over the floor. */ export function relatedTabFloorKeys(key: string): string[] { return [ `detail.related.${key}.label`, `detail.related.${key}.empty`, `detail.related.${key}.loading`, `detail.related.${key}.error`, `detail.related.${key}.create`, `detail.related.${key}.viewAll`, `detail.related.${key}.count`, `detail.related.${key}.previous`, `detail.related.${key}.next`, ] } /** * Parse + normalise a pagespec's raw `relatedTabs` array. Invalid entries are * returned in `rejected` with their Zod issues (the audits turn each into a * blocking finding; generators must NOT silently drop them). */ export function parseRelatedTabs(raw: unknown): { tabs: PageRelatedTab[] rejected: Array<{ index: number; issues: string[] }> } { const tabs: PageRelatedTab[] = [] const rejected: Array<{ index: number; issues: string[] }> = [] if (!Array.isArray(raw)) return { tabs, rejected } raw.forEach((entry, index) => { const result = PageRelatedTabSchema.safeParse(entry) if (result.success) { tabs.push(normalizePageRelatedTab(result.data)) } else { rejected.push({ index, issues: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`), }) } }) return { tabs, rejected } }