diff --git a/.changeset/6051-gantt-flat-config-declared-keys.md b/.changeset/6051-gantt-flat-config-declared-keys.md new file mode 100644 index 000000000..7aa10e1dc --- /dev/null +++ b/.changeset/6051-gantt-flat-config-declared-keys.md @@ -0,0 +1,82 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-gantt': minor +--- + +`ObjectGanttSchema` declares the flattened `GanttConfig` face `ObjectGantt` +actually reads (objectui#6051). `getGanttConfig` has two branches: when +`startDateField` and `endDateField` are both present at the TOP level it builds +its config from top-level keys and returns early; otherwise it reads the `gantt` +block. Everything the first branch reads was undeclared — and unlike +objectui#5903's ten, none of it was hidden behind a cast. `BaseSchema` carries +`[key: string]: any` (objectui#5155's structural ceiling) and the helper's +parameter was `ObjectGridSchema | any`, so `schema.colorField` type-checked as +`any` with no syntax anywhere to grep for. That is also why the census here is an +AST enumeration and not a compile-and-observe: an index signature absorbs every +literal name, so annotating the parameter compiles clean while enforcing nothing. + +**27 keys join the declared surface, each additive and each with a live read +site.** 24 flattened `GanttConfig` members — `colorField`, `borderColorField`, +`dependenciesField`, `parentField`, `typeField`, `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `tooltipFields`, `baselineStartField`, +`baselineEndField`, `groupByField`, `resourceView`, `assigneeField`, +`effortField`, `capacity`, `quickFilters`, `autoZoomToFilter`, `timeSegments`, +`interactions`, `exportFileName`, `timeZone`, `dependencyTypes` — plus the three +query keys the fetch path reads, `staticData`, `filter` and `sort`. Nothing is +declared that the renderer does not consume. + +**`GanttConfig` itself gains nine members and is a published type**, exported by +name from `packages/types/src/index.ts`: `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, +`timeZone`, `exportFileName`, `interactions`. The entry file's diff is empty only +because the export list already named the type — the widening happened at the +declaration. + +**The 28th measured key, `gantt` (the block face), is deliberately NOT declared** +— see the closing section. + +The 24 are DERIVED from `GanttConfig` rather than restated, so the flat spelling +cannot fork from the block spelling, and the invariant is pinned in the type +system: every key of `GanttConfig` must be declared at the node's top level. +Making that derivation possible moved nine members — `lockField`, `objectField`, +`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, +`timeZone`, `exportFileName`, `interactions` — out of `plugin-gantt`'s +package-private `GanttConfigEx` and into `@object-ui/types`' `GanttConfig`. They +are a MOVE, not new vocabulary: the `gantt` block already honoured all nine, and +a type private to the plugin could be referenced by neither authoring face. + +Both halves move together, as in objectui#5903: the TS declaration and its zod +mirror gain the same 27 keys at the same requiredness (all optional), the +spec-modelled ones taken from `GanttConfigSchema.shape` by reference, so the +`zod-mirror-parity` ratchet stays at zero drift for this pair and no `KnownDrift` +or `UnmirroredDeclared` entry is added. The mirror builds the flat face and the +`gantt` block from one field map, so they are one schema expressed twice. + +Accept-set change, stated plainly. All 27 keys are additive — every one is +optional, and nothing previously legal loses its slot. What changes is that a +**declared** key is now type-validated, so `capacity: 'one'` and +`summaryExtent: 'parent'` are refused where they used to parse green. An +**undeclared** key is still accepted — `BaseSchema` is `.passthrough()`, so this +bought no rejection of misspellings. There is no narrowing anywhere in this +change. + +**`gantt` is severed on purpose (objectui#6475), not overlooked.** It is the 28th +key of the measured residue and a genuine read — `getGanttConfig`'s second branch +honours it in full — but it is the one key whose declaration would NOT have been +additive. It has no mirror entry today, so a block rides through `.passthrough()` +unvalidated; declaring it as `GanttConfig` means it gets parsed against the spec's +`GanttConfigSchema`, which REQUIRES `startDateField`, `endDateField` and +`titleField`, and `ObjectGanttSchema` reaches the CLI's `validate` / `check` +through `AnyComponentSchema`. A published CLI's refusal behaviour is decided on +its own card, where reviewers can see what they are approving; objectui#6475 +carries the full measurement, including the case FOR enforcing it (the renderer +already feeds that block to `GanttConfigSchema.safeParse` and warns, so enforcing +restores declared = enforced rather than inventing a contract). Today's behaviour +is pinned in the test file so the omission is a measured state, not a silent gap. +`packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts` pins both +halves so neither can be misread. + +Which face WINS is unchanged and was not decided here: the flat branch is checked +first and returns early, so a node carrying both spellings still renders the flat +one. (`plugin-map` had the opposite precedence ruled on in objectui#5018; no +equivalent ruling exists for gantt.) diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index ae81c5c22..e886e376d 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -24,7 +24,7 @@ import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { toast } from 'sonner'; -import type { ObjectGanttSchema, ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; +import type { ObjectGanttSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; // Aliased on import, following PR #4169's convention: this repo has its OWN // `resolveI18nLabel` over a DIFFERENT vocabulary (the KEYED `{ key, defaultValue }` @@ -89,8 +89,19 @@ export interface QuickFilterDef { } /** - * Hierarchy/type fields are ObjectUI extensions on top of the spec's - * GanttConfig (not yet in @objectstack/spec GanttConfigSchema). + * The gantt config as THIS renderer consumes it: `GanttConfig` from + * `@object-ui/types` — the spec's `GanttConfigSchema` plus objectui's own + * extensions — with `quickFilters` and `timeSegments` narrowed to the plugin's + * runtime types, and the spec-declared members re-documented with the behaviour + * this renderer gives them. + * + * ⚠️ Nothing here may declare a key `GanttConfig` does not (objectui#6051). Nine + * members that lived ONLY here — `lockField`, `objectField`, `summaryExtent`, + * `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, `timeZone`, + * `exportFileName`, `interactions` — were lifted into `@object-ui/types`, because + * a type private to this package can be referenced by neither authoring face. + * Each key is now declared once and both faces derive from it: the `gantt` block + * and the flattened top-level spelling on `ObjectGanttSchema`. */ type GanttConfigEx = GanttConfig & { parentField?: string; @@ -101,55 +112,9 @@ type GanttConfigEx = GanttConfig & { * style levels that only group, never schedule. */ typeField?: string; - /** - * Record field marking a node as view-only (truthy → locked). A locked - * row's bar can't be dragged/resized, its progress can't be dragged, no - * dependency can be drawn from it, and its inline-edit / context-menu - * edit+delete are hidden — but clicking it (open drawer / jump) still works. - * Independent of the global `readOnly`; use to freeze individual levels (e.g. - * work orders) while siblings stay editable. Maps to {@link GanttTask.locked}. - */ - lockField?: string; - /** - * Record field carrying the row's OBJECT API NAME. Mixed-object - * trees (an `api` provider composing parent-object rows with child-object rows) - * need the detail drawer and its full-page link to follow each row's REAL - * object — otherwise a child row's `→` link builds a URL under the view's bound - * object and 404s. Empty/missing value → falls back to the bound object. - */ - objectField?: string; - /** - * How a summary bar's span is computed. `'children'` (default) - * rolls the bar up from its children — min start / max end / duration-weighted - * progress — and IGNORES the record's own dates. `'self'` renders the bar from - * the record's OWN start/end/progress, falling back to rollup - * only for records without dates (e.g. pure grouping levels). Use `'self'` - * when the parent's schedule is authoritative — e.g. a shift plan whose - * work-order children are locked history: under rollup, dragging the plan - * persists its own dates but the bar snaps back to the children's extent on - * refetch. - */ - summaryExtent?: 'children' | 'self'; - /** - * Auto-collapse tree nodes at/below this 0-indexed depth on first render. - * Roots are depth 0. Every node at depth `>= defaultCollapsedDepth` - * with children starts folded; the user can still expand them. Example: a - * project→product→production-plan→work-order tree uses - * `defaultCollapsedDepth: 2` so every production plan (and its work orders) - * starts collapsed. Forwarded to {@link GanttView}. - */ - defaultCollapsedDepth?: number; /** Baseline (planned) start/end fields → planned-vs-actual reference bars. */ baselineStartField?: string; baselineEndField?: string; - /** - * Record field carrying a per-task alert stroke color: any CSS color or - * semantic palette name (red/orange/…). When present the bar keeps its fill - * but gets an outline + halo in that color — e.g. red for overdue, orange for - * due-soon — typically a server-computed alert field. Empty/null → no stroke. - * Maps to {@link GanttTask.borderColor}. - */ - borderColorField?: string; /** * Dynamic Group by. When set, leaf tasks are bucketed by this * field and rendered under one synthesized summary row per distinct value @@ -182,37 +147,6 @@ type GanttConfigEx = GanttConfig & { * (unfiltered) task set while filtering only hides bars. */ autoZoomToFilter?: boolean; - /** - * Whether the backing store persists dependency link TYPES (fs/ss/ff/sf). - * Default true. Set false when dependencies are bare predecessor ids - * (predecessor ids only) — the link menu hides the type switcher (a switch would be - * silently reverted on refetch) and drag-created links are always FS. - * Forwarded to {@link GanttView}. - */ - dependencyTypes?: boolean; - /** - * Business time zone, IANA name like 'Asia/Shanghai'. Renders the - * chart's calendar — shift bands, day columns, snapping, today line, date - * labels — in this zone's wall time for every viewer, instead of the - * browser's zone (which misplaces shift bands for viewers elsewhere). Persisted - * data stays real instants. Forwarded to {@link GanttView}. - */ - timeZone?: string; - /** - * Base name for exported PNG/PDF files, e.g. the view's display - * label — the host's view schema often reaches this component stripped of - * `label`, so views declare it here. Falls back to the object schema label, - * then the object API name. A timestamp suffix is always appended. - */ - exportFileName?: string; - /** - * Per-interaction switches: `move` / `resize` / `progress` / `link`, - * each defaulting to true. Metadata-drivable so a view can e.g. allow bar - * moves but pin durations (`{ resize: false }`) or keep the dependency UI - * read-only (`{ link: false }`). They only narrow what `readOnly` / row locks - * already allow. Forwarded to {@link GanttView}. - */ - interactions?: GanttInteractions; /** * Shift segmentation. When set, the day-mode timeline splits each shift-day * (starting at `dayStart`) into the configured bands (day | night | …): @@ -289,10 +223,14 @@ export interface ObjectGanttProps { * that hid even that. Removing the casts without moving the type would have * changed nothing — the reads would still land on the index signature. * - * The grid-style `{ gantt: { … } }` block keeps working exactly as before: - * `getGanttConfig` reads it through the same index signature, and the - * registered renderer (`index.tsx`) passes `schema: any`, so no runtime shape - * is turned away. + * objectui#6051 declared what the FLAT branch reads: the 24 flattened + * `GanttConfig` keys `getGanttConfig`'s first branch consumes, plus the + * `staticData` / `filter` / `sort` the fetch path reads. The grid-style + * `{ gantt: { … } }` block keeps working exactly as before and is still read + * through the index signature — declaring it is the one change that would not + * have been additive, and it is severed to objectui#6475. The registered + * renderer (`index.tsx`) still passes `schema: any`, so no runtime shape is + * turned away either way. */ schema: ObjectGanttSchema; dataSource?: DataSource; @@ -363,10 +301,10 @@ function extractServerMessage(err: unknown): string | null { /** * Helper to get gantt configuration from schema */ -function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null { +function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null { let config: GanttConfigEx | null = null; - // 1. Check top-level properties (ObjectGanttSchema style) + // 1. Check top-level properties (the flattened ObjectGanttSchema style) if (schema.startDateField && schema.endDateField) { config = { startDateField: schema.startDateField, @@ -402,7 +340,7 @@ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null { return config; } - // 2. Check schema.gantt (ObjectGridSchema style) + // 2. Check schema.gantt (the block face, ObjectGridSchema style) if (schema.gantt) { config = schema.gantt as GanttConfigEx; } diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts new file mode 100644 index 000000000..b2efa27e2 --- /dev/null +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -0,0 +1,392 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Declaration pin — the flattened `GanttConfig` face, plus the query keys, that + * `ObjectGantt` reads off the node and `ObjectGanttSchema` did not declare + * (objectui#6051). + * + * ## The concealment is an INDEX SIGNATURE, not a cast + * + * objectui#5903's ten keys were hidden by `(schema as any).K` — a syntax a regex + * can find. These are hidden by `BaseSchema`'s `[key: string]: any` + * (objectui#5155's structural ceiling) reached through a parameter typed + * `ObjectGridSchema | any`: `schema.colorField` type-checked as `any` with no cast + * anywhere. Same outcome, no syntax to grep for. + * + * The consequence for anyone re-measuring this: "annotate the parameter and see + * what errors" DOES NOT WORK here. The index signature absorbs every literal name, + * so the annotation compiles clean while enforcing nothing — the same blind + * instrument objectui#6373 records. The census behind this card is therefore an + * AST enumeration of every top-level key read off the `schema` prop in + * `packages/plugin-gantt/src/**` (non-test), stripping `as` / parenthesis / + * non-null wrappers and following local aliases. + * + * ## The measurement, re-derived on the post-#5903 tree + * + * The card reported 24 keys. #5903 landed (PR #6053) between the filing and this + * work, so the population was re-derived rather than inherited: + * + * - 47 distinct top-level keys are read (the card's 47, reproduced); + * - 19 of them are declared — `ObjectGanttSchema`'s own plus `BaseSchema`'s + * `label`/`data`, and the ten #5903 added; + * - 28 are the residue. #5903 absorbed NONE of the card's 24: its ten + * (`skipWeekends`, `holidays`, `persistLayout`, `viewName`, `navigation`, + * `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`) + * are disjoint from them. + * + * 27 of the 28 are declared here. The 28th, `gantt`, is severed to objectui#6475 + * — see the section below. + * + * The residue is four LARGER than the card's list, and #5903 is why. The card + * scored "declared by neither `ObjectGanttSchema` nor `ObjectGridSchema`" over + * `getGanttConfig`'s flat branch only. #5903 retyped `ObjectGanttProps.schema` + * from `ObjectGridSchema` to `ObjectGanttSchema` — correct, and it is what makes + * these reads resolve against THIS interface — but `staticData`, `filter` and + * `sort` were declared on `ObjectGridSchema` and are not on this one. `gantt`, the + * block face, was outside the line range the card cited and is declared by + * neither. All four have live read sites; all four are declared here. + * + * ## Every key is DERIVED, so the two faces cannot fork + * + * The 24 flattened members take their type from {@link GanttConfig} — the same + * type the `gantt` block carries — rather than restating it. The invariant that + * keeps that true as either side moves is the type-level pin at the bottom: + * every key of `GanttConfig` is declared on the node's flat face. + * + * ## Why `gantt` is measured here but NOT declared here + * + * The 27 declared below are new OPTIONAL members: additive on both sides, nothing + * previously legal loses its slot. `gantt` is the one that would not have been. + * It has no mirror entry at all, so a block rides through `.passthrough()` + * unvalidated; declaring it as `GanttConfig` means it gets parsed, and + * `GanttConfig` derives from the spec's `GanttConfigSchema`, which REQUIRES + * `startDateField`, `endDateField` and `titleField`. `ObjectGanttSchema` is a + * member of `AnyComponentSchema`, so that reaches `safeValidateSchema` and with + * it the CLI's `validate` / `check` — a block missing one of the three would move + * from "accepted, then warned about at runtime" to "refused at authoring time". + * + * PM ruling (2026-08-26): sever it, so a published CLI's refusal behaviour is + * decided on its own card rather than inside a 27-key declaration PR. + * objectui#6475 carries the full measurement, including the case FOR enforcing — + * `getGanttConfig`'s block branch already feeds the block to + * `GanttConfigSchema.safeParse` and logs `[ObjectGantt] Invalid gantt + * configuration`, so declaring it restores declared = enforced rather than + * inventing a stricter contract. + * + * Today's behaviour is pinned below rather than left implicit, so the omission is + * a measured state and not a silent gap. + * + * ## What the pin has teeth against, and what it does not + * + * Unchanged from #5903, and worth restating because it is the half people read + * wrongly: `BaseSchema` is `.passthrough()` on the zod side and carries + * `[key: string]: any` on the TS side, so declaring these keys does NOT buy + * rejection of a misspelling. What it buys is that a DECLARED key is validated + * (`capacity: 'one'` is refused where it used to parse green), that the published + * types now TEACH the vocabulary, and that the type-level pins below fail when a + * declaration is removed. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectGanttSchema } from '../zod/objectql.zod.js'; +import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; + +const MINIMAL = { + type: 'object-gantt', + objectName: 'task', + startDateField: 'start', + endDateField: 'end', +} as const; + +/** + * The 27 keys this card declared, each with a value its declared type refuses. + * + * 24 flattened `GanttConfig` members and the three query keys (`staticData` / + * `filter` / `sort`). The 28th measured key, `gantt`, is severed to objectui#6475 + * and is pinned separately below as an UNDECLARED read. + */ +const DECLARED: ReadonlyArray = [ + // — the flattened GanttConfig face — + ['colorField', 5], + ['borderColorField', 5], + ['dependenciesField', 5], + ['parentField', 5], + ['typeField', 5], + ['lockField', 5], + ['objectField', 5], + ['summaryExtent', 'parent'], + ['defaultCollapsedDepth', '2'], + ['tooltipFields', 'name'], + ['baselineStartField', 5], + ['baselineEndField', 5], + ['groupByField', 5], + ['resourceView', 'yes'], + ['assigneeField', 5], + ['effortField', 5], + ['capacity', 'one'], + ['quickFilters', [{ label: 'Owner' }]], + ['autoZoomToFilter', 'yes'], + ['timeSegments', { bands: [{ label: 'Day' }] }], + ['interactions', 'none'], + ['exportFileName', 5], + ['timeZone', 5], + ['dependencyTypes', 'yes'], + // — the query keys the fetch path reads — + ['staticData', { id: 1 }], + ['filter', 'name = 1'], + ['sort', 5], +]; + +/** One well-typed value per declared key — the counter-probe for the block above. */ +const GOOD = { + colorField: 'status', + borderColorField: 'alert', + dependenciesField: 'predecessors', + parentField: 'parent', + typeField: 'kind', + lockField: 'locked', + objectField: 'object_name', + summaryExtent: 'self' as const, + defaultCollapsedDepth: 2, + tooltipFields: ['owner', { field: 'stage', label: 'Stage' }], + baselineStartField: 'plan_start', + baselineEndField: 'plan_end', + groupByField: 'owner', + resourceView: true, + assigneeField: 'owner', + effortField: 'effort', + capacity: 2, + quickFilters: [{ field: 'owner', label: 'Owner' }], + autoZoomToFilter: false, + timeSegments: { + dayStart: '08:00', + bands: [{ key: 'day', label: 'Day shift', start: '08:00', end: '20:00' }], + showMidnight: true, + }, + interactions: { move: true, resize: false, progress: true, link: false }, + exportFileName: 'Shift Plan', + timeZone: 'Asia/Shanghai', + dependencyTypes: false, + staticData: [{ id: 1, name: 'Task' }], + filter: [['name', '=', 'Task']], + sort: 'name desc', +}; + +describe('ObjectGanttSchema — the flattened gantt config is declared (objectui#6051)', () => { + it('the mirror declares every one of the 27', () => { + const shape = Object.keys(ObjectGanttSchema.shape); + for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); + }); + + it('the census is the measured 28, not a shorter list that drifted', () => { + // Non-vacuity for the loops below: they iterate DECLARED, so a truncated + // DECLARED would pass everything while checking less. The number is the + // measured residue stated in this file's header. + expect(DECLARED).toHaveLength(27); + expect(new Set(DECLARED.map(([k]) => k)).size).toBe(27); + expect(Object.keys(GOOD).sort()).toEqual(DECLARED.map(([k]) => k).sort()); + }); + + it('declares them all OPTIONAL — none of the 27 may become required', () => { + // Requiredness is the half the zod-mirror-parity ratchet compares against + // `../objectql.ts`, where all 27 are `?:`. A mirror that required one would + // reject every gantt already published. + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('materialises NO defaults — an omitted key stays absent after parse', () => { + // `autoZoomToFilter` and `dependencyTypes` default ON *in the renderer*, which + // reads `!== false`. A `.default(true)` here would arrive downstream as an + // explicit author choice; the two spellings are not interchangeable. + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success).toBe(true); + if (!result.success) return; + for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false); + }); + + it('refuses a wrong-typed value on each declared key', () => { + for (const [key, bad] of DECLARED) { + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); + expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); + if (result.success) continue; + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `${key} failed, but not on the ${key} path`).toBeTruthy(); + } + }); + + it('accepts a well-typed value on every declared key', () => { + // Counter-probe for the assertion above: it must be the VALUE being refused, + // not the key. A pin that only ever sees red proves nothing. + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, ...GOOD }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('the `gantt` BLOCK face is still undeclared, and today rides through UNVALIDATED', () => { + // The 28th measured key, severed to objectui#6475. Pinned rather than left + // implicit so the omission is a measured state: a block missing the trio the + // spec's `GanttConfigSchema` REQUIRES parses green today, because the mirror + // has no `gantt` entry and `BaseSchema` is `.passthrough()`. + expect(Object.keys(ObjectGanttSchema.shape)).not.toContain('gantt'); + const missingTrio = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { lockField: 'locked' } }); + expect(missingTrio.success).toBe(true); + // Not even a wrong-TYPED block is refused — that is what "no entry" means, and + // it is exactly what objectui#6475 proposes to change. + expect(ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: 'flat' }).success).toBe(true); + }); + + it('does NOT reject an undeclared key — objectui#5155 ceiling, measured not assumed', () => { + // Declaring the 28 bought validation of DECLARED keys, not rejection of + // undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this card + // as "misspellings now fail" is reading it wrong, and this pin says so in the + // one place that cannot rot. + const misspelled = ObjectGanttSchema.safeParse({ ...MINIMAL, colourField: 'status', lockFeild: 'locked' }); + expect(misspelled.success).toBe(true); + }); +}); + +/* ── The derived invariant: the two authoring faces are one vocabulary ─────── */ + +/** + * A declaration's OWN declared members, with any index signature stripped. + * + * Same construction as `zod-mirror-parity.test.ts` and for the same measured + * reason: `keyof ObjectGanttSchema` resolves to bare `string`, because + * `BaseSchema`'s `[key: string]: any` absorbs every literal name. A homomorphic + * mapped type maps declared members and index signatures separately, so remapping + * the index-signature keys to `never` leaves the literal members. + */ +type WithoutIndexSignature = { + [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K]; +}; +type DeclaredKeys = Extract, string>; + +/** + * Keys of the BLOCK face that the FLAT face does not declare. `never` is the contract. + * + * `DeclaredKeys` is applied to BOTH sides, and that is load-bearing rather than + * symmetry for its own sake: the spec's `GanttConfigSchema` is `$loose`, so + * `GanttConfig` carries `[x: string]: unknown` of its own and bare + * `keyof GanttConfig` resolves to `string` — measured, when this pin was first + * written that way, and it made the `Exclude` unconditionally `string`. Two index + * signatures, two chances for the same vacuity; the non-vacuity test below pins + * both. + */ +type FlatFaceGaps = Exclude, DeclaredKeys>; + +describe('ObjectGanttSchema (TS) — the flat face declares the whole block vocabulary', () => { + it('every GanttConfig key is declared at the top level too', () => { + // Derived, with no key list to maintain: add a member to `GanttConfig` (or to + // the spec's `GanttConfigSchema`, which it derives from) without declaring the + // flattened spelling and this line stops compiling, NAMING the missing key. + const noGaps: FlatFaceGaps extends never ? true : FlatFaceGaps = true; + expect(noGaps).toBe(true); + }); + + it('the invariant above is not vacuous', () => { + // Two ways `FlatFaceGaps` could be `never` while proving nothing. + // + // 1. `DeclaredKeys` degenerating to `string` — the exact + // index-signature trap this card is about — would `Exclude` everything. + const notWidened: string extends DeclaredKeys ? never : true = true; + // 2. the SAME degeneration on the other side would make the `Exclude` source + // `string`, which is what happened before `DeclaredKeys` was applied here. + const blockNotWidened: string extends DeclaredKeys ? never : true = true; + // 3. `DeclaredKeys` resolving to `never` would leave nothing to + // exclude, and both the spec's members and objectui's must be in it. + const blockHasSpecKeys: 'colorField' extends DeclaredKeys ? true : never = true; + const blockHasLocalKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; + // 4. ...and the flat face must really carry the derived members, not `any`. + const flatHasKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; + expect([notWidened, blockNotWidened, blockHasSpecKeys, blockHasLocalKeys, flatHasKeys]) + .toEqual([true, true, true, true, true]); + }); +}); + +describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () => { + it('refuses a wrong-typed value on every declared key', () => { + // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stops being declared, because the member then resolves to + // `any` through `BaseSchema`'s index signature and the assignment starts + // succeeding. That failure is the signal this card exists to create, and + // `tsconfig.test.json` compiles this file, so it is real enforcement (#3009). + + // @ts-expect-error — `colorField` is declared `string | undefined`. + const colorField: ObjectGanttSchemaTS['colorField'] = 5; + // @ts-expect-error — `borderColorField` is declared `string | undefined`. + const borderColorField: ObjectGanttSchemaTS['borderColorField'] = 5; + // @ts-expect-error — `dependenciesField` is declared `string | undefined`. + const dependenciesField: ObjectGanttSchemaTS['dependenciesField'] = 5; + // @ts-expect-error — `parentField` is declared `string | undefined`. + const parentField: ObjectGanttSchemaTS['parentField'] = 5; + // @ts-expect-error — `typeField` is declared `string | undefined`. + const typeField: ObjectGanttSchemaTS['typeField'] = 5; + // @ts-expect-error — `lockField` is declared `string | undefined`. + const lockField: ObjectGanttSchemaTS['lockField'] = 5; + // @ts-expect-error — `objectField` is declared `string | undefined`. + const objectField: ObjectGanttSchemaTS['objectField'] = 5; + // @ts-expect-error — `summaryExtent` is declared `'children' | 'self' | undefined`. + const summaryExtent: ObjectGanttSchemaTS['summaryExtent'] = 'parent'; + // @ts-expect-error — `defaultCollapsedDepth` is declared `number | undefined`. + const defaultCollapsedDepth: ObjectGanttSchemaTS['defaultCollapsedDepth'] = '2'; + // @ts-expect-error — `tooltipFields` is declared an ARRAY of field refs. + const tooltipFields: ObjectGanttSchemaTS['tooltipFields'] = 'name'; + // @ts-expect-error — `baselineStartField` is declared `string | undefined`. + const baselineStartField: ObjectGanttSchemaTS['baselineStartField'] = 5; + // @ts-expect-error — `baselineEndField` is declared `string | undefined`. + const baselineEndField: ObjectGanttSchemaTS['baselineEndField'] = 5; + // @ts-expect-error — `groupByField` is declared `string | undefined`. + const groupByField: ObjectGanttSchemaTS['groupByField'] = 5; + // @ts-expect-error — `resourceView` is declared `boolean | undefined`. + const resourceView: ObjectGanttSchemaTS['resourceView'] = 'yes'; + // @ts-expect-error — `assigneeField` is declared `string | undefined`. + const assigneeField: ObjectGanttSchemaTS['assigneeField'] = 5; + // @ts-expect-error — `effortField` is declared `string | undefined`. + const effortField: ObjectGanttSchemaTS['effortField'] = 5; + // @ts-expect-error — `capacity` is declared `number | undefined`. + const capacity: ObjectGanttSchemaTS['capacity'] = 'one'; + // @ts-expect-error — `quickFilters[].field` is required. + const quickFilters: ObjectGanttSchemaTS['quickFilters'] = [{ label: 'Owner' }]; + // @ts-expect-error — `autoZoomToFilter` is declared `boolean | undefined`. + const autoZoomToFilter: ObjectGanttSchemaTS['autoZoomToFilter'] = 'yes'; + // @ts-expect-error — `timeSegments.bands[]` requires `start` and `end`. + const timeSegments: ObjectGanttSchemaTS['timeSegments'] = { bands: [{ label: 'Day' }] }; + // @ts-expect-error — `interactions` is declared an object of switches. + const interactions: ObjectGanttSchemaTS['interactions'] = 'none'; + // @ts-expect-error — `exportFileName` is declared `string | undefined`. + const exportFileName: ObjectGanttSchemaTS['exportFileName'] = 5; + // @ts-expect-error — `timeZone` is declared `string | undefined`. + const timeZone: ObjectGanttSchemaTS['timeZone'] = 5; + // @ts-expect-error — `dependencyTypes` is declared `boolean | undefined`. + const dependencyTypes: ObjectGanttSchemaTS['dependencyTypes'] = 'yes'; + // @ts-expect-error — `staticData` is declared `any[] | undefined`. + const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; + // @ts-expect-error — `filter` is declared `any[] | undefined`. + const filter: ObjectGanttSchemaTS['filter'] = 'name = 1'; + // @ts-expect-error — `sort` is declared `string | SortConfig[] | undefined`. + const sort: ObjectGanttSchemaTS['sort'] = 5; + + expect([ + colorField, borderColorField, dependenciesField, parentField, typeField, + lockField, objectField, summaryExtent, defaultCollapsedDepth, tooltipFields, + baselineStartField, baselineEndField, groupByField, resourceView, assigneeField, + effortField, capacity, quickFilters, autoZoomToFilter, timeSegments, + interactions, exportFileName, timeZone, dependencyTypes, + staticData, filter, sort, + ]).toHaveLength(27); + }); + + it('accepts the well-typed value on every declared key', () => { + // Counter-probe for the directives above: without this, a declaration narrowed + // to `never` would satisfy every one of them. + const ok: ObjectGanttSchemaTS = { ...MINIMAL, ...GOOD }; + expect(ok.summaryExtent).toBe('self'); + expect(ok.interactions?.resize).toBe(false); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3ebeefacc..b84200d08 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -204,6 +204,105 @@ export type GanttConfig = SpecGanttConfig & { */ showMidnight?: boolean; }; + // ── objectui's own extensions, lifted out of `plugin-gantt` (objectui#6051) ── + // + // The nine members below were declared ONLY in `plugin-gantt`'s package-private + // `GanttConfigEx` intersection — the type `getGanttConfig` casts the `gantt` + // block to. `ObjectGantt` honours every one of them on BOTH authoring faces + // (the `gantt: { … }` block AND the flattened top-level spelling declared on + // `ObjectGanttSchema`), and a type that lives inside the plugin can be + // referenced by neither declaration — so the vocabulary is lifted here rather + // than restated, and the two faces derive from ONE source that cannot fork. + // + // Like `timeSegments` above, each is legal metadata rather than a second + // dialect: `GanttConfigSchema` is `$loose` upstream, so a key the spec does not + // model passes its parse instead of being rejected. + /** + * Record field marking a node as view-only (truthy → locked). A locked + * row's bar can't be dragged/resized, its progress can't be dragged, no + * dependency can be drawn from it, and its inline-edit / context-menu + * edit+delete are hidden — but clicking it (open drawer / jump) still works. + * Independent of the global `readOnly`; use to freeze individual levels (e.g. + * work orders) while siblings stay editable. Maps to `GanttTask.locked`. + */ + lockField?: string; + /** + * Record field carrying the row's OBJECT API NAME. Mixed-object + * trees (an `api` provider composing parent-object rows with child-object rows) + * need the detail drawer and its full-page link to follow each row's REAL + * object — otherwise a child row's `→` link builds a URL under the view's bound + * object and 404s. Empty/missing value → falls back to the bound object. + */ + objectField?: string; + /** + * How a summary bar's span is computed. `'children'` (default) + * rolls the bar up from its children — min start / max end / duration-weighted + * progress — and IGNORES the record's own dates. `'self'` renders the bar from + * the record's OWN start/end/progress, falling back to rollup + * only for records without dates (e.g. pure grouping levels). Use `'self'` + * when the parent's schedule is authoritative — e.g. a shift plan whose + * work-order children are locked history: under rollup, dragging the plan + * persists its own dates but the bar snaps back to the children's extent on + * refetch. + */ + summaryExtent?: 'children' | 'self'; + /** + * Auto-collapse tree nodes at/below this 0-indexed depth on first render. + * Roots are depth 0. Every node at depth `>= defaultCollapsedDepth` + * with children starts folded; the user can still expand them. Example: a + * project→product→production-plan→work-order tree uses + * `defaultCollapsedDepth: 2` so every production plan (and its work orders) + * starts collapsed. Forwarded to `GanttView`. + */ + defaultCollapsedDepth?: number; + /** + * Record field carrying a per-task alert stroke color: any CSS color or + * semantic palette name (red/orange/…). When present the bar keeps its fill + * but gets an outline + halo in that color — e.g. red for overdue, orange for + * due-soon — typically a server-computed alert field. Empty/null → no stroke. + * Maps to `GanttTask.borderColor`. + */ + borderColorField?: string; + /** + * Whether the backing store persists dependency link TYPES (fs/ss/ff/sf). + * Default true. Set false when dependencies are bare predecessor ids + * (predecessor ids only) — the link menu hides the type switcher (a switch would be + * silently reverted on refetch) and drag-created links are always FS. + * Forwarded to `GanttView`. + */ + dependencyTypes?: boolean; + /** + * Business time zone, IANA name like 'Asia/Shanghai'. Renders the + * chart's calendar — shift bands, day columns, snapping, today line, date + * labels — in this zone's wall time for every viewer, instead of the + * browser's zone (which misplaces shift bands for viewers elsewhere). Persisted + * data stays real instants. Forwarded to `GanttView`. + */ + timeZone?: string; + /** + * Base name for exported PNG/PDF files, e.g. the view's display + * label — the host's view schema often reaches this component stripped of + * `label`, so views declare it here. Falls back to the object schema label, + * then the object API name. A timestamp suffix is always appended. + */ + exportFileName?: string; + /** + * Per-interaction switches: `move` / `resize` / `progress` / `link`, + * each defaulting to true. Metadata-drivable so a view can e.g. allow bar + * moves but pin durations (`{ resize: false }`) or keep the dependency UI + * read-only (`{ link: false }`). They only narrow what `readOnly` / row locks + * already allow. Forwarded to `GanttView`. + */ + interactions?: { + /** Bar / subtree dragging (move along the timeline). */ + move?: boolean; + /** Edge resize grips (change duration). */ + resize?: boolean; + /** The progress drag handle. */ + progress?: boolean; + /** Dependency UI: drag-to-link dots AND the create/delete menu entries. */ + link?: boolean; + }; }; /** @@ -2292,6 +2391,119 @@ export interface ObjectGanttSchema extends BaseSchema { * with) {@link readOnly}. Read at `ObjectGantt.tsx`. */ mobileReadOnly?: boolean; + + // ── The flattened `GanttConfig` face (objectui#6051) ──────────────────────── + // + // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. When + // `startDateField` AND `endDateField` are present at the TOP LEVEL it builds the + // config from top-level keys and RETURNS EARLY; otherwise it reads the `gantt` + // block declared below. The keys of the first branch were declared by neither + // this interface nor `ObjectGridSchema`: they were reachable only through + // `BaseSchema`'s `[key: string]: any`, so `schema.colorField` type-checked as + // `any` with no cast anywhere to grep for. That is why the census behind this + // card is an AST enumeration and not a compile-and-observe — an index signature + // swallows exactly the evidence a type annotation would have produced. + // + // Every member below is DERIVED from {@link GanttConfig}, the same type the + // `gantt` block carries, so the flat spelling cannot drift from the block + // spelling. All are optional, matching the renderer: the flat branch reads each + // key bare and forwards `undefined` unchanged. + // + // ⚠️ WHICH face wins is unchanged here and is not this card's question: the flat + // branch is checked first and returns early, so a node carrying both spellings + // renders the flat one. (`plugin-map` had the opposite precedence ruled on in + // objectui#5018; no equivalent ruling exists for gantt.) + + /** Record field carrying the bar fill colour. See {@link GanttConfig}. */ + colorField?: GanttConfig['colorField']; + /** Per-task alert stroke colour field. See {@link GanttConfig.borderColorField}. */ + borderColorField?: GanttConfig['borderColorField']; + /** + * Record field holding this task's predecessors. The CANONICAL spelling — the + * flat branch reads `dependenciesField || dependencyField`, so the singular + * {@link ObjectGanttSchema.dependencyField} above stays accepted as the legacy + * alias and this one wins. + */ + dependenciesField?: GanttConfig['dependenciesField']; + /** Single-parent pointer field building the task tree. See {@link GanttConfig}. */ + parentField?: GanttConfig['parentField']; + /** Record field mapping onto a node kind (task/summary/milestone/group). */ + typeField?: GanttConfig['typeField']; + /** Record field marking a row view-only. See {@link GanttConfig.lockField}. */ + lockField?: GanttConfig['lockField']; + /** Record field carrying the row's own object API name (mixed-object trees). */ + objectField?: GanttConfig['objectField']; + /** How a summary bar's span is computed. See {@link GanttConfig.summaryExtent}. */ + summaryExtent?: GanttConfig['summaryExtent']; + /** Auto-collapse depth on first render. See {@link GanttConfig.defaultCollapsedDepth}. */ + defaultCollapsedDepth?: GanttConfig['defaultCollapsedDepth']; + /** Extra record fields listed in the bar tooltip. See {@link GanttConfig}. */ + tooltipFields?: GanttConfig['tooltipFields']; + /** Baseline (planned) start field → planned-vs-actual reference bars. */ + baselineStartField?: GanttConfig['baselineStartField']; + /** Baseline (planned) end field → planned-vs-actual reference bars. */ + baselineEndField?: GanttConfig['baselineEndField']; + /** Dynamic group-by field, replacing the parent hierarchy. */ + groupByField?: GanttConfig['groupByField']; + /** Render the per-resource load histogram instead of the timeline grid. */ + resourceView?: GanttConfig['resourceView']; + /** Record field the resource view buckets by. Required for {@link resourceView}. */ + assigneeField?: GanttConfig['assigneeField']; + /** Record field carrying each task's load units (default 1). */ + effortField?: GanttConfig['effortField']; + /** Per-resource capacity ceiling (default 1); loads above it flag overload. */ + capacity?: GanttConfig['capacity']; + /** Quick-filter dropdowns rendered above the chart. See {@link GanttConfig}. */ + quickFilters?: GanttConfig['quickFilters']; + /** Recompute the timeline range when filtering (default true). */ + autoZoomToFilter?: GanttConfig['autoZoomToFilter']; + /** Shift segmentation for the day-mode timeline. See {@link GanttConfig.timeSegments}. */ + timeSegments?: GanttConfig['timeSegments']; + /** Per-interaction switches. See {@link GanttConfig.interactions}. */ + interactions?: GanttConfig['interactions']; + /** Base name for exported PNG/PDF files. See {@link GanttConfig.exportFileName}. */ + exportFileName?: GanttConfig['exportFileName']; + /** Business time zone (IANA name) the calendar renders in. */ + timeZone?: GanttConfig['timeZone']; + /** Whether the store persists dependency link TYPES (fs/ss/ff/sf). */ + dependencyTypes?: GanttConfig['dependencyTypes']; + + // ⛔ `gantt` — the BLOCK face — is DELIBERATELY still undeclared here + // (objectui#6475). It is the 28th key of this card's residue and the only one + // objectui#6051 did not declare; the omission is a scoping decision, not an + // oversight, and reading it as "nothing reads `gantt`" would be wrong — + // `getGanttConfig`'s second branch reads it and honours it in full. + // + // Declaring it is not additive the way the 27 above are. It has no mirror entry + // at all today, so a block rides through `.passthrough()` UNVALIDATED; declaring + // it as {@link GanttConfig} means it gets parsed, and `GanttConfig` derives from + // the spec's `GanttConfigSchema`, which REQUIRES `startDateField`, + // `endDateField` and `titleField`. Because `ObjectGanttSchema` is a member of + // `AnyComponentSchema`, that reaches `safeValidateSchema` and therefore the + // CLI's `validate` / `check` commands: a block missing one of the three moves + // from "accepted, then warned about at runtime" to "refused at authoring time". + // + // That is very likely the RIGHT change — the renderer already feeds the block to + // `GanttConfigSchema.safeParse` and warns, so enforcing restores + // declared = enforced rather than inventing a contract — but it is a published + // CLI's refusal behaviour, and an in-repo census cannot see authored metadata + // living outside this tree. objectui#6475 carries the full measurement and the + // decision. + + // ── The query/data keys the fetch path reads (objectui#6051) ──────────────── + // + // These are NOT gantt config: they are the read the component issues. They were + // declared on `ObjectGridSchema`, which is what `ObjectGanttProps.schema` used + // to be typed as — objectui#5903 retyped that prop to this interface, which is + // correct and is why they now have to be declared HERE. `plugin-gantt`'s + // registry mapping (`OBJECT_GANTT_DATA_SOURCE` in `index.tsx`) names `filter` + // and `sort` as the two keys the element data-source binding maps onto. + /** Inline records, wrapped into a `{ provider: 'value' }` config by `getDataConfig`. */ + staticData?: any[]; + /** Query filter (JSON Rules format), forwarded verbatim as `$filter`. */ + filter?: any[]; + /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. */ + sort?: string | SortConfig[]; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e8b9441de..13559a758 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -594,6 +594,61 @@ export const ObjectTreeSchema = BaseSchema.extend({ defaultExpandedDepth: z.number().optional().describe('Default expansion depth (0 = roots only)'), }); +/** + * objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares + * on {@link GanttConfig} beyond the spec's `GanttConfigSchema` (objectui#6051 + * lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`; + * `timeSegments` was already there). + * + * Held as ONE field map rather than inlined, so the flattened top-level spelling + * below is built from a single source — the same way the TS side derives its + * flattened members from `GanttConfig`. It is deliberately the shape the nested + * `gantt` block would ALSO be built from, one line, if objectui#6475 rules that + * block in; today that entry is severed and this map has one consumer. + * + * Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads + * `^export const` out of this directory and would require a registered TS + * counterpart for it. It has none of its own — it is a fragment of `GanttConfig`, + * and `GanttConfig` is checked through the two faces that carry it. + */ +const GanttConfigExtensionFields = { + borderColorField: z.string().optional().describe('Record field carrying a per-task alert stroke colour'), + lockField: z.string().optional().describe('Record field marking a row view-only (truthy → locked)'), + objectField: z.string().optional().describe("Record field carrying the row's own object API name"), + summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed"), + defaultCollapsedDepth: z.number().optional().describe('Auto-collapse tree nodes at/below this 0-indexed depth'), + dependencyTypes: z.boolean().optional().describe('Whether the store persists dependency link TYPES (fs/ss/ff/sf)'), + timeZone: z.string().optional().describe("Business time zone (IANA name) the chart's calendar renders in"), + exportFileName: z.string().optional().describe('Base name for exported PNG/PDF files'), + interactions: z + .object({ + move: z.boolean().optional().describe('Bar / subtree dragging'), + resize: z.boolean().optional().describe('Edge resize grips'), + progress: z.boolean().optional().describe('The progress drag handle'), + link: z.boolean().optional().describe('Dependency UI: drag-to-link dots and the create/delete menu'), + }) + .optional() + .describe('Per-interaction switches, each defaulting to true'), + timeSegments: z + .object({ + dayStart: z.string().optional().describe("Clock time the shift-day begins, 'HH:mm'"), + bands: z + .array( + z.object({ + key: z.string().optional().describe('Stable band id'), + label: z.string().describe('Display label'), + start: z.string().describe("Band start, 'HH:mm'"), + end: z.string().describe("Band end, 'HH:mm'"), + color: z.string().optional().describe('Accent colour for the column tint'), + }) + ) + .describe('Ordered bands covering the 24h shift-day'), + showMidnight: z.boolean().optional().describe('Draw the dashed calendar-midnight cue'), + }) + .optional() + .describe('Shift segmentation for the day-mode timeline'), +}; + /** * ObjectGantt Schema */ @@ -642,6 +697,47 @@ export const ObjectGanttSchema = BaseSchema.extend({ showBaselines: z.boolean().optional().describe('Render planned-vs-actual baseline bars — defaults ON, only an explicit false disables'), readOnly: z.boolean().optional().describe('Disable every write path and lock the record drawer'), mobileReadOnly: z.boolean().optional().describe('Auto read-only on narrow viewports — defaults ON, only an explicit false disables'), + // objectui#6051 — the FLATTENED `GanttConfig` face. `getGanttConfig` builds its + // config from these top-level keys and returns early whenever `startDateField` + // and `endDateField` are both present; nothing declared them, on either side, + // because `BaseSchema`'s index signature admits them untyped. Mirrored at the + // SAME requiredness as `../objectql.ts` (all optional) so the zod-mirror-parity + // ratchet stays at zero drift for this pair. + // + // The spec-modelled members are taken from `SpecGanttConfigSchema.shape` by + // reference, exactly as `viewMode` above is, so the vocabulary cannot fork. + colorField: SpecGanttConfigSchema.shape.colorField, + dependenciesField: SpecGanttConfigSchema.shape.dependenciesField, + parentField: SpecGanttConfigSchema.shape.parentField, + typeField: SpecGanttConfigSchema.shape.typeField, + tooltipFields: SpecGanttConfigSchema.shape.tooltipFields, + baselineStartField: SpecGanttConfigSchema.shape.baselineStartField, + baselineEndField: SpecGanttConfigSchema.shape.baselineEndField, + groupByField: SpecGanttConfigSchema.shape.groupByField, + resourceView: SpecGanttConfigSchema.shape.resourceView, + assigneeField: SpecGanttConfigSchema.shape.assigneeField, + effortField: SpecGanttConfigSchema.shape.effortField, + capacity: SpecGanttConfigSchema.shape.capacity, + quickFilters: SpecGanttConfigSchema.shape.quickFilters, + autoZoomToFilter: SpecGanttConfigSchema.shape.autoZoomToFilter, + // …and objectui's own ten, from the one field map above. + ...GanttConfigExtensionFields, + // ⛔ `gantt` — the BLOCK face `getGanttConfig`'s second branch reads — is + // DELIBERATELY still unmirrored (objectui#6475). It would be one line here, + // `SpecGanttConfigSchema.extend(GanttConfigExtensionFields).optional()`, built + // from the same field map as the flat face above; the reason it is not is that + // it is the one entry that NARROWS. With no entry a block rides through + // `.passthrough()` unvalidated; with one it is parsed against the spec's + // `GanttConfigSchema`, which REQUIRES startDateField/endDateField/titleField — + // and this mirror reaches the CLI's `validate`/`check` through + // `AnyComponentSchema`, so that is a published refusal change. See the TS + // declaration's note in `../objectql.ts` and objectui#6475. + // The query/data keys the fetch path reads. They were declared on + // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before + // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here. + staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config'), + filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), + sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration, forwarded as $orderby'), }); /**