From b2eea189b68be56b6f844aa72096e7dfcb2a77f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:12:02 +0000 Subject: [PATCH 1/5] refactor(plugin-calendar,plugin-gantt)!: retire the flat fallback read; the calendar and gantt renderers read the block only getCalendarConfig and getGanttConfig read the `calendar` / `gantt` block and nothing flat. The package-local ObjectCalendarFlatFields / ObjectCalendarHandoff types are gone; ObjectGanttFlatFields shrinks to ObjectGanttSearchFields (the `search` / `searchableFields` pair ListView writes at runtime). A flat node reaches the configuration-required screen, which names the block, and the gantt dev warning names every flat key it did not read. The renderer tests that authored flat nodes move to the block form. The spec's object-calendar / object-gantt refusals drop the clause that the renderer still reads the flat spelling, and three comment polishes from the item-1 review land with it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- ...23-calendar-gantt-flat-fallback-retired.md | 24 ++ packages/spec/src/ui/component.test.ts | 6 +- packages/spec/src/ui/component.zod.ts | 62 ++--- ui/packages/plugin-calendar/README.md | 7 +- .../CalendarView.popoverLocale-10747.test.tsx | 6 +- ...Calendar.quickCreateHandoff-12291.test.tsx | 2 +- ...ObjectCalendar.refusalRemedy-8170.test.tsx | 10 +- .../plugin-calendar/src/ObjectCalendar.tsx | 112 ++------- ...Calendar.unconfiguredRefusal-7029.test.tsx | 25 +- ...endar.filterIsNotAConfigSlot-7711.test.tsx | 10 +- .../calendarUnionReads-8651.test.tsx | 69 +++--- .../objectCalendarConfigMembers-8071.test.tsx | 6 +- ui/packages/plugin-calendar/src/index.tsx | 7 +- ...t-calendar-renderer.propsContract.test.tsx | 4 +- ui/packages/plugin-gantt/README.md | 23 +- ui/packages/plugin-gantt/demo/main.tsx | 24 +- .../src/ObjectGantt.blockPrecedence.test.tsx | 161 ++++++------ ...ObjectGantt.contractEnvelope-6839.test.tsx | 20 +- .../src/ObjectGantt.dependencyAlias.test.tsx | 140 ++++------- .../ObjectGantt.exportFileName.i18n.test.tsx | 14 +- .../src/ObjectGantt.persistfilters.test.tsx | 23 +- .../ObjectGantt.propertiesBag-10859.test.tsx | 19 +- ...ctGantt.quickfilter.i18n.fallback.test.tsx | 18 +- .../src/ObjectGantt.quickfilter.i18n.test.tsx | 20 +- .../src/ObjectGantt.quickfilter.test.tsx | 58 +++-- .../ObjectGantt.referenceArms-6837.test.tsx | 10 +- .../plugin-gantt/src/ObjectGantt.test.tsx | 13 +- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 232 ++++++------------ ...ectGantt.unconfiguredRefusal-7070.test.tsx | 15 +- .../src/ObjectGantt.viewmode.test.tsx | 19 +- .../src/__tests__/ganttHandoff-25923.test.ts | 102 ++++++++ .../ganttHandoffFlatFields-25923.test.ts | 120 --------- .../calendar-flat-color-allday-8466.test.ts | 59 ++--- ...-dependency-field-deprecated-alias.test.ts | 12 +- .../gantt-flat-config-declared-keys.test.ts | 8 +- .../__tests__/gantt-search-keys-10250.test.ts | 4 +- ui/packages/types/src/objectql.ts | 12 +- ui/packages/types/src/zod/objectql.zod.ts | 14 +- 38 files changed, 677 insertions(+), 813 deletions(-) create mode 100644 .changeset/25923-calendar-gantt-flat-fallback-retired.md create mode 100644 ui/packages/plugin-gantt/src/__tests__/ganttHandoff-25923.test.ts delete mode 100644 ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts diff --git a/.changeset/25923-calendar-gantt-flat-fallback-retired.md b/.changeset/25923-calendar-gantt-flat-fallback-retired.md new file mode 100644 index 00000000000..0b2aebe5598 --- /dev/null +++ b/.changeset/25923-calendar-gantt-flat-fallback-retired.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-gantt': minor +'@objectstack/spec': patch +--- + +refactor(plugin-calendar,plugin-gantt)!: the calendar and gantt renderers read their binding from the `calendar` / `gantt` block only; the flat fallback read is retired (#25923) + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +`ObjectCalendar` and `ObjectGantt` kept a second, flat reading of their field binding for a host that mounts them directly with the keys written on the node. The spec refuses those keys on the node, `@object-ui/types` no longer declares them, and no producer writes them, so that read was a second dialect nothing should reach. It is retired. + +- **`@object-ui/plugin-calendar`**: `getCalendarConfig` reads the `calendar` block only. A node that writes `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat, with no block, shows the "Calendar configuration required" screen, whose hint names the `calendar` block. The package-local `ObjectCalendarFlatFields` and `ObjectCalendarHandoff` types are gone, and `ObjectCalendarProps['schema']` is the published `ObjectCalendarSchema`. +- **`@object-ui/plugin-gantt`**: `getGanttConfig` reads the `gantt` block only. A node that writes its `GanttConfig` keys flat, with no block, shows the "Gantt configuration required" screen, which now names the `gantt` block, and in development a warning names every flat key the chart did not read, with or without a block beside it. The legacy singular `dependencyField` is read nowhere (the spec spells it `dependenciesField`, inside the block). The package-local `ObjectGanttFlatFields` type is gone; `ObjectGanttProps['schema']` keeps the two search keys `ListView` writes at runtime, now on `ObjectGanttSearchFields`. +- **`@objectstack/spec`**: the `object-calendar` and `object-gantt` flat-key refusals no longer say the renderer still reads the flat spelling. The prescription is unchanged: write the keys in the `calendar` / `gantt` block. + +**BREAKING** for code that mounts `` or `` with a flat node. + +- **Before.** `` drew a chart, and the flat keys compiled against `ObjectGanttProps['schema']`. +- **Now.** The flat keys are a compile error on the props type, and the node shows the configuration-required screen. +- **Fix.** Write the binding in the block: `{ type: 'object-gantt', objectName: 'task', gantt: { startDateField: 's', endDateField: 'e', titleField: 'name' } }` (the `gantt` block requires `titleField`; the flat branch used to floor it at `'name'`), and `{ type: 'object-calendar', calendar: { startDateField: 'start' } }` for a calendar. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index c64d92809f9..6b353b86f58 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3290,9 +3290,9 @@ describe('object-* block props schemas — declared, so the props gate has a sch }); it("object-calendar's flat field spellings get the wrong-layer prescription, not a rename", () => { - // Read as back-compat by getCalendarConfig, emitted by ObjectView/ListView - // handoffs — but the authored spelling is the `calendar` object (one key - // per concept; the `body` → `children` precedent). + // Not read by getCalendarConfig since objectstack#25923, and not emitted by + // ObjectView/ListView — the authored spelling is the `calendar` object (one + // key per concept; the `body` → `children` precedent). const message = refuse(ComponentPropsMap['object-calendar'], { objectName: 'task', startDateField: 'due_date' }); expect(message).toContain('`calendar`'); expect(message).toContain('startDateField'); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5192784930a..7319c9c0145 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6042,15 +6042,13 @@ export type ObjectKanbanProps = z.input; export type ObjectKanbanPropsParsed = z.infer; /** - * The flat per-field spellings `ObjectCalendar` keeps reading as a - * stored-document fallback (`getCalendarConfig`; read points recorded in - * `liveness/page.json`, row `regions`). `ObjectView`/`ListView` hand the - * binding off in the `calendar` block, not flat (objectstack#25923), and the - * flat keys live on `plugin-calendar`'s own hand-off type, not on - * `@object-ui/types`' `ObjectCalendarSchema`. Read, but - * NOT authorable — one composition key per concept (Prime Directive #12, the - * `body` → `children` precedent on {@link PageContainerProps}): the authored - * spelling is the `calendar` object. + * The flat per-field spellings of the `calendar` block, refused on the node. + * `ObjectView`/`ListView` hand the binding off in the `calendar` block, not + * flat, `@object-ui/types`' `ObjectCalendarSchema` declares none of them, and + * `ObjectCalendar`'s `getCalendarConfig` no longer reads them either: its flat + * fallback retired with objectstack#25923. NOT authorable — one composition + * key per concept (Prime Directive #12, the `body` → `children` precedent on + * {@link PageContainerProps}): the authored spelling is the `calendar` object. */ const OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE: readonly KeySetGuidance[] = [ ...COMPONENT_LEVEL_GUIDANCE, @@ -6061,8 +6059,8 @@ const OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE: readonly KeySetGuidance[] = [ prescription: 'Write this as a key of the `calendar` config object instead — `calendar: { startDateField, ' + 'endDateField, titleField, colorField, allDayField }`. `ObjectView`/`ListView` hand the binding ' - + 'off in that block too; the flat spelling is only a stored-document fallback the renderer still ' - + 'reads, not a second authorable spelling (one key per concept, Prime Directive #12).', + + 'off in that block too, and the renderer reads the binding there only: the flat spelling is not ' + + 'read and not a second authorable spelling (one key per concept, Prime Directive #12).', }, ]; @@ -7373,26 +7371,21 @@ export type ObjectMapProps = z.input; export type ObjectMapPropsParsed = z.infer; /** - * The flat `GanttConfig` spellings `getGanttConfig`'s branch 2 reads as a - * stored-document fallback — objectui's own `FLAT_GANTT_CONFIG_KEYS` (read - * points recorded in `liveness/page.json`, row `regions`). `ObjectView` / - * `ListView` hand the binding off in the `gantt` block, not flat - * (objectstack#25923). Read, but - * NOT authorable, for the reason the map's twin set above records: objectui#6469 - * inherited the objectui#5018 ruling for this block, so the `gantt` block is the - * authoring shape, it is taken WHOLE when present, and every flat key beside it - * is named as ignored in a dev warning. + * The flat `GanttConfig` spellings, refused on the node — objectui's own + * `FLAT_GANTT_CONFIG_KEYS`. `ObjectView` / `ListView` hand the binding off in + * the `gantt` block, not flat, and `getGanttConfig` reads that block only: its + * flat fallback branch retired with objectstack#25923. NOT authorable, for the + * reason the map's twin set above records: objectui#6469 inherited the + * objectui#5018 ruling for this block, so the `gantt` block is the authoring + * shape, it is taken WHOLE, and a dev warning names every flat key as not read. * - * The block branch is the path both view layers take — they hand the `gantt` - * block on whole — and the flat branch is only the stored-document fallback. * The prescription still matters: an author who met the flat spelling in an - * older stored document writes it on an SDUI node next. The flat keys live on - * `plugin-gantt`'s package-local hand-off type (`ObjectGanttFlatFields`), not - * on `@object-ui/types`' `ObjectGanttSchema`, which declares only the block. + * older document writes it on an SDUI node next. `@object-ui/types`' + * `ObjectGanttSchema` declares only the block. * * Membership is `GanttConfigSchema`'s own shape plus the legacy singular - * `dependencyField` alias the flat branch still reads beside `dependenciesField` - * — the same two halves objectui derives its list from. Spelled out rather than + * `dependencyField` alias of `dependenciesField` — the same two halves objectui + * derives its list from. Spelled out rather than * read off `GanttConfigSchema.shape` here because that schema is a * {@link lazySchema} proxy and forcing it at module load would build `view.zod` * mid-initialisation (the import-cycle footgun `ruleArrayFilterError` defers @@ -7413,10 +7406,9 @@ const OBJECT_GANTT_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ examples: ['startDateField', 'endDateField', 'viewMode'], prescription: 'Write this as a key of the `gantt` config object instead — `gantt: { startDateField, endDateField, ' - + 'titleField, ... }`. `ObjectView`/`ListView` hand the binding off in that block too; the flat ' - + 'top-level spelling is only a stored-document fallback the renderer still reads, not a second ' - + 'authoring spelling: whenever a `gantt` block is present the renderer takes it WHOLE and names ' - + 'every flat key beside it as ignored. The ' + + 'titleField, ... }`. `ObjectView`/`ListView` hand the binding off in that block too, and the ' + + 'renderer reads the binding there only: the flat top-level spelling is not read and not a second ' + + 'authoring spelling. The ' + 'legacy singular `dependencyField` is `dependenciesField` inside that block.', }, ]; @@ -7483,8 +7475,8 @@ function objectGanttMarker() { * `showBaselines`, `readOnly` (the chart and the record overlay's lock) and * `mobileReadOnly`. * - * Measured and deliberately NOT declared: the flat `GanttConfig` spellings (the - * stored-document fallback — {@link OBJECT_GANTT_FLAT_CONFIG_GUIDANCE}); `title`, which + * Measured and deliberately NOT declared: the flat `GanttConfig` spellings (not + * read since objectstack#25923 — {@link OBJECT_GANTT_FLAT_CONFIG_GUIDANCE}); `title`, which * this renderer never reads (the export-name chain is `gantt.exportFileName` → * `label` → the OBJECT's label → `objectName`); a row cap — the reload's `$top` * is the platform ceiling, spread in as core's `nonGridRowCeilingQuery()` (one @@ -7494,8 +7486,8 @@ function objectGanttMarker() { * props. ⚠️ Recorded, not ruled: objectui#10250 gave the renderer two more * reads, `search` and `searchableFields` (sent as `$search` / `$searchFields`), * which `ListView` writes onto the node it generates from its toolbar Search - * box (its `case 'gantt'` arm) — a host-generated key pair in the same position - * as the flat fallback spellings, and not declared here. + * box (its `case 'gantt'` arm) — a host-generated key pair, and not declared + * here. * * VALUE posture: `gantt` is the one config block in this family whose value * contract is already the SPEC's — the renderer validates it against diff --git a/ui/packages/plugin-calendar/README.md b/ui/packages/plugin-calendar/README.md index 6d69a078e4f..73e14d44c45 100644 --- a/ui/packages/plugin-calendar/README.md +++ b/ui/packages/plugin-calendar/README.md @@ -325,10 +325,9 @@ this. `@objectstack/spec` refuses them at authoring with `unrecognized_keys` and names the `calendar` block in the same diagnostic (one key per concept, its Prime Directive #12), and since objectstack#25923 `ObjectCalendarSchema` does not declare them flat either: `ObjectView` and `ListView` emit the `calendar` -block on the node they build. `getCalendarConfig` still falls back to the flat -spelling when a node has no `calendar` block, for a host that mounts the -renderer directly; that read is typed on this package's own hand-off type, -`ObjectCalendarHandoff`, not on the published schema. +block on the node they build. The renderer does not read the flat spelling +either: a node that carries the five flat keys and no `calendar` block reaches +the "Calendar configuration required" screen, which names the block. ```typescript import type { ObjectCalendarSchema } from '@object-ui/types'; diff --git a/ui/packages/plugin-calendar/src/CalendarView.popoverLocale-10747.test.tsx b/ui/packages/plugin-calendar/src/CalendarView.popoverLocale-10747.test.tsx index beb2c03096a..66bd81bf715 100644 --- a/ui/packages/plugin-calendar/src/CalendarView.popoverLocale-10747.test.tsx +++ b/ui/packages/plugin-calendar/src/CalendarView.popoverLocale-10747.test.tsx @@ -144,8 +144,10 @@ describe('object-calendar — an AUTHORED `locale` reaches the header popover (o type: 'plugin-calendar:object-calendar', id: 'n', objectName: 'accounts', - startDateField: 'start_at', - titleField: 'name', + calendar: { + startDateField: 'start_at', + titleField: 'name', + }, data: [{ id: 'r1', name: 'Standup', start_at: new Date(2020, 2, 10, 10).toISOString() }], ...extra, } as never diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx index 224c7e74fae..ae8d994f6c1 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.quickCreateHandoff-12291.test.tsx @@ -202,7 +202,7 @@ describe('objectui#12291 — an object quick-create cannot satisfy hands the day render( diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx index c60e6598af8..c31cc654549 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx @@ -145,8 +145,10 @@ describe('ObjectCalendar refusal screen — the remedy it states (objectui#8170) { type: 'object-calendar', objectName: 'crm_leave_request', - startDateField: 'start_date', - titleField: 'name', + calendar: { + startDateField: 'start_date', + titleField: 'name', + }, } as any } dataSource={makeDataSource()} @@ -168,7 +170,9 @@ describe('ObjectCalendar refusal screen — the remedy it states (objectui#8170) { type: 'object-calendar', objectName: 'crm_leave_request', - startDateField: 'start_date', + calendar: { + startDateField: 'start_date', + }, } as any } dataSource={makeDataSource()} diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx index d0f263c2d2c..5d31508f72a 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -132,26 +132,6 @@ import { * Pinned in `__tests__/calendarUnionReads-8651.test.tsx`. */ -/** - * The five field-name keys written FLAT on the node: the package-local - * hand-off spelling (objectstack#25923, ui/AGENTS.md Commandment #0.1). - * - * `@objectstack/spec` refuses all five on `object-calendar` and prescribes the - * `calendar` block, so the published {@link ObjectCalendarSchema} declares - * none of them, and both producers in this tree (`ListView`, and - * `ObjectView`'s `generateViewSchema`) emit the block. `getCalendarConfig` - * still reads the flat five when a node carries no block, so a host that mounts - * `` with them keeps its calendar; this type is that - * read, kept here, beside the one consumer, and off the authoring face. - * DERIVED from the block's own type, so the two spellings cannot fork. - */ -export type ObjectCalendarFlatFields = Partial< - Pick ->; - -/** The node `ObjectCalendar` reads: the published schema plus {@link ObjectCalendarFlatFields}. */ -export type ObjectCalendarHandoff = ObjectCalendarSchema & ObjectCalendarFlatFields; - /** * Props of the `ObjectCalendar` React component. * @@ -169,7 +149,7 @@ export type ObjectCalendarHandoff = ObjectCalendarSchema & ObjectCalendarFlatFie * no importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`. */ export interface ObjectCalendarComponentProps { - schema: ObjectCalendarHandoff; + schema: ObjectCalendarSchema; dataSource?: DataSource; className?: string; /** Pre-fetched records passed by a parent (e.g. ObjectView). When provided, skips internal data fetching. */ @@ -322,7 +302,7 @@ type ObjectCalendarConfig = CalendarConfig & { * ⇒ the ruling lands the removal TOGETHER WITH the refusal that makes the same * document fail loudly instead: `@object-ui/types` declares both spellings as * `aliasKeyRefusal()` arms on the view-level calendar block, on this element's - * own `calendar` container, and on the FLAT node face this function reads — so + * own `calendar` container, and on the FLAT node face — so * an author writing `dateField` is refused BY NAME at the key path, pointed at * `startDateField`, on every surface. `ListView`'s calendar branch stops * flattening the two onto the node in the same change. ⛔ Never remove one half @@ -340,36 +320,20 @@ type ObjectCalendarConfig = CalendarConfig & { * `calendar-date-alias-refusal-8355.test.ts` and `plugin-list`'s * `ListView.calendarAliasRefused-8355.test.tsx`. */ -function getCalendarConfig(schema: ObjectCalendarHandoff): ObjectCalendarConfig | null { - // The declared configuration container — read FIRST, as the spec declares it. - if (schema.calendar) { - return schema.calendar as ObjectCalendarConfig; - } - - // The flat spelling — the package-local hand-off (`ObjectCalendarFlatFields`). - // No producer in this tree emits it since objectstack#25923 (`ListView` and - // `ObjectView` hand the view's block on as `calendar`), and the spec refuses - // it on an authored node; a host that mounts this component with it is the - // reader this branch keeps working. +function getCalendarConfig(schema: ObjectCalendarSchema): ObjectCalendarConfig | null { + // The declared configuration container — the one place the binding is read, + // as the spec declares it. // - // ⛔ The two alias rungs are spelled as a cast ON `schema` at each read site, - // never through a renamed local. The repo's read census (objectui#6576's - // `schemaReads`, used by - // `types/src/__tests__/calendar-flat-color-allday-8466.test.ts` and by this - // card's own pin) matches `schema.KEY` and `(schema as T).KEY` — a local alias - // would hide these two reads from the very instrument meant to enumerate them, - // which is the same class of blindness that produced the false census above. - if (schema.startDateField) { - return { - startDateField: schema.startDateField, - endDateField: schema.endDateField, - titleField: schema.titleField, - colorField: schema.colorField, - allDayField: schema.allDayField - } as ObjectCalendarConfig; - } - - return null; + // ⛔ No flat fallback (objectstack#25923). The five field-name keys written + // FLAT on the node (`startDateField`, `endDateField`, `titleField`, + // `colorField`, `allDayField`) are not read: `@objectstack/spec` refuses all + // five on `object-calendar` and prescribes this block, `@object-ui/types` + // declares none of them, and both producers in this tree (`ListView`, and + // `ObjectView`'s `generateViewSchema`) hand the view's block on as + // `calendar`. A node that carries them and no block reaches the "Calendar + // configuration required" refusal screen, whose hint names the block. The + // stored-row census taken before the read retired found no stored flat row. + return schema.calendar ? (schema.calendar as ObjectCalendarConfig) : null; } /** @@ -671,47 +635,25 @@ export const ObjectCalendar: React.FC = ({ schema.staticData, schema.objectName, ]); - // Every key `getCalendarConfig` reads, and nothing it does not. + // Every key `getCalendarConfig` reads, and nothing it does not: since + // objectstack#25923 that is the `calendar` block alone. // // `schema.filter` used to head this list, because the function used to read // `schema.filter.calendar` (objectui#7711). With that arm retired, keeping // `filter` here would say the calendar's CONFIGURATION depends on the query - // filter — the very claim this card removes — and would recompute on every - // filter change for nothing. - // - // Dropping it also removes an accidental co-trigger, so the three flat keys - // the function reads but this list never named were added in the same edit: - // `startDateField`, `endDateField` and `allDayField`. Before that change a - // simultaneous `filter` change could recompute the memo and pick those up by - // luck; that luck is now gone, so the list has to be honest. Pinned in + // filter — the very claim that card removed — and would recompute on every + // filter change for nothing. Pinned in // `__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx`. // - // ⚠️ The two retired alias rungs are NO LONGER listed, and the direction - // matters: this list is exactly what `getCalendarConfig` reads, in BOTH - // directions, so an entry for a key that function no longer reads would be - // as wrong as a missing one. A cut of objectui#8651 dropped them alongside a - // retirement that broke a live authoring path and they came back; objectui#8355 - // retires the rungs FOR REAL — together with the by-name refusal that makes an - // authored alias loud instead of inert — so they leave here with them. - // - // ⭐ objectui#8026 — `allDayField` is now LOAD-BEARING here, not merely - // honest. When #7711 named it, nothing read the key, so the dependency could - // only ever cost a recompute; the events pass below now reads it, so a change - // to the authored `allDayField` genuinely changes what is drawn and this - // entry is what makes that reach the screen. ⚠️ Measured while doing so: the - // #7711 file named above pins `startDateField`'s recompute and `filter`'s - // NON-recompute and mentions neither `allDayField` nor `colorField` — the - // dependency itself has never had an assertion on it. The behaviour rows that - // now depend on this entry live in + // ⚠️ The direction matters: this list is exactly what `getCalendarConfig` + // reads, in BOTH directions, so an entry for a key that function no longer + // reads would be as wrong as a missing one. The two pre-#2231 alias rungs + // left it with objectui#8355, and the five flat field-name keys left it with + // the flat fallback read (objectstack#25923). A key inside the block — the + // load-bearing `allDayField` of objectui#8026 among them — reaches the + // memo through the block's identity. The behaviour rows live in // `__tests__/ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. - const calendarConfig = useMemo(() => getCalendarConfig(schema), [ - schema.calendar, - schema.startDateField, - schema.endDateField, - schema.titleField, - schema.colorField, - schema.allDayField - ]); + const calendarConfig = useMemo(() => getCalendarConfig(schema), [schema.calendar]); const hasInlineData = dataConfig?.provider === 'value'; /** * The record-fetch effect below used to key on `dataConfig` itself — the diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx index 34e1ab8da3d..1238c27f2a0 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx @@ -103,26 +103,25 @@ describe('ObjectCalendar — an unconfigured view reaches the refusal screen (ob await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); }); - it('CONTROL: a correctly configured calendar is completely unaffected', async () => { - // The declared binding renders its events exactly as before — same fields, - // same records, no refusal. Without this case a fix that refused - // EVERYTHING would look identical to the fix that was ruled. + it('REFUSES a binding written FLAT on the node — the hint names the block (objectstack#25923)', async () => { + // The retired fallback. This node used to draw: `getCalendarConfig` read + // the five field-name keys flat when no `calendar` block was present. The + // spec refuses them on the node, no producer emits them, and the stored-row + // census found no stored flat row, so the read is gone and the node lands + // on the refusal screen, whose hint names where the binding belongs. The + // CONTROL below draws the same binding written in the block. render( , ); - await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy()); - expect(screen.getByText('Grace out')).toBeTruthy(); - expect(screen.queryByText(REFUSAL)).toBeNull(); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + expect(screen.getByText(/calendar block/)).toBeTruthy(); + expect(screen.queryByText('Ada out')).toBeNull(); + expect(screen.queryByText('Grace out')).toBeNull(); }); it('CONTROL: the nested spec `calendar` block still configures the renderer', async () => { diff --git a/ui/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx b/ui/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx index e7220327a22..dac332628e0 100644 --- a/ui/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx @@ -217,11 +217,13 @@ describe('objectui#7711 — a legitimate filter on a field NAMED `calendar` stay }); describe('objectui#7711 — the `calendarConfig` memo depends on every key the config can come from', () => { - it('recomputes when only `startDateField` changes', async () => { + it('recomputes when only the block\'s `startDateField` changes', async () => { + // Since objectstack#25923 the `calendar` block is the one key the config + // comes from, so a changed binding arrives as a new block. const dataSource = makeDataSource(); - const base = { type: 'object-calendar', objectName: OBJECT, titleField: 'name' }; - const schemaA: any = { ...base, startDateField: 'starts_at' }; - const schemaB: any = { ...base, startDateField: 'other_at' }; + const base = { type: 'object-calendar', objectName: OBJECT }; + const schemaA: any = { ...base, calendar: { titleField: 'name', startDateField: 'starts_at' } }; + const schemaB: any = { ...base, calendar: { titleField: 'name', startDateField: 'other_at' } }; const { rerender } = render(); await waitFor(() => expect(screen.getByTestId('calendar-view')).toBeTruthy()); diff --git a/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx index caa86c82b34..f7655a5edc0 100644 --- a/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -137,10 +137,14 @@ * spec refuses them on the node and prescribes the `calendar` block, which both * producers now emit. `getCalendarConfig` still reads them flat, so they are * declared on this package's hand-off type, `ObjectCalendarFlatFields`, and the - * props type is `ObjectCalendarHandoff`. The population row below therefore - * takes its declared set from BOTH: the mirror's shape, and the hand-off's five, - * pinned equal to that type's keys at compile time. The paragraphs above are - * this card's reading at the time, kept as written. + * props type is `ObjectCalendarHandoff`. The paragraphs above are this card's + * reading at the time, kept as written. + * + * Then the same card retired the flat fallback read, once the stored-row census + * found no stored flat row: `getCalendarConfig` reads the `calendar` block only, + * the hand-off type is gone, and the props type is the published schema again. + * The population row below takes its declared set from the mirror's shape + * alone, and the five are pinned as read nowhere flat. * * ## The ceiling, stated rather than assumed (objectui#5155 / #7927) * @@ -166,8 +170,6 @@ import type { ObjectCalendarSchema } from '@object-ui/types'; import { ObjectCalendar, type ObjectCalendarComponentProps, - type ObjectCalendarFlatFields, - type ObjectCalendarHandoff, } from '../ObjectCalendar'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) @@ -229,11 +231,10 @@ const CANONICAL_TWINS = ['startDateField', 'endDateField'] as const; const PRODUCER = 'packages/plugin-list/src/ListView.tsx'; /** - * The five keys the renderer reads FLAT, declared on the package-local hand-off - * type rather than on the published schema (objectstack#25923). Pinned equal to - * `keyof ObjectCalendarFlatFields` below, so this list cannot drift from it. + * The five field-name keys the renderer read FLAT until objectstack#25923 + * retired that fallback; they are read in the `calendar` block only now. */ -const HANDOFF_FLAT_KEYS = ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField'] as const; +const RETIRED_FLAT_KEYS = ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField'] as const; /** A key nothing reads and nothing declares: the both-ways control. */ const CONTROL_KEY = 'zzqxNoSuchField'; @@ -294,35 +295,26 @@ type Expect = T; * and a one-way check accepts `any` on both sides — which is precisely the * before-state this card removed. */ -export type assertionSchemaPropIsThePublishedElementSchemaPlusTheHandoff = - Expect>; -export type assertionHandoffIsThePublishedSchemaPlusTheFlatFive = - Expect>; -/** The runtime list above is the hand-off type's key set, exactly. */ -export type assertionHandoffKeysAreTheList = - Expect>; -/** …and none of the five is a member of the PUBLISHED schema any more (objectstack#25923). */ +export type assertionSchemaPropIsThePublishedElementSchema = + Expect>; +/** …and none of the five is a member of it (objectstack#25923), so none is typed on the node at all. */ export type assertionPublishedSchemaDeclaresNoFlatKey = - Expect, never>>; + Expect, never>>; /** The helper can FAIL — synthetic control. */ export type assertionEqualCanFail = Expect, false>>; /* ── 1. The props type ────────────────────────────────────────────────────── */ describe('objectui#8651 — the props type is the published element schema', () => { - it('accepts the flat calendar vocabulary contextually typed, with NO cast', () => { - const schema: ObjectCalendarComponentProps['schema'] = { + it('refuses the flat calendar vocabulary, contextually typed (objectstack#25923)', () => { + const flat: ObjectCalendarComponentProps['schema'] = { type: 'object-calendar', objectName: 'duly_task', + // @ts-expect-error — the flat key is no member of the node; write `calendar.startDateField` startDateField: 'kickoff', - endDateField: 'wrapup', - titleField: 'nickname', - colorField: 'status_colour', - allDayField: 'is_all_day', defaultView: 'week', }; - expect(schema.startDateField).toBe('kickoff'); - expect(schema.defaultView).toBe('week'); + expect(flat.defaultView).toBe('week'); }); it('accepts the nested `calendar` container the spec names, contextually typed', () => { @@ -340,8 +332,8 @@ describe('objectui#8651 — the props type is the published element schema', () describe('objectui#8651 — every key read off the node is declared on that schema', () => { it('no read is undeclared — `navigation` included, since objectui#8652', () => { const reads = rendererReads(); - // The mirror's shape, plus the hand-off's five (objectstack#25923). - const declared = new Set([...shapeKeys(ObjectCalendarMirror), ...HANDOFF_FLAT_KEYS]); + // The mirror's shape alone since objectstack#25923 retired the flat reads. + const declared = new Set(shapeKeys(ObjectCalendarMirror)); const exempt = new Set([...LEDGERED_OTHER_CARD_READS]); const undeclared = [...reads] .filter((key) => !declared.has(key)) @@ -382,12 +374,12 @@ describe('objectui#8651 — every key read off the node is declared on that sche }); }); -describe('objectstack#25923 — the five flat reads are the hand-off\'s, not the published schema\'s', () => { - it('every one is still READ, and none is a member of the mirror', () => { +describe('objectstack#25923 — the five flat field-name keys are read nowhere flat', () => { + it('none is READ off the node, and none is a member of the mirror', () => { const reads = rendererReads(); const mirror = shapeKeys(ObjectCalendarMirror); - for (const key of HANDOFF_FLAT_KEYS) { - expect([...reads], `the renderer no longer reads the flat ${key}; shrink the hand-off type`).toContain(key); + for (const key of RETIRED_FLAT_KEYS) { + expect([...reads], `the renderer reads the flat ${key} again`).not.toContain(key); expect(mirror, `the published mirror declares the flat ${key} again`).not.toContain(key); } // CONTROL, same two instruments: `calendar` is read AND declared, so the @@ -458,11 +450,14 @@ describe('objectui#8355 — the `dateField` / `endField` rungs are RETIRED at bo } }); - it('CONTROL: the same census sees the canonical twins, and invents nothing', () => { + it('CONTROL: the same census sees the block the canonical twins live in, and invents nothing', () => { // Without this row the absence above is not a reading: a census that had - // stopped matching anything would report the same two zeros. + // stopped matching anything would report the same two zeros. Since + // objectstack#25923 the canonical twins are read inside the `calendar` + // block, never off the node, so the census sees the block. const reads = rendererReads(); - for (const key of CANONICAL_TWINS) expect([...reads]).toContain(key); + expect([...reads]).toContain('calendar'); + for (const key of CANONICAL_TWINS) expect(reads.has(key)).toBe(false); expect(reads.has(CONTROL_KEY)).toBe(false); }); @@ -650,7 +645,7 @@ describe('objectui#8355 — the retired node refuses, and the canonical one stil it('CONTROL: the canonical spelling on the same rows draws — the ladder removal narrowed TWO keys', async () => { render( , ); diff --git a/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx b/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx index f766cfec611..897ca077955 100644 --- a/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx @@ -171,8 +171,10 @@ describe('object-calendar.calendar — member shape (objectui#8071 slice 5)', () it('CONTROL: with no `calendar` object authored, the flat legacy spelling is read instead', async () => { const [event] = await eventsFor({ - startDateField: 'other_start', - titleField: 'other_title', + calendar: { + startDateField: 'other_start', + titleField: 'other_title', + }, }); expect(event.start).toEqual(new Date(ROW.other_start)); expect(event.title).toBe(ROW.other_title); diff --git a/ui/packages/plugin-calendar/src/index.tsx b/ui/packages/plugin-calendar/src/index.tsx index 1c70001a1dd..d17a100f797 100644 --- a/ui/packages/plugin-calendar/src/index.tsx +++ b/ui/packages/plugin-calendar/src/index.tsx @@ -291,10 +291,9 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any {/* The forward set is exactly `ObjectCalendarComponentProps` — nothing else can reach the component, because nothing is spread from `rest` (objectui#4492). - The registry's own declared inputs (`objectName`, `calendar`, and the - flat `startDateField` / `titleField` / … spelling `ObjectView` and - `ListView` emit) are consumed through `schema`, never through this - channel, so none of them needs a forward. + The registry's own declared inputs (`objectName` and the `calendar` + block `ObjectView` and `ListView` emit) are consumed through `schema`, + never through this channel, so none of them needs a forward. */} {(bound) => ( ` directly, typed on this package's -own hand-off type, `ObjectGanttHandoff`. +**2. How the fields map — `getGanttConfig`.** One spelling: the **`gantt` +block**, taken WHOLE and validated against the spec's `GanttConfigSchema`. The +same keys written flat on the node are not read. A node that carries them and +no `gantt` block reaches the "Gantt configuration required" screen, and in dev +a warning names the top-level keys the chart ignored. **Author the `gantt` +block**: an authored node cannot carry the flat spelling — `objectui validate` +refuses each flat key and names `properties.gantt.KEY`, and the spec's row +refuses it inside the bag too. Since objectstack#25923 `ObjectGanttSchema` does +not declare the flat keys, `ObjectView` and `ListView` write the `gantt` block +onto the node they compose, and the renderer's flat fallback read is retired. Precedence follows the maintainer ruling on objectui#5018 (2026-08-17), which settled the identical two-faces shape for `plugin-map`; objectui#6469 inherited diff --git a/ui/packages/plugin-gantt/demo/main.tsx b/ui/packages/plugin-gantt/demo/main.tsx index 9e61963e8af..7256b1e4ed5 100644 --- a/ui/packages/plugin-gantt/demo/main.tsx +++ b/ui/packages/plugin-gantt/demo/main.tsx @@ -367,17 +367,19 @@ const planDataSource = { const planSchema = { type: 'gantt', objectName: 'production_plan', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - progressField: undefined, - quickFilters: [ - { field: 'project', label: '项目' }, - { field: 'product', label: '产品' }, - { field: 'status', label: '状态' }, - { field: 'dispatch_type', label: '派工类别' }, - { field: 'owner', label: '管理责任人' }, - ], + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + progressField: undefined, + quickFilters: [ + { field: 'project', label: '项目' }, + { field: 'product', label: '产品' }, + { field: 'status', label: '状态' }, + { field: 'dispatch_type', label: '派工类别' }, + { field: 'owner', label: '管理责任人' }, + ], + }, // autoZoomToFilter defaults to true → timeline rescales to the filtered span. } as any; diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx index 62490f91f66..a9e4635e8ab 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -7,37 +7,31 @@ */ /** - * objectui#6469 — the `gantt` BLOCK outranks the flat top-level spelling, and - * the losing face's keys are NAMED in a dev-mode warning. + * The `gantt` BLOCK is the one spelling `getGanttConfig` reads, and flat + * top-level config keys are NAMED in a dev-mode warning as not read. * - * ## What changed and why the pin is written this way + * ## History * * `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. + * authored `gantt` block was discarded with NO diagnostic. objectui#6469 + * inherited the `plugin-map` ruling (maintainer on objectui#5018, 2026-08-17, + * landed PR #5156): the block won, and the shadowed flat keys were named. The + * flat spelling stayed as a second branch for a node with no block, because + * `ObjectView` and `ListView` flattened `options.gantt` onto top-level keys. * - * 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. + * objectstack#25923 moved both producers to the block and then retired that + * second branch, once the stored-row census found no stored flat row. So a + * node that writes its binding flat now reaches the "Gantt configuration + * required" refusal screen, and the warning names the flat keys on every node + * that carries them, with a block or without. * - * ## 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. + * The pins are RENDERED-VALUES assertions: the two spellings name DIFFERENT + * record fields, so which one was read is visible in the bars themselves — + * `BLOCK …` titles spanning January, never `FLAT …` titles spanning March. */ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ObjectGantt, FLAT_GANTT_CONFIG_KEYS, type KnownGanttConfigKey } from './ObjectGantt'; @@ -110,87 +104,90 @@ afterEach(() => { 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')); +const flatKeyWarnings = () => + calls().filter((c) => String(c[0]).includes('top-level keys are NOT READ')); + +const REFUSAL = /Gantt configuration required/; -describe('precedence: the `gantt` block outranks the flat spelling (objectui#6469)', () => { +describe('one spelling: the `gantt` block (objectstack#25923)', () => { 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 () => { + it('REFUSES a node whose binding is written flat with no `gantt` block', async () => { + // The retired fallback. This fixture used to render `FLAT Alpha`; with the + // flat branch gone it carries no binding the chart reads. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + expect(screen.getByText(REFUSAL).textContent).toContain('gantt block'); + expect(screen.queryByTestId('gantt-view')).toBeNull(); + // …and the dev warning names the keys that were not read. + const hits = flatKeyWarnings(); + expect(hits).toHaveLength(1); + for (const key of ['startDateField', 'endDateField', 'titleField']) { + expect(String(hits[0][0])).toContain(`\`${key}\``); + } + }); + + it('CONTROL: the same binding moved into the block draws', async () => { const { titles, starts } = await rendered({ type: 'object-gantt', - ...FLAT, + gantt: { ...FLAT }, data: { provider: 'value', items: INLINE }, - objectName: 'precedence_b', + objectName: 'flat_moved_into_block', } as any); expect(titles).toBe('FLAT Alpha'); expect(starts).toBe('2024-03-01'); + expect(flatKeyWarnings()).toHaveLength(0); }); }); -describe('the losing face is NAMED, not dropped silently (objectui#6469)', () => { - it('names every shadowed top-level key in the dev-mode warning', async () => { +describe('flat keys are NAMED, not dropped silently (objectui#6469, objectstack#25923)', () => { + it('names every flat top-level key in the dev-mode warning', async () => { await rendered( bothSpellings({ objectName: 'named_keys', colorField: 'flat_color', capacity: 3 }), ); - const hits = shadowWarnings(); + const hits = flatKeyWarnings(); 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. + // Each key the node carries flat is asserted BY NAME: a warning that merely + // says "some keys were ignored" is the silence objectui#6469 ended. 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. + // Node-level keys that are not gantt config are never named. expect(text).not.toContain('`objectName`'); expect(text).not.toContain('`data`'); }); - it('says which face won and how to fix it', async () => { + it('says where the keys belong', async () => { await rendered(bothSpellings({ objectName: 'message_shape' })); - const text = String(shadowWarnings()[0][0]); + const text = String(flatKeyWarnings()[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); + expect(text).toContain('NOT READ'); + expect(text).toContain('objectstack#25923'); }); 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 }, @@ -200,39 +197,37 @@ describe('the losing face is NAMED, not dropped silently (objectui#6469)', () => objectName: 'hoisted_beside_block', } as any); - const hits = shadowWarnings(); + const hits = flatKeyWarnings(); 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 () => { + it('does NOT fire for a block with no flat keys beside it — the ObjectView / ListView product', async () => { await rendered({ type: 'object-gantt', gantt: { ...BLOCK }, data: { provider: 'value', items: INLINE }, objectName: 'block_only', } as any); - expect(shadowWarnings()).toHaveLength(0); + expect(flatKeyWarnings()).toHaveLength(0); }); - it('warns ONCE per distinct shadowing, not once per render', async () => { + it('warns ONCE per distinct key set, not once per render', async () => { const schema = bothSpellings({ objectName: 'warn_once' }); await rendered(schema); await rendered(schema); await rendered(schema); - expect(shadowWarnings()).toHaveLength(1); + expect(flatKeyWarnings()).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. + it('still reports an incomplete block through the existing safeParse warning', async () => { + // The spec's `GanttConfigSchema` REQUIRES startDateField/endDateField/ + // titleField, so an INCOMPLETE block is refused even when flat keys beside + // it would complete it — merging them in would be the lenient consumer + // fallback AGENTS.md #0.1 forbids. The author is told twice instead: the + // config is invalid, AND these flat keys are not read. render( />, ); await waitFor(() => expect(warnText()).toContain('Invalid gantt configuration')); - expect(shadowWarnings()).toHaveLength(1); - expect(String(shadowWarnings()[0][0])).toContain('`startDateField`'); + expect(flatKeyWarnings()).toHaveLength(1); + expect(String(flatKeyWarnings()[0][0])).toContain('`startDateField`'); }); }); @@ -264,7 +259,7 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () // keep arriving — now by the same derivation as the line above, not by list. expect(FLAT_GANTT_CONFIG_KEYS).toContain('lockField'); expect(FLAT_GANTT_CONFIG_KEYS).toContain('timeSegments'); - // The legacy singular alias the flat branch still reads. + // The legacy singular alias, named so a node that writes it is told too. 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); @@ -273,8 +268,8 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () /** * 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 + * `never` exactly while every `GanttConfig` key appears in + * `FLAT_GANTT_CONFIG_KEYS`, so the warning can name any of them. A `GanttConfig` key that * `GanttConfigSchema.shape` does not model makes this line fail to compile, * NAMING the missing key. Before objectui#7845 a second literal * (`GANTT_CONFIG_EXTENSION_KEYS`) could also satisfy it; the spec now declares diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx index 6b2bc9382ad..847b6e7acbf 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx @@ -126,15 +126,17 @@ const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length }); const GANTT_SCHEMA = { type: 'gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - quickFilters: [ - { field: 'project', label: 'Project' }, - // The control dimension — resolved by the SAME `setLookupOptions` commit - // as `project`, and answered OUTSIDE the envelope under test. - { field: 'owner', label: 'Owner' }, - ], + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [ + { field: 'project', label: 'Project' }, + // The control dimension — resolved by the SAME `setLookupOptions` commit + // as `project`, and answered OUTSIDE the envelope under test. + { field: 'owner', label: 'Owner' }, + ], + }, } as any; /** diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.dependencyAlias.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.dependencyAlias.test.tsx index fd9ab6cccf2..c5bb1404db3 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.dependencyAlias.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.dependencyAlias.test.tsx @@ -7,47 +7,29 @@ */ /** - * Behaviour pin — deprecating `dependencyField` changed NOTHING at the read site - * (objectui#6470). + * Behaviour pin — the deprecated singular `dependencyField` is READ NOWHERE + * since objectstack#25923. * - * ## What this file is for + * ## History * - * objectui#6470 marks the singular `dependencyField` `@deprecated` on both - * published declaration faces (`packages/types/src/objectql.ts` and its zod - * mirror), naming the spec's `dependenciesField` as canonical. The marker is - * documentation; `getGanttConfig`'s flat branch still reads - * `dependenciesField || dependencyField` and every author who wrote the singular - * must keep rendering exactly as before. + * objectui#6470 marked the singular `@deprecated` on both published declaration + * faces, naming the spec's `dependenciesField` as canonical, and this suite + * pinned that `getGanttConfig`'s flat branch still read + * `dependenciesField || dependencyField`, so removal would be a decision rather + * than an accident. objectstack#25923 took that decision: the singular left the + * published faces first, then the flat fallback branch retired, and the alias + * limb with it. The spec never had the singular (`GanttConfigSchema` declares + * `dependenciesField` only), so the one spelling left is the plural, inside the + * `gantt` block. * - * ⛔ Removal was explicitly excluded from that card and deferred to a future - * enforce-or-remove decision. This suite is what makes the exclusion mechanical: - * drop the `|| schema.dependencyField` limb and the first two cases below go red - * naming the break, rather than a published surface silently losing an accepted - * spelling. + * ## Why the spellings name DIFFERENT record fields here * - * ## Why the two spellings name DIFFERENT record fields here - * - * A fixture where both spellings point at the same field can only prove that - * SOMETHING resolved; it cannot show WHICH limb of the `||` produced it, so it - * would stay green with either limb deleted. Pointing them at different fields - * (`legacy_preds` vs `canonical_preds`, with values that cannot be confused) - * makes the resolved config visible in the rendered tasks — the same technique - * `ObjectGantt.blockPrecedence.test.tsx` uses for the block/flat flip. - * - * The "same config" half the card asks for is then pinned directly, by the - * third case: the two spellings aimed at ONE field produce byte-identical task - * dependencies. - * - * ## The observable - * - * `dependenciesField` is not passed to `GanttView` as a config value — it is - * consumed on the way in (`dependencies: normalizeDependencies(record[dep])`) - * and again as the `autoSchedule` / `onDependencyCreate` switches. Both are - * observed: the per-task dependency ids say WHICH field was read, and - * `autoSchedule` says the config resolved to a truthy field name at all. + * The legacy field's ids start `L`, the canonical field's start `C`, so the + * rendered tasks say WHICH field was read, and `autoSchedule` says whether the + * config resolved to a dependency field at all. */ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ObjectGantt } from './ObjectGantt'; @@ -67,7 +49,7 @@ vi.mock('./ResourceWorkload', () => ({ })); /** - * One record carrying BOTH candidate fields, with values that cannot be + * One record carrying both candidate fields, with values that cannot be * confused: the legacy field's ids start `L`, the canonical field's start `C`. */ const INLINE = [ @@ -78,22 +60,22 @@ const INLINE = [ end: '2024-01-05', legacy_preds: 'L1,L2', canonical_preds: 'C1,C2', - shared_preds: 'S1,S2', }, ]; const DATES = { startDateField: 'start', endDateField: 'end', titleField: 'name' }; -async function rendered(extra: Record, objectName: string) { +/** `gantt` adds members to the node's `gantt` block; `flat` lands on the node itself. */ +async function rendered(gantt: Record, flat: Record, objectName: string) { const { container } = render( , @@ -106,70 +88,52 @@ async function rendered(extra: Record, objectName: string) { }; } -describe('both dependency spellings still resolve to the same config (objectui#6470)', () => { - it('the DEPRECATED singular is still read', async () => { - // ⛔ The removal guard. Deleting `|| schema.dependencyField` from - // `getGanttConfig` turns this red — every author who wrote the singular - // loses their dependency links, and `autoSchedule` silently switches off - // with them. - const { deps, autoSchedule } = await rendered( - { dependencyField: 'legacy_preds' }, - 'alias_singular', - ); - expect(deps).toBe('L1+L2'); - expect(autoSchedule).toBe('true'); - }); - - it('the CANONICAL plural is read', async () => { - // The counter-probe: the assertion above must be about the singular limb, - // not about dependencies working at all. - const { deps, autoSchedule } = await rendered( - { dependenciesField: 'canonical_preds' }, - 'alias_plural', - ); +describe('the dependency binding has one spelling: `gantt.dependenciesField` (objectstack#25923)', () => { + it('the CANONICAL plural in the block is read', async () => { + const { deps, autoSchedule } = await rendered({ dependenciesField: 'canonical_preds' }, {}, 'alias_plural'); expect(deps).toBe('C1+C2'); expect(autoSchedule).toBe('true'); }); - it('aimed at ONE field, the two spellings produce an identical result', async () => { - // The card's acceptance criterion, stated directly: the deprecation is a - // marker, so the singular and the plural are interchangeable at the read - // site. Compared value-to-value rather than each against a literal, so this - // stays true however `normalizeDependencies` evolves. - const viaLegacy = await rendered({ dependencyField: 'shared_preds' }, 'alias_same_a'); - const viaCanonical = await rendered({ dependenciesField: 'shared_preds' }, 'alias_same_b'); - expect(viaLegacy).toEqual(viaCanonical); - expect(viaLegacy.deps).toBe('S1+S2'); + it('the DEPRECATED singular written flat beside the block is not read', async () => { + const { deps, autoSchedule } = await rendered({}, { dependencyField: 'legacy_preds' }, 'alias_singular_flat'); + expect(deps).toBe(''); + expect(autoSchedule).toBe('false'); }); - it('the canonical spelling WINS when a node carries both', async () => { - // Precedence, and the reason the deprecation marker is truthful: an author - // migrating key-by-key gets the canonical value, not a merge and not the - // legacy one. + it('the canonical spelling is the one read when a node carries both', async () => { const { deps } = await rendered( - { dependenciesField: 'canonical_preds', dependencyField: 'legacy_preds' }, + { dependenciesField: 'canonical_preds' }, + { dependencyField: 'legacy_preds' }, 'alias_both', ); expect(deps).toBe('C1+C2'); }); - it('an EMPTY canonical value falls through to the alias — a property of `||`', async () => { - // Recorded, not designed. `dependenciesField: ''` is falsy, so the `||` - // reaches the singular; `??` would not. Pinned so that a future rewrite of - // that expression is a decision rather than an accident — and so the - // enforce-or-remove card inherits the real accept semantics instead of - // re-deriving them. - const { deps } = await rendered( - { dependenciesField: '', dependencyField: 'legacy_preds' }, - 'alias_empty_canonical', + it('the singular INSIDE the block is refused, never drawn', async () => { + // `GanttConfigSchema` is closed (objectstack#15469) and has no singular, so + // the block fails its parse and reaches the refusal screen. + render( + , ); - expect(deps).toBe('L1+L2'); + await waitFor(() => expect(screen.getByTestId('gantt-invalid-config')).toBeTruthy()); + expect(screen.getByTestId('gantt-invalid-config').textContent).toContain('dependencyField'); + expect(screen.queryByTestId('gantt-view')).toBeNull(); }); it('neither spelling authored: no dependencies, and auto-scheduling stays OFF', async () => { - // Non-vacuity for every case above — without this, a harness that always - // reported the same string would pass them all. - const { deps, autoSchedule } = await rendered({}, 'alias_none'); + // Non-vacuity for the absence rows above — without this, a harness that + // always reported the same string would pass them. + const { deps, autoSchedule } = await rendered({}, {}, 'alias_none'); expect(deps).toBe(''); expect(autoSchedule).toBe('false'); }); diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.exportFileName.i18n.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.exportFileName.i18n.test.tsx index 9866b1ce6a9..97deb77531b 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.exportFileName.i18n.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.exportFileName.i18n.test.tsx @@ -76,13 +76,17 @@ const ITEMS = [ { id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10' }, ]; -function ganttSchema(extra: Record = {}) { +/** `extra` lands on the node; `gantt` adds members to its `gantt` block. */ +function ganttSchema(extra: Record = {}, gantt: Record = {}) { return { type: 'object-gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + ...gantt, + }, data: { provider: 'value', items: ITEMS }, ...extra, } as any; @@ -196,7 +200,7 @@ describe('ObjectGantt export filename resolves the inline locale map (objectui#6 it('CONTROL — an explicit exportFileName still wins the chain over the map', async () => { const name = await exportPngName( - ganttSchema({ label: INLINE_MAP, exportFileName: 'Shift Plan Gantt' }), + ganttSchema({ label: INLINE_MAP }, { exportFileName: 'Shift Plan Gantt' }), 'zh-CN', ); expect(name).toMatch(named('Shift Plan Gantt')); diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx index 074836c6481..6d3d20b86ec 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx @@ -35,14 +35,18 @@ const INLINE = [ { id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', status: 'doing' }, ]; -function schema(extra: Record = {}) { +/** `extra` lands on the node; `gantt` adds members to its `gantt` block. */ +function schema(extra: Record = {}, gantt: Record = {}) { return { type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [{ field: 'status', label: '状态', options: ['todo', 'doing'] }], + ...gantt, + }, data: { provider: 'value', items: INLINE }, - quickFilters: [{ field: 'status', label: '状态', options: ['todo', 'doing'] }], ...extra, } as any; } @@ -93,11 +97,10 @@ describe('ObjectGantt tooltip fields skip empty values (悬浮分层字段)', () { id: '1', name: 'Plan', start: '2024-01-01', end: '2024-01-05', category: '排产', qty: 0, owner: '' }, { id: '2', name: 'Dispatch', start: '2024-02-01', end: '2024-02-10', owner: '张三', tags: [] }, ]; - const s = schema({ - data: { provider: 'value', items }, - quickFilters: undefined, - tooltipFields: ['category', 'qty', 'owner', 'tags', 'missing'], - }); + const s = schema( + { data: { provider: 'value', items } }, + { quickFilters: undefined, tooltipFields: ['category', 'qty', 'owner', 'tags', 'missing'] }, + ); const { container, getByTestId } = render(); await waitFor(() => expect(gv(container).getAttribute('data-count')).toBe('2')); // Row 1: category + qty (0 is a real value) survive; empty owner, absent diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx index 8867eaf3539..df913675028 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx @@ -19,10 +19,12 @@ * * - the bag queries its `objectName` with its own `filter` and `sort`, and * hands the chart one task per returned row, titled by its `gantt` block; - * - the flat spellings code still composes — the `gantt` block on the node, - * and the flattened `GanttConfig` keys `ObjectView` writes — make the - * identical call and hand the chart the same tasks: `SchemaRenderer` hoists - * the bag onto the node before `ObjectGantt` reads it; + * - the flat spelling code still composes — the props on the node, with the + * `gantt` block — makes the identical call and hands the chart the same + * tasks: `SchemaRenderer` hoists the bag onto the node before `ObjectGantt` + * reads it (the flattened `GanttConfig` keys `ObjectView` once wrote are + * not read since objectstack#25923; `ObjectGantt.blockPrecedence.test.tsx` + * pins their refusal); * - a bag bound through the node's `dataSource` queries the bound object; * - a bag on inline rows draws them without a host query; * - control: a bag naming a different object queries that object and never @@ -87,8 +89,6 @@ const ROWS = [ const BAG: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT } }; /** The same props written flat, with the `gantt` block — what a code composer can build. */ const FLAT: DeclaredNode = { type: 'object-gantt', objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT }; -/** The flattened `GanttConfig` keys `ObjectView` writes onto the node it composes. */ -const FLAT_CONFIG: DeclaredNode = { type: 'object-gantt', objectName: 'task', ...GANTT, filter: FILTER, sort: SORT }; /** The bag with its object supplied by the node's binding instead. */ const BOUND_BAG: DeclaredNode = { type: 'object-gantt', dataSource: { object: 'task' }, properties: { gantt: GANTT } }; /** The bag on inline rows. */ @@ -147,7 +147,6 @@ describe('object-gantt renders from its `properties` bag (objectui#10859 batch 6 expect(safeValidateSchema(BOUND_BAG).success).toBe(true); expect(safeValidateSchema(INLINE_BAG).success).toBe(true); expect(safeValidateSchema(FLAT).success).toBe(false); - expect(safeValidateSchema(FLAT_CONFIG).success).toBe(false); }); it('the bag queries its object with its own filter and sort, and hands the chart one task per row', async () => { @@ -161,10 +160,8 @@ describe('object-gantt renders from its `properties` bag (objectui#10859 batch 6 expect(tasks.map((task) => task.title).sort()).toEqual(['Build', 'Design']); }); - it.each([ - ['the `gantt` block on the node', FLAT], - ['the flattened `GanttConfig` keys', FLAT_CONFIG], - ] as const)('the flat spelling a composer builds (%s) makes the identical call and hands the chart the same tasks', async (_label, flatNode) => { + it('the flat spelling a composer builds (the `gantt` block on the node) makes the identical call and hands the chart the same tasks', async () => { + const flatNode = FLAT; const bag = renderNode(BAG); await waitFor(() => expect(bag.adapter.find).toHaveBeenCalled()); const bagTasks = await tasksOf(bag.container, ROWS.length); diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.fallback.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.fallback.test.tsx index 69fcaf1e99e..f7534986269 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.fallback.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.fallback.test.tsx @@ -25,9 +25,16 @@ vi.mock('./GanttView', () => ({ const SCHEMA: any = { type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [ + { field: 'status', label: 'Status', options: ['todo', 'doing', 'done'] }, + // No record carries `owner` → zero options → renders the `empty` placeholder. + { field: 'owner', label: 'Owner' }, + ], + }, data: { provider: 'value', items: [ @@ -36,11 +43,6 @@ const SCHEMA: any = { { id: '3', name: 'Gamma task', start: '2024-03-01', end: '2024-03-20', status: 'done' }, ], }, - quickFilters: [ - { field: 'status', label: 'Status', options: ['todo', 'doing', 'done'] }, - // No record carries `owner` → zero options → renders the `empty` placeholder. - { field: 'owner', label: 'Owner' }, - ], }; describe('ObjectGantt quick-filter labels without an I18nProvider (objectstack#5427)', () => { diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.test.tsx index 87974d11351..a52e30230d2 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.i18n.test.tsx @@ -36,16 +36,18 @@ const INLINE = [ const SCHEMA: any = { type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [ + { field: 'status', label: 'Status', options: ['todo', 'doing', 'done'] }, + // No `options`, and no record carries `owner` → resolves to zero options, + // which is the only way to render the `empty` placeholder. + { field: 'owner', label: 'Owner' }, + ], + }, data: { provider: 'value', items: INLINE }, - quickFilters: [ - { field: 'status', label: 'Status', options: ['todo', 'doing', 'done'] }, - // No `options`, and no record carries `owner` → resolves to zero options, - // which is the only way to render the `empty` placeholder. - { field: 'owner', label: 'Owner' }, - ], }; /** diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx index 7b52c1ce7b0..0d34c9279c6 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.quickfilter.test.tsx @@ -38,17 +38,21 @@ const INLINE = [ { id: '3', name: 'Gamma task', start: '2024-03-01', end: '2024-03-20', status: 'done', project: 'Zephyr' }, ]; -function inlineSchema(extra: Record = {}) { +/** `extra` lands on the node; `gantt` adds members to its `gantt` block. */ +function inlineSchema(extra: Record = {}, gantt: Record = {}) { return { type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [ + { field: 'status', label: '状态', options: ['todo', 'doing', 'done'] }, + { field: 'project', label: '项目' }, + ], + ...gantt, + }, data: { provider: 'value', items: INLINE }, - quickFilters: [ - { field: 'status', label: '状态', options: ['todo', 'doing', 'done'] }, - { field: 'project', label: '项目' }, - ], ...extra, } as any; } @@ -131,7 +135,7 @@ describe('ObjectGantt quick filters', () => { it('pins the range to the full task span when autoZoomToFilter is false', async () => { const { container, getByTestId } = render( - , + , ); await waitFor(() => expect(gv(container)).toBeTruthy()); const startBefore = gv(container).getAttribute('data-start'); @@ -150,9 +154,11 @@ describe('ObjectGantt quick filters', () => { it('does not render a filter bar when no quickFilters are configured', async () => { const schema: any = { type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + }, data: { provider: 'value', items: INLINE }, }; const { container, queryByTestId } = render(); @@ -175,12 +181,14 @@ describe('ObjectGantt quick filters — tree-aware (保留祖先链)', () => { const treeSchema = () => ({ type: 'gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - parentField: 'parentId', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + parentField: 'parentId', + quickFilters: [{ field: 'status', label: '状态', options: ['todo', 'doing', 'done'] }], + }, data: { provider: 'value', items: TREE }, - quickFilters: [{ field: 'status', label: '状态', options: ['todo', 'doing', 'done'] }], }) as any; it('keeps the full ancestor chain of a matched task', async () => { @@ -259,13 +267,15 @@ describe('ObjectGantt quick filters — schema-driven options', () => { ({ type: 'gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - quickFilters: [ - { field: 'status', label: '状态' }, - { field: 'project', label: '项目' }, - ], + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [ + { field: 'status', label: '状态' }, + { field: 'project', label: '项目' }, + ], + }, ...extra, }) as any; diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx index ba964c44a76..627b8d8966d 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx @@ -199,10 +199,12 @@ function makeDataSource(projectDef: Record) { const GANTT_SCHEMA = { type: 'gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', - quickFilters: [{ field: 'project', label: 'Project' }], + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + quickFilters: [{ field: 'project', label: 'Project' }], + }, } as any; /** diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.test.tsx index 454356602de..6d8e54366e9 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.test.tsx @@ -252,19 +252,20 @@ describe('ObjectGantt', () => { expect(plan.getAttribute('data-has-own-dates')).toBe('true'); }); - it('forwards summaryExtent from top-level (flattened ListView) config too', async () => { - // The console's ListView spreads view.gantt keys at the TOP level of the - // object-gantt schema, which getGanttConfig re-picks key by key — a new - // key missing from that pick list silently vanishes on the console path. + it('does not read a summaryExtent written flat beside the block (objectstack#25923)', async () => { + // The console's ListView used to spread view.gantt keys at the TOP level of + // the object-gantt node, and getGanttConfig re-picked them key by key. Both + // producers hand the block on since objectstack#25923 and the flat fallback + // read is retired, so a flat key is ignored even beside a valid block. const schema: any = { type: 'object-gantt', - titleField: 'name', startDateField: 'start_date', endDateField: 'end_date', + gantt: { titleField: 'name', startDateField: 'start_date', endDateField: 'end_date' }, summaryExtent: 'self', data: { provider: 'value', items: [{ id: '1', name: 'T', start_date: '2024-01-01', end_date: '2024-01-02' }] }, }; render(); await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeDefined()); - expect(screen.getByTestId('gantt-view').getAttribute('data-summary-extent')).toBe('self'); + expect(screen.getByTestId('gantt-view').getAttribute('data-summary-extent')).not.toBe('self'); }); it('omits tooltip fields when none configured', async () => { diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index 701bd5b1917..d3ff84756ac 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -132,10 +132,9 @@ export interface QuickFilterDef { * `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 spellings derive from it: the `gantt` - * block and the flattened top-level spelling, which since objectstack#25923 is - * this package's own {@link ObjectGanttFlatFields} rather than a member list of - * the published `ObjectGanttSchema`. + * Each key is now declared once, for the `gantt` block, the one spelling this + * renderer reads (the flattened top-level spelling and its fallback read + * retired in objectstack#25923). */ type GanttConfigEx = GanttConfig & GanttConfigRestated; @@ -301,28 +300,18 @@ export function normalizeDependencies(raw: unknown): GanttDependency[] { } /** - * The keys `getGanttConfig`'s FLAT branch and the fetch path read off the node - * that the published {@link ObjectGanttSchema} does not declare: the package-local - * hand-off spelling (objectstack#25923, ui/AGENTS.md Commandment #0.1). + * The two keys the fetch path reads off the node that the published + * {@link ObjectGanttSchema} does not declare: the package-local hand-off + * (objectstack#25923, ui/AGENTS.md Commandment #0.1). * - * `ComponentPropsMap['object-gantt']` refuses every one of them on the node: - * the `GanttConfig` keys (written in the `gantt` block instead), the legacy - * `dependencyField` alias, and the `search` / `searchableFields` pair, which - * only `ListView`'s toolbar writes at runtime. So they left the published - * interface and its zod mirror, and live here, beside the one consumer. Both - * producers in this tree (`ListView`, and `ObjectView`'s `generateViewSchema`) - * emit the `gantt` block; the flat branch keeps a host that mounts - * `` with the flat keys drawing. - * - * The `GanttConfig` half is DERIVED from {@link GanttConfig}, the type the - * `gantt` block carries, so the two spellings cannot fork. + * `ListView`'s toolbar writes them at runtime, because the chart runs its own + * query (objectui#10250), and `ComponentPropsMap['object-gantt']` refuses both + * on an authored node, so they live here, beside the one reader, and off the + * authoring face. Until objectstack#25923 retired `getGanttConfig`'s flat + * fallback read, this type also carried the flat spelling of every + * `GanttConfig` key and the legacy `dependencyField` alias; they left with it. */ -export type ObjectGanttFlatFields = Partial> & { - /** - * @deprecated Legacy singular alias of `dependenciesField`, which wins where - * both carry a value. No producer writes it and the spec has no such key. - */ - dependencyField?: string; +export type ObjectGanttSearchFields = { /** * Full-text search term, forwarded as `$search` (objectui#10250). `ListView` * writes it from its toolbar Search box: the chart runs its own query. @@ -332,14 +321,14 @@ export type ObjectGanttFlatFields = Partial = keyof { export type KnownGanttConfigKey = KnownKeys; /** - * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat - * branch reads ({@link ObjectGanttFlatFields}). - * - * Until objectstack#25923 both producers (`plugin-view/src/ObjectView.tsx` and - * `plugin-list/src/ListView.tsx`, each in `case 'gantt'`) EMITTED this spelling - * and no `gantt` key. Both now hand the view's block on as the node's `gantt`, - * so the block branch is the producers' path and this list only names what a - * node carrying both spellings loses to its block. + * The FLAT spelling of `GanttConfig`'s keys — what a node carries when its + * binding is written at the top level instead of in the `gantt` block. Nothing + * reads them: `getGanttConfig`'s flat fallback retired in objectstack#25923, + * so this list only names, in the dev warning below, what the chart ignores. * * DERIVED from `GanttConfigSchema` in full — the same zod object the block - * branch validates against — so a key added to the spec reaches the shadow + * branch validates against — so a key added to the spec reaches the * diagnostic without a second edit (the discipline `FLAT_MAP_CONFIG_KEYS` set in * objectui#5177). Until objectstack#15469 that derivation covered only part of * the vocabulary and a second literal, `GANTT_CONFIG_EXTENSION_KEYS`, named the @@ -560,8 +546,8 @@ export type KnownGanttConfigKey = KnownKeys; * literal is retired (objectui#7845) — keeping it would have made every one of * the ten a DUPLICATE entry here, which is what the no-duplicates pin in * `ObjectGantt.blockPrecedence.test.tsx` measured. `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. + * legacy singular alias of `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)[]), @@ -569,85 +555,74 @@ export const FLAT_GANTT_CONFIG_KEYS = [ ]; /** - * Warn once per distinct shadowing, not once per evaluation: `getGanttConfig` + * Warn once per distinct key set, 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(); +const warnedFlatGanttKeys = new Set(); /** - * The `gantt` block won and the flat top-level keys alongside it were ignored — - * say which ones, in dev. + * The node carries `GanttConfig` keys at the top level, which this chart does + * not read — 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 flat spelling 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. + * Since objectstack#25923 the `gantt` block is the one spelling `getGanttConfig` + * reads. A flat key beside a block is ignored (the block is taken WHOLE, the + * ruling of objectui#6469 inheriting objectui#5018), and a flat key with no + * block is ignored too: the chart reaches the "Gantt configuration required" + * refusal screen. Either way the diagnostic names the keys and where they + * belong, instead of leaving the author to infer it from what is drawn. * - * It cannot fire on the ordinary ObjectView / ListView path: since - * objectstack#25923 both producers emit the `gantt` block and NO flat key, so - * their output reaches the block branch — the only caller of this warning — - * with nothing to shadow. Reaching it means one schema carries both spellings, - * which is exactly the case the flip changes. + * It cannot fire on the ordinary ObjectView / ListView path: both producers + * emit the `gantt` block and NO flat key. */ -function warnOnShadowedFlatGanttKeys(schema: ObjectGanttHandoff): void { +function warnOnFlatGanttKeys(schema: ObjectGanttHandoff): void { if (!isDev()) return; - // `key` is a `GanttConfigSchema` key or the legacy `dependencyField`, and - // `ObjectGanttHandoff` declares every one of those, so the read is typed as it - // stands; no conversion is needed (objectui#11355). - const shadowed = FLAT_GANTT_CONFIG_KEYS.filter((key) => schema[key] !== undefined); - if (shadowed.length === 0) return; + // The keys are read through a record view: none of them is declared on the + // node any more, which is the point of the warning. + const node = schema as Record; + const flat = FLAT_GANTT_CONFIG_KEYS.filter((key) => node[key] !== undefined); + if (flat.length === 0) return; - const memo = `${schema.type ?? 'gantt'}::${schema.objectName ?? ''}::${shadowed.join(',')}`; - if (warnedShadowedFlatGanttKeys.has(memo)) return; - warnedShadowedFlatGanttKeys.add(memo); + const memo = `${schema.type ?? 'gantt'}::${schema.objectName ?? ''}::${flat.join(',')}`; + if (warnedFlatGanttKeys.has(memo)) return; + warnedFlatGanttKeys.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 a hand-off form a host may mount this ' + - 'chart with, 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.', + '[ObjectGantt] These top-level keys are NOT READ: ' + + `${flat.map((k) => `\`${k}\``).join(', ')}. The \`gantt\` block is the one spelling ` + + 'this chart reads, so write them as keys of `gantt` (for example `gantt.startDateField`). ' + + 'objectstack#25923.', ); } /** * 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. + * ONE SPELLING since objectstack#25923: the `gantt` block. The flattened + * top-level spelling used to be a second branch, consulted only when no block + * was present (objectui#6469 put the block first, inheriting the maintainer + * ruling on objectui#5018, 2026-08-17; before it the flat branch returned early + * and discarded an authored block). No producer in this tree emits the flat + * spelling, the spec refuses it on an authored node, and the stored-row census + * taken before the read retired found no stored flat row, so that branch is + * gone: a node that writes its binding flat reaches the "Gantt configuration + * required" refusal screen, and {@link warnOnFlatGanttKeys} names the keys. * - * The producers' path is branch 1 since objectstack#25923: `ListView` and - * `ObjectView`'s `generateViewSchema` hand the view's block on as `gantt` — - * only once both date fields are declared, and with `titleField` floored at - * `'name'` there, the two conditions branch 2 applies here — so a view renders - * as it did when they flattened it, except that the block is validated here and - * branch 2 never was: a binding carrying a value the spec refuses, which branch - * 2 drew, now reaches the refusal screen (a pre-door stored row like that is - * converted at the store, ADR-0087, never tolerated). Branch 2 stays for a host that - * mounts this component with the flat keys ({@link ObjectGanttFlatFields}). + * `ListView` and `ObjectView`'s `generateViewSchema` hand the view's block on + * as `gantt` — only once both date fields are declared, and with `titleField` + * floored at `'name'` there — and the block is validated here: a binding + * carrying a value the spec refuses reaches the refusal screen (a pre-door + * stored row like that is converted at the store, ADR-0087, never tolerated). * - * 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 that block is REFUSED (below). Both - * diagnostics fire on that node — `Invalid gantt configuration` from the - * `safeParse` below, and the shadow warning naming the flat keys that lost. + * The block is taken WHOLE, never merged with flat keys beside it; merging + * would be the lenient consumer fallback AGENTS.md #0.1 forbids. The spec's + * `GanttConfigSchema` REQUIRES `startDateField` / `endDateField` / `titleField`, + * so an INCOMPLETE block is REFUSED (below) even when the flat keys beside it + * would complete it. Both diagnostics fire on that node — `Invalid gantt + * configuration` from the `safeParse` below, and the warning naming the flat + * keys that are not read. * * A block that fails `GanttConfigSchema` is refused, never drawn (objectui#13459). * It used to be warned about in the console and then returned as-is, so a block @@ -661,60 +636,15 @@ function getGanttConfig(schema: ObjectGanttHandoff): { config: GanttConfigEx | null; invalidBlock: readonly string[] | null; } { - // 1. The `gantt` block (the ObjectGridSchema-style shape) — the authoring - // face, and the winner whenever it is present. - if (schema.gantt) { - const result = GanttConfigSchema.safeParse(schema.gantt); - warnOnShadowedFlatGanttKeys(schema); - if (!result.success) { - console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format()); - return { config: null, invalidBlock: describeGanttBlockIssues(result.error.issues) }; - } - return { config: schema.gantt as GanttConfigEx, invalidBlock: null }; + warnOnFlatGanttKeys(schema); + // The `gantt` block — the authoring face, and the one spelling read. + if (!schema.gantt) return { config: null, invalidBlock: null }; + const result = GanttConfigSchema.safeParse(schema.gantt); + if (!result.success) { + console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format()); + return { config: null, invalidBlock: describeGanttBlockIssues(result.error.issues) }; } - - // 2. The flat hand-off form (`ObjectGanttFlatFields`) — what ObjectView / - // ListView emitted until objectstack#25923, kept for a host that mounts this - // component with it. Taken only when BOTH date fields are present; 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) { - const flat: GanttConfigEx = { - startDateField: schema.startDateField, - endDateField: schema.endDateField, - titleField: schema.titleField || 'name', - progressField: schema.progressField, - dependenciesField: schema.dependenciesField || schema.dependencyField, - colorField: schema.colorField, - borderColorField: schema.borderColorField, - parentField: schema.parentField, - typeField: schema.typeField, - lockField: schema.lockField, - objectField: schema.objectField, - summaryExtent: schema.summaryExtent, - defaultCollapsedDepth: schema.defaultCollapsedDepth, - tooltipFields: schema.tooltipFields, - baselineStartField: schema.baselineStartField, - baselineEndField: schema.baselineEndField, - groupByField: schema.groupByField, - resourceView: schema.resourceView, - assigneeField: schema.assigneeField, - effortField: schema.effortField, - capacity: schema.capacity, - quickFilters: schema.quickFilters, - autoZoomToFilter: schema.autoZoomToFilter, - viewMode: schema.viewMode, - timeSegments: schema.timeSegments, - interactions: schema.interactions, - exportFileName: schema.exportFileName, - timeZone: schema.timeZone, - dependencyTypes: schema.dependencyTypes, - }; - return { config: flat, invalidBlock: null }; - } - - return { config: null, invalidBlock: null }; + return { config: schema.gantt as GanttConfigEx, invalidBlock: null }; } /** @@ -2402,7 +2332,7 @@ export const ObjectGantt: React.FC = ({
- Gantt configuration required. Please specify startDateField, endDateField, and titleField. + Gantt configuration required. Please specify startDateField, endDateField, and titleField in the view's gantt block.
diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx index ea8c8468d8a..67c8cb3f11b 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx @@ -103,9 +103,10 @@ describe('ObjectGantt — an unconfigured view reaches the refusal screen (objec }); it('REFUSES a HALF-declared axis — one date field is not a gantt', async () => { - // `getGanttConfig`'s flat branch is taken only when BOTH date fields are - // present; a partial spelling falls through to `null`. Pinned because the - // fixed faces now emit exactly this shape for a view that declared one rung. + // The producers hand the `gantt` block on only once BOTH date fields are + // declared, so a view that declared one rung reaches this component with + // no block. Written flat here, the one rung is not read either: the flat + // fallback branch retired in objectstack#25923. render( = {}) { +/** + * An `object-gantt` node with its binding in the `gantt` block, the one + * spelling the renderer reads since objectstack#25923; `gantt` adds members. + */ +function schema(gantt: Record = {}) { return { type: 'object-gantt', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'name', + ...gantt, + }, data: { provider: 'value', items: INLINE }, - ...extra, } as any; } @@ -65,7 +70,7 @@ const modeOf = async (container: HTMLElement, testId: string) => { }; describe('ObjectGantt viewMode wiring — timeline branch (objectui#5074)', () => { - it('honours an authored viewMode on the flattened object-gantt schema', async () => { + it('honours an authored viewMode in the object-gantt node\'s gantt block', async () => { const { container } = render(); expect(await modeOf(container, 'gantt-view')).toBe('month'); }); diff --git a/ui/packages/plugin-gantt/src/__tests__/ganttHandoff-25923.test.ts b/ui/packages/plugin-gantt/src/__tests__/ganttHandoff-25923.test.ts new file mode 100644 index 00000000000..4ddf76cec56 --- /dev/null +++ b/ui/packages/plugin-gantt/src/__tests__/ganttHandoff-25923.test.ts @@ -0,0 +1,102 @@ +/** + * 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. + */ + +/** + * objectstack#25923 — `ObjectGantt`'s node is the published schema plus the two + * search keys `ListView` writes at runtime, and nothing flat. + * + * `ComponentPropsMap['object-gantt']` refuses every `GanttConfig` key written + * flat on the node, plus the legacy `dependencyField` alias and the + * `search` / `searchableFields` pair only `ListView`'s toolbar writes. The card + * first moved all 32 off the published `ObjectGanttSchema` and its mirror onto a + * package-local hand-off type here, beside the one reader (ui/AGENTS.md + * Commandment #0.1); `@object-ui/types` pins the absence + * (`gantt-flat-config-declared-keys.test.ts` and siblings). It then retired + * `getGanttConfig`'s flat fallback read, once the stored-row census found no + * stored flat row, so the 30 config keys left the hand-off type too. This file + * pins what is left: + * + * 1. the hand-off adds exactly `search` / `searchableFields`, with their types; + * 2. neither is a member of the published interface (no double home); + * 3. the renderer's prop is typed with the hand-off, so the fetch path's read + * compiles without a cast; + * 4. no `GanttConfig` key and no `dependencyField` is typed on the node, and + * the source reads neither the alias nor a flat config key off the node. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import type { ObjectGanttSchema } from '@object-ui/types'; +import type { + KnownGanttConfigKey, + ObjectGanttHandoff, + ObjectGanttProps, + ObjectGanttSearchFields, +} from '../ObjectGantt'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +export type assertionHandoffIsTheSearchPair = [ + // 1. exactly the two host-written keys, each with its type + Expect>, + Expect>, + Expect>, + // 2. no double home: the published interface declares neither + Expect, never>>, + // 3. the renderer reads the hand-off + Expect>, + // 4. no flat config key and no alias is typed on the node + Expect, never>>, +]; + +/** Non-vacuity: the derivation the absence row reads is not `never`/`string`. */ +export type assertionInstrumentFires = [ + Expect, false>>, + Expect, false>>, + Expect<'startDateField' extends KnownGanttConfigKey ? true : false>, + // the `gantt` block, the one spelling, IS on the node + Expect<'gantt' extends keyof ObjectGanttHandoff ? true : false>, +]; + +const SRC = readFileSync(fileURLToPath(new URL('../ObjectGantt.tsx', import.meta.url)), 'utf8'); + +describe('objectstack#25923 — the flat fallback read is retired', () => { + it('the source reads neither the alias nor a flat date key off the node', () => { + expect(SRC).not.toContain('schema.dependencyField'); + expect(SRC).not.toContain('schema.startDateField'); + expect(SRC).not.toContain('schema.endDateField'); + // Control: the fetch path's read of the hand-off key is spelled the same way. + expect(SRC).toContain('schema.search'); + }); + + it('the two keep their types — wrong values are refused, right ones compile', () => { + // @ts-expect-error — `search` is `string | undefined` + const badSearch: ObjectGanttSearchFields = { search: 5 }; + // @ts-expect-error — `searchableFields` is `string[] | undefined` + const badFields: ObjectGanttSearchFields = { searchableFields: 'name' }; + const flat: ObjectGanttHandoff = { + type: 'object-gantt', + objectName: 'task', + // @ts-expect-error — a flat config key is no member of the node; write `gantt.startDateField` + startDateField: 'start', + }; + // Counter-probe: a narrowing to `never` would satisfy the directives above. + const ok: ObjectGanttHandoff = { + type: 'object-gantt', + objectName: 'task', + gantt: { startDateField: 'start', endDateField: 'end', titleField: 'name' }, + search: 'needle', + searchableFields: ['name'], + }; + expect([badSearch, badFields, flat]).toHaveLength(3); + expect(ok.search).toBe('needle'); + }); +}); diff --git a/ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts b/ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts deleted file mode 100644 index fa18bc02bdc..00000000000 --- a/ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * 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. - */ - -/** - * objectstack#25923 — the flat `object-gantt` keys live on THIS package's - * hand-off type, `ObjectGanttFlatFields`, and nowhere on the published faces. - * - * `ComponentPropsMap['object-gantt']` refuses every `GanttConfig` key written - * flat on the node, plus the legacy `dependencyField` alias and the - * `search` / `searchableFields` pair only `ListView`'s toolbar writes. Before - * this card `@object-ui/types` published all 32 on `ObjectGanttSchema` and its - * mirror; they moved here, beside the one reader (`getGanttConfig`'s flat - * branch and the fetch path), per ui/AGENTS.md Commandment #0.1. `@object-ui/types` - * pins the absence (`gantt-flat-config-declared-keys.test.ts` and siblings); - * this file pins the other half: - * - * 1. the hand-off's keys are exactly the `GanttConfig` keys plus the three, - * DERIVED, so the flat and block spellings cannot fork; - * 2. none of them is a member of the published interface (no double home); - * 3. the renderer's prop is typed with the hand-off, so the flat read compiles - * without a cast; - * 4. the alias keeps its `@deprecated` tag naming the canonical key, and each - * of the three keeps its type (the guards `gantt-dependency-field-deprecated- - * alias.test.ts` and `gantt-search-keys-10250.test.ts` used to give on the - * published face). - */ - -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { describe, it, expect } from 'vitest'; -import type { GanttConfig, ObjectGanttSchema } from '@object-ui/types'; -import type { - KnownGanttConfigKey, - ObjectGanttFlatFields, - ObjectGanttHandoff, - ObjectGanttProps, -} from '../ObjectGantt'; - -type Equal = - (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -type Expect = T; - -type HostOnlyKey = 'dependencyField' | 'search' | 'searchableFields'; - -export type assertionHandoffIsDerived = [ - // 1. exactly the block's declared keys plus the three host-only ones - Expect>, - // …and each block key keeps the block's own type - Expect>, - Expect>, - // 2. no double home: the published interface declares none of them - Expect, never>>, - // 3. the renderer reads the hand-off - Expect>, - // 4. the three keep their types - Expect>, - Expect>, - Expect>, -]; - -/** Non-vacuity: the derivation is not `never`/`string`, and the instrument fires. */ -export type assertionInstrumentFires = [ - Expect, false>>, - Expect, false>>, - Expect<'startDateField' extends KnownGanttConfigKey ? true : false>, - Expect, false>>, -]; - -const SRC = readFileSync(fileURLToPath(new URL('../ObjectGantt.tsx', import.meta.url)), 'utf8'); - -/** The JSDoc block immediately preceding `member`, or `null`. */ -function docBlockBefore(member: string): string | null { - const at = SRC.indexOf(member); - if (at === -1) return null; - const before = SRC.slice(0, at); - const close = before.lastIndexOf('*/'); - if (close === -1 || before.slice(close + 2).trim() !== '') return null; - const open = before.lastIndexOf('/**', close); - return open === -1 ? null : before.slice(open, close + 2); -} - -describe('objectstack#25923 — the hand-off type carries the flat keys', () => { - it('the alias is declared once, tagged @deprecated, naming the canonical key', () => { - const alias = 'dependencyField?: string;'; - expect(SRC.split(alias)).toHaveLength(2); - const doc = docBlockBefore(alias); - expect(doc).toContain('@deprecated'); - expect(doc).toContain('dependenciesField'); - }); - - it('the flat branch still reads the alias, after the canonical key', () => { - expect(SRC).toContain('schema.dependenciesField || schema.dependencyField'); - }); - - it('the three keep their types — wrong values are refused, right ones compile', () => { - // @ts-expect-error — `dependencyField` is `string | undefined` - const badAlias: ObjectGanttFlatFields = { dependencyField: 5 }; - // @ts-expect-error — `search` is `string | undefined` - const badSearch: ObjectGanttFlatFields = { search: 5 }; - // @ts-expect-error — `searchableFields` is `string[] | undefined` - const badFields: ObjectGanttFlatFields = { searchableFields: 'name' }; - // Counter-probe: a narrowing to `never` would satisfy the three directives. - const ok: ObjectGanttHandoff = { - type: 'object-gantt', - objectName: 'task', - startDateField: 'start', - endDateField: 'end', - dependencyField: 'preds', - search: 'needle', - searchableFields: ['name'], - }; - expect([badAlias, badSearch, badFields]).toHaveLength(3); - expect(ok.dependencyField).toBe('preds'); - }); -}); diff --git a/ui/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/ui/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts index 13f2de84411..2f4b43e2b85 100644 --- a/ui/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts +++ b/ui/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -29,20 +29,17 @@ * renderer READS the flat spelling, not that an author should write it. The * README row below pins the new teaching. * - * ⚠️ Dated note, objectstack#25923 — superseded in part. The producers now hand - * the binding off in the `calendar` block, the flat spelling is only a - * stored-document fallback the renderer still reads, and the five flat - * declarations DID move: off `ObjectCalendarSchema`, onto `plugin-calendar`'s - * own hand-off type. This file now pins their absence. - * * ⚠️ Dated note — objectstack#25923. The declarations this file pinned DID move - * after all: the five left both published faces of `ObjectCalendarSchema` - * (they are `plugin-calendar`'s package-local hand-off type now, where the one - * reader lives), because a declared flat key is one the strict authoring face - * derives from, so `objectui validate` accepted the very spelling the spec - * refuses. The reads, the README, the spec and the `inputs` rows below are - * unchanged; the TS and mirror rows are inverted to the new state, and the - * strict face's refusal is pinned in the mirror section below. + * after all, and then the read went too. First the five left both published + * faces of `ObjectCalendarSchema` for `plugin-calendar`'s package-local hand-off + * type, because a declared flat key is one the strict authoring face derives + * from, so `objectui validate` accepted the very spelling the spec refuses; the + * producers hand the binding off in the `calendar` block instead. Then the + * renderer's flat fallback read retired with that hand-off type, once the + * stored-row census found no stored flat row. The README, the spec and the + * `inputs` rows below are unchanged; the TS, mirror and read rows are inverted + * to the new state, and the strict face's refusal is pinned in the mirror + * section below. * * ## Why BOTH keys, and the measurement that decided the second one * @@ -147,9 +144,9 @@ const CALENDAR_REGISTRATION = 'packages/plugin-calendar/src/index.tsx'; const CALENDAR_README = 'packages/plugin-calendar/README.md'; /** - * The five field-name keys `getCalendarConfig` reads off the node FLAT (a - * stored-document fallback since objectstack#25923, when the producers moved to - * the block), and that the README authors inside the `calendar` block. + * The five field-name keys `getCalendarConfig` read off the node FLAT until + * objectstack#25923 retired that fallback, and that the README authors inside + * the `calendar` block. */ const FLAT_KEYS = ['titleField', 'startDateField', 'endDateField', 'allDayField', 'colorField'] as const; /** The three that were already declared — the precedent the two new ones join. */ @@ -415,41 +412,45 @@ function specShapeKeys(type: string): string[] { /* ── The reads: the fact the declarations record ──────────────────────────── */ -describe('objectui#8466 — the renderer reads these keys, which is what the declarations record', () => { - it('`getCalendarConfig` reads all five flat keys off the node', () => { +describe('objectui#8466 — the renderer reads these keys in the `calendar` block only (objectstack#25923)', () => { + it('`getCalendarConfig` reads none of the five flat keys off the node', () => { // The FACT, not the spelling. This used to assert the source contained the // literal `(schema as any).KEY` — the very cast a cleanup deletes once the // key is declared, which this card is what makes possible. So the pin went // red on the improvement it existed to enable (objectui#8832; PR #8799 is // the precedent, dropping `(schema as any).navigation` once `navigation` // was declared). The census reads both forms, so the cleanup lands green. + // + // ⏱ objectstack#25923 inverted the verdict: the flat fallback read retired, + // so the function reads the `calendar` block and nothing flat. const reads = configReaderReads(readRepo(CALENDAR_READER)); for (const key of FLAT_KEYS) { - expect([...reads], `getCalendarConfig no longer reads the flat ${key}`).toContain(key); + expect([...reads], `getCalendarConfig reads the flat ${key} again`).not.toContain(key); } // Controls, both fired in THIS slice: `calendar` is the config container the - // function prefers over the flat five and is not one of them, so the slice is - // not empty by accident; `objectName` is read all over the same FILE and + // function reads and is not one of the five, so the slice is not empty by + // accident; `objectName` is read all over the same FILE and // never inside this function, so a slice that had swallowed the file — which // is what would make the five verdicts above cheap — fails here instead. expect([...reads]).toContain('calendar'); expect(reads.has(READ_CONTROL_KEY)).toBe(false); }); - it('…and `allDayField` is LOAD-BEARING, not merely resolved (objectui#8026)', () => { - // The premise that removed triage's "declaring an inert key would be worse" - // objection. If the renderer ever stops honouring the key, this reddens - // BEFORE anyone trusts the declaration to mean something. - // - // The fact: the key is in the dependency list of the memo that recomputes - // the config, which is what makes an authored change reach the screen. That + it('…and the config memo depends on the `calendar` block, not on flat keys', () => { + // This row used to pin `allDayField` as LOAD-BEARING (objectui#8026): the + // flat key was in the dependency list of the memo that recomputes the + // config, which is what made an authored change reach the screen. That // used to be a regex over the list's TEXT, so a formatter run, a reordered // dep or a changed print width moved the verdict with no behaviour moving // (objectui#8832). The list is sliced out structurally instead, and read // with the same census, so only the dependency itself can move it. + // + // ⏱ objectstack#25923: the five are block members now, so the memo names + // the block, and a flat entry here would be a dependency on a key nothing + // reads. `allDayField` reaches the memo through the block's identity. const deps = configMemoDeps(readRepo(CALENDAR_READER)); for (const key of FLAT_KEYS) { - expect([...deps], `${key} left the config memo's dependency list`).toContain(key); + expect([...deps], `${key} is a flat entry in the config memo's dependency list again`).not.toContain(key); } // Controls in both directions, each known to fire in THIS region: `calendar` // is in this very list and is not one of the five, so the slice is not empty diff --git a/ui/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts b/ui/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts index 883bf653862..d5c33426aba 100644 --- a/ui/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts +++ b/ui/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts @@ -56,12 +56,12 @@ * * `ComponentPropsMap['object-gantt']` refuses `dependenciesField` and * `dependencyField` written flat on the node, so neither stays a member of the - * published interface or its mirror. The alias, still `@deprecated`, lives on - * `plugin-gantt`'s package-local hand-off type `ObjectGanttFlatFields`, where - * the flat branch still READS it: its tag, its type and its acceptance are - * pinned there, in `ganttHandoffFlatFields-25923.test.ts`. Failure mode (2) above - * keeps its guard on that side. What this file pins now is the absence on both - * published faces, which is the narrowing the card shipped. + * published interface or its mirror. The alias then lived on `plugin-gantt`'s + * package-local hand-off type until the same card retired the flat fallback + * read, and the alias with it: no reader is left, so failure mode (2) above has + * nothing left to guard (`plugin-gantt`'s `ganttHandoff-25923.test.ts` pins the + * absence there). What this file pins now is the absence on both published + * faces, which is the narrowing the card shipped. * * ## Why the TS face is read as SOURCE TEXT and the zod face at RUNTIME * diff --git a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index 957dd5410c1..06a475e03fb 100644 --- a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -100,10 +100,10 @@ * * `ComponentPropsMap['object-gantt']` refuses every `GanttConfig` key written * flat on the node, so a published flat member was an authorable spelling the - * spec refuses. The 24 moved to `plugin-gantt`'s package-local hand-off type - * (`ObjectGanttFlatFields`, DERIVED from `GanttConfig` there, so the "one - * vocabulary" invariant below holds by construction on that side and is pinned - * in `plugin-gantt`'s `ganttHandoffFlatFields-25923.test.ts`). What this file + * spec refuses. The 24 moved to `plugin-gantt`'s package-local hand-off type, + * and left it again when the same card retired the renderer's flat fallback + * read, so the `gantt` block is the one spelling anywhere (pinned in + * `plugin-gantt`'s `ganttHandoff-25923.test.ts`). What this file * pins now: the 24 are members of NEITHER published face, the three query keys * still are (and are still value-validated), and the `gantt` block is unchanged. * The paragraphs above are this card's reading at the time, kept as written. diff --git a/ui/packages/types/src/__tests__/gantt-search-keys-10250.test.ts b/ui/packages/types/src/__tests__/gantt-search-keys-10250.test.ts index a42f3236828..573fc7eb990 100644 --- a/ui/packages/types/src/__tests__/gantt-search-keys-10250.test.ts +++ b/ui/packages/types/src/__tests__/gantt-search-keys-10250.test.ts @@ -30,8 +30,8 @@ * `ComponentPropsMap['object-gantt']` refuses both keys on the node: a key only * the host writes is typed on the package-local hand-off type, never on the * authoring face (ui/AGENTS.md Commandment #0.1). So both left this interface - * and its mirror for `plugin-gantt`'s `ObjectGanttFlatFields`, where their types - * are pinned now (`ganttHandoffFlatFields-25923.test.ts`). The rows below pin + * and its mirror for `plugin-gantt`'s `ObjectGanttSearchFields`, where their + * types are pinned now (`ganttHandoff-25923.test.ts`). The rows below pin * the absence; the paragraphs above are objectui#10250's reading, kept as written. */ diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 1b1d9b16bb8..0a197b80ec6 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -314,13 +314,11 @@ export type GanttConfig = SpecGanttConfig & { // // 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 - // since objectstack#25923 on `plugin-gantt`'s own hand-off type - // `ObjectGanttFlatFields`, not 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. + // block to. `ObjectGantt` honours every one of them in the `gantt: { … }` + // block, the one spelling it reads since objectstack#25923 retired the + // flattened top-level fallback, and a type that lives inside the plugin + // cannot be referenced by this declaration — so the vocabulary is lifted + // here rather than restated, from ONE source that cannot fork. // // Like `timeSegments` above, each of the nine is now DECLARED by the spec — // objectstack#15469 closed `GanttConfigSchema` and modelled them, so they are diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index f1534c87827..c5ee9abea5c 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -1877,9 +1877,11 @@ const CALENDAR_DATE_ALIAS_STEM = * - `'binding'` — the value reaches `ObjectCalendar` through a producer's * hand-off (since objectstack#25923 in the node's own `calendar` block, * before that as a FLAT member of the node). Every producer-fed surface is - * this: the view-level `calendar` block - * and its legacy `options.calendar` twin, a named view's two nestings, and - * the flat node face itself. + * this: the view-level `calendar` block and its legacy `options.calendar` + * twin, and a named view's two nestings. The flat node face takes the same + * clause for its own reason: since objectstack#25923 nothing reads a key + * written flat on the node, so the value falls through to the same refusal + * screen. * - `'container'` — the `object-calendar` element's OWN `calendar` container, * which `getCalendarConfig` reads FIRST and returns WHOLE rather than * lifting member by member. @@ -1954,7 +1956,7 @@ const calendarAliasDetail = (alias: 'dateField' | 'endField', surface: CalendarA * The two arms as a VIEW-level calendar block wears them — the list view's * `calendar:` block below, its legacy `options.calendar` twin, and a named * view's two nestings on `ObjectViewSchema`. Every one of those is producer-fed, - * so the authored value reaches the renderer as a flat node binding. + * so the authored value reaches the renderer in the node's `calendar` block. */ const CalendarBlockDateAliasRefusals = { dateField: aliasKeyRefusal('dateField', 'startDateField', 'this calendar configuration', calendarAliasDetail('dateField', 'binding')), @@ -1977,7 +1979,9 @@ const CalendarContainerDateAliasRefusals = { /** * The same two arms as the `object-calendar` NODE wears them. This is the FLAT * spelling `ListView` used to flatten the block into, and where the retired - * ladder actually read — a binding surface, so it takes the binding clause. + * ladder actually read. Nothing reads the flat node face since objectstack#25923, + * so a value there reaches the refusal screen, and the arm takes the binding + * clause. * * ⏱ objectstack#25923: the canonical keys are no flat members of this node any * more (the spec and the strict face refuse `startDateField` written flat), so From dcfdb95eb5ce9e4c30ebe65f773bd1f67cb6d008 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:28:42 +0000 Subject: [PATCH 2/5] test(app-shell): the widget leak sweep mounts object-calendar with its binding in the calendar block Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/ui/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index 76d136d6257..0a8e38c118e 100644 --- a/ui/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/ui/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -585,10 +585,11 @@ const OBJECT_CHART_EXTRAS = { data: CHART_DATA, series: CHART_SERIES, }; +// The binding lives in the `calendar` block, the one spelling `ObjectCalendar` +// reads since objectstack#25923 retired its flat fallback. const CALENDAR_OBJECT_EXTRAS = { objectName: 'accounts', - startDateField: 'start_at', - titleField: 'name', + calendar: { startDateField: 'start_at', titleField: 'name' }, }; /* ── `packages/components`: the renderer family objectui#5574 widened this to ── From 6001470dceeae8a1413c05c3943d99d97cedef3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:11:42 +0000 Subject: [PATCH 3/5] test(plugin-calendar,plugin-gantt,types): move the last flat-bound renderer and mirror nodes to the block; read the unread flat keys with Reflect.get An after-change census found five more test nodes written flat (a gantt expand test, the console filter-token sweep, a calendar invalidation test and two @object-ui/types mirror pins). They now write the block. Three comments that still said the renderer reads the binding flat are rewritten. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../__tests__/filterContextTokensSweep-10666.test.tsx | 2 +- .../ObjectCalendar.invalidationRefetch-10572.test.tsx | 2 +- .../src/ObjectGantt.expandFls-7230.test.tsx | 4 +--- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 7 +++---- .../types/src/__tests__/gantt-declared-keys.test.ts | 3 +-- .../object-kanban-calendar-navigation-8652.test.ts | 2 +- ui/packages/types/src/objectql.ts | 10 ++++++---- ui/packages/types/src/zod/objectql.zod.ts | 6 ++++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ui/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx b/ui/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx index 985dfdaf37b..26210260f1d 100644 --- a/ui/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx +++ b/ui/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx @@ -209,7 +209,7 @@ const ROWS: NodeRow[] = [ // this block (objectui#8348); its `data` row is the array arm. withFilter: top({ type: 'object-calendar', calendar: CALENDAR, staticData: [{ ...ROW }] }) }, { node: 'object-gantt', shape: 'tuple', path: 'dataSource', filterParam: '$filter', - withFilter: top({ type: 'object-gantt', objectName: OBJECT, titleField: 'name', startDateField: 'start', endDateField: 'end' }) }, + withFilter: top({ type: 'object-gantt', objectName: OBJECT, gantt: { titleField: 'name', startDateField: 'start', endDateField: 'end' } }) }, { node: 'object-kanban', shape: 'tuple', path: 'dataSource', filterParam: '$filter', withFilter: top({ type: 'object-kanban', objectName: OBJECT, groupBy: 'status', columns: [{ id: 'open', title: 'Open' }] }) }, { node: 'object-map (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx index 535ef6eca39..4e36cd32177 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.invalidationRefetch-10572.test.tsx @@ -57,7 +57,7 @@ const renderBlock = (schema: Record, ds: ReturnType, ); -const BLOCK = { type: 'object-calendar', objectName: 'event', dateField: 'due', titleField: 'name' }; +const BLOCK = { type: 'object-calendar', objectName: 'event', calendar: { startDateField: 'due', titleField: 'name' } }; describe('object-calendar re-reads on the data-invalidation bus (objectui#10572)', () => { it('an unscoped change (objectName "*") re-runs its query once', async () => { diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx index 09a95f1a3cd..ada03b4fcb3 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx @@ -104,9 +104,7 @@ const TASK_FIELDS: Record = { const GANTT_SCHEMA = { type: 'gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', - titleField: 'name', + gantt: { startDateField: 'start', endDateField: 'end', titleField: 'name' }, } as any; function makeDataSource() { diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index 5c82065c7fa..8759f6cb5ab 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -578,10 +578,9 @@ const warnedFlatGanttKeys = new Set(); function warnOnFlatGanttKeys(schema: ObjectGanttHandoff): void { if (!isDev()) return; - // The keys are read through a record view: none of them is declared on the - // node any more, which is the point of the warning. - const node = schema as Record; - const flat = FLAT_GANTT_CONFIG_KEYS.filter((key) => node[key] !== undefined); + // The keys are read by name with `Reflect.get`: none of them is declared on + // the node any more, which is the point of the warning. + const flat = FLAT_GANTT_CONFIG_KEYS.filter((key) => Reflect.get(schema, key) !== undefined); if (flat.length === 0) return; const memo = `${schema.type ?? 'gantt'}::${schema.objectName ?? ''}::${flat.join(',')}`; diff --git a/ui/packages/types/src/__tests__/gantt-declared-keys.test.ts b/ui/packages/types/src/__tests__/gantt-declared-keys.test.ts index 0a5179ea6bc..02b92e47093 100644 --- a/ui/packages/types/src/__tests__/gantt-declared-keys.test.ts +++ b/ui/packages/types/src/__tests__/gantt-declared-keys.test.ts @@ -56,8 +56,7 @@ import type { ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; const MINIMAL = { type: 'object-gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', + gantt: { startDateField: 'start', endDateField: 'end', titleField: 'name' }, } as const; /** The ten keys this card declared, each with a value its declared type refuses. */ diff --git a/ui/packages/types/src/__tests__/object-kanban-calendar-navigation-8652.test.ts b/ui/packages/types/src/__tests__/object-kanban-calendar-navigation-8652.test.ts index 0b94df327ff..238f803d203 100644 --- a/ui/packages/types/src/__tests__/object-kanban-calendar-navigation-8652.test.ts +++ b/ui/packages/types/src/__tests__/object-kanban-calendar-navigation-8652.test.ts @@ -91,7 +91,7 @@ const ARMS: Arm[] = [ { type: 'object-calendar', mirror: CalendarMirror as unknown as Parser, - base: { type: 'object-calendar', objectName: 'event', startDateField: 'starts_at' }, + base: { type: 'object-calendar', objectName: 'event', calendar: { startDateField: 'starts_at' } }, }, ]; diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 5b73334f63f..256ad8f64ce 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -3839,8 +3839,10 @@ export interface ObjectTreeSchema extends BaseSchema { * (`ObjectView` and `ListView` lowering a stored gantt view into the node's * `gantt` block, a host mounting ``). Since * objectstack#25923 it carries no flat `GanttConfig` key and no `search` pair: - * the spec refuses those on the node, and what the renderer still reads flat - * is `plugin-gantt`'s package-local hand-off type. + * the spec refuses those on the node, and the renderer reads none of them flat. + * The one pair it does read flat, the toolbar's `search` / `searchableFields` + * that `ListView` writes at runtime, is `plugin-gantt`'s package-local + * hand-off type. * * It is NOT the shape of an AUTHORED `object-gantt` document. An authored node * takes its props in the spec's `properties` bag, whose members are @@ -4174,8 +4176,8 @@ export interface ObjectCalendarSchema extends BaseSchema { * concept, the spec's Prime Directive #12). This interface declares none of * the five flat, so a fresh literal carrying one is a compile error; both * producers (`ListView`, and `ObjectView`'s `generateViewSchema`) emit this - * block. The flat fallback `getCalendarConfig` still reads is typed on - * `plugin-calendar`'s package-local hand-off type (Commandment #0.1). + * block, and it is the one spelling `getCalendarConfig` reads: its flat + * fallback retired in objectstack#25923. * * `@objectstack/spec` declares the KEY and, since 17.7.0, its SHAPE: * `ComponentPropsMap['object-calendar'].calendar` is a strict copy of the list diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 60059b23af7..97a0b0504b9 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -2972,8 +2972,10 @@ export const ObjectGanttSchema = BaseSchema.extend({ // members of this mirror. `ComponentPropsMap['object-gantt']` refuses every // one of them on the node; the `gantt` block below is their one spelling, and // both producers (`ListView`, `ObjectView`'s `generateViewSchema`) emit it. - // What the renderer still reads flat lives on `plugin-gantt`'s package-local - // hand-off type (Commandment #0.1), off this published face. + // The one pair the renderer reads flat, the toolbar's `search` / + // `searchableFields` that `ListView` writes at runtime, lives on + // `plugin-gantt`'s package-local hand-off type (Commandment #0.1), off this + // published face. // // objectui#5903 — ten keys `ObjectGantt` reads and this mirror did not // declare. They were reachable only through `(schema as any).K`, so nothing From 25a73f45607072e268bd4d15b84e2f561b006f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:24:21 +0000 Subject: [PATCH 4/5] fix(plugin-calendar,plugin-gantt): grade the fallback retirement patch, name the item-1 sentences it supersedes, drop an unused import Metadata-protocol none takes patch under ruling A. This PR's changeset now names the sentences of the item-1 changeset that the retirement makes false, in the same release, instead of editing that note. One calendar test title now matches its block-authored body, and ObjectCalendar.tsx no longer imports ObjectCalendarBlockConfig, which only the removed hand-off types used (the Lint UI error). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .changeset/25923-calendar-gantt-flat-fallback-retired.md | 6 ++++-- ui/packages/plugin-calendar/src/ObjectCalendar.tsx | 2 +- .../src/__tests__/objectCalendarConfigMembers-8071.test.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/25923-calendar-gantt-flat-fallback-retired.md b/.changeset/25923-calendar-gantt-flat-fallback-retired.md index 0b2aebe5598..a0326b3e4a0 100644 --- a/.changeset/25923-calendar-gantt-flat-fallback-retired.md +++ b/.changeset/25923-calendar-gantt-flat-fallback-retired.md @@ -1,6 +1,6 @@ --- -'@object-ui/plugin-calendar': minor -'@object-ui/plugin-gantt': minor +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-gantt': patch '@objectstack/spec': patch --- @@ -17,6 +17,8 @@ Metadata-protocol: none - **`@object-ui/plugin-gantt`**: `getGanttConfig` reads the `gantt` block only. A node that writes its `GanttConfig` keys flat, with no block, shows the "Gantt configuration required" screen, which now names the `gantt` block, and in development a warning names every flat key the chart did not read, with or without a block beside it. The legacy singular `dependencyField` is read nowhere (the spec spells it `dependenciesField`, inside the block). The package-local `ObjectGanttFlatFields` type is gone; `ObjectGanttProps['schema']` keeps the two search keys `ListView` writes at runtime, now on `ObjectGanttSearchFields`. - **`@objectstack/spec`**: the `object-calendar` and `object-gantt` flat-key refusals no longer say the renderer still reads the flat spelling. The prescription is unchanged: write the keys in the `calendar` / `gantt` block. +**Supersedes, in the same release:** `25923-calendar-gantt-flat-keys-handoff.md` (the earlier slice of #25923) says the calendar and gantt renderers are unchanged and still read the flat spelling a host hands them, that `ObjectCalendarProps['schema']` and `ObjectGanttProps['schema']` carry the flat keys, and that the spec refusals call the flat spelling a fallback the renderer still reads. This change retires that read, so those sentences no longer hold: the renderers read the block only, the props types carry no flat binding key, and the refusals no longer say it. + **BREAKING** for code that mounts `` or `` with a flat node. - **Before.** `` drew a chart, and the flat keys compiled against `ObjectGanttProps['schema']`. diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx index 90b4808db77..66dad71f994 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -23,7 +23,7 @@ */ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; -import type { ObjectCalendarSchema, ObjectCalendarBlockConfig, DataSource, CalendarConfig } from '@object-ui/types'; +import type { ObjectCalendarSchema, DataSource, CalendarConfig } from '@object-ui/types'; import { CalendarView, type CalendarViewEvent } from './CalendarView'; import { getVisibleDateRange } from './visibleDays'; import { usePullToRefresh } from '@object-ui/mobile'; diff --git a/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx b/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx index 897ca077955..f62aa104112 100644 --- a/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx @@ -169,7 +169,7 @@ describe('object-calendar.calendar — member shape (objectui#8071 slice 5)', () expect(event.end).toBeUndefined(); }); - it('CONTROL: with no `calendar` object authored, the flat legacy spelling is read instead', async () => { + it('CONTROL: a `calendar` object with no flat sibling is read as authored', async () => { const [event] = await eventsFor({ calendar: { startDateField: 'other_start', From 0226b9c803fc6fce8a741b514adb027f5115e9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:40:16 +0000 Subject: [PATCH 5/5] chore(changeset): carry the supersession note into the types, plugin-list and plugin-view changelogs The item-1 changeset compiles into those three changelogs too, so this note lands beside it at patch in every changelog it reaches. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .changeset/25923-calendar-gantt-flat-fallback-retired.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/25923-calendar-gantt-flat-fallback-retired.md b/.changeset/25923-calendar-gantt-flat-fallback-retired.md index a0326b3e4a0..5a551465d76 100644 --- a/.changeset/25923-calendar-gantt-flat-fallback-retired.md +++ b/.changeset/25923-calendar-gantt-flat-fallback-retired.md @@ -2,6 +2,9 @@ '@object-ui/plugin-calendar': patch '@object-ui/plugin-gantt': patch '@objectstack/spec': patch +'@object-ui/types': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-view': patch --- refactor(plugin-calendar,plugin-gantt)!: the calendar and gantt renderers read their binding from the `calendar` / `gantt` block only; the flat fallback read is retired (#25923)