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..5a551465d76 --- /dev/null +++ b/.changeset/25923-calendar-gantt-flat-fallback-retired.md @@ -0,0 +1,29 @@ +--- +'@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) + +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. + +**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']`. +- **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 14ddc4365d6..5b5f9111547 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/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/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 ── diff --git a/ui/packages/plugin-calendar/README.md b/ui/packages/plugin-calendar/README.md index 6f46a1b4ae7..c81b0b979c8 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.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-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 117c6287676..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'; @@ -131,26 +131,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. * @@ -168,7 +148,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. */ @@ -321,7 +301,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 @@ -339,36 +319,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; } /** @@ -670,47 +634,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..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,10 +169,12 @@ 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({ - 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 ad3beb56316..6ff9574636a 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.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.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 65f56138d71..8759f6cb5ab 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -131,10 +131,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; @@ -300,28 +299,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. @@ -331,14 +320,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 @@ -559,8 +545,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)[]), @@ -568,85 +554,73 @@ 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 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 ?? ''}::${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 @@ -660,60 +634,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 }; } /** @@ -2398,7 +2327,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-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__/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/__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 89959bd18a6..256ad8f64ce 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 @@ -3841,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 @@ -4176,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 400448bae27..97a0b0504b9 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 @@ -2968,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