/** * lib/page-spec-kanban.ts — Canonical Zod schema + resolver for the * first-order `kanban` block of a LIST pagespec. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` (SmartKanban block) → * `pagespec.kanban` → scaffold-component (the `viewMode === 'kanban'` branch * of the LIST page), scaffold-business (the move-action transition-matrix * guard) and the audits that verify it (PRD-135, DEV-UI-048). * * WHY: a kanban is a REPRESENTATION of the list, never its own section * (XD-006) — yet the legacy pipeline rendered it as a SEPARATE page * (`/…/list/kanban`) built from an invocation-level `kanbanConfig` that NO * deriver ever produced, with no filters, no column governance and a drop * handler that posted any status. This block folds the board into the list * pagespec exactly like the cards representation (`viewModes`, PRD-117): * the FilterBar, search, segments and URL state are shared by construction, * and the BA's workflow rules finally govern the drag & drop: * * - `columns[]` — the BA-authored board columns, keyed by the status enum * values VERBATIM (entité.md). Order is the BA's order; `initiallyHidden` * seeds the per-user column visibility (the user can still reveal it). * - `transitions[]` — the from→to edges projected from the module's Flow * business rules (`Type: workflow|state-transition`). The Flow graph stays * the transition SSOT — this block is its deterministic projection, written * by `create-prd/cli/derive-kanban-spec`, never hand-invented. ABSENT means * an OPEN matrix (any drop allowed when a move action exists); an EMPTY * array means an explicitly read-only board. * * The generated board compiles `transitions[]` down to an inlined * `ALLOWED_TRANSITIONS` record (drops onto a non-allowed column are refused * natively — `onDragOver` does not preventDefault), and scaffold-business * compiles the SAME edges into the C# guard of the `move` action — the two * halves of the wire come out of one projection (`moveMatrixOf`). * * @see business-analyse/create-screen/levels/kanban-screens.md (authors the SmartKanban block) * @see business-analyse/create-prd/cli/derive-kanban-spec (deterministic backfill + PRD-135 check engine) * @see development/frontend/component/cli/scaffold-component (compiles the block into the list page's board branch) * @see development/backend/business-layer/cli/scaffold-business (compiles transitions into the move guard) */ import { z } from 'zod' import { toPascalCase } from './string-utils.js' /** The list-representation vocabulary — SSOT of the `viewModes` enum. * `kanban`, like `cards`, is a REPRESENTATION of the list page (one route, * one FilterBar); it is never a standalone pagespec view. */ export const LIST_VIEW_MODES = ['table', 'cards', 'kanban'] as const export type ListViewMode = (typeof LIST_VIEW_MODES)[number] /** Action code that moves a card between columns (the status-update custom * action). One name, referenced by the DnD activation (scaffold-component), * the guard injection (page-spec-actions/scaffold-business) and the deriver. */ export const KANBAN_MOVE_ACTION_CODE = 'move' /** BA color palette (kanban-screens.md table) — CLOSED. Invented colors are * dropped by the deriver, never invented by the generator. */ export const KANBAN_BA_COLORS = ['gray', 'blue', 'green', 'red', 'orange'] as const export type KanbanBaColor = (typeof KANBAN_BA_COLORS)[number] export type KanbanTone = 'neutral' | 'info' | 'success' | 'error' | 'warning' /** * BA color → theme token family. The generator styles a column header with * the family's tokens (`--info-*`, `--success-*`, `--error-*`, `--warning-*`, * neutral) — NEVER a hex literal (R19/R20). kanban-screens.md's color table * points here; changing a mapping is a one-line, drift-tested edit. */ export const KANBAN_COLOR_FAMILY: Record = { gray: 'neutral', blue: 'info', green: 'success', red: 'error', orange: 'warning', } export const KanbanColumnSchema = z.object({ /** Status enum value VERBATIM (entité.md) — the bucket key. NOT camelized: * it is a data value, not a field name. */ key: z.string().min(1), /** * i18n key of the column header — `kanban.columns.` with the enum * value VERBATIM (possibly kebab / uppercase). Deliberately NOT passed * through normalizeI18nKey: like action labels, the key must match the * seeded locale entry byte-for-byte on both sides. */ labelKey: z.string().min(1), /** BA palette color — mapped to a token family via KANBAN_COLOR_FAMILY. */ color: z.enum(KANBAN_BA_COLORS).optional(), /** Seeded as hidden in the per-user column prefs (the user can reveal it). */ initiallyHidden: z.boolean().optional(), }).passthrough() export type KanbanColumn = z.infer export const KanbanTransitionSchema = z.object({ /** Source status enum value, VERBATIM. */ from: z.string().min(1), /** Target status enum value, VERBATIM. */ to: z.string().min(1), /** BR-... the edge comes from (traceability — DEV-API-008 reads the trace * the backend guard emits per entry). */ rule: z.string().regex(/^BR-[A-Z0-9_-]+$/).optional(), /** Documentation only (the Flow `by:` facet) — never rendered/enforced here; * actor enforcement is RBAC's job. */ by: z.string().optional(), /** Documentation only (the Flow `guard:` facet) — the enforceable guard * lives in the business rule / service, not in the board. */ guard: z.string().optional(), }).passthrough() export type KanbanTransition = z.infer export const PageKanbanSchema = z.object({ /** * camelCase pagespec field key of the state-semantics enum attribute — the * bucket/groupBy field. Must be one of the list's fields[]; the resolver * REJECTS the whole block otherwise (a board that cannot bucket rows). */ statusField: z.string().min(1), columns: z.array(KanbanColumnSchema).min(2), /** Card title field (camelCase). Absent → the generator's display-field * cascade (first non-formula field). */ titleField: z.string().optional(), subtitleField: z.string().optional(), /** 0-4 short fields rendered on the card body (camelCase). */ cardFields: z.array(z.string().min(1)).max(4).optional(), /** * from→to edges projected from the Flow business rules. ABSENT = open * matrix (any drop allowed when a move action exists); [] = explicitly * read-only board (kanbanDndEnabled returns false regardless of dndCards). */ transitions: z.array(KanbanTransitionSchema).optional(), /** i18n key of the "transition not allowed" feedback. */ transitionErrorKey: z.string().optional(), /** Wire/business error code the backend guard throws with (usually the * workflow BR's `Code d'erreur`, e.g. `orders.status.invalid-transition`). */ transitionErrorCode: z.string().optional(), /** Card drag & drop. Omitted = auto: enabled iff a `move` action exists on * the pagespec (kanbanDndEnabled). `false` always wins. */ dndCards: z.boolean().optional(), /** * Column keys whose cards can never be dragged OUT (closed states). Absent * AND `transitions` authored → derived: the columns with no outgoing edge. */ terminalColumns: z.array(z.string().min(1)).optional(), }).passthrough() export type PageKanban = z.infer export const DEFAULT_TRANSITION_ERROR_KEY = 'kanban.moveNotAllowed' /** * Parse a pagespec's raw `kanban` block. Never throws: an invalid block is * returned as `rejected` issues (audits turn each into a finding; the * generator warns and renders WITHOUT the board — the table/cards output, * byte-identical to a spec without the block). */ export function parsePageKanban(raw: unknown): { kanban: PageKanban | undefined rejected: string[] } { if (raw === undefined || raw === null) return { kanban: undefined, rejected: [] } const result = PageKanbanSchema.safeParse(raw) if (result.success) return { kanban: result.data, rejected: [] } return { kanban: undefined, rejected: result.error.issues.map(i => `kanban.${i.path.join('.')}: ${i.message}`), } } function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } export interface ResolvedKanbanColumn { key: string labelKey: string tone: KanbanTone initiallyHidden: boolean } export interface ResolvedKanban { /** camelCase bucket field, present on the list's fields[]. */ statusField: string /** camelCase, validated against fields[] — absent means "use the cascade". */ titleField?: string subtitleField?: string cardFields: string[] columns: ResolvedKanbanColumn[] /** from → allowed targets. undefined = OPEN matrix (no gating). An entry * set is never empty. */ allowed?: ReadonlyMap> /** Columns whose cards are never draggable out. */ terminal: ReadonlySet /** The verbatim (deduped) edges — the backend projection reads these. */ transitions?: KanbanTransition[] transitionErrorKey: string transitionErrorCode?: string dndCards?: boolean } /** * Resolve a pagespec's `kanban` block against the list's fields. Pure, total, * deterministic: * - absent/invalid block → `{ kanban: undefined }` (legacy render, safe); * - unknown `statusField` → the WHOLE block is rejected (nothing to bucket by); * - duplicate column keys / unknown card fields / unknown terminal columns / * self-loop or duplicate transitions → reported in `rejected` and dropped, * the board survives; * - fewer than 2 usable columns after dedup → the whole block is rejected. */ export function resolveKanban( fields: F[], pageSpec: { kanban?: unknown } | undefined, ): { kanban?: ResolvedKanban; rejected: string[] } { const { kanban: block, rejected } = parsePageKanban(pageSpec?.kanban) if (!block) return { kanban: undefined, rejected } const fieldCamels = new Set(fields.map(f => toCamelFirst(f.name))) const statusCamel = toCamelFirst(block.statusField) if (!fieldCamels.has(statusCamel)) { rejected.push( `kanban.statusField: '${block.statusField}' is not a pagespec field — the board cannot bucket rows`, ) return { kanban: undefined, rejected } } // Columns — dedupe by key, first occurrence wins. const columns: ResolvedKanbanColumn[] = [] const seenKeys = new Set() for (const col of block.columns) { if (seenKeys.has(col.key)) { rejected.push(`kanban.columns: duplicate key '${col.key}' — first occurrence kept`) continue } seenKeys.add(col.key) columns.push({ key: col.key, labelKey: col.labelKey, tone: col.color ? KANBAN_COLOR_FAMILY[col.color] : 'neutral', initiallyHidden: col.initiallyHidden === true, }) } if (columns.length < 2) { rejected.push('kanban.columns: fewer than 2 usable columns — not a board') return { kanban: undefined, rejected } } const resolveField = (name: string | undefined, slot: string): string | undefined => { if (name === undefined) return undefined const camel = toCamelFirst(name) if (!fieldCamels.has(camel)) { rejected.push(`kanban.${slot}: unknown field '${name}' — dropped`) return undefined } return camel } const titleField = resolveField(block.titleField, 'titleField') const subtitleField = resolveField(block.subtitleField, 'subtitleField') const cardFields: string[] = [] for (const name of block.cardFields ?? []) { const camel = resolveField(name, 'cardFields') if (camel === undefined) continue if (camel === statusCamel) { rejected.push(`kanban.cardFields: '${name}' is the statusField — the column already carries it, dropped`) continue } if (!cardFields.includes(camel)) cardFields.push(camel) } // Transitions — dedupe (from,to), drop self-loops. let allowed: Map> | undefined let transitions: KanbanTransition[] | undefined if (block.transitions !== undefined) { allowed = new Map() transitions = [] const seenEdges = new Set() for (const t of block.transitions) { if (t.from === t.to) { rejected.push(`kanban.transitions: self-loop '${t.from}' → '${t.to}' — dropped`) continue } const edge = `${t.from} ${t.to}` if (seenEdges.has(edge)) { rejected.push(`kanban.transitions: duplicate edge '${t.from}' → '${t.to}' — dropped`) continue } seenEdges.add(edge) transitions.push(t) const targets = allowed.get(t.from) ?? new Set() targets.add(t.to) allowed.set(t.from, targets) } } // Terminal columns — explicit wins; else derived from the authored graph // (a column with no outgoing edge is a closed state). No graph → none. const terminal = new Set() if (block.terminalColumns !== undefined) { for (const key of block.terminalColumns) { if (!seenKeys.has(key)) { rejected.push(`kanban.terminalColumns: '${key}' is not a column key — dropped`) continue } terminal.add(key) } } else if (allowed !== undefined) { for (const col of columns) { if (!allowed.has(col.key)) terminal.add(col.key) } } return { kanban: { statusField: statusCamel, titleField, subtitleField, cardFields, columns, ...(allowed !== undefined ? { allowed } : {}), terminal, ...(transitions !== undefined ? { transitions } : {}), transitionErrorKey: block.transitionErrorKey ?? DEFAULT_TRANSITION_ERROR_KEY, ...(block.transitionErrorCode !== undefined ? { transitionErrorCode: block.transitionErrorCode } : {}), ...(block.dndCards !== undefined ? { dndCards: block.dndCards } : {}), }, rejected, } } /** * Card drag & drop activation — ONE rule for the generator and the audits: * - `transitions: []` (explicitly read-only board) → false, always; * - `dndCards: false` → false; * - `dndCards: true` → true; * - omitted → enabled iff the pagespec declares a `move` action * (KANBAN_MOVE_ACTION_CODE) — the legacy `cfg.dnd` auto behaviour. */ export function kanbanDndEnabled( kanban: Pick, hasMoveAction: boolean, ): boolean { if (kanban.transitions !== undefined && kanban.transitions.length === 0) return false if (kanban.dndCards === false) return false if (kanban.dndCards === true) return true return hasMoveAction } function quote(s: string): string { return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` } /** * Render the inlined `ALLOWED_TRANSITIONS` literal the generated board embeds * (`Record`). Deterministic: keys and targets are * sorted, edges deduped. `indent` is the per-line prefix inside the braces. * * allowedTransitionsLiteral([{from:'draft',to:'submitted'}]) * → "{\n draft: ['submitted'],\n}" */ export function allowedTransitionsLiteral( transitions: readonly Pick[], indent = ' ', ): string { const byFrom = new Map>() for (const t of transitions) { if (t.from === t.to) continue const targets = byFrom.get(t.from) ?? new Set() targets.add(t.to) byFrom.set(t.from, targets) } const froms = [...byFrom.keys()].sort() if (froms.length === 0) return '{}' const idRe = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const lines = froms.map(from => { const targets = [...byFrom.get(from)!].sort().map(quote).join(', ') const key = idRe.test(from) ? from : quote(from) return `${indent}${key}: [${targets}],` }) return `{\n${lines.join('\n')}\n}` } /** * The move-action context scaffold-business's matrix guard is fed with — * extracted ONCE from the pagespec (`moveMatrixOf`) so the frontend board and * the backend guard compile the SAME edges. */ export interface KanbanMoveContext { transitions: { from: string; to: string; rule?: string }[] /** PascalCase entity property of the status field (`status` → `Status`). */ statusProperty: string /** Business error code of the refused-transition throw, when authored. */ errorCode?: string } /** * Extract the transition matrix a `move` action must guard with. Returns * undefined when the pagespec carries no kanban block, an invalid one, or no * authored edges (open matrix and read-only board alike need no matrix guard — * an explicit [] board derives NO move action in the first place). */ export function moveMatrixOf( pageSpec: { kanban?: unknown } | undefined, ): KanbanMoveContext | undefined { const { kanban } = parsePageKanban(pageSpec?.kanban) if (!kanban || !kanban.transitions || kanban.transitions.length === 0) return undefined const seen = new Set() const transitions: KanbanMoveContext['transitions'] = [] for (const t of kanban.transitions) { if (t.from === t.to) continue const edge = `${t.from} ${t.to}` if (seen.has(edge)) continue seen.add(edge) transitions.push({ from: t.from, to: t.to, ...(t.rule !== undefined ? { rule: t.rule } : {}) }) } if (transitions.length === 0) return undefined return { transitions, statusProperty: toPascalCase(kanban.statusField), ...(kanban.transitionErrorCode !== undefined ? { errorCode: kanban.transitionErrorCode } : {}), } }