diff --git a/.changeset/6469-gantt-block-precedence.md b/.changeset/6469-gantt-block-precedence.md new file mode 100644 index 000000000..ffb8feea5 --- /dev/null +++ b/.changeset/6469-gantt-block-precedence.md @@ -0,0 +1,38 @@ +--- +'@object-ui/plugin-gantt': minor +'@object-ui/types': minor +--- + +**plugin-gantt: the `gantt` block now outranks the flat top-level spelling, and +the losing face's keys are named instead of dropped.** + +`getGanttConfig` checked the flattened top-level spelling first and returned +early, so a node carrying both spellings rendered the flat one and every key +inside an authored `gantt` block was discarded with **no diagnostic** — not even +the `GanttConfigSchema.safeParse` warning, which sat behind that early return. + +`plugin-map` had the identical two-faces shape ruled the other way (maintainer +ruling on objectui#5018, 2026-08-17, landed in PR #5156): the block wins, with a +dev-mode warning naming the ignored top-level keys. objectui#6469 inherits that +ruling, so the two sibling view plugins now answer the same question the same +way. + +What changes: + +- A node carrying **both** spellings now renders the **`gantt` block's** values. + The block is taken **whole** — the flat keys are not merged into it. +- In dev, `[ObjectGantt] … these top-level keys are IGNORED: …` names every + shadowed flat key, once per distinct shadowing. +- Nothing else moves. A node with only the flat spelling, or only a block, is + read exactly as before. + +**Producer-safe:** `ObjectView` (`case 'gantt'`) and `ListView` (`case 'gantt'`) +both flatten `options.gantt` onto top-level keys and emit **no** `gantt` key, so +every gantt reached through either view layer still takes the flat branch, and +the new warning cannot fire on that path. This is the same producer check the +`plugin-map` flip pinned, re-run on today's `main`. + +This **supersedes** the precedence sentence in the objectui#6051 changeset +(`.changeset/6051-gantt-flat-config-declared-keys.md`), which recorded the flat +branch winning — accurate for that change, which deliberately did not touch +precedence, and reversed by this one. diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 68ddba5dc..6c651d337 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -227,15 +227,24 @@ chart renders empty: `{ provider: 'schema', schemaId }`. **2. How the fields map — `getGanttConfig`.** Two spellings, checked in order. -Top-level keys are used **only when `startDateField` and `endDateField` are both -present**; otherwise the whole `gantt` block is read instead: +The **`gantt` block wins whenever it is present**, and it is taken WHOLE — the +flat top-level keys are not merged into it. The flat spelling is read only when +there is no `gantt` block, and then only when `startDateField` and +`endDateField` are both present. A node carrying both spellings renders the +block's values and gets a dev-mode warning naming the ignored top-level keys. + +Precedence follows the maintainer ruling on objectui#5018 (2026-08-17), which +settled the identical two-faces shape for `plugin-map`; objectui#6469 inherited +it here. Before that flip the flat branch returned first, so an authored `gantt` +block was discarded silently. ```typescript { type: 'gantt', objectName: 'project_tasks', - // (a) flat spelling — requires BOTH date fields to be taken + // (a) flat spelling — read only when there is no `gantt` block, + // and then only with BOTH date fields present startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', // defaults to 'name' @@ -246,7 +255,7 @@ present**; otherwise the whole `gantt` block is read instead: typeField: 'task_kind', viewMode: 'week', // 'day'|'week'|'month'|'quarter'|'year' - // (b) …or the same configuration as one block: + // (b) …or the same configuration as one block, which OUTRANKS (a): // gantt: { startDateField: 'start_date', endDateField: 'end_date', … } } ``` @@ -255,9 +264,10 @@ present**; otherwise the whole `gantt` block is read instead: spec's `GanttConfigSchema.viewMode`) and is honoured by **both** renderer branches — the timeline and the resource-workload grid. It reaches the renderer through `getGanttConfig`, so it only takes effect alongside a taken gantt -config: as a top-level key it needs `startDateField` + `endDateField` beside it, -or it can sit inside the `gantt` block. Omitting it is meaningful — a persisted -layout then seeds the granularity before the renderer's `'day'` fallback. +config: as a top-level key it needs `startDateField` + `endDateField` beside it +and no `gantt` block on the node, or it can sit inside the `gantt` block. +Omitting it is meaningful — a persisted layout then seeds the granularity before +the renderer's `'day'` fallback. #### Keys this page used to teach that the renderer never reads diff --git a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx new file mode 100644 index 000000000..06a619038 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -0,0 +1,282 @@ +/** + * 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. + */ + +/** + * objectui#6469 — the `gantt` BLOCK outranks the flat top-level spelling, and + * the losing face's keys are NAMED in a dev-mode warning. + * + * ## What changed and why the pin is written this way + * + * `getGanttConfig` used to check the flat spelling FIRST and `return` early, so + * a node carrying both spellings rendered the flat one and every key inside the + * authored `gantt` block was discarded with NO diagnostic — not even the + * `GanttConfigSchema.safeParse` warning, which lived behind that early return. + * `plugin-map` had the identical two-faces shape ruled the other way (maintainer + * on objectui#5018, 2026-08-17, landed PR #5156); this card inherits that + * ruling, so the block wins and the shadowed flat keys are named. + * + * The precedence pin below is written as a RENDERED-VALUES assertion rather than + * a call-level one: the two faces name DIFFERENT record fields, so the direction + * of the flip is visible in the bars themselves. Before the flip the same + * fixture rendered `FLAT …` titles spanning March; after it, `BLOCK …` titles + * spanning January. + * + * ## Why the warning cannot spray + * + * The flat branch is the HOT path for gantt in practice: `ObjectView` + * (`case 'gantt'`) and `ListView` (`case 'gantt'`) both FLATTEN `options.gantt` + * onto top-level keys and emit NO `gantt` key at all, so a hand-authored block + * reaching this component through either view layer has already been flattened + * before `getGanttConfig` sees it. The warning is raised only from the block + * branch, which their output never enters — pinned directly below by the + * "flatten product" case. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { GanttConfig } from '@object-ui/types'; +import { ObjectGantt, FLAT_GANTT_CONFIG_KEYS, type KnownGanttConfigKey } from './ObjectGantt'; + +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
t.title).join('|')} + data-starts={tasks + .map((t: any) => (t.start instanceof Date ? t.start.toISOString().slice(0, 10) : String(t.start))) + .join('|')} + /> + ), +})); + +vi.mock('./ResourceWorkload', () => ({ + ResourceWorkload: ({ tasks }: any) =>
, +})); + +/** + * One record carrying BOTH faces' field names, with values that cannot be + * confused: the block's fields say "BLOCK"/January, the flat spelling's say + * "FLAT"/March. + */ +const INLINE = [ + { + id: '1', + block_name: 'BLOCK Alpha', + flat_name: 'FLAT Alpha', + b_start: '2024-01-01', + b_end: '2024-01-05', + f_start: '2024-03-01', + f_end: '2024-03-05', + }, +]; + +const BLOCK = { startDateField: 'b_start', endDateField: 'b_end', titleField: 'block_name' }; +const FLAT = { startDateField: 'f_start', endDateField: 'f_end', titleField: 'flat_name' }; + +function bothSpellings(extra: Record = {}) { + return { + type: 'object-gantt', + ...FLAT, + gantt: { ...BLOCK }, + data: { provider: 'value', items: INLINE }, + ...extra, + } as any; +} + +async function rendered(schema: any) { + const { container } = render(); + const el = () => container.querySelector('[data-testid="gantt-view"]') as HTMLElement; + await waitFor(() => expect(el()?.getAttribute('data-count')).toBe('1')); + return { + titles: el().getAttribute('data-titles'), + starts: el().getAttribute('data-starts'), + }; +} + +let warn: ReturnType; +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); +}); + +/** Every recorded `console.warn` argument list. */ +const calls = (): unknown[][] => warn.mock.calls as unknown as unknown[][]; +/** …flattened to one searchable string. */ +const warnText = () => calls().map((c) => c.map(String).join(' ')).join('\n'); +const shadowWarnings = () => + calls().filter((c) => String(c[0]).includes('so these top-level keys are')); + +describe('precedence: the `gantt` block outranks the flat spelling (objectui#6469)', () => { + it('renders the BLOCK values when a node carries both spellings', async () => { + // ⛔ The direction pin. Before the flip this same fixture rendered + // `FLAT Alpha` / 2024-03-01 — the flat branch returned before the block was + // ever read. Reverting `getGanttConfig`'s branch order turns this red. + const { titles, starts } = await rendered(bothSpellings({ objectName: 'precedence_a' })); + expect(titles).toBe('BLOCK Alpha'); + expect(starts).toBe('2024-01-01'); + }); + + it('still reads the flat spelling when there is no `gantt` block — the flatten product is unaffected', async () => { + const { titles, starts } = await rendered({ + type: 'object-gantt', + ...FLAT, + data: { provider: 'value', items: INLINE }, + objectName: 'precedence_b', + } as any); + expect(titles).toBe('FLAT Alpha'); + expect(starts).toBe('2024-03-01'); + }); +}); + +describe('the losing face is NAMED, not dropped silently (objectui#6469)', () => { + it('names every shadowed top-level key in the dev-mode warning', async () => { + await rendered( + bothSpellings({ objectName: 'named_keys', colorField: 'flat_color', capacity: 3 }), + ); + + const hits = shadowWarnings(); + expect(hits).toHaveLength(1); + const text = String(hits[0][0]); + + // The keys this node actually shadows — the three flat `FLAT` keys plus the + // two extras. Each is asserted BY NAME: a warning that merely says "some + // keys were ignored" is the silence this card exists to end. + for (const key of ['startDateField', 'endDateField', 'titleField', 'colorField', 'capacity']) { + expect(text).toContain(`\`${key}\``); + } + // …and it does not invent keys the node never carried. + expect(text).not.toContain('`progressField`'); + expect(text).not.toContain('`assigneeField`'); + // The node-level keys that are NOT part of the flat gantt-config face must + // never be named: `objectName` / `data` / `type` are read straight off the + // schema and are unaffected by which config face wins. + expect(text).not.toContain('`objectName`'); + expect(text).not.toContain('`data`'); + }); + + it('says which face won and how to fix it', async () => { + await rendered(bothSpellings({ objectName: 'message_shape' })); + const text = String(shadowWarnings()[0][0]); + expect(text).toContain('[ObjectGantt]'); + expect(text).toContain('`gantt` block'); + expect(text).toContain('IGNORED'); + expect(text).toContain('objectui#6469'); + }); + + it('does NOT fire on the ObjectView / ListView flatten product', async () => { + // The hot path: flat keys, no `gantt` key. Both flatteners emit exactly + // this shape, so a warning here would fire on every flattened gantt node in + // the product. + await rendered({ + type: 'object-gantt', + ...FLAT, + progressField: 'progress', + dependenciesField: 'deps', + data: { provider: 'value', items: INLINE }, + objectName: 'flatten_product', + } as any); + expect(shadowWarnings()).toHaveLength(0); + }); + + it('names GanttConfig keys hoisted beside a block, with no top-level date pair', async () => { + // The shape published authoring guidance actually produces: a `gantt` block + // plus GanttConfig keys hoisted to the top level as if they were node-level + // options. There is no top-level date pair, so this node took the BLOCK + // branch before the flip too — the hoisted keys were already inert, just + // silently. The warning is what changes for it. + await rendered({ + type: 'object-gantt', + gantt: { ...BLOCK }, + quickFilters: [{ field: 'status' }], + autoZoomToFilter: true, + data: { provider: 'value', items: INLINE }, + objectName: 'hoisted_beside_block', + } as any); + + const hits = shadowWarnings(); + expect(hits).toHaveLength(1); + const text = String(hits[0][0]); + expect(text).toContain('`quickFilters`'); + expect(text).toContain('`autoZoomToFilter`'); + }); + + it('does NOT fire for a block with no flat keys beside it', async () => { + await rendered({ + type: 'object-gantt', + gantt: { ...BLOCK }, + data: { provider: 'value', items: INLINE }, + objectName: 'block_only', + } as any); + expect(shadowWarnings()).toHaveLength(0); + }); + + it('warns ONCE per distinct shadowing, not once per render', async () => { + const schema = bothSpellings({ objectName: 'warn_once' }); + await rendered(schema); + await rendered(schema); + await rendered(schema); + expect(shadowWarnings()).toHaveLength(1); + }); + + it('still reports an incomplete winning block through the existing safeParse warning', async () => { + // The gantt-specific consequence of block-wins, stated out loud: the spec's + // `GanttConfigSchema` REQUIRES startDateField/endDateField/titleField + // (`ObjectMapConfigSchema`, the map case this ruling is inherited from, + // requires nothing), so an INCOMPLETE block outranks a complete flat + // spelling. The block is taken whole — merging the flat keys in would be + // the lenient consumer fallback AGENTS.md #0.1 forbids — so the author is + // told twice instead: the config is invalid, AND these flat keys lost. + render( + , + ); + await waitFor(() => expect(warnText()).toContain('Invalid gantt configuration')); + expect(shadowWarnings()).toHaveLength(1); + expect(String(shadowWarnings()[0][0])).toContain('`startDateField`'); + }); +}); + +describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () => { + it('covers the spec-modelled half by derivation and the extensions by list', () => { + // Runtime half: the list is built from `GanttConfigSchema.shape`, so a key + // added to the spec arrives here without a second edit. + expect(FLAT_GANTT_CONFIG_KEYS).toContain('startDateField'); + expect(FLAT_GANTT_CONFIG_KEYS).toContain('quickFilters'); + // objectui's own members, lifted into `GanttConfig` by objectui#6472. + expect(FLAT_GANTT_CONFIG_KEYS).toContain('lockField'); + expect(FLAT_GANTT_CONFIG_KEYS).toContain('timeSegments'); + // The legacy singular alias the flat branch still reads. + expect(FLAT_GANTT_CONFIG_KEYS).toContain('dependencyField'); + // No duplicates — the two halves must not overlap. + expect(new Set(FLAT_GANTT_CONFIG_KEYS).size).toBe(FLAT_GANTT_CONFIG_KEYS.length); + }); +}); + +/** + * Compile-time coverage pin (`tsc -p tsconfig.test.json` type-checks this file). + * `never` exactly while every `GanttConfig` key — the one declaration both faces + * derive from — appears in `FLAT_GANTT_CONFIG_KEYS`. A `GanttConfig` key that + * neither `GanttConfigSchema.shape` nor `GANTT_CONFIG_EXTENSION_KEYS` models + * makes this line fail to compile, NAMING the missing key. + */ +type AssertNever = T; +export type UncoveredGanttConfigKey = AssertNever< + Exclude +>; diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index e886e376d..87f9de182 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -298,15 +298,180 @@ function extractServerMessage(err: unknown): string | null { return null; } +/** + * Dev-only guard for the authoring diagnostics below. Mirrors `plugin-map`'s + * (`ObjectMap.tsx`): the warnings are feedback for whoever wrote the schema, and + * a production bundle should not pay for them. + */ +const isDev = (): boolean => + (globalThis as { process?: { env?: Record } }).process?.env + ?.NODE_ENV !== 'production'; + +/** + * `keyof T` with the string / number INDEX SIGNATURE stripped out. + * + * Load-bearing, not tidiness. `GanttConfig` derives from the spec's + * `GanttConfigSchema`, which carries an index signature, so a bare + * `keyof GanttConfig` widens to `string` — and every guard written against it + * (the `satisfies` below, the coverage pin in + * `ObjectGantt.blockPrecedence.test.tsx`) then constrains NOTHING while looking + * exactly like a guard that does. That is the same blind instrument + * objectui#6051's declaration pin records: an index signature absorbs precisely + * the evidence a type annotation would have produced. Measured here — the pin + * came back `string` before this alias existed. + */ +type KnownKeys = keyof { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + +/** The DECLARED members of `GanttConfig` — its index signature removed. */ +export type KnownGanttConfigKey = KnownKeys; + +/** + * objectui's own `GanttConfig` members — the ten the spec's `GanttConfigSchema` + * does not model. They lived in this file's private `GanttConfigEx` until + * objectui#6472 lifted them into `@object-ui/types`, which is what makes + * `GanttConfig` the single declaration BOTH faces derive from. + * + * Listed here rather than derived because the runtime object that models them + * (`GanttConfigExtensionFields` in `@object-ui/types/zod`) is module-private + * there. `satisfies` keeps every entry a real `GanttConfig` key, and the + * coverage pin in `ObjectGantt.blockPrecedence.test.tsx` fails to compile if + * `GanttConfig` grows a key that neither source models. + */ +const GANTT_CONFIG_EXTENSION_KEYS = [ + 'borderColorField', + 'lockField', + 'objectField', + 'summaryExtent', + 'defaultCollapsedDepth', + 'dependencyTypes', + 'timeZone', + 'exportFileName', + 'interactions', + 'timeSegments', +] as const satisfies readonly KnownGanttConfigKey[]; + +/** + * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat + * branch reads, and what `ObjectView` / `ListView` EMIT. + * + * Both flatteners build an `object-gantt` schema by spreading `options.gantt`'s + * CONTENTS at the top level (`plugin-view/src/ObjectView.tsx` `case 'gantt'`, + * `plugin-list/src/ListView.tsx` `case 'gantt'`); the product carries these keys + * and NO `gantt` key at all. That is an internal transport form, not a second + * authoring surface — and it is why the precedence flip below strands neither + * producer. + * + * The spec-modelled half is DERIVED from `GanttConfigSchema` — the same zod + * object the block branch validates against — so a key added to the spec reaches + * the shadow diagnostic without a second edit (the discipline + * `FLAT_MAP_CONFIG_KEYS` set in objectui#5177). `dependencyField` is the legacy + * singular alias the flat branch still reads beside `dependenciesField`; it is + * not a `GanttConfig` key, so it is named on its own. + */ +export const FLAT_GANTT_CONFIG_KEYS = [ + ...(Object.keys(GanttConfigSchema.shape) as (keyof typeof GanttConfigSchema.shape)[]), + ...GANTT_CONFIG_EXTENSION_KEYS, + 'dependencyField' as const, +]; + +/** + * Warn once per distinct shadowing, not once per evaluation: `getGanttConfig` + * runs on every render of the chart (hover, zoom, quick-filter changes all + * re-render it), and a warning that floods the console is a warning that gets + * muted. Same discipline as `plugin-map`'s `warnedShadowedFlatKeys`. + */ +const warnedShadowedFlatGanttKeys = new Set(); + +/** + * The `gantt` block won and the flat top-level keys alongside it were ignored — + * say which ones, in dev. + * + * Silence is what the precedence rule costs if it is not diagnosed: two + * spellings of ONE vocabulary (objectui#6051 proved they are one — both derive + * from `GanttConfig`) in a single schema, one of them inert. The ruling picks + * the author's block over the flatten product deliberately — maintainer on + * objectui#5018 (2026-08-17) for `plugin-map`, inherited here by objectui#6469 — + * so the diagnostic names what was dropped instead of leaving the author to + * infer it from a chart that renders the other spelling's values. + * + * It cannot fire on the ordinary ObjectView / ListView path, and that matters + * more here than it did for the map: the flat branch is the HOT path for gantt, + * because a hand-authored `gantt` block reaching this component through either + * view layer has already been flattened before `getGanttConfig` sees it. Both + * flatteners emit the flat keys and NO `gantt` key, so this function's block + * branch — the only caller of this warning — is not even reached for their + * output. Reaching it means one schema carries both spellings, which is exactly + * the case the flip changes. + */ +function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void { + if (!isDev()) return; + + const shadowed = FLAT_GANTT_CONFIG_KEYS.filter( + (key) => (schema as Record)[key] !== undefined, + ); + if (shadowed.length === 0) return; + + const memo = `${schema.type ?? 'gantt'}::${schema.objectName ?? ''}::${shadowed.join(',')}`; + if (warnedShadowedFlatGanttKeys.has(memo)) return; + warnedShadowedFlatGanttKeys.add(memo); + + console.warn( + '[ObjectGantt] The `gantt` block configures this chart, so these top-level keys are ' + + `IGNORED: ${shadowed.map((k) => `\`${k}\``).join(', ')}. The \`gantt\` block is the ` + + 'authoring shape; the flat top-level spelling is the internal form ObjectView/ListView ' + + 'produce when they flatten `options.gantt`, and what the author wrote outranks it. The ' + + 'block is taken WHOLE — the flat keys are not merged into it — so move anything you ' + + 'still need into `gantt`, or drop the `gantt` block. objectui#6469.', + ); +} + /** * Helper to get gantt configuration from schema + * + * PRECEDENCE (objectui#6469, inheriting the maintainer ruling on objectui#5018, + * 2026-08-17): the `gantt` block is checked FIRST and wins outright; the + * flattened top-level spelling is consulted only when no `gantt` block is + * present. This REVERSES the pre-#6469 order, under which the flat branch + * returned early and every key inside an authored `gantt` block was discarded + * with no diagnostic at all. `plugin-map` had the identical two-faces shape + * ruled the other way (PR #5156); gantt's answer had never been ruled — it was + * just what the early `return` happened to do. + * + * Safe for the producer path: neither flattener emits a `gantt` key, so their + * output still takes branch 2 exactly as before — see `FLAT_GANTT_CONFIG_KEYS`. + * + * The block is taken WHOLE, not merged over the flat keys. That is the ruling's + * shape ("what the author wrote outranks it"), and merging would be the lenient + * consumer fallback AGENTS.md #0.1 forbids. One consequence is gantt-specific + * and worth stating, because the map case cannot produce it: the spec's + * `GanttConfigSchema` REQUIRES `startDateField` / `endDateField` / `titleField` + * (`ObjectMapConfigSchema` requires nothing), so an INCOMPLETE block now + * outranks a complete flat spelling and yields an incomplete config. Both + * diagnostics fire on that node — `Invalid gantt configuration` from the + * `safeParse` below, and the shadow warning naming the flat keys that lost. */ function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null { - let config: GanttConfigEx | null = null; + // 1. The `gantt` block (the ObjectGridSchema-style shape) — the authoring + // face, and the winner whenever it is present. + if (schema.gantt) { + const config = schema.gantt as GanttConfigEx; + const result = GanttConfigSchema.safeParse(config); + if (!result.success) { + console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format()); + } + warnOnShadowedFlatGanttKeys(schema); + return config; + } - // 1. Check top-level properties (the flattened ObjectGanttSchema style) + // 2. The internal flat form — the ObjectView / ListView flatten product. + // Taken only when BOTH date fields are present, unchanged by the flip; a + // partial flat spelling still falls through to `null`. Deliberately NOT + // `safeParse`d, also unchanged: this branch never validated, and adding + // validation to it is a separate question from precedence. if (schema.startDateField && schema.endDateField) { - config = { + return { startDateField: schema.startDateField, endDateField: schema.endDateField, titleField: schema.titleField || 'name', @@ -337,22 +502,8 @@ function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null { timeZone: schema.timeZone, dependencyTypes: schema.dependencyTypes, }; - return config; - } - - // 2. Check schema.gantt (the block face, ObjectGridSchema style) - if (schema.gantt) { - config = schema.gantt as GanttConfigEx; } - if (config) { - const result = GanttConfigSchema.safeParse(config); - if (!result.success) { - console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format()); - } - return config; - } - return null; } diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b84200d08..573e194df 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2394,10 +2394,11 @@ export interface ObjectGanttSchema extends BaseSchema { // ── 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 + // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. The + // `gantt` block wins whenever it is present (objectui#6469); this flat face is + // read only when there is no block, and then only when `startDateField` AND + // `endDateField` are both present at the TOP LEVEL. The keys of the flat 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 @@ -2409,10 +2410,12 @@ export interface ObjectGanttSchema extends BaseSchema { // 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.) + // ⚠️ WHICH face wins was NOT decided by objectui#6051, which declared these keys. + // It was settled afterwards by objectui#6469, inheriting the maintainer ruling on + // objectui#5018 (2026-08-17) that `plugin-map` shipped in PR #5156: the BLOCK + // wins, taken whole, and the shadowed flat keys are named in a dev-mode warning + // instead of being dropped silently. So a node carrying both spellings renders + // the `gantt` block's values — the reverse of the pre-#6469 order. /** Record field carrying the bar fill colour. See {@link GanttConfig}. */ colorField?: GanttConfig['colorField']; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 13559a758..e5f1c5ec1 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -698,8 +698,9 @@ export const ObjectGanttSchema = BaseSchema.extend({ 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, + // config from these top-level keys when the node carries no `gantt` block and + // `startDateField` / `endDateField` are both present — the block OUTRANKS this + // face (objectui#6469); 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.