From 0ea44ba975d56e6e445f69775b839ffa373c651c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:34:58 +0000 Subject: [PATCH 1/3] fix(plugin-gantt): let the `gantt` block outrank the flat top-level spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getGanttConfig` checked the flattened 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 at all — 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 on objectui#5018, 2026-08-17, landed PR #5156): the block wins, with a dev-mode warning naming the ignored top-level keys. objectui#6469 inherits that ruling. - `getGanttConfig` checks the `gantt` block FIRST and returns it whole; the flat branch is unchanged and is consulted only when no block is present. - `warnOnShadowedFlatGanttKeys` names the shadowed flat keys once per distinct shadowing. It is raised only from the block branch, which neither flattener's output ever enters — `ObjectView` / `ListView` emit the flat keys and no `gantt` key — so it cannot fire on the hot path. - `FLAT_GANTT_CONFIG_KEYS` derives the spec half from `GanttConfigSchema.shape`; objectui's ten `GanttConfig` extensions and the legacy `dependencyField` alias are listed, with a compile-time coverage pin in the new test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- packages/plugin-gantt/README.md | 21 +- .../src/ObjectGantt.blockPrecedence.test.tsx | 258 ++++++++++++++++++ packages/plugin-gantt/src/ObjectGantt.tsx | 165 +++++++++-- 3 files changed, 421 insertions(+), 23 deletions(-) create mode 100644 packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 68ddba5dc7..e21036c23e 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,8 +264,8 @@ 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 +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 0000000000..ed3fa597c1 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -0,0 +1,258 @@ +/** + * 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 } 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 `console.warn` argument list, flattened to one searchable string. */ +const warnText = () => warn.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); +const shadowWarnings = () => + warn.mock.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('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 e886e376db..8f0c274d28 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -298,15 +298,160 @@ 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'; + +/** + * 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 (keyof GanttConfig)[]; + +/** + * 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 +482,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; } From 35e3496aa74b454a8ade4d871ab4e571a46db7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:48:18 +0000 Subject: [PATCH 2/3] docs(plugin-gantt,types): state the flipped precedence, and un-blind the key-coverage guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `packages/types/src/objectql.ts` and its zod mirror documented the OLD order ("the flat branch is checked first and RETURNS EARLY", "no equivalent ruling exists for gantt"). Both now state that the block wins per objectui#6469. - `KnownGanttConfigKey` strips `GanttConfig`'s inherited index signature. A bare `keyof GanttConfig` widens to `string`, so the `satisfies` on the extension list and the coverage pin in the test constrained NOTHING while reading as guards — measured: the pin came back `string`, not `never`. Same blind instrument objectui#6051's declaration pin records. - Adds the changeset, and a pin for the shape published authoring guidance produces (GanttConfig keys hoisted beside a `gantt` block). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6469-gantt-block-precedence.md | 38 +++++++++++++++++++ .../src/ObjectGantt.blockPrecedence.test.tsx | 34 ++++++++++++++--- packages/plugin-gantt/src/ObjectGantt.tsx | 22 ++++++++++- packages/types/src/objectql.ts | 19 ++++++---- packages/types/src/zod/objectql.zod.ts | 5 ++- 5 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 .changeset/6469-gantt-block-precedence.md diff --git a/.changeset/6469-gantt-block-precedence.md b/.changeset/6469-gantt-block-precedence.md new file mode 100644 index 0000000000..ffb8feea59 --- /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/src/ObjectGantt.blockPrecedence.test.tsx b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx index ed3fa597c1..06a6190382 100644 --- a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -40,7 +40,7 @@ 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 } from './ObjectGantt'; +import { ObjectGantt, FLAT_GANTT_CONFIG_KEYS, type KnownGanttConfigKey } from './ObjectGantt'; vi.mock('./GanttView', () => ({ GanttView: ({ tasks }: any) => ( @@ -107,10 +107,12 @@ afterEach(() => { warn.mockRestore(); }); -/** Every `console.warn` argument list, flattened to one searchable string. */ -const warnText = () => warn.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); +/** 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 = () => - warn.mock.calls.filter((c) => String(c[0]).includes('so these top-level keys are')); + 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 () => { @@ -184,6 +186,28 @@ describe('the losing face is NAMED, not dropped silently (objectui#6469)', () => 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', @@ -254,5 +278,5 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () */ type AssertNever = T; export type UncoveredGanttConfigKey = AssertNever< - Exclude + Exclude >; diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 8f0c274d28..87f9de1826 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -307,6 +307,26 @@ 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 @@ -330,7 +350,7 @@ const GANTT_CONFIG_EXTENSION_KEYS = [ 'exportFileName', 'interactions', 'timeSegments', -] as const satisfies readonly (keyof GanttConfig)[]; +] as const satisfies readonly KnownGanttConfigKey[]; /** * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b84200d08d..573e194dff 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 13559a7588..e5f1c5ec13 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. From 9421ba72be6d8b441432e6cf7d473fd0a86b557a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:59:39 +0000 Subject: [PATCH 3/3] docs(plugin-gantt): rewrap the viewMode precedence paragraph Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- packages/plugin-gantt/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index e21036c23e..6c651d3376 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -265,8 +265,9 @@ 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 -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. +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