From 66322823906689a15170a37468cdebfa90675286 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:58:53 +0000 Subject: [PATCH 1/8] wip(types,plugin-*): calendar and gantt flat keys off the published faces; producers emit the blocks Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J Co-authored-by: Claude --- .../plugin-calendar/src/ObjectCalendar.tsx | 32 +- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 103 ++++-- ui/packages/plugin-list/src/ListView.tsx | 61 +++- ui/packages/plugin-view/src/ObjectView.tsx | 50 ++- ui/packages/types/src/objectql.ts | 301 ++---------------- ui/packages/types/src/zod/objectql.zod.ts | 179 +++-------- 6 files changed, 252 insertions(+), 474 deletions(-) diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx index 93891cd00d6..e183f9ed3fa 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, DataSource, CalendarConfig } from '@object-ui/types'; +import type { ObjectCalendarSchema, ObjectCalendarBlockConfig, DataSource, CalendarConfig } from '@object-ui/types'; import { CalendarView, type CalendarViewEvent } from './CalendarView'; import { getVisibleDateRange } from './visibleDays'; import { usePullToRefresh } from '@object-ui/mobile'; @@ -132,6 +132,26 @@ 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. * @@ -149,7 +169,7 @@ import { * no importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`. */ export interface ObjectCalendarComponentProps { - schema: ObjectCalendarSchema; + schema: ObjectCalendarHandoff; dataSource?: DataSource; className?: string; /** Pre-fetched records passed by a parent (e.g. ObjectView). When provided, skips internal data fetching. */ @@ -320,13 +340,17 @@ type ObjectCalendarConfig = CalendarConfig & { * `calendar-date-alias-refusal-8355.test.ts` and `plugin-list`'s * `ListView.calendarAliasRefused-8355.test.tsx`. */ -function getCalendarConfig(schema: ObjectCalendarSchema): ObjectCalendarConfig | null { +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, which `ObjectView` and `ListView` emit. + // 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. // // ⛔ 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 diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index c92aca31a90..05f1fa004ec 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -132,8 +132,10 @@ 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 faces derive from it: the `gantt` block - * and the flattened top-level spelling on `ObjectGanttSchema`. + * 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`. */ type GanttConfigEx = GanttConfig & GanttConfigRestated; @@ -162,9 +164,9 @@ type GanttConfigEx = GanttConfig & GanttConfigRestated; * the key. It does NOT reach every member: four are bare here — * `parentField`, `baselineEndField`, `assigneeField`, `effortField` — the last * three covered by a neighbour's docblock, `parentField` by nothing. Nor is - * this prose unique any more: since objectui#6472 the flattened face - * (`ObjectGanttSchema` in `@object-ui/types`) documents all twelve, and - * objectui#6561 expanded `parentField`'s entry there. + * this prose unique: objectui#6472 documented all twelve on the flattened + * face of `ObjectGanttSchema` in `@object-ui/types`, which objectstack#25923 + * retired from that interface, so this is again the fullest description. * * 2. PIN OPERAND — this reason reaches EVERY member, and for `parentField` it is * the only one. The pin below is derived over `keyof GanttConfigRestated`: a @@ -298,10 +300,46 @@ export function normalizeDependencies(raw: unknown): GanttDependency[] { return out; } +/** + * 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). + * + * `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. + */ +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; + /** + * Full-text search term, forwarded as `$search` (objectui#10250). `ListView` + * writes it from its toolbar Search box: the chart runs its own query. + */ + search?: string; + /** Narrows the fields `search` matches, forwarded as `$searchFields` alongside a term. */ + searchableFields?: string[]; +}; + +/** The node `ObjectGantt` reads: the published schema plus {@link ObjectGanttFlatFields}. */ +export type ObjectGanttHandoff = ObjectGanttSchema & ObjectGanttFlatFields; + export interface ObjectGanttProps { /** - * The gantt node. Typed as {@link ObjectGanttSchema} (objectui#5903) — the - * declaration this component's schema reads actually resolve against. + * The gantt node. Typed as {@link ObjectGanttHandoff}: {@link ObjectGanttSchema} + * (objectui#5903), plus the flat keys this component still reads that left the + * published interface in objectstack#25923. * * It used to be `ObjectGridSchema`, and that is why ten genuine reads had to * be spelled `(schema as any).K`: the keys are not grid keys, so the only @@ -319,7 +357,7 @@ export interface ObjectGanttProps { * renderer (`index.tsx`) still passes `schema: any`, so no runtime shape is * turned away either way. */ - schema: ObjectGanttSchema; + schema: ObjectGanttHandoff; dataSource?: DataSource; className?: string; onTaskClick?: (record: any) => void; @@ -504,14 +542,13 @@ export type KnownGanttConfigKey = KnownKeys; /** * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat - * branch reads, and what `ObjectView` / `ListView` EMIT. + * branch reads ({@link ObjectGanttFlatFields}). * - * Both flatteners build an `object-gantt` schema by spreading `options.gantt`'s - * CONTENTS at the top level (`plugin-view/src/ObjectView.tsx` `case 'gantt'`, - * `plugin-list/src/ListView.tsx` `case 'gantt'`); the product carries these keys - * and NO `gantt` key at all. That is an internal transport form, not a second - * authoring surface — and it is why the precedence flip below strands neither - * producer. + * 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. * * DERIVED from `GanttConfigSchema` in full — the same zod object the block * branch validates against — so a key added to the spec reaches the shadow @@ -551,20 +588,17 @@ const warnedShadowedFlatGanttKeys = new Set(); * so the diagnostic names what was dropped instead of leaving the author to * infer it from a chart that renders the other spelling's values. * - * It cannot fire on the ordinary ObjectView / ListView path, and that matters - * more here than it did for the map: the flat branch is the HOT path for gantt, - * because a hand-authored `gantt` block reaching this component through either - * view layer has already been flattened before `getGanttConfig` sees it. Both - * flatteners emit the flat keys and NO `gantt` key, so this function's block - * branch — the only caller of this warning — is not even reached for their - * output. Reaching it means one schema carries both spellings, which is exactly - * the case the flip changes. + * 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. */ -function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void { +function warnOnShadowedFlatGanttKeys(schema: ObjectGanttHandoff): void { if (!isDev()) return; // `key` is a `GanttConfigSchema` key or the legacy `dependencyField`, and - // `ObjectGanttSchema` declares every one of those, so the read is typed as it + // `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; @@ -576,8 +610,8 @@ function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void { console.warn( '[ObjectGantt] The `gantt` block configures this chart, so these top-level keys are ' + `IGNORED: ${shadowed.map((k) => `\`${k}\``).join(', ')}. The \`gantt\` block is the ` + - 'authoring shape; the flat top-level spelling is the internal form ObjectView/ListView ' + - 'produce when they flatten `options.gantt`, and what the author wrote outranks it. The ' + + '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.', ); @@ -595,8 +629,12 @@ function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void { * ruled the other way (PR #5156); gantt's answer had never been ruled — it was * just what the early `return` happened to do. * - * Safe for the producer path: neither flattener emits a `gantt` key, so their - * output still takes branch 2 exactly as before — see `FLAT_GANTT_CONFIG_KEYS`. + * The 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 + * exactly as it did when they flattened it. Branch 2 stays for a host that + * mounts this component with the flat keys ({@link ObjectGanttFlatFields}). * * 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 @@ -616,7 +654,7 @@ function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void { * downstream can read the unvalidated block. Same posture as `ObjectTimeline`'s * refusal: the renderer says what is wrong rather than guessing what was meant. */ -function getGanttConfig(schema: ObjectGanttSchema): { +function getGanttConfig(schema: ObjectGanttHandoff): { config: GanttConfigEx | null; invalidBlock: readonly string[] | null; } { @@ -632,8 +670,9 @@ function getGanttConfig(schema: ObjectGanttSchema): { return { config: schema.gantt as GanttConfigEx, invalidBlock: null }; } - // 2. The internal flat form — the ObjectView / ListView flatten product. - // Taken only when BOTH date fields are present, unchanged by the flip; a + // 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. diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 429811f1792..d4b36a10f9e 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -4185,14 +4185,31 @@ export const ListView = React.forwardRef(({ schema.calendar?.colorField || schema.options?.calendar?.colorField; const allDayField = schema.calendar?.allDayField || schema.options?.calendar?.allDayField; + // ⭐ objectstack#25923 — the five go out NESTED, as the node's own + // `calendar` block: the spec's `ComponentPropsMap['object-calendar']` + // refuses them flat, so the published `ObjectCalendarSchema` no longer + // declares them flat either. `getCalendarConfig` reads this block FIRST + // and returns it whole. + // + // ⛔ Only when a start binding is DECLARED (objectui#7029, above). The + // block's `startDateField` is required, and `getCalendarConfig` takes a + // present block as configured, so an empty or start-less block would skip + // the renderer's own refusal screen; with no block the node reaches that + // screen exactly as the flat spelling did, whose branch also asked for + // `startDateField`. + const calendarBlock = startDateField + ? { + startDateField, + ...(endDateField ? { endDateField } : {}), + ...(titleField ? { titleField } : {}), + ...(colorField ? { colorField } : {}), + ...(allDayField ? { allDayField } : {}), + } + : undefined; return { type: 'object-calendar', ...baseProps, - ...(startDateField ? { startDateField } : {}), - ...(endDateField ? { endDateField } : {}), - ...(titleField ? { titleField } : {}), - ...(colorField ? { colorField } : {}), - ...(allDayField ? { allDayField } : {}), + ...(calendarBlock ? { calendar: calendarBlock } : {}), // objectui#12081 — the days the calendar draws come back here, and // this view's fetch windows on them (`calendarWindowed`). Handed only // where this view windows: elsewhere nothing would read the report. @@ -4320,6 +4337,32 @@ export const ListView = React.forwardRef(({ // `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`). const startDateField = schema.gantt?.startDateField || schema.options?.gantt?.startDateField; const endDateField = schema.gantt?.endDateField || schema.options?.gantt?.endDateField; + // ⭐ objectstack#25923 — the block goes out NESTED, as the node's own + // `gantt`: `ComponentPropsMap['object-gantt']` refuses every + // `GanttConfig` key written flat, so the published `ObjectGanttSchema` + // no longer declares them flat either. `getGanttConfig` reads the block + // FIRST and validates it against the spec's `GanttConfigSchema`. + // + // It is handed on under the two conditions the renderer's flat branch + // applied to the flat spelling, so a view draws exactly as it did: + // only once BOTH date fields are declared (otherwise no block, and the + // node reaches the renderer's "configuration required" screen, as a + // partial flat spelling did), and with `titleField` floored at `'name'` + // (the flat branch's own floor — a display-name default, not a date + // axis — which the block, requiring `titleField`, cannot leave to it). + const ganttBlock = + startDateField && endDateField + ? { + // The block's DECLARED keys, bag under block per key, exactly as + // the two raw spreads this replaced merged them (objectui#6152 + // round 14). See `GANTT_CONFIG_SPELLING` for what the spreads + // let through. + ...pickFlatMapConfig({ ...(schema.options?.gantt || {}), ...(schema.gantt || {}) }, GANTT_CONFIG_SPELLING), + startDateField, + endDateField, + titleField: schema.gantt?.titleField || schema.options?.gantt?.titleField || 'name', + } + : undefined; return { type: 'object-gantt', ...baseProps, @@ -4391,13 +4434,7 @@ export const ListView = React.forwardRef(({ // block (`object` / `value`) must reach ObjectGantt, whose // getDataConfig prefers schema.data over the objectName fallback. ...(schema.data ? { data: schema.data } : {}), - ...(startDateField ? { startDateField } : {}), - ...(endDateField ? { endDateField } : {}), - ...(schema.gantt?.titleField ? { titleField: schema.gantt.titleField } : {}), - // The block's DECLARED keys, bag under block per key, exactly as the - // two raw spreads this replaces merged them (objectui#6152 round 14). - // See `GANTT_CONFIG_SPELLING` for what the spreads let through. - ...pickFlatMapConfig({ ...(schema.options?.gantt || {}), ...(schema.gantt || {}) }, GANTT_CONFIG_SPELLING), + ...(ganttBlock ? { gantt: ganttBlock } : {}), }; } case 'map': { diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index e975c829b23..6428b0b95c3 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -2323,15 +2323,29 @@ export const ObjectView: React.FC = ({ // a block key named like a node key that overrode the node's own value). // `colorField` and `allDayField`, which only the spread carried, are // named now. `ListView`'s twin retired the same spread. + // + // ⭐ objectstack#25923 — and it hands them on NESTED, as the node's own + // `calendar` block, the shape `ListView` hands `ObjectCalendar` too: the + // spec's `ComponentPropsMap['object-calendar']` refuses the five flat, so + // the published `ObjectCalendarSchema` no longer declares them flat. + // Only when a start binding is declared: the block requires one, and a + // present block counts as configured, so a start-less block would skip + // the renderer's own refusal screen that a view with no binding reaches. const calendarCfg = (viewOptions.calendar || {}) as Record; return { type: 'object-calendar', ...baseProps, - ...(calendarCfg.startDateField ? { startDateField: calendarCfg.startDateField } : {}), - ...(calendarCfg.endDateField ? { endDateField: calendarCfg.endDateField } : {}), - ...(calendarCfg.titleField ? { titleField: calendarCfg.titleField } : {}), - ...(calendarCfg.colorField ? { colorField: calendarCfg.colorField } : {}), - ...(calendarCfg.allDayField ? { allDayField: calendarCfg.allDayField } : {}), + ...(calendarCfg.startDateField + ? { + calendar: { + startDateField: calendarCfg.startDateField, + ...(calendarCfg.endDateField ? { endDateField: calendarCfg.endDateField } : {}), + ...(calendarCfg.titleField ? { titleField: calendarCfg.titleField } : {}), + ...(calendarCfg.colorField ? { colorField: calendarCfg.colorField } : {}), + ...(calendarCfg.allDayField ? { allDayField: calendarCfg.allDayField } : {}), + }, + } + : {}), }; } case 'gallery': @@ -2416,18 +2430,28 @@ export const ObjectView: React.FC = ({ // Deleting them cannot resurrect a config — `getGanttConfig` gates on // the two date fields alone, so the refusal screen stays as reachable // as it was (`plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070`). + // + // ⭐ objectstack#25923 — the block goes out NESTED, as the node's own + // `gantt`, the shape `ListView` hands `ObjectGantt` too: the spec's + // `ComponentPropsMap['object-gantt']` refuses every `GanttConfig` key + // written flat, so the published `ObjectGanttSchema` no longer declares + // them flat. Handed on under the two conditions the renderer's flat + // branch applied: only once BOTH date fields are declared, and with + // `titleField` floored at `'name'`, which the block requires. return { type: 'object-gantt', ...baseProps, - ...(viewOptions.gantt?.startDateField - ? { startDateField: viewOptions.gantt.startDateField } - : {}), - ...(viewOptions.gantt?.endDateField - ? { endDateField: viewOptions.gantt.endDateField } + ...(viewOptions.gantt?.startDateField && viewOptions.gantt?.endDateField + ? { + gantt: { + // The block's DECLARED keys, where a raw spread of the whole + // block stood until objectui#6152 round 14. See + // `GANTT_CONFIG_SPELLING`. + ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), + titleField: viewOptions.gantt.titleField || 'name', + }, + } : {}), - // The block's DECLARED keys, where a raw spread of the whole block - // stood until objectui#6152 round 14. See `GANTT_CONFIG_SPELLING`. - ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), }; case 'map': // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_SPELLING`, diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 71e936789f7..e910d79812b 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -3819,10 +3819,11 @@ export interface ObjectTreeSchema extends BaseSchema { * * The `object-gantt` node as `ObjectGantt` READS it: after `SchemaRenderer` has * hoisted the node's `properties` bag onto it, or as code composes it - * (`ObjectView` and `ListView` flattening a stored gantt view, a host mounting - * ``). That is why it still carries the flat - * `GanttConfig` keys (`startDateField`, `viewMode`, …) and the `search` pair - * the composers write. + * (`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. * * 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 @@ -3831,8 +3832,7 @@ export interface ObjectTreeSchema extends BaseSchema { * `{ type: 'object-gantt', properties: { objectName, gantt: { startDateField, * endDateField, titleField }, … } }`. `ObjectGanttBlockSchema` * (`./zod/objectql.zod.ts`) judges it, and refuses a prop written flat on the - * node by name. `SchemaRenderer` reads both spellings, so a node built in code - * keeps its flat keys. + * node by name. */ export interface ObjectGanttSchema extends BaseSchema { type: 'object-gantt'; @@ -3871,46 +3871,14 @@ export interface ObjectGanttSchema extends BaseSchema { * {@link ObjectMapSchema.data}. */ data?: ViewData; - /** Field for task start date */ - startDateField?: string; - /** Field for task end date */ - endDateField?: string; - /** Field for task title/name */ - titleField?: string; - /** - * Field for task dependencies. - * - * @deprecated Legacy alias — author {@link ObjectGanttSchema.dependenciesField} - * instead. The plural is the spec's spelling (`@objectstack/spec` - * `GanttConfigSchema.dependenciesField`); this singular has NO spec - * counterpart. It is pre-spec objectui vocabulary, and until objectui#6051 it - * was the ONLY dependencies spelling this interface declared — so for the whole - * time the alias existed the published type taught the non-spec key and hid the - * canonical one. That is what this tag exists to correct: the two were declared - * as equals, and a reader had no way to learn which one to write. - * - * Still accepted, deliberately. `getGanttConfig`'s flat branch reads - * `dependenciesField || dependencyField`, so metadata already written against - * this key renders unchanged and the canonical key wins wherever both carry a - * value. Deprecating is NOT removing: dropping the alias would narrow the - * accept set of a published surface, which is a separate enforce-or-remove - * decision (objectui#6470) and not something this marker takes. - */ - dependencyField?: string; - /** Field for progress (0-100) */ - progressField?: string; - /** - * Initial timeline granularity, honoured by BOTH renderer branches (the - * timeline and the resource-workload grid). DERIVED from the spec's - * `GanttConfigSchema.viewMode` member so the member list cannot drift - * (objectui#5074). - * - * Deliberately NO default: an omitted `viewMode` lets a persisted layout - * (保存布局, `persistLayoutKey`) seed the granularity before the renderer's - * `'day'` fallback. A default here would arrive downstream as an explicit - * author choice and defeat that seeding. - */ - viewMode?: SpecGanttConfig['viewMode']; + // objectstack#25923 — the flat `GanttConfig` keys (`startDateField`, + // `viewMode`, the legacy `dependencyField` …) and the toolbar's `search` pair + // are NOT members of this interface. `ComponentPropsMap['object-gantt']` + // refuses every one of them on the node, and the {@link ObjectGanttSchema.gantt} + // block is their one spelling here: both producers (`ListView`, and + // `ObjectView`'s `generateViewSchema`) emit it. What the renderer still reads + // flat is typed on `plugin-gantt`'s package-local hand-off type, never on this + // published face (Commandment #0.1). /** * Skip weekends in duration / auto-schedule math (objectui#5903). * @@ -4012,116 +3980,6 @@ export interface ObjectGanttSchema extends BaseSchema { */ mobileReadOnly?: boolean; - // ── The flattened `GanttConfig` face (objectui#6051) ──────────────────────── - // - // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. The - // `gantt` block wins whenever it is present (objectui#6469); this flat face is - // read only when there is no block, and then only when `startDateField` AND - // `endDateField` are both present at the TOP LEVEL. The keys of the flat branch - // were declared by neither - // this interface nor `ObjectGridSchema`: they were reachable only through - // `BaseSchema`'s `[key: string]: any`, so `schema.colorField` type-checked as - // `any` with no cast anywhere to grep for. That is why the census behind this - // card is an AST enumeration and not a compile-and-observe — an index signature - // swallows exactly the evidence a type annotation would have produced. - // - // Every member below is DERIVED from {@link GanttConfig}, the same type the - // `gantt` block carries, so the flat spelling cannot drift from the block - // spelling. All are optional, matching the renderer: the flat branch reads each - // key bare and forwards `undefined` unchanged. - // - // ⚠️ WHICH face wins was NOT decided by objectui#6051, which declared these keys. - // It was settled afterwards by objectui#6469, inheriting the maintainer ruling on - // objectui#5018 (2026-08-17) that `plugin-map` shipped in PR #5156: the BLOCK - // wins, taken whole, and the shadowed flat keys are named in a dev-mode warning - // instead of being dropped silently. So a node carrying both spellings renders - // the `gantt` block's values — the reverse of the pre-#6469 order. - - /** - * Record field carrying the bar's FILL colour: any CSS colour or a semantic - * palette name (red/orange/…), typically a server-computed status colour. - * When it is unset — or when the record's value is empty — the bar falls back - * to the record's own `status`/`state`/`priority`/`severity` value, so the - * timeline tells the same colour story as list/kanban. With neither, bars - * take the platform default blue. - */ - colorField?: GanttConfig['colorField']; - /** Per-task alert stroke colour field. See {@link GanttConfig.borderColorField}. */ - borderColorField?: GanttConfig['borderColorField']; - /** - * Record field holding this task's predecessors. The CANONICAL spelling — the - * flat branch reads `dependenciesField || dependencyField`, so the singular - * {@link ObjectGanttSchema.dependencyField} above stays accepted as the legacy - * alias and this one wins. That alias is `@deprecated` (objectui#6470): still - * read, no longer taught. - */ - dependenciesField?: GanttConfig['dependenciesField']; - /** - * Record field holding this row's PARENT id — the single-parent pointer the - * task tree is built from: indentation, expand/collapse and summary rollup - * all follow it. An empty value, or one naming no loaded row, renders that - * row as a root. Leave unset for a flat chart; `groupByField` is the - * alternative, bucketing leaves under synthesized rows instead of a - * record-declared hierarchy. - */ - parentField?: GanttConfig['parentField']; - /** Record field mapping onto a node kind (task/summary/milestone/group). */ - typeField?: GanttConfig['typeField']; - /** Record field marking a row view-only. See {@link GanttConfig.lockField}. */ - lockField?: GanttConfig['lockField']; - /** Record field carrying the row's own object API name (mixed-object trees). */ - objectField?: GanttConfig['objectField']; - /** How a summary bar's span is computed. See {@link GanttConfig.summaryExtent}. */ - summaryExtent?: GanttConfig['summaryExtent']; - /** Auto-collapse depth on first render. See {@link GanttConfig.defaultCollapsedDepth}. */ - defaultCollapsedDepth?: GanttConfig['defaultCollapsedDepth']; - /** - * Extra record fields listed as label/value rows in a bar's hover tooltip, in - * the order given. Each entry is a field name (dot-paths allowed) or - * `{ field, label }` to set the label explicitly; otherwise the label comes - * from the object schema, falling back to a humanized field name, and the - * value is formatted by field type the way a list cell would render it. A row - * whose value is empty is DROPPED rather than dashed, so a mixed-object tree - * can list the union of every level's fields here. Any surviving rows replace - * the tooltip's default date · duration · progress line. - */ - tooltipFields?: GanttConfig['tooltipFields']; - /** Baseline (planned) start field → planned-vs-actual reference bars. */ - baselineStartField?: GanttConfig['baselineStartField']; - /** Baseline (planned) end field → planned-vs-actual reference bars. */ - baselineEndField?: GanttConfig['baselineEndField']; - /** Dynamic group-by field, replacing the parent hierarchy. */ - groupByField?: GanttConfig['groupByField']; - /** Render the per-resource load histogram instead of the timeline grid. */ - resourceView?: GanttConfig['resourceView']; - /** Record field the resource view buckets by. Required for {@link resourceView}. */ - assigneeField?: GanttConfig['assigneeField']; - /** Record field carrying each task's load units (default 1). */ - effortField?: GanttConfig['effortField']; - /** Per-resource capacity ceiling (default 1); loads above it flag overload. */ - capacity?: GanttConfig['capacity']; - /** - * Quick-filter dropdowns rendered above the chart — a row of multi-selects, - * each narrowing the visible bars by one record field. A dimension's options - * resolve from the object schema (a select's options, or the referenced - * records for a lookup), so the dropdown offers that field's full domain - * rather than only the values present in the loaded page; declare `options` - * on the dimension to override that with a fixed list. - */ - quickFilters?: GanttConfig['quickFilters']; - /** Recompute the timeline range when filtering (default true). */ - autoZoomToFilter?: GanttConfig['autoZoomToFilter']; - /** Shift segmentation for the day-mode timeline. See {@link GanttConfig.timeSegments}. */ - timeSegments?: GanttConfig['timeSegments']; - /** Per-interaction switches. See {@link GanttConfig.interactions}. */ - interactions?: GanttConfig['interactions']; - /** Base name for exported PNG/PDF files. See {@link GanttConfig.exportFileName}. */ - exportFileName?: GanttConfig['exportFileName']; - /** Business time zone (IANA name) the calendar renders in. */ - timeZone?: GanttConfig['timeZone']; - /** Whether the store persists dependency link TYPES (fs/ss/ff/sf). */ - dependencyTypes?: GanttConfig['dependencyTypes']; - // ── The BLOCK face (the `ObjectGridSchema`-style shape, objectui#6475) ────── // // `getGanttConfig`'s FIRST branch (`plugin-gantt/src/ObjectGantt.tsx`) reads @@ -4178,20 +4036,6 @@ export interface ObjectGanttSchema extends BaseSchema { filter?: SpecObjectGanttProps['filter']; /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. Array only — the legacy string clause is retired (objectui#8221). */ sort?: SortConfig[]; - /** - * Full-text search term, forwarded as `$search` (objectui#10250). The server - * resolves which fields it matches from the object's metadata (ADR-0061). - * `ListView` writes it from its toolbar Search box: the chart runs its own - * query, so without this key the box changed the list's fetch and nothing - * drawn. - */ - search?: string; - /** - * Narrows the fields {@link ObjectGanttSchema.search} matches, forwarded as - * `$searchFields` — and only alongside a term, as a list's own query sends - * it. Same key and meaning as {@link ObjectGridSchema.searchableFields}. - */ - searchableFields?: string[]; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-gantt` reads NEITHER * content channel; see `children` below for the measurement. @@ -4303,16 +4147,18 @@ export interface ObjectCalendarSchema extends BaseSchema { /** * The configuration container, and the FIRST thing this element's renderer * reads: `plugin-calendar/src/ObjectCalendar.tsx`'s `getCalendarConfig` - * returns this block whole when it is present, and only falls through to the - * flat members below when it is not. + * returns this block whole when it is present. * - * ⭐ This is where the five field-name keys are AUTHORED (objectui#8831): + * ⭐ This is the ONE spelling of the five field-name keys (objectui#8831, + * objectstack#25923): * `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`. * `ComponentPropsMap['object-calendar']` refuses the same five written flat on * the node, and its diagnostic prescribes exactly this block (one key per - * concept, the spec's Prime Directive #12). The flat members below are the - * runtime handoff, declared because the renderer reads them, not a second - * spelling to write. + * 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). * * `@objectstack/spec` declares the KEY and, since 17.7.0, its SHAPE: * `ComponentPropsMap['object-calendar'].calendar` is a strict copy of the list @@ -4342,32 +4188,11 @@ export interface ObjectCalendarSchema extends BaseSchema { * the block, it is refused; write it flat. */ calendar?: ObjectCalendarBlockConfig; - /** - * Field for event start — the FLAT spelling, READ BUT NOT AUTHORED - * (objectui#8831). Write `calendar.startDateField` instead. - * - * This is the runtime handoff: `ObjectView` and `ListView` emit the five - * field-name keys flat on the `object-calendar` node they build, and - * `getCalendarConfig` reads them only when the node carries no - * {@link ObjectCalendarSchema.calendar} block. `@objectstack/spec` refuses - * the same five at this element with `unrecognized_keys`, and its diagnostic - * names the {@link ObjectCalendarSchema.calendar} block as the place to write - * them (one key per concept, its Prime Directive #12). The five stay declared - * here because the renderer reads them; a declaration records that read, it - * is not a second authoring spelling. - */ - startDateField?: string; - /** - * Field for event end — the FLAT spelling, read but not authored. Write - * `calendar.endDateField`; see {@link ObjectCalendarSchema.startDateField}. - */ - endDateField?: string; /** * ⛔ RETIRED (objectui#8355, director ruling of 2026-09-16) — `dateField` was - * the pre-#2231 objectui spelling of {@link ObjectCalendarSchema.startDateField} - * and is refused BY NAME on both faces: `?: never` here, an - * `aliasKeyRefusal()` arm on the zod mirror (`zod/objectql.zod.ts`). Write - * `startDateField`. + * the pre-#2231 objectui spelling of `startDateField` and is refused BY NAME + * on both faces: `?: never` here, an `aliasKeyRefusal()` arm on the zod + * mirror (`zod/objectql.zod.ts`). Write `calendar.startDateField`. * * ⚠️ It is a TOMBSTONE, not a deletion, and the difference is the whole * ruling. `BaseSchema` is `.passthrough()` on the mirror (and carried @@ -4379,85 +4204,15 @@ export interface ObjectCalendarSchema extends BaseSchema { */ dateField?: never; /** - * ⛔ RETIRED (objectui#8355) — the pre-#2231 spelling of - * {@link ObjectCalendarSchema.endDateField}, refused by name for the reason - * spelled out on {@link ObjectCalendarSchema.dateField} above. Write - * `endDateField`. + * ⛔ RETIRED (objectui#8355) — the pre-#2231 spelling of `endDateField`, + * refused by name for the reason spelled out on + * {@link ObjectCalendarSchema.dateField} above. Write `calendar.endDateField`. * * ⚠️ This one degraded even more quietly than its sibling before the * retirement: the node still resolved through `startDateField`, so the * calendar drew, and only the end binding went missing. */ endField?: never; - /** - * Field for event title — the FLAT spelling, read but not authored. Write - * `calendar.titleField`; see {@link ObjectCalendarSchema.startDateField}. - */ - titleField?: string; - /** - * Record field carrying the event's colour — the FLAT spelling, read but not - * authored: write `calendar.colorField`, and see - * {@link ObjectCalendarSchema.startDateField} for why the flat member stays - * declared. - * - * The value is any CSS colour or a semantic palette name, typically a - * server-computed status colour. Resolved PER - * RECORD by `plugin-calendar/src/ObjectCalendar.tsx`, which falls back to the - * record's own `color` value and then to the platform default, so an authored - * value that never arrives is invisible rather than loud. - * - * DERIVED from {@link CalendarConfig}, the same type the `calendar` block - * carries, so the flat spelling cannot drift from the block spelling. That is - * verbatim the pattern objectui#6051 established for - * {@link ObjectGanttSchema.colorField} — the same key name, the same - * mechanism, on this same file — and the reason this member is an indexed - * access rather than a fresh `string`. - * - * Undeclared here until objectui#8466, so an authored value reached the - * renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted, - * never examined — while `plugin-calendar/README.md` taught it as authorable. - */ - colorField?: SpecCalendarConfig['colorField']; - /** - * Record field carrying the all-day flag — the FLAT spelling, read but not - * authored: write `calendar.allDayField`, and see - * {@link ObjectCalendarSchema.startDateField} for why the flat member stays - * declared. - * - * LOAD-BEARING since objectui#8026: - * the events pass in `plugin-calendar/src/ObjectCalendar.tsx` reads it and a - * change to the key genuinely changes what is drawn. It is also in - * that component's `getCalendarConfig` memo dependency list, which is what - * makes the change reach the screen. - * - * Through `@objectstack/spec` 17.4.0 this key was objectui-LOCAL in both - * positions: `CalendarConfigSchema` was a `strictObject` of exactly - * `startDateField`, `endDateField`, `titleField` and `colorField`, and - * answered `allDayField` and a nonsense key with the identical - * `unrecognized_keys` diagnostic (objectui#8651). Since 17.5.0 that schema - * declares `allDayField` too, so `calendar.allDayField` is a spec key like its - * four neighbours (objectui#11073). This flat member is still typed `string` - * rather than derived from {@link CalendarConfig}; the two types are equal. - * - * ⛔ Declaring it widens NO accept set, which is why Commandment #0.1 is not - * engaged. Measured on spec 17.3.0: `ComponentPropsMap['object-calendar']` - * refuses ALL FIVE flat field-name keys with `unrecognized_keys` — including - * {@link ObjectCalendarSchema.titleField}, - * {@link ObjectCalendarSchema.startDateField} and - * {@link ObjectCalendarSchema.endDateField} above, which have shipped - * DECLARED for releases. This key is the fifth member of that flat handoff, - * not a new dialect. And under `BaseSchema`'s - * index signature the value was already `any`, so declaring only NARROWED. - * - * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — a sibling - * calendar interface in the same plugin, whose OWN renderer - * (`plugin-calendar/src/calendar-view-renderer.tsx`) reads the same five flat - * keys — has shipped all five declared, `allDayField` included, on both - * faces. Two interfaces, two renderers, one flat vocabulary: this interface - * was the odd one out, not the pioneer, and the pin keeps that shared - * vocabulary from drifting apart. - */ - allDayField?: string; /** * Default view mode — the renderer's rendered set. `'agenda'` was retired * (`ed8df3e50`, following `b55a34647`): `CalendarView` renders no agenda view, diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 6ba08878cfb..78456ae92ca 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -2888,8 +2888,8 @@ const OBJECT_GANTT_NEITHER_CHANNEL = neitherContentChannelGuidance( * * This mirror is the node as `ObjectGantt` reads it: after `SchemaRenderer` * has hoisted the node's `properties` bag onto it, or as code composes it - * (`ObjectView` / `ListView` flattening a stored gantt view, which also write - * the flat `GanttConfig` keys and the `search` pair below). It left + * (`ObjectView` / `ListView` lowering a stored gantt view, which hand the + * view's block on as the node's `gantt`). It left * `ObjectQLComponentSchema`, and so `AnyComponentSchema`: the AUTHORED * `object-gantt` node is armed by `ObjectGanttBlockSchema` below, whose * `properties` is the spec's `ComponentPropsMap['object-gantt']` row. It stays @@ -2904,26 +2904,14 @@ export const ObjectGanttSchema = BaseSchema.extend({ .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), objectName: z.string().optional().describe('ObjectQL object name — the THIRD record source resolveRecordSourceConfig resolves, after data and staticData; one of the three must be present, unless the node\'s dataSource.object names the object, which ElementDataSourceGate lands here (objectui#11117)'), data: ViewDataSchema.optional().describe('Data source configuration — read FIRST by resolveRecordSourceConfig'), - startDateField: z.string().optional().describe('Start date field'), - endDateField: z.string().optional().describe('End date field'), - titleField: z.string().optional().describe('Title field'), - // The legacy singular alias. Kept accepted — `getGanttConfig`'s flat branch - // reads `dependenciesField || dependencyField` — but no longer declared as an - // equal of the canonical key: same treatment `KanbanConfig` above gives - // `groupField`/`cardFields`, so this adopts the ruled idiom rather than a - // second spelling of "deprecated". The canonical `dependenciesField` is - // declared below, BY REFERENCE to the spec (objectui#6470). - /** @deprecated legacy alias for the spec's `dependenciesField` */ - dependencyField: z.string().optional().describe('Deprecated alias for dependenciesField'), - progressField: z.string().optional().describe('Progress field'), - // DERIVED from the spec's `GanttConfigSchema.shape.viewMode` (an optional - // enum, deliberately WITHOUT a default) so the member list cannot drift - // (objectui#5074). Absence semantics are load-bearing: an omitted `viewMode` - // lets a persisted layout seed the timeline granularity before the - // renderer's 'day' fallback — do NOT add `.default('day')` here. - viewMode: stripImportedDefaults(SpecGanttConfigSchema).shape.viewMode.describe( - 'Initial timeline granularity, honoured by both renderer branches; when omitted, a persisted layout may seed it' - ), + // objectstack#25923 — the flat `GanttConfig` keys (`startDateField`, + // `viewMode`, `dependencyField` …) and the toolbar's `search` pair are NOT + // 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. + // // objectui#5903 — ten keys `ObjectGantt` reads and this mirror did not // declare. They were reachable only through `(schema as any).K`, so nothing // connected the read to a declaration. Mirrored here at the SAME requiredness @@ -2953,58 +2941,15 @@ export const ObjectGanttSchema = BaseSchema.extend({ showBaselines: z.boolean().optional().describe('Render planned-vs-actual baseline bars — defaults ON, only an explicit false disables'), readOnly: z.boolean().optional().describe('Disable every write path and lock the record drawer'), mobileReadOnly: z.boolean().optional().describe('Auto read-only on narrow viewports — defaults ON, only an explicit false disables'), - // objectui#6051 — the FLATTENED `GanttConfig` face. `getGanttConfig` builds its - // config from these top-level keys when the node carries no `gantt` block and - // `startDateField` / `endDateField` are both present — the block OUTRANKS this - // face (objectui#6469); nothing declared them, on either side, - // because `BaseSchema`'s index signature admitted them untyped. Mirrored at the - // SAME requiredness as `../objectql.ts` (all optional) so the zod-mirror-parity - // ratchet stays at zero drift for this pair. - // - // The spec-modelled members are taken from `SpecGanttConfigSchema.shape` by - // reference, exactly as `viewMode` above is, so the vocabulary cannot fork. - colorField: stripImportedDefaults(SpecGanttConfigSchema).shape.colorField, - dependenciesField: stripImportedDefaults(SpecGanttConfigSchema).shape.dependenciesField, - parentField: stripImportedDefaults(SpecGanttConfigSchema).shape.parentField, - typeField: stripImportedDefaults(SpecGanttConfigSchema).shape.typeField, - tooltipFields: stripImportedDefaults(SpecGanttConfigSchema).shape.tooltipFields, - baselineStartField: stripImportedDefaults(SpecGanttConfigSchema).shape.baselineStartField, - baselineEndField: stripImportedDefaults(SpecGanttConfigSchema).shape.baselineEndField, - groupByField: stripImportedDefaults(SpecGanttConfigSchema).shape.groupByField, - resourceView: stripImportedDefaults(SpecGanttConfigSchema).shape.resourceView, - assigneeField: stripImportedDefaults(SpecGanttConfigSchema).shape.assigneeField, - effortField: stripImportedDefaults(SpecGanttConfigSchema).shape.effortField, - capacity: stripImportedDefaults(SpecGanttConfigSchema).shape.capacity, - quickFilters: stripImportedDefaults(SpecGanttConfigSchema).shape.quickFilters, - autoZoomToFilter: stripImportedDefaults(SpecGanttConfigSchema).shape.autoZoomToFilter, - // …and the ten that used to be a SECOND declaration here. objectui read them - // through `GanttConfigSchema`'s then-open `.passthrough()` window and had to - // model them locally (`GanttConfigExtensionFields`, objectui#6051/#6475); - // objectstack#15469 closed the window and declared all ten upstream, so they - // arrive by reference like every member above and the local field map is - // retired (objectui#7845). Compared member for member before deleting: the - // spec's declarations cover the same keys, the same `interactions` - // (move/resize/progress/link) and `timeSegments` (dayStart/bands/showMidnight) - // sub-shapes and the same band members, with strictly fuller describes — the - // local copy carried no describe, alias or member the spec's lacks. - borderColorField: stripImportedDefaults(SpecGanttConfigSchema).shape.borderColorField, - lockField: stripImportedDefaults(SpecGanttConfigSchema).shape.lockField, - objectField: stripImportedDefaults(SpecGanttConfigSchema).shape.objectField, - summaryExtent: stripImportedDefaults(SpecGanttConfigSchema).shape.summaryExtent, - defaultCollapsedDepth: stripImportedDefaults(SpecGanttConfigSchema).shape.defaultCollapsedDepth, - dependencyTypes: stripImportedDefaults(SpecGanttConfigSchema).shape.dependencyTypes, - timeZone: stripImportedDefaults(SpecGanttConfigSchema).shape.timeZone, - exportFileName: stripImportedDefaults(SpecGanttConfigSchema).shape.exportFileName, - interactions: stripImportedDefaults(SpecGanttConfigSchema).shape.interactions, - timeSegments: stripImportedDefaults(SpecGanttConfigSchema).shape.timeSegments, // `gantt` — the BLOCK face `getGanttConfig`'s FIRST branch reads and prefers - // (objectui#6469 ruled block-over-flat) — objectui#6475. It is now - // `SpecGanttConfigSchema` itself, the same vocabulary the flat face above - // derives from key by key, so the two authoring faces cannot fork. The + // (objectui#6469 ruled block-over-flat) — objectui#6475. It is + // `SpecGanttConfigSchema` itself, and since objectstack#25923 the ONE + // spelling of those keys on this node: the flat face that used to derive + // from it key by key is gone. The // `.extend(GanttConfigExtensionFields)` this used to carry is gone with the // field map (objectui#7845): the spec declares those ten itself. // - // This is the one entry among the 28 that NARROWS rather than merely names: a + // This was the one entry among the 28 that NARROWED rather than merely named: a // `gantt` block previously rode through `.passthrough()` entirely unvalidated; // now it is PARSED against the spec's `GanttConfigSchema`, which REQUIRES // `startDateField`/`endDateField`/`titleField`. This mirror reached the CLI's @@ -3019,7 +2964,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // Option A: enforce as-is, no warning window (excluded by the startup-stage // no-gradualism rule, objectstack#12668 — no named external-user evidence). gantt: stripImportedDefaults(SpecGanttConfigSchema).optional().describe( - 'Nested gantt config block — the authoring face, and the winner over the flattened top-level keys whenever present' + 'Nested gantt config block — the one spelling of the field mapping on this node; the spec refuses the same keys written flat' ), // The query/data keys the fetch path reads. They were declared on // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before @@ -3032,11 +2977,6 @@ export const ObjectGanttSchema = BaseSchema.extend({ // so no authored door moves; the read stays wider (see the TS twin's member). filter: stripImportedDefaults(SpecObjectGanttPropsSchema).shape.filter, sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), - // objectui#10250 — the full-text pair the record query carries, declared in - // step with the twin in `../objectql.ts`. ListView's toolbar Search writes - // both onto a gantt node, because the chart queries for itself. - search: z.string().optional().describe('Full-text search term, forwarded as $search (the server resolves the matched fields, ADR-0061)'), - searchableFields: z.array(z.string()).optional().describe('Narrows the fields `search` matches, forwarded as $searchFields alongside a term'), // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. body: retirementTombstone(OBJECT_GANTT_NEITHER_CHANNEL), @@ -3050,23 +2990,6 @@ const OBJECT_CALENDAR_NEITHER_CHANNEL = neitherContentChannelGuidance( 'the records of `objectName` as events from `startDateField` to `endDateField`', ); -/** - * objectui#8831 — ONE description for the five FLAT field-name members of - * `ObjectCalendarSchema`, so the five cannot teach five different things. - * - * The flat spelling is read, not authored. `@objectstack/spec` refuses all five - * at this element and its diagnostic names the canonical form, - * `calendar: { startDateField, endDateField, titleField, colorField, allDayField }` - * (one key per concept, its Prime Directive #12). The members stay declared - * because the renderer reads them: `ObjectView` and `ListView` emit this - * spelling on the node they build, and `getCalendarConfig` falls back to it - * when the node has no `calendar` block. ⛔ Declared is not a licence to author - * it; the description says where the key is written instead. - */ -function objectCalendarFlatField(what: string, key: string): string { - return `${what} — FLAT spelling, read but not authored: the runtime handoff ObjectView/ListView emit, read by getCalendarConfig only when the node has no calendar block. Author calendar.${key} instead; the spec refuses the flat key on object-calendar (Prime Directive #12)`; -} - /** * ObjectCalendar Schema * @@ -3127,22 +3050,23 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // mirror aligns to it rather than forking the contract. Mirrored at the SAME // requiredness as `../objectql.ts` (both optional) so the zod-mirror-parity // ratchet stays at zero drift for this pair, exactly as the `filter`/`sort` - // and `colorField`/`allDayField` pairs below. + // pair below. // - // objectui#8831 — this container is the AUTHORED spelling of the five - // field-name keys. `ComponentPropsMap['object-calendar']` refuses them FLAT - // and its own diagnostic prescribes - // `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`, - // so this description names the container as the place to write them, and - // the five flat members below describe themselves as the runtime handoff. + // objectui#8831, objectstack#25923 — this container is the ONE spelling of + // the five field-name keys. `ComponentPropsMap['object-calendar']` refuses + // them FLAT and its own diagnostic prescribes + // `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`. + // Neither face of this package declares the flat five any more, so the strict + // authoring face refuses them as the spec does. The flat spelling lives on + // only as `plugin-calendar`'s package-local hand-off type, which + // `getCalendarConfig` reads when a node carries no block (Commandment #0.1: + // a key only code writes at runtime stays off the authoring face). // // objectui#6152 round 9 — the container's SHAPE is the row's own slot now, // by reference: strict, `startDateField` required (see // `ObjectCalendarBlockConfigSchema` above). The `calendar` key itself stays // optional on both faces, as before. - calendar: ObjectCalendarBlockConfigSchema.optional().describe('Calendar configuration container, and the AUTHORED spelling of the five field-name keys: startDateField (required), endDateField, titleField, colorField, allDayField; any other key is refused, as ComponentPropsMap[\'object-calendar\'].calendar refuses it. Read FIRST by getCalendarConfig, ahead of the flat members, which are the runtime handoff and not a second authorable spelling'), - startDateField: z.string().optional().describe(objectCalendarFlatField('Start date field', 'startDateField')), - endDateField: z.string().optional().describe(objectCalendarFlatField('End date field', 'endDateField')), + calendar: ObjectCalendarBlockConfigSchema.optional().describe('Calendar configuration container, and the AUTHORED spelling of the five field-name keys: startDateField (required), endDateField, titleField, colorField, allDayField; any other key is refused, as ComponentPropsMap[\'object-calendar\'].calendar refuses it. The five are never written flat on the node: the spec refuses them there, and so does the strict authoring face'), // ⭐ objectui#8355 — the FLAT spelling the retired ladder actually read, and // the one position where an unrefused alias is worst: `BaseSchema` ends // `.passthrough()`, so the key was KEPT, carried into the renderer, and — with @@ -3151,33 +3075,6 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // `?: never` twin and `tsc` refuses the key at the authoring site too. dateField: CalendarNodeDateAliasRefusals.dateField, endField: CalendarNodeDateAliasRefusals.endField, - titleField: z.string().optional().describe(objectCalendarFlatField('Title field', 'titleField')), - // objectui#8466 — the last two members of the FLAT field-name face, which - // `ObjectCalendar.tsx`'s `getCalendarConfig` reads bare off the node. When - // that card landed, `plugin-calendar/README.md` taught them as authorable; - // since objectui#8831 it teaches the `calendar` container instead, and the - // flat members stay declared because the renderer still reads them. Neither - // published face of this package named them: they rode `BaseSchema`'s - // `[key: string]: any` on the TS side and its `.passthrough()` here — - // admitted, never examined, so a misspelling left the calendar silently - // colourless while every published gate passed. - // - // Mirrored at the SAME requiredness as `../objectql.ts` (both optional) so - // the zod-mirror-parity ratchet stays at zero drift for this pair, exactly as - // the `filter`/`sort` pair above. - // - // ⛔ Neither key is added to `plugin-calendar`'s registration `inputs`, and - // that asymmetry is deliberate: `ComponentPropsMap['object-calendar']` - // refuses all five flat keys with `unrecognized_keys`, so declaring them - // THERE would redden the FORWARD direction of - // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. - // `titleField`/`startDateField`/`endDateField` have shipped declared here, and - // absent from `inputs`, for releases. What the five declarations record is a - // READ, not an authoring lane (objectui#8831): `ObjectView`/`ListView` emit - // this spelling on the node they build, and `getCalendarConfig` falls back to - // it only when the node carries no `calendar` block. - colorField: z.string().optional().describe(objectCalendarFlatField('Field carrying the per-record event colour — a CSS colour or a semantic palette name', 'colorField')), - allDayField: z.string().optional().describe(objectCalendarFlatField('Field carrying the all-day flag, LOAD-BEARING since objectui#8026', 'allDayField')), defaultView: z.enum(['month', 'week', 'day']).optional().describe("Default view — 'month' | 'week' | 'day', the renderer's rendered set ('agenda' was retired)"), // objectui#8174 — the two query keys `ObjectCalendar.tsx` lowers onto its own // `dataSource.find` (`$filter: schema.filter`, @@ -4936,9 +4833,10 @@ const OBJECT_GANTT_FLAT_PROP_REFUSALS = Object.fromEntries( type ObjectGanttConfigKey = keyof NonNullable['gantt']>; /** - * The flat mirror's `GanttConfig` members (`startDateField`, `viewMode`, - * `colorField`, … — the flatten product `getGanttConfig` reads when a node has - * no `gantt` block) and its legacy alias `dependencyField`, each refused by name + * The `GanttConfig` keys written flat (`startDateField`, `viewMode`, + * `colorField`, … — the spelling `getGanttConfig` still reads off a node with no + * `gantt` block, typed on `plugin-gantt`'s hand-off type since + * objectstack#25923) and the legacy alias `dependencyField`, each refused by name * when written on the authored node and pointed at its home, the bag's `gantt` * block: `properties.gantt.KEY` (`dependencyField` at the canonical * `properties.gantt.dependenciesField`). The key set is the row's own `gantt` @@ -4998,11 +4896,11 @@ const OBJECT_GANTT_FLAT_CONFIG_REFUSALS = { * because the gantt's `data` is the row's member. `label` is the one row member * that stays: the spec's page component declares a node-level `label` of its * own, so a `label` on the node is not mis-layered, and it keeps `BaseSchema`'s - * member. The flat mirror's `GanttConfig` members and its `dependencyField` - * alias are refused too, pointed at the bag's `gantt` block + * member. The `GanttConfig` keys and the `dependencyField` alias written flat + * are refused too, pointed at the bag's `gantt` block * (`OBJECT_GANTT_FLAT_CONFIG_REFUSALS` above). A flat key neither the row nor - * that block declares — the mirror's `search` / `searchableFields`, which a - * list view writes onto the node it composes, among them — is left as every + * that block declares — the `search` / `searchableFields` pair, which a list + * view writes onto the node it composes, among them — is left as every * arm leaves an undeclared key: unjudged by the tolerant face, refused by the * strict one. * @@ -5027,9 +4925,10 @@ const OBJECT_GANTT_FLAT_CONFIG_REFUSALS = { * * The TypeScript `ObjectGanttSchema` and its zod mirror above stay published: * they are the node as `ObjectGantt` reads it after the hoist, and as - * `ObjectView` / `ListView` compose it when they flatten a stored gantt view. A - * composed flat node keeps working, because `SchemaRenderer` reads both - * spellings and no composed node passes through `safeValidateSchema`. + * `ObjectView` / `ListView` compose it when they lower a stored gantt view + * into the node's `gantt` block. Since objectstack#25923 neither carries a flat + * `GanttConfig` key: the flat spelling the renderer still reads is + * `plugin-gantt`'s package-local hand-off type, off the published faces. */ export const ObjectGanttBlockSchema = BaseSchema.extend({ type: z.literal('object-gantt'), From 4770759527c93c6f9d6920a89139a698d1935713 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:33:32 +0000 Subject: [PATCH 2/8] test(types,plugin-*): re-point the calendar and gantt pins to the block spelling; hand-off pins in plugin-gantt The published faces no longer declare the flat keys, so the pins that read them there now pin their absence, and the producer tests read the emitted calendar / gantt block. READMEs and the changeset follow. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .../25923-calendar-gantt-flat-keys-handoff.md | 27 ++ ui/packages/plugin-calendar/README.md | 14 +- .../calendarUnionReads-8651.test.tsx | 65 +++- ui/packages/plugin-gantt/README.md | 11 +- .../ganttHandoffFlatFields-25923.test.ts | 120 +++++++ .../ListView.calendar-binding-7029.test.tsx | 9 +- ...istView.calendarAliasRefused-8355.test.tsx | 24 +- ...ListView.calendarDateWindow-12081.test.tsx | 2 +- .../ListView.gantt-binding-7070.test.tsx | 49 ++- ...tView.ganttNavigationForward-7334.test.tsx | 6 +- .../ListView.readersRetired-6152.test.tsx | 23 +- .../ListViewBlock.specInputs-13047.test.tsx | 16 +- ...ectView.calendarAliasRefused-8355.test.tsx | 25 +- .../ObjectView.calendarBinding-7029.test.tsx | 30 +- .../ObjectView.ganttBinding-7070.test.tsx | 64 ++-- ...ctView.namedViewProtocolKeys-8980.test.tsx | 7 +- .../ObjectView.readersRetired-6152.test.tsx | 11 +- .../calendar-date-alias-refusal-8355.test.ts | 13 +- .../calendar-flat-color-allday-8466.test.ts | 120 +++---- ...-dependency-field-deprecated-alias.test.ts | 182 +++-------- .../gantt-flat-config-declared-keys.test.ts | 308 +++++++----------- .../__tests__/gantt-search-keys-10250.test.ts | 68 ++-- .../gantt-view-mode-declared.test.ts | 51 +-- ...ject-gantt-properties-bag-10859-b6.test.ts | 10 +- 24 files changed, 695 insertions(+), 560 deletions(-) create mode 100644 .changeset/25923-calendar-gantt-flat-keys-handoff.md create mode 100644 ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts diff --git a/.changeset/25923-calendar-gantt-flat-keys-handoff.md b/.changeset/25923-calendar-gantt-flat-keys-handoff.md new file mode 100644 index 00000000000..3e70cd4f878 --- /dev/null +++ b/.changeset/25923-calendar-gantt-flat-keys-handoff.md @@ -0,0 +1,27 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-view': minor +--- + +refactor(types,plugin-calendar,plugin-gantt,plugin-list,plugin-view)!: the flat `object-calendar` and `object-gantt` field-mapping keys leave the published schemas; producers emit the `calendar` / `gantt` block (#25923) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + + + +`@object-ui/types` published, on `ObjectCalendarSchema` and `ObjectGanttSchema`, a flat spelling of the field mapping that the spec refuses on the node. A declared flat key is one the strict authoring face derives from, so `objectui validate` accepted the `object-calendar` spelling the spec refuses. The keys only the host writes now live on each renderer package's own hand-off type (ui/AGENTS.md Commandment #0.1), and the producers write the block. + +- **`@object-ui/types`**: `ObjectCalendarSchema` (TypeScript and zod) no longer declares `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat; they are written in its `calendar` block. The `dateField` / `endField` tombstones stay. `ObjectGanttSchema` no longer declares the 29 `GanttConfig` keys flat (`startDateField`, `endDateField`, `titleField`, `progressField`, `dependenciesField`, `colorField`, `viewMode`, `summaryExtent`, `interactions` and the rest), nor the legacy `dependencyField` alias or the `search` / `searchableFields` pair; they are written in its `gantt` block (the pair is not authored at all). `staticData`, `filter`, `sort`, `gantt` and the other node keys stay. For `object-gantt` the strict authoring face already refused every flat key (its arm is the `properties` bag), so the narrowing there is the published TypeScript and zod export. +- **`@object-ui/plugin-calendar`**: `ObjectCalendarFlatFields` and `ObjectCalendarHandoff` type the flat read `getCalendarConfig` keeps for a host that mounts the renderer directly. The renderer is unchanged: the `calendar` block wins, and the flat spelling is read only without it. +- **`@object-ui/plugin-gantt`**: `ObjectGanttFlatFields` (derived from `GanttConfig`, plus the alias and the search pair) and `ObjectGanttHandoff` type the flat read the same way. The renderer is unchanged. +- **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. + +**BREAKING** for TypeScript code or hand-written nodes that write the flat keys on these two node types. + +- **Before.** `const node: ObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', startDateField: 'start' }` compiled, and the `object-calendar` strict face accepted it. +- **Now.** The flat key is a compile error, and `objectui validate` refuses it by name on `object-calendar` (it already did on `object-gantt`). A renderer handed a flat node still draws it. +- **Fix.** Move the keys into the block: FROM `{ type: 'object-calendar', startDateField: 'start', titleField: 'name' }` TO `{ type: 'object-calendar', calendar: { startDateField: 'start', titleField: 'name' } }`, and FROM `{ type: 'object-gantt', startDateField: 's', endDateField: 'e', progressField: 'p' }` TO `{ type: 'object-gantt', gantt: { startDateField: 's', endDateField: 'e', titleField: 'name', progressField: 'p' } }` (the `gantt` block requires `titleField`). Code that hands a flat node straight to `` or `` types it as `ObjectCalendarProps['schema']` or `ObjectGanttProps['schema']`, which carry the flat keys. diff --git a/ui/packages/plugin-calendar/README.md b/ui/packages/plugin-calendar/README.md index 4bef3e92b34..6d69a078e4f 100644 --- a/ui/packages/plugin-calendar/README.md +++ b/ui/packages/plugin-calendar/README.md @@ -321,12 +321,14 @@ of its own — the only lever there is `sourceView`, pointed at a view that declares the block (objectui#8170). The same five keys written flat on the node are **not** a second way to author -this. That flat spelling is the runtime handoff — `ObjectView` and `ListView` -emit it on the node they build, and `getCalendarConfig` falls back to it only -when the node has no `calendar` block — so `ObjectCalendarSchema` declares it -because the renderer reads it. `@objectstack/spec` refuses it at authoring with -`unrecognized_keys` and names the `calendar` block in the same diagnostic (one -key per concept, its Prime Directive #12). +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. ```typescript import type { ObjectCalendarSchema } from '@object-ui/types'; 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 997ef7b8b34..336ca7ef8f7 100644 --- a/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -130,6 +130,18 @@ * ratchet plus the compile-time pins in this file, so one row covers both * published faces without this file re-implementing a second checker. * + * ## ⏱ objectstack#25923 — the five flat field-name reads moved to a hand-off type + * + * The five flat keys (`startDateField` `endDateField` `titleField` `colorField` + * `allDayField`) left the published `ObjectCalendarSchema` and its mirror: the + * 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. + * * ## The ceiling, stated rather than assumed (objectui#5155 / #7927) * * The mirror is `.passthrough()`, so declaring a key buys VALUE validation and @@ -151,7 +163,12 @@ import { ComponentPropsMap } from '@objectstack/spec/ui'; import { ObjectCalendarSchema as ObjectCalendarMirror } from '@object-ui/types/zod'; import type { ObjectCalendarSchema } from '@object-ui/types'; -import { ObjectCalendar, type ObjectCalendarComponentProps } from '../ObjectCalendar'; +import { + ObjectCalendar, + type ObjectCalendarComponentProps, + type ObjectCalendarFlatFields, + type ObjectCalendarHandoff, +} from '../ObjectCalendar'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; @@ -211,6 +228,13 @@ const CANONICAL_TWINS = ['startDateField', 'endDateField'] as const; /** The producer that makes the two aliases reachable, and the branch that does it. */ 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. + */ +const HANDOFF_FLAT_KEYS = ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField'] as const; + /** A key nothing reads and nothing declares: the both-ways control. */ const CONTROL_KEY = 'zzqxNoSuchField'; @@ -270,8 +294,16 @@ type Expect = T; * and a one-way check accepts `any` on both sides — which is precisely the * before-state this card removed. */ -export type assertionSchemaPropIsThePublishedElementSchema = - Expect>; +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 assertionPublishedSchemaDeclaresNoFlatKey = + Expect, never>>; /** The helper can FAIL — synthetic control. */ export type assertionEqualCanFail = Expect, false>>; @@ -308,7 +340,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(); - const declared = new Set(shapeKeys(ObjectCalendarMirror)); + // The mirror's shape, plus the hand-off's five (objectstack#25923). + const declared = new Set([...shapeKeys(ObjectCalendarMirror), ...HANDOFF_FLAT_KEYS]); const exempt = new Set([...LEDGERED_OTHER_CARD_READS]); const undeclared = [...reads] .filter((key) => !declared.has(key)) @@ -341,7 +374,7 @@ describe('objectui#8651 — every key read off the node is declared on that sche // …and does not invent one that is not. expect(rendererReads().has(CONTROL_KEY)).toBe(false); // The declared set is a real set, not everything. - expect(shapeKeys(ObjectCalendarMirror)).toContain('startDateField'); + expect(shapeKeys(ObjectCalendarMirror)).toContain('calendar'); expect(shapeKeys(ObjectCalendarMirror)).not.toContain(CONTROL_KEY); // And the census fires on both spellings, so the cast cleanup cannot mute it. const probe = schemaReads('const a = schema.alphaKey; const b = (schema as any).betaKey;'); @@ -349,6 +382,21 @@ 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', () => { + 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); + expect(mirror, `the published mirror declares the flat ${key} again`).not.toContain(key); + } + // CONTROL, same two instruments: `calendar` is read AND declared, so the + // absences above are readings rather than an empty shape. + expect([...reads]).toContain('calendar'); + expect(mirror).toContain('calendar'); + }); +}); + /* ── 3. `calendar`, the container the spec names ───────────────────────────── */ describe('objectui#8651 — `calendar` is declared; the spec sets the KEY, and since 17.7.0 its SHAPE too', () => { @@ -442,13 +490,12 @@ describe('objectui#8355 — the `dateField` / `endField` rungs are RETIRED at bo it('CONTROL: the canonical spellings still parse green on the same instrument', () => { // So the four rows above are a narrowing of two keys, not "the node face - // refuses everything". + // refuses everything". In the `calendar` block, their one spelling since + // objectstack#25923. const ok = ObjectCalendarMirror.safeParse({ type: 'object-calendar', objectName: 'duly_task', - startDateField: 'kickoff', - endDateField: 'wrapup', - titleField: 'nickname', + calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'nickname' }, }); expect(ok.success, JSON.stringify(ok.error?.issues)).toBe(true); }); diff --git a/ui/packages/plugin-gantt/README.md b/ui/packages/plugin-gantt/README.md index b418ab84f12..21a7a5dfad8 100644 --- a/ui/packages/plugin-gantt/README.md +++ b/ui/packages/plugin-gantt/README.md @@ -320,10 +320,13 @@ flat top-level keys are not merged into it. The flat spelling is read only when there is no `gantt` block, and then only when `startDateField` and `endDateField` are both present. A node carrying both spellings renders the block's values and gets a dev-mode warning naming the ignored top-level keys. -**Author the `gantt` block**: the flat spelling is the runtime handoff -`ObjectView` and `ListView` write onto the node they compose, and an authored -node cannot carry it — `objectui validate` refuses each flat key and names -`properties.gantt.KEY`, and the spec's row refuses it inside the bag too. +**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 either, and `ObjectView` and +`ListView` write the `gantt` block onto the node they compose. The flat read is +kept for a host that mounts `` directly, typed on this package's +own hand-off type, `ObjectGanttHandoff`. 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/src/__tests__/ganttHandoffFlatFields-25923.test.ts b/ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts new file mode 100644 index 00000000000..fa18bc02bdc --- /dev/null +++ b/ui/packages/plugin-gantt/src/__tests__/ganttHandoffFlatFields-25923.test.ts @@ -0,0 +1,120 @@ +/** + * 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/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx index b6a25bb0f7c..ffef4b9bc86 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx @@ -97,9 +97,16 @@ async function calendarProps(schema: Record) { , ); await waitFor(() => expect(captured.length).toBeGreaterThan(0)); - return captured[captured.length - 1].schema; + // objectstack#25923 — the binding goes out as the node's own `calendar` block, + // never flat (the spec refuses the five flat on `object-calendar`). The rows + // below read the block; this guard reds if a flat spelling comes back. + const node = captured[captured.length - 1].schema; + for (const key of CALENDAR_FLAT_KEYS) expect(node, `flat ${key} on the node`).not.toHaveProperty(key); + return node.calendar ?? {}; } +const CALENDAR_FLAT_KEYS = ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField'] as const; + const queryViewOption = (name: string) => screen.queryByRole('tab', { name }) ?? screen.queryByRole('button', { name }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx index 12605bea552..f39e9f20f77 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx @@ -109,6 +109,13 @@ async function generatedCalendarNode(view: Record) { * captured: an authored block carrying the retired spellings and no canonical * one, which used to emit a node with a flat `dateField` and NO `startDateField`. */ +/** + * The emitted binding. objectstack#25923: the five canonical keys go out as the + * node's own `calendar` block, never flat (the spec refuses them flat on + * `object-calendar`), so the rows below read them there. + */ +const bindingOf = (node: Record): Record => node.calendar ?? {}; + const ALIASED_VIEW = { type: 'list-view', objectName: OBJECT, @@ -153,6 +160,8 @@ describe('objectui#8355 · half 1 — the generated node carries the canonical k // where the renderer's alias ladder read them. expect(Object.keys(node)).not.toContain('dateField'); expect(Object.keys(node)).not.toContain('endField'); + expect(Object.keys(bindingOf(node))).not.toContain('dateField'); + expect(Object.keys(bindingOf(node))).not.toContain('endField'); }); it('…and no canonical binding is invented from them either', async () => { @@ -162,17 +171,17 @@ describe('objectui#8355 · half 1 — the generated node carries the canonical k // view produces NO date binding — and the author has already been told why // by half 2 below. const node = await generatedCalendarNode(ALIASED_VIEW); - expect(node.startDateField).toBeUndefined(); - expect(node.endDateField).toBeUndefined(); + expect(bindingOf(node).startDateField).toBeUndefined(); + expect(bindingOf(node).endDateField).toBeUndefined(); }); it('CONTROL: the canonical block still reaches the node intact', async () => { // Without this row, "the aliases do not reach the node" is satisfied by a // branch that forwards nothing at all. const node = await generatedCalendarNode(CANONICAL_VIEW); - expect(node.startDateField).toBe('kickoff'); - expect(node.endDateField).toBe('wrapup'); - expect(node.titleField).toBe('name'); + expect(bindingOf(node).startDateField).toBe('kickoff'); + expect(bindingOf(node).endDateField).toBe('wrapup'); + expect(bindingOf(node).titleField).toBe('name'); }); it('CONTROL: the block\'s other DECLARED keys still reach the node; an undeclared one does not (objectui#6152 round 14)', async () => { @@ -184,8 +193,9 @@ describe('objectui#8355 · half 1 — the generated node carries the canonical k const node = await generatedCalendarNode({ calendar: { startDateField: 'kickoff', allDayField: 'is_all_day', defaultView: 'week' }, }); - expect(node.allDayField).toBe('is_all_day'); + expect(bindingOf(node).allDayField).toBe('is_all_day'); expect(node).not.toHaveProperty('defaultView'); + expect(bindingOf(node)).not.toHaveProperty('defaultView'); }); it('CONTROL: the LEGACY `options.calendar` nesting is stripped on the same path', async () => { @@ -195,7 +205,7 @@ describe('objectui#8355 · half 1 — the generated node carries the canonical k options: { calendar: { dateField: 'kickoff', titleField: 'name' } }, }); expect(Object.keys(node)).not.toContain('dateField'); - expect(node.startDateField).toBeUndefined(); + expect(bindingOf(node).startDateField).toBeUndefined(); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendarDateWindow-12081.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendarDateWindow-12081.test.tsx index 9a1fe249804..a009c85220c 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.calendarDateWindow-12081.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendarDateWindow-12081.test.tsx @@ -168,7 +168,7 @@ describe('ListView — a hosted calendar is handed every record of its visible w // …and paged through, not capped: the walk took more than one step. expect(ds.calls.length).toBeGreaterThan(1); // Every step carries the window on the field the calendar draws by. - expect(lastCalendarProps.schema.startDateField).toBe('end_date'); + expect(lastCalendarProps.schema.calendar?.startDateField).toBe('end_date'); for (const params of ds.calls) expect(JSON.stringify(params.$filter)).toContain('"end_date"'); // The records of months the grid does not show are not the calendar's: a // window that was not sent would have handed August's over first. diff --git a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx index 707747fa829..a43df162474 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -105,9 +105,19 @@ async function ganttProps(schema: Record) { , ); await waitFor(() => expect(captured.length).toBeGreaterThan(0)); - return captured[captured.length - 1].schema; + // objectstack#25923 — the binding goes out as the node's own `gantt` block, + // never flat (the spec refuses every `GanttConfig` key flat on + // `object-gantt`). The rows below read the block; this guard reds if a flat + // spelling comes back. + const node = captured[captured.length - 1].schema; + for (const key of GANTT_FLAT_PROBES) expect(node, `flat ${key} on the node`).not.toHaveProperty(key); + return node.gantt ?? {}; } +const GANTT_FLAT_PROBES = ['startDateField', 'endDateField', 'titleField', 'progressField', 'dependenciesField', 'colorField'] as const; +/** The declared axis. The block is handed on only once BOTH dates are declared (objectstack#25923). */ +const AXIS = { startDateField: 'start_date', endDateField: 'end_date' } as const; + const queryViewOption = (name: string) => screen.queryByRole('tab', { name }) ?? screen.queryByRole('button', { name }); @@ -154,14 +164,26 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec // // The DECLARED half of the class predicate is asserted in the same breath: // `progressField` still arrives verbatim. - const props = await ganttProps({ ...BASE, gantt: { progressField: 'pct' } }); - expect(props.startDateField).toBeUndefined(); - expect(props.endDateField).toBeUndefined(); + // + // objectstack#25923: the block is handed on only with its declared axis, so + // the axis is declared here; the axis-less case is the row below. + const props = await ganttProps({ ...BASE, gantt: { ...AXIS, progressField: 'pct' } }); + expect(props.startDateField).toBe('start_date'); + expect(props.endDateField).toBe('end_date'); expect(props.progressField).toBe('pct'); expect(props.dependenciesField).toBeUndefined(); expect('dependenciesField' in props).toBe(false); }); + it('hands NO block when only non-axis keys are declared — no date binding is invented (objectstack#25923)', async () => { + // The renderer's flat branch asked for both dates before it drew; the block + // is held back under the same condition, so this view still reaches the + // renderer's "configuration required" screen rather than a refusal of a + // half-made block. + const node = await ganttProps({ ...BASE, gantt: { progressField: 'pct' } }); + expect(node).toEqual({}); + }); + it('OMITS both non-axis keys for a gantt view that declares no config (objectui#7499)', async () => { // The undeclared half of the class predicate, at the same face. `'progress'` // / `'dependencies'` used to be handed down here unconditionally. @@ -180,7 +202,7 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec // The control that gives the two assertions above their meaning: this // harness CAN see a `dependenciesField` on the captured props, so reading // `undefined` there is a measurement and not a spy that never receives one. - const props = await ganttProps({ ...BASE, gantt: { dependenciesField: 'preds', progressField: 'pct' } }); + const props = await ganttProps({ ...BASE, gantt: { ...AXIS, dependenciesField: 'preds', progressField: 'pct' } }); expect(props.dependenciesField).toBe('preds'); expect(props.progressField).toBe('pct'); }); @@ -190,7 +212,7 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec // legs. Deleting them must not cost the `options.gantt` leg its pass-through. const props = await ganttProps({ ...BASE, - options: { gantt: { progressField: 'percent_done', dependenciesField: 'depends_on' } }, + options: { gantt: { ...AXIS, progressField: 'percent_done', dependenciesField: 'depends_on' } }, }); expect(props.progressField).toBe('percent_done'); expect(props.dependenciesField).toBe('depends_on'); @@ -218,10 +240,17 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec expect(props.colorField).toBe('status'); }); - it('CONTROL: a partially declared axis keeps its declared half and only that', async () => { - const props = await ganttProps({ ...BASE, gantt: { startDateField: 'start_date' } }); - expect(props.startDateField).toBe('start_date'); - expect(props.endDateField).toBeUndefined(); + it('CONTROL: a partially declared axis hands no block, and the full axis floors `titleField` (objectstack#25923)', async () => { + // Before objectstack#25923 the declared half went out flat, and the + // renderer's flat branch, needing both dates, refused it. The block is held + // back under that same condition, so the outcome on screen is unchanged. + expect(await ganttProps({ ...BASE, gantt: { startDateField: 'start_date' } })).toEqual({}); + cleanup(); + captured = []; + // …and with both dates the block carries the flat branch's own `'name'` + // floor, which the spec's block (requiring `titleField`) cannot leave out. + const props = await ganttProps({ ...BASE, gantt: { ...AXIS } }); + expect(props).toEqual({ ...AXIS, titleField: 'name' }); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.ganttNavigationForward-7334.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.ganttNavigationForward-7334.test.tsx index 94b760d5bad..477b618fd43 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.ganttNavigationForward-7334.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.ganttNavigationForward-7334.test.tsx @@ -211,9 +211,9 @@ describe('ListView forwards an authored `navigation` down the gantt view-schema gantt: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, }); expect(node.type).toBe('object-gantt'); - expect(node.startDateField).toBe('start_date'); - expect(node.endDateField).toBe('end_date'); - expect(node.titleField).toBe('name'); + // objectstack#25923 — the binding rides the node's own `gantt` block now. + expect(node.gantt).toEqual({ startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }); + expect(node).not.toHaveProperty('startDateField'); expect(node.objectName).toBe('crm_leave_request'); expect(typeof node.onRowClick).toBe('function'); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx index 9f075d011eb..b985ed311d3 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx @@ -151,8 +151,8 @@ beforeEach(() => { describe('1 · a block key named like a node key no longer overrides the node (objectui#6152 round 14)', () => { const ARMS: Array<[(typeof KINDS)[number], Record, (node: Record) => void]> = [ ['object-kanban', { options: { kanban: { groupByField: 'owner', objectName: HIJACK } } }, (n) => expect(n.groupBy).toBe('owner')], - ['object-calendar', { options: { calendar: { startDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.startDateField).toBe('due')], - ['object-gantt', { options: { gantt: { startDateField: 'due', endDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.startDateField).toBe('due')], + ['object-calendar', { options: { calendar: { startDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.calendar?.startDateField).toBe('due')], + ['object-gantt', { options: { gantt: { startDateField: 'due', endDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.gantt?.startDateField).toBe('due')], ['object-tree', { options: { tree: { parentField: 'parent', objectName: HIJACK } } }, (n) => expect(n.parentField).toBe('parent')], ['object-grid', { options: { grid: { objectName: HIJACK } } }, () => {}], ]; @@ -178,10 +178,11 @@ describe('2 · the declared keys keep their route (objectui#6152 round 14)', () it('calendar: `colorField` and `allDayField` reach the calendar, from either nesting', async () => { const top = await generatedNode('object-calendar', { calendar: { startDateField: 'due', colorField: 'stage', allDayField: 'amount' } }); - expect([top.colorField, top.allDayField]).toEqual(['stage', 'amount']); + // objectstack#25923 — the five ride the node's own `calendar` block, never flat. + expect([top.calendar?.colorField, top.calendar?.allDayField]).toEqual(['stage', 'amount']); cleanup(); const bag = await generatedNode('object-calendar', { options: { calendar: { startDateField: 'due', colorField: 'stage', allDayField: 'amount' } } }); - expect([bag.colorField, bag.allDayField]).toEqual(['stage', 'amount']); + expect([bag.calendar?.colorField, bag.calendar?.allDayField]).toEqual(['stage', 'amount']); }); it('gantt: EVERY key the spec declares on the block reaches the gantt, the block winning over the bag per key', async () => { @@ -192,7 +193,10 @@ describe('2 · the declared keys keep their route (objectui#6152 round 14)', () const block = Object.fromEntries(declared.map((k) => [k, `BLOCK_${k}`])); const bag = Object.fromEntries(declared.map((k) => [k, `BAG_${k}`])); const node = await generatedNode('object-gantt', { gantt: block, options: { gantt: { ...bag, onlyInTheBag: 'x' } } }); - for (const k of declared) expect(node[k], k).toBe(`BLOCK_${k}`); + // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. + for (const k of declared) expect(node.gantt?.[k], k).toBe(`BLOCK_${k}`); + for (const k of declared) expect(k in node, `flat ${k}`).toBe(false); + expect('onlyInTheBag' in node.gantt).toBe(false); expect('onlyInTheBag' in node).toBe(false); }); }); @@ -229,12 +233,15 @@ describe('3 · the retired keys reach nothing (objectui#6152 round 14)', () => { it('calendar: `defaultView` is not lifted, from either nesting, and an undeclared key is not forwarded', async () => { const top = await generatedNode('object-calendar', { calendar: { startDateField: 'due', defaultView: 'week', zzzUndeclared: 1 } }); - expect(top.startDateField).toBe('due'); - expect('defaultView' in top).toBe(false); - expect('zzzUndeclared' in top).toBe(false); + expect(top.calendar?.startDateField).toBe('due'); + for (const where of [top, top.calendar]) { + expect('defaultView' in where).toBe(false); + expect('zzzUndeclared' in where).toBe(false); + } cleanup(); const bag = await generatedNode('object-calendar', { options: { calendar: { startDateField: 'due', defaultView: 'week' } } }); expect('defaultView' in bag).toBe(false); + expect('defaultView' in bag.calendar).toBe(false); }); it('gallery: a bag `imageField` binds no cover on the node; `coverField` does, nested', async () => { diff --git a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index 7e396b62a77..17b37193d40 100644 --- a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -645,33 +645,33 @@ describe('objectui#13047 — each kind\'s own block, handed to the node of the v it('`calendar`: `startDateField` / `endDateField` / `titleField` bind the calendar', async () => { const control = mount({ type: 'calendar' }); await loaded(control.dataSource); - expect(lastHanded('object-calendar')?.startDateField).toBeUndefined(); + expect((lastHanded('object-calendar')?.calendar as Row | undefined)?.startDateField).toBeUndefined(); cleanup(); resetHanded(); const { dataSource } = mount({ type: 'calendar', calendar: { startDateField: 'due', endDateField: 'finish', titleField: 'owner' } }); await handedTo('object-calendar'); await loaded(dataSource); + // objectstack#25923 — the binding rides the node's own `calendar` block, never flat. const calendar = lastHanded('object-calendar'); - expect(calendar.startDateField).toBe('due'); - expect(calendar.endDateField).toBe('finish'); - expect(calendar.titleField).toBe('owner'); + expect(calendar.calendar).toEqual({ startDateField: 'due', endDateField: 'finish', titleField: 'owner' }); + expect(calendar).not.toHaveProperty('startDateField'); }); it('`gantt`: `startDateField` / `endDateField` / `titleField` bind the chart', async () => { const control = mount({ type: 'gantt' }); await loaded(control.dataSource); - expect(lastHanded('object-gantt')?.startDateField).toBeUndefined(); + expect((lastHanded('object-gantt')?.gantt as Row | undefined)?.startDateField).toBeUndefined(); cleanup(); resetHanded(); const { dataSource } = mount({ type: 'gantt', gantt: { startDateField: 'due', endDateField: 'finish', titleField: 'owner' } }); await handedTo('object-gantt'); await loaded(dataSource); + // objectstack#25923 — the binding rides the node's own `gantt` block, never flat. const gantt = lastHanded('object-gantt'); - expect(gantt.startDateField).toBe('due'); - expect(gantt.endDateField).toBe('finish'); - expect(gantt.titleField).toBe('owner'); + expect(gantt.gantt).toEqual({ startDateField: 'due', endDateField: 'finish', titleField: 'owner' }); + expect(gantt).not.toHaveProperty('startDateField'); }); it('`gallery`: `coverField` and `titleField` reach the gallery', async () => { diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx index 8474d9db2a4..79b6b58731f 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx @@ -151,6 +151,13 @@ async function generatedCalendarNode(entry: Record): Promise): Record => node.calendar ?? {}; + const objectViewDoc = (entry: Record) => ({ type: 'object-view', objectName: 'task', @@ -172,6 +179,8 @@ describe('objectui#8355 · second route, half 1 — the emitted node carries can const node = await generatedCalendarNode({ calendar: { dateField: 'kickoff', endField: 'wrapup', titleField: 'name' } }); expect(Object.keys(node)).not.toContain('dateField'); expect(Object.keys(node)).not.toContain('endField'); + expect(Object.keys(bindingOf(node))).not.toContain('dateField'); + expect(Object.keys(bindingOf(node))).not.toContain('endField'); }); it('…and no canonical binding is invented from them', async () => { @@ -180,23 +189,23 @@ describe('objectui#8355 · second route, half 1 — the emitted node carries can // still pass. The ruling refused that end state on the first route; this // route follows it rather than forking. const node = await generatedCalendarNode({ calendar: { dateField: 'kickoff', endField: 'wrapup', titleField: 'name' } }); - expect(node.startDateField).toBeUndefined(); - expect(node.endDateField).toBeUndefined(); + expect(bindingOf(node).startDateField).toBeUndefined(); + expect(bindingOf(node).endDateField).toBeUndefined(); }); it('CONTROL: the canonical block still reaches the node intact', async () => { // Without this row, "the aliases do not reach the node" is satisfied by a // branch that forwards nothing at all. const node = await generatedCalendarNode({ calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'name' } }); - expect(node.startDateField).toBe('kickoff'); - expect(node.endDateField).toBe('wrapup'); - expect(node.titleField).toBe('name'); + expect(bindingOf(node).startDateField).toBe('kickoff'); + expect(bindingOf(node).endDateField).toBe('wrapup'); + expect(bindingOf(node).titleField).toBe('name'); }); it('CONTROL: an unrelated renderer-ahead knob still rides the block through', async () => { // The branch strips exactly TWO keys; it did not become a whitelist. const node = await generatedCalendarNode({ calendar: { startDateField: 'kickoff', allDayField: 'is_all_day' } }); - expect(node.allDayField).toBe('is_all_day'); + expect(bindingOf(node).allDayField).toBe('is_all_day'); }); it('CONTROL: the LEGACY `options.calendar` nesting is stripped on the same path — and since objectui#7928 not read at all', async () => { @@ -208,8 +217,8 @@ describe('objectui#8355 · second route, half 1 — the emitted node carries can // strip above), so its `titleField` does not reach the node either. const node = await generatedCalendarNode({ options: { calendar: { dateField: 'kickoff', titleField: 'name' } } }); expect(Object.keys(node)).not.toContain('dateField'); - expect(node.startDateField).toBeUndefined(); - expect(node.titleField).toBeUndefined(); + expect(bindingOf(node).startDateField).toBeUndefined(); + expect(bindingOf(node).titleField).toBeUndefined(); }); }); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx index 633c65aecce..565d6f68992 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx @@ -81,15 +81,27 @@ async function renderCalendarView(view: Record) { return rendered[rendered.length - 1]; } +/** + * The emitted binding. objectstack#25923: it goes out as the node's own + * `calendar` block, never flat (the spec refuses the five flat on + * `object-calendar`). The guard reds if a flat spelling comes back. + */ +const blockOf = (node: any): Record => { + for (const key of ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField']) { + expect(node, `flat ${key} on the node`).not.toHaveProperty(key); + } + return node.calendar ?? {}; +}; + describe('ObjectView.generateViewSchema — calendar restates only a DECLARED binding (objectui#7029)', () => { it('invents NO binding for a calendar view that declares no config', async () => { const schema = await renderCalendarView({}); expect(schema.type).toBe('object-calendar'); // All three used to be fabricated here. Absent bindings are the only route // to `ObjectCalendar`'s refusal screen. - expect(schema.startDateField).toBeUndefined(); - expect(schema.endDateField).toBeUndefined(); - expect(schema.titleField).toBeUndefined(); + expect(blockOf(schema).startDateField).toBeUndefined(); + expect(blockOf(schema).endDateField).toBeUndefined(); + expect(blockOf(schema).titleField).toBeUndefined(); }); it('CONTROL: forwards a declared calendar block unchanged', async () => { @@ -100,15 +112,15 @@ describe('ObjectView.generateViewSchema — calendar restates only a DECLARED bi calendar: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'subject' }, }); expect(schema.type).toBe('object-calendar'); - expect(schema.startDateField).toBe('start_date'); - expect(schema.endDateField).toBe('end_date'); - expect(schema.titleField).toBe('subject'); + expect(blockOf(schema).startDateField).toBe('start_date'); + expect(blockOf(schema).endDateField).toBe('end_date'); + expect(blockOf(schema).titleField).toBe('subject'); }); it('CONTROL: a partially declared block keeps its declared half and only that', async () => { const schema = await renderCalendarView({ calendar: { startDateField: 'start_date' } }); - expect(schema.startDateField).toBe('start_date'); - expect(schema.endDateField).toBeUndefined(); - expect(schema.titleField).toBeUndefined(); + expect(blockOf(schema).startDateField).toBe('start_date'); + expect(blockOf(schema).endDateField).toBeUndefined(); + expect(blockOf(schema).titleField).toBeUndefined(); }); }); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx index 03622ca0fa1..4f0f695ca1f 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx @@ -79,6 +79,21 @@ async function renderGanttView(view: Record) { return rendered[rendered.length - 1]; } +/** + * The emitted binding. objectstack#25923: it goes out as the node's own `gantt` + * block, never flat (the spec refuses every `GanttConfig` key flat on + * `object-gantt`), and only once BOTH dates are declared — the condition the + * renderer's flat branch applied before it drew. The guard reds if a flat + * spelling comes back. + */ +const blockOf = (node: any): Record => { + for (const key of ['startDateField', 'endDateField', 'titleField', 'progressField', 'dependenciesField']) { + expect(node, `flat ${key} on the node`).not.toHaveProperty(key); + } + return node.gantt ?? {}; +}; +const AXIS = { startDateField: 'planned_start', endDateField: 'planned_end' } as const; + describe('ObjectView.generateViewSchema — gantt restates only a DECLARED binding (objectui#7070)', () => { it('invents NO date binding for a gantt view that declares no config', async () => { const schema = await renderGanttView({}); @@ -86,8 +101,8 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi // Both used to be fabricated here. Absent bindings are the only route to // `ObjectGantt`'s refusal screen — measured to exist before this deletion, // and pinned in `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070`. - expect(schema.startDateField).toBeUndefined(); - expect(schema.endDateField).toBeUndefined(); + expect(blockOf(schema).startDateField).toBeUndefined(); + expect(blockOf(schema).endDateField).toBeUndefined(); }); it('invents no date binding for an EMPTY gantt block', async () => { @@ -99,8 +114,8 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi // job a declared-config control exists to do. Written the wrong way, THIS // case would have passed while measuring nothing at all. const schema = await renderGanttView({ gantt: {} }); - expect(schema.startDateField).toBeUndefined(); - expect(schema.endDateField).toBeUndefined(); + expect(blockOf(schema).startDateField).toBeUndefined(); + expect(blockOf(schema).endDateField).toBeUndefined(); }); it('OMITS `progress` / `dependencies` rather than inventing them — the retirement #7070 left a slot for (objectui#7499)', async () => { @@ -123,13 +138,13 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi // and no diagnostic. Omission keeps the legitimate absence rendering // exactly as before and stops inventing the name. const schema = await renderGanttView({}); - expect(schema.progressField).toBeUndefined(); - expect(schema.dependenciesField).toBeUndefined(); + expect(blockOf(schema).progressField).toBeUndefined(); + expect(blockOf(schema).dependenciesField).toBeUndefined(); // Absent, not present-and-undefined. `getGanttConfig`'s flat branch reads - // `schema.dependenciesField || schema.dependencyField`, so whether the key + // `blockOf(schema).dependenciesField || schema.dependencyField`, so whether the key // is PRESENT is a different fact about this config than what it holds. - expect(Object.keys(schema)).not.toContain('progressField'); - expect(Object.keys(schema)).not.toContain('dependenciesField'); + expect(Object.keys(blockOf(schema))).not.toContain('progressField'); + expect(Object.keys(blockOf(schema))).not.toContain('dependenciesField'); }); it('CONTROL: a DECLARED `progressField` / `dependenciesField` still passes verbatim (objectui#7499)', async () => { @@ -137,19 +152,19 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi // see the pair on the rendered schema, so reading `undefined` above is a // measurement rather than a probe that never had anything to find. const schema = await renderGanttView({ - gantt: { progressField: 'percent_done', dependenciesField: 'depends_on' }, + gantt: { ...AXIS, progressField: 'percent_done', dependenciesField: 'depends_on' }, }); - expect(schema.progressField).toBe('percent_done'); - expect(schema.dependenciesField).toBe('depends_on'); + expect(blockOf(schema).progressField).toBe('percent_done'); + expect(blockOf(schema).dependenciesField).toBe('depends_on'); }); it('CONTROL: declaring ONE of the pair leaves the other absent, not fabricated (objectui#7499)', async () => { // The class predicate's mixed case, the one the floors used to erase: a // view that declares progress but not dependencies got `'dependencies'` // handed down anyway. - const schema = await renderGanttView({ gantt: { progressField: 'percent_done' } }); - expect(schema.progressField).toBe('percent_done'); - expect(schema.dependenciesField).toBeUndefined(); + const schema = await renderGanttView({ gantt: { ...AXIS, progressField: 'percent_done' } }); + expect(blockOf(schema).progressField).toBe('percent_done'); + expect(blockOf(schema).dependenciesField).toBeUndefined(); }); it('CONTROL: forwards a declared gantt block unchanged', async () => { @@ -157,14 +172,19 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi gantt: { startDateField: 'planned_start', endDateField: 'planned_end', titleField: 'subject' }, }); expect(schema.type).toBe('object-gantt'); - expect(schema.startDateField).toBe('planned_start'); - expect(schema.endDateField).toBe('planned_end'); - expect(schema.titleField).toBe('subject'); + expect(blockOf(schema).startDateField).toBe('planned_start'); + expect(blockOf(schema).endDateField).toBe('planned_end'); + expect(blockOf(schema).titleField).toBe('subject'); }); - it('CONTROL: a partially declared axis keeps its declared half and only that', async () => { - const schema = await renderGanttView({ gantt: { startDateField: 'planned_start' } }); - expect(schema.startDateField).toBe('planned_start'); - expect(schema.endDateField).toBeUndefined(); + it('CONTROL: a partially declared axis hands no block, and the full axis floors `titleField` (objectstack#25923)', async () => { + // Before objectstack#25923 the declared half went out flat, and the + // renderer's flat branch, needing both dates, refused it; the block is held + // back under that same condition, so the outcome on screen is unchanged. + expect((await renderGanttView({ gantt: { startDateField: 'planned_start' } })).gantt).toBeUndefined(); + // …an axis-less config hands nothing either… + expect((await renderGanttView({ gantt: { progressField: 'percent_done' } })).gantt).toBeUndefined(); + // …and with both dates the block carries the flat branch's own `'name'` floor. + expect(blockOf(await renderGanttView({ gantt: { ...AXIS } }))).toEqual({ ...AXIS, titleField: 'name' }); }); }); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index 57b40e4eba0..b1e7769bc36 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -195,7 +195,8 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re it('`calendar` → `object-calendar` (ObjectCalendar)', async () => { const node = await generatedNodeFor({ label: 'Cal', type: 'calendar', calendar: { startDateField: 'due_at' } }); expect(node.type).toBe('object-calendar'); - expect(node.startDateField).toBe('due_at'); + // objectstack#25923 — the node's own `calendar` block, never flat. + expect(node.calendar.startDateField).toBe('due_at'); }); it('`gallery` → `object-gallery` (ObjectGallery)', async () => { @@ -225,8 +226,8 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re gantt: { startDateField: 'start_at', endDateField: 'end_at', titleField: 'subject' }, }); expect(node.type).toBe('object-gantt'); - expect(node.startDateField).toBe('start_at'); - expect(node.endDateField).toBe('end_at'); + // objectstack#25923 — the node's own `gantt` block, never flat. + expect(node.gantt).toEqual({ startDateField: 'start_at', endDateField: 'end_at', titleField: 'subject' }); }); it('`map` → `object-map` (ObjectMap)', async () => { diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx index 01d9e8e2d55..b43be2aea1e 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx @@ -118,8 +118,12 @@ describe('2 · the declared keys keep their route; the undeclared ones do not (o const node = await generatedNode('calendar', { calendar: { startDateField: 'a', endDateField: 'b', titleField: 'c', colorField: 'd', allDayField: 'e', defaultView: 'week' }, }); - expect([node.startDateField, node.endDateField, node.titleField, node.colorField, node.allDayField]).toEqual(['a', 'b', 'c', 'd', 'e']); + // objectstack#25923 — the five ride the node's own `calendar` block, never flat. + const { calendar } = node; + expect([calendar.startDateField, calendar.endDateField, calendar.titleField, calendar.colorField, calendar.allDayField]).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(node).not.toHaveProperty('startDateField'); expect(node).not.toHaveProperty('defaultView'); + expect(calendar).not.toHaveProperty('defaultView'); }); it('gantt: EVERY key the spec declares on the block reaches the gantt, an undeclared one does not', async () => { @@ -127,8 +131,11 @@ describe('2 · the declared keys keep their route; the undeclared ones do not (o expect(declared.length).toBeGreaterThan(20); const block = Object.fromEntries(declared.map((k) => [k, `V_${k}`])); const node = await generatedNode('gantt', { gantt: { ...block, zzzUndeclared: 1 } }); - for (const k of declared) expect(node[k], k).toBe(`V_${k}`); + // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. + for (const k of declared) expect(node.gantt?.[k], k).toBe(`V_${k}`); + for (const k of declared) expect(node, `flat ${k}`).not.toHaveProperty(k); expect(node).not.toHaveProperty('zzzUndeclared'); + expect(node.gantt).not.toHaveProperty('zzzUndeclared'); }); it('timeline: EVERY key the spec declares on the block reaches the timeline flat; the `dateField` alias does not', async () => { diff --git a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index 642df6816fd..f96b35dc241 100644 --- a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -136,19 +136,22 @@ const canonicalBlockCompiles: ListViewInferred = { const nodeRefusesBothByTsc: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'duly_task', - startDateField: 'kickoff', + calendar: { startDateField: 'kickoff' }, // @ts-expect-error objectui#8355 — `dateField?: never` on the node face. dateField: 'kickoff', // @ts-expect-error objectui#8355 — `endField?: never` on the node face. endField: 'wrapup', }; -/** POSITIVE CONTROL: the canonical flat node compiles. */ +/** + * POSITIVE CONTROL: the canonical node compiles. Since objectstack#25923 the + * canonical spelling is the `calendar` block: the five flat members left the + * published node face (the spec refuses them flat). + */ const canonicalNodeCompiles: TsObjectCalendarSchema = { type: 'object-calendar', objectName: 'duly_task', - startDateField: 'kickoff', - endDateField: 'wrapup', + calendar: { startDateField: 'kickoff', endDateField: 'wrapup' }, }; /* ── The RUNTIME face — this is what vitest actually gates ────────────────── */ @@ -165,7 +168,7 @@ const issueAt = (result: ReturnType, path: string) => describe('objectui#8355 · the compile-time fixtures above are real program inputs', () => { it('exists so the fixtures cannot be dropped as unused — `tsc` is their only gate', () => { expect(canonicalBlockCompiles.calendar?.startDateField).toBe('kickoff'); - expect(canonicalNodeCompiles.endDateField).toBe('wrapup'); + expect(canonicalNodeCompiles.calendar?.endDateField).toBe('wrapup'); expect((viewBlockRefusesBothByTsc.calendar as Record).dateField).toBe('kickoff'); expect((nodeRefusesBothByTsc as unknown as Record).endField).toBe('wrapup'); }); 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 bec6c22658e..68c401b5066 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,6 +29,15 @@ * renderer READS the flat spelling, not that an author should write it. The * README row below pins the new teaching. * + * ⚠️ 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. + * * ## Why BOTH keys, and the measurement that decided the second one * * The two keys look asymmetric and are not. `colorField` IS a spec key — but @@ -117,7 +126,7 @@ import { CalendarConfigSchema, ComponentPropsMap } from '@objectstack/spec/ui'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; -import { ObjectCalendarSchema, safeValidateSchema } from '../zod/index.zod'; +import { ObjectCalendarSchema, safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod'; import type { ObjectCalendarSchema as TsObjectCalendarSchema } from '../objectql'; import type { CalendarViewSchema as TsCalendarViewSchema } from '../complex'; @@ -167,16 +176,15 @@ type IsAny = 0 extends (1 & T) ? true : false; /** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ type IsOptional = Record extends Pick ? true : false; -// `colorField` is DERIVED from the spec's `CalendarConfig`, so it resolves to -// that member's type. Delete the member and the indexed access fell back to -// `[key: string]: any`, making `IsAny` true and this `Equal` false; since -// objectui#8347 it stops compiling instead. -export type _ColorFieldIsString = Expect>; -export type _ColorFieldIsNotAny = Expect, false>>; -export type _ColorFieldIsOptional = Expect>; -export type _AllDayFieldIsString = Expect>; -export type _AllDayFieldIsNotAny = Expect, false>>; -export type _AllDayFieldIsOptional = Expect>; +// objectstack#25923 — none of the five is a member of the published interface +// any more: the flat spelling is `plugin-calendar`'s hand-off type. In the +// `calendar` block, their one spelling, both are typed from the spec's block. +export type _NoFlatKeyIsAMember = Expect, never>>; +type CalendarBlock = NonNullable; +export type _BlockColorFieldIsString = Expect>; +export type _BlockAllDayFieldIsString = Expect>; +export type _BlockColorFieldIsNotAny = Expect, false>>; +export type _BlockAllDayFieldIsOptional = Expect>; // The control key is undeclared, exactly as the two above were before this card. // Until objectui#8347 it fell through `BaseSchema`'s index signature and read // `any`; that signature is gone, so it is no member at all. `IsAny` is the @@ -206,21 +214,18 @@ const siblingPins: [ // read `any` through the index signature until objectui#8347 removed it. export type _SiblingControlIsNoMember = Expect>; -// The TS face ACCEPTS the flat handoff shape… +// The TS face ACCEPTS the two in the `calendar` block… const calendarLiteral: TsObjectCalendarSchema = { ...CALENDAR_NODE, - colorField: 'status_colour', - allDayField: 'is_all_day', + calendar: { startDateField: 'starts_at', colorField: 'status_colour', allDayField: 'is_all_day' }, }; -// …and REFUSES wrong-typed values the index signature used to admit. Each -// directive goes unused — TS2578, a hard failure — if its member is widened to -// accept the value. A deletion keeps it used since objectui#8347 (the value is -// refused as an undeclared key) and is caught by the `Equal` rows above. -// @ts-expect-error — `colorField` names a FIELD, so it is a string, not the colour itself -const calendarBadColorField: TsObjectCalendarSchema = { ...CALENDAR_NODE, colorField: 0xff0000 }; -// @ts-expect-error — `allDayField` names a FIELD; a boolean is the VALUE, the confusion this declaration catches -const calendarBadAllDayField: TsObjectCalendarSchema = { ...CALENDAR_NODE, allDayField: true }; +// …REFUSES them written flat on the node (objectstack#25923). Each directive +// goes unused — TS2578, a hard failure — if a flat member comes back. +// @ts-expect-error — the flat `colorField` is no member of the published interface; write `calendar.colorField` +const calendarBadColorField: TsObjectCalendarSchema = { ...CALENDAR_NODE, colorField: 'status_colour' }; +// @ts-expect-error — the flat `allDayField` is no member of the published interface; write `calendar.allDayField` +const calendarBadAllDayField: TsObjectCalendarSchema = { ...CALENDAR_NODE, allDayField: 'is_all_day' }; // …and, since objectui#8347 removed `BaseSchema`'s index signature, the misspelling itself. // @ts-expect-error — `colourField` is no member of `ObjectCalendarSchema`; the key is `colorField` const calendarMisspelledKey: TsObjectCalendarSchema = { ...CALENDAR_NODE, colourField: 'status_colour' }; @@ -608,72 +613,51 @@ describe('objectui#8466 — the registration `inputs` deliberately declares NO f /* ── The zod mirror ───────────────────────────────────────────────────────── */ -describe('objectui#8466 — the mirror declares what the interface declares', () => { +describe('objectstack#25923 — the mirror declares none of the five flat, as the interface does not', () => { it('membership, read off the mirror shape (acceptance cannot tell declared from admitted)', () => { const keys = shapeKeys(ObjectCalendarSchema); - for (const key of [...ALREADY_DECLARED, ...NEWLY_DECLARED]) expect(keys).toContain(key); - // The control stays out, which keeps the assertions above from being - // satisfied by a shape that simply contains everything. + for (const key of [...ALREADY_DECLARED, ...NEWLY_DECLARED]) expect(keys).not.toContain(key); + // Firing control on the same shape: the block that carries them is declared. + expect(keys).toContain('calendar'); expect(keys).not.toContain(CONTROL_KEY); - expect(keys).not.toContain(MISSPELLING); }); - it('accepts the flat handoff shape `ObjectView`/`ListView` emit, and the values SURVIVE the parse', () => { - const node = { ...CALENDAR_NODE, colorField: 'status_colour', allDayField: 'is_all_day' }; + it('the TOLERANT face still admits a flat key unexamined — the objectui#7927 ceiling, unchanged', () => { + // `.passthrough()`: undeclared is admitted and kept, never value-checked. + // That is the tolerant face's deliberate posture; the refusal that matters + // is the STRICT face's, below. + const node = { ...CALENDAR_NODE, colorField: 0xff0000, allDayField: true, [MISSPELLING]: 'x' }; const r = ObjectCalendarSchema.safeParse(node); expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); - if (r.success) { - expect((r.data as Record).colorField).toBe('status_colour'); - expect((r.data as Record).allDayField).toBe('is_all_day'); - } - // …and through the published union entry point, so the right arm is reached. expect(safeValidateSchema(node).success).toBe(true); }); - it('both members stay OPTIONAL: the node without them parses green — this adds no requiredness', () => { - expect(ObjectCalendarSchema.safeParse(CALENDAR_NODE).success).toBe(true); - expect(safeValidateSchema(CALENDAR_NODE).success).toBe(true); - }); - - it.each([ - ['colorField', 0xff0000], - ['colorField', { hex: '#ff0000' }], - // The confusion the declaration catches: the FLAG rather than the FIELD NAME. - ['allDayField', true], - ['allDayField', ['is_all_day']], - ] as const)('refuses a wrong-typed `%s` (%j) AT the key — the verdict declaring MOVES', (key, value) => { - // Before this card every one of these rode `.passthrough()` unexamined. - const r = ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [key]: value }); - expect(r.success).toBe(false); + it.each(FLAT_KEYS)('the STRICT authoring face refuses a flat `%s` by name, as the spec does', (key) => { + const r = StrictAnyComponentSchema.safeParse({ ...CALENDAR_NODE, [key]: 'some_field' }); + expect(r.success, `the strict face accepts a flat ${key} again`).toBe(false); if (!r.success) { - // The refusal must land ON the key, not merely somewhere in the node — - // which is what distinguishes value validation from an unrelated refusal. - const paths = r.error.issues.map((i) => (i.path ?? []).join('.')); - expect(paths, JSON.stringify(paths)).toContain(key); + // The strict face names an undeclared key with `unrecognized_keys` at the + // node, carrying the key — the refusal is BY NAME, not a generic misfit. + const named = r.error.issues.some( + (i) => i.code === 'unrecognized_keys' && (i as { keys?: string[] }).keys?.includes(key), + ); + expect(named, JSON.stringify(r.error.issues)).toBe(true); } - // …and the same refusal through the path the CLI's `validate` / `check` reach. - expect(safeValidateSchema({ ...CALENDAR_NODE, [key]: value }).success).toBe(false); }); - it('the objectui#7927 ceiling is UNCHANGED: a misspelled key is still admitted', () => { - // This card buys value validation, not misspelling detection. Pinning the - // ceiling is what stops the change being read as more than it is — and - // turns red if `.passthrough()` is ever tightened, which would be #7927's - // job and would need this file revisited. - expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: 'x' }).success).toBe(true); - expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [CONTROL_KEY]: 'x' }).success).toBe(true); - // Even a wrong-TYPED misspelling rides through, which is the sharp edge: - // the value validation above reaches only the spelling that is declared. - expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: true }).success).toBe(true); + it('CONTROL: the strict face accepts the same five inside the `calendar` block', () => { + const block = Object.fromEntries(FLAT_KEYS.map((key) => [key, `f_${key}`])); + const r = StrictAnyComponentSchema.safeParse({ ...CALENDAR_NODE, calendar: block }); + expect(r.success, JSON.stringify(r.success ? [] : r.error.issues)).toBe(true); }); }); /* ── Keep the type-level consts referenced (they are the pins) ─────────────── */ -describe('objectui#8466 — the TS face accepts the flat handoff node', () => { +describe('objectstack#25923 — the TS face takes the two in the `calendar` block only', () => { it('the accepted literal carries the values it was authored with', () => { - expect(calendarLiteral.colorField).toBe('status_colour'); - expect(calendarLiteral.allDayField).toBe('is_all_day'); + expect(calendarLiteral.calendar?.colorField).toBe('status_colour'); + expect(calendarLiteral.calendar?.allDayField).toBe('is_all_day'); // The refused literals exist only so their `@ts-expect-error` directives do; // referencing them keeps `noUnusedLocals` off this file's back. expect([calendarBadColorField, calendarBadAllDayField, calendarMisspelledKey]).toHaveLength(3); 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 8aaafbf9bdb..883bf653862 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 @@ -52,6 +52,17 @@ * asserting only (2) passes on a tree where the tag never shipped. Both halves * are pinned. * + * ## ⏱ objectstack#25923 — both spellings LEFT the published faces + * + * `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. + * * ## Why the TS face is read as SOURCE TEXT and the zod face at RUNTIME * * They carry the statement in different places, and each is checked where it @@ -76,153 +87,54 @@ const OBJECTQL_SRC = readFileSync( 'utf8', ); -/** The alias member, exactly as `objectql.ts` spells it. */ +/** The alias member, as `objectql.ts` spelled it until objectstack#25923. */ const ALIAS = 'dependencyField?: string;'; -/** The canonical member, exactly as `objectql.ts` spells it. */ +/** The canonical member, as `objectql.ts` spelled it until objectstack#25923. */ const CANONICAL = "dependenciesField?: GanttConfig['dependenciesField'];"; -/** - * The JSDoc block immediately preceding a member, or `null` when the member is - * not preceded by one. - * - * Anchored to the member rather than searching the file for `@deprecated`: - * `objectql.ts` carries fifteen unrelated deprecations, so a file-wide search - * would go green on any of them and say nothing about this key. - */ -function docBlockBefore(member: string): string | null { - const at = OBJECTQL_SRC.indexOf(member); - if (at === -1) return null; - const before = OBJECTQL_SRC.slice(0, at); - const close = before.lastIndexOf('*/'); - // Only whitespace may sit between the block and the member, otherwise the - // block belongs to some earlier member and says nothing about this one. - if (close === -1 || before.slice(close + 2).trim() !== '') return null; - const open = before.lastIndexOf('/**', close); - if (open === -1) return null; - return before.slice(open, close + 2); -} - -describe('ObjectGanttSchema.dependencyField — the TS face declares the deprecation', () => { - it('the member is unique in the file, so the anchor cannot drift onto a twin', () => { - // Non-vacuity for every `docBlockBefore(ALIAS)` below: `indexOf` takes the - // FIRST hit, so a second declaration of the same member would let this suite - // report on the wrong one. - expect(OBJECTQL_SRC.split(ALIAS)).toHaveLength(2); - }); +const MINIMAL = { type: 'object-gantt', objectName: 'task' } as const; - it('is still declared — deprecating is not deleting', () => { - expect(OBJECTQL_SRC).toContain(ALIAS); +describe('ObjectGanttSchema — neither dependencies spelling is published flat (objectstack#25923)', () => { + it('the TS face declares neither member', () => { + expect(OBJECTQL_SRC).not.toContain(ALIAS); + expect(OBJECTQL_SRC).not.toContain(CANONICAL); + // CONTROL: the block that now carries the canonical key is declared, and + // the source read is the real file. + expect(OBJECTQL_SRC).toContain('export interface ObjectGanttSchema'); + expect(OBJECTQL_SRC).toMatch(/\bgantt\?: GanttConfig;/); }); - it('carries a JSDoc block attached to the member itself', () => { - expect(docBlockBefore(ALIAS)).not.toBeNull(); + it('the zod mirror declares neither key, and declares the block', () => { + const shape = Object.keys(ObjectGanttSchema.shape); + expect(shape).not.toContain('dependencyField'); + expect(shape).not.toContain('dependenciesField'); + expect(shape).toContain('gantt'); }); - it('tags that block `@deprecated`', () => { - expect(docBlockBefore(ALIAS)).toContain('@deprecated'); - }); - - it('names the canonical spelling, so the marker is actionable', () => { - // A bare `@deprecated` tells an author to stop, not what to write instead — - // and "what to write instead" is the entire content of this card. - expect(docBlockBefore(ALIAS)).toContain('dependenciesField'); - }); - - it('says the alias is still READ, so nobody reads the tag as a removal notice', () => { - expect(docBlockBefore(ALIAS)).toContain('dependenciesField || dependencyField'); - }); - - it('does NOT deprecate the canonical key — the control', () => { - // The tag must be attached to ONE member. If a future edit widened the - // block, or moved it, the spec's own key would read as deprecated and this - // card would have inverted its own result. - const canonicalDoc = docBlockBefore(CANONICAL); - expect(canonicalDoc).not.toBeNull(); - expect(canonicalDoc).not.toContain('@deprecated '); - expect(canonicalDoc).toContain('CANONICAL'); - }); -}); - -describe('ObjectGanttSchema.dependencyField — the zod mirror declares it too', () => { - it('is still declared on the mirror', () => { - expect(Object.keys(ObjectGanttSchema.shape)).toContain('dependencyField'); - }); - - it('its description states the deprecation and names the replacement', () => { - // Runtime-readable, which is the half a JSDoc cannot give: this string is on - // the schema object, so a gate or a generated form can ask. - const description = ObjectGanttSchema.shape.dependencyField.description; - expect(description).toBeTruthy(); - expect(description).toMatch(/deprecated/i); - expect(description).toContain('dependenciesField'); - }); - - it('follows the ruled idiom rather than a third spelling of "deprecated"', () => { - // `KanbanConfig`'s pre-#2231 aliases are the shape this adopts. If that - // convention is ever restated differently, this pin makes the divergence a - // decision instead of a drift. - expect(ObjectGanttSchema.shape.dependencyField.description).toMatch( - /^Deprecated alias for /, - ); - }); - - it('the canonical key is NOT described as deprecated — the control', () => { - const canonical = ObjectGanttSchema.shape.dependenciesField.description; - expect(canonical ?? '').not.toMatch(/deprecated/i); - }); -}); - -describe('ObjectGanttSchema.dependencyField — still ACCEPTED (removal was excluded)', () => { - const MINIMAL = { - type: 'object-gantt', - objectName: 'task', - startDateField: 'start', - endDateField: 'end', - } as const; - - it('parses green when the singular is authored alone', () => { - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, dependencyField: 'preds' }); - expect(result.success ? null : result.error.issues).toBe(null); - }); - - it('parses green when both spellings are authored together', () => { - const result = ObjectGanttSchema.safeParse({ - ...MINIMAL, - dependencyField: 'preds', - dependenciesField: 'predecessors', - }); + it('the tolerant mirror no longer examines the alias: `dependencyField: 5` rides `.passthrough()`', () => { + // Stated so nobody reads the mirror as judging it still (objectui#5155). + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, dependencyField: 5 }); expect(result.success ? null : result.error.issues).toBe(null); }); - it('still TYPE-validates the alias — a deprecated key is not an unchecked one', () => { - // Counter-probe for the two greens above: it must be the KEY that is - // accepted, not everything. `dependencyField: 5` was refused before this - // card and is refused after it. - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, dependencyField: 5 }); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.issues.find((i) => i.path[0] === 'dependencyField')).toBeTruthy(); + it('the canonical key is validated inside the `gantt` block, the one spelling left', () => { + const block = { startDateField: 'start', endDateField: 'end', titleField: 'name' }; + const good = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { ...block, dependenciesField: 'preds' } }); + expect(good.success ? null : good.error.issues).toBe(null); + const bad = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { ...block, dependenciesField: 5 } }); + expect(bad.success).toBe(false); }); -}); -describe('ObjectGanttSchema.dependencyField — the TS member is unchanged', () => { - it('is still declared `string | undefined`', () => { - // Real enforcement: `tsconfig.test.json` compiles this file. When written, a - // deleted member resolved to `any` through `BaseSchema`'s - // `[key: string]: any` index signature, the assignment below started - // succeeding, and the directive failed the build as an unused - // `@ts-expect-error` (TS2578) — NAMING the removal. Since objectui#8347 a - // deletion makes the indexed access itself an error: the directive swallows - // it, and the undirected counter-probe below fails instead. That is the pin against - // the deprecation quietly becoming the excluded removal. - - // @ts-expect-error — `dependencyField` is declared `string | undefined`. - const bad: ObjectGanttSchemaTS['dependencyField'] = 5; - - // Counter-probe: without this, a declaration narrowed to `never` would - // satisfy the directive above while breaking every existing author. - const good: ObjectGanttSchemaTS['dependencyField'] = 'dependent_task_ids'; - - expect([bad, good]).toHaveLength(2); + it('the TS face refuses either spelling on a fresh literal', () => { + // @ts-expect-error — `dependencyField` is no member of the published interface + const alias: ObjectGanttSchemaTS = { ...MINIMAL, dependencyField: 'preds' }; + // @ts-expect-error — `dependenciesField` is written in the `gantt` block + const canonical: ObjectGanttSchemaTS = { ...MINIMAL, dependenciesField: 'preds' }; + // Counter-probe: the same key inside the block compiles. + const ok: ObjectGanttSchemaTS = { + ...MINIMAL, + gantt: { startDateField: 'start', endDateField: 'end', titleField: 'name', dependenciesField: 'preds' }, + }; + expect([alias.type, canonical.type, ok.gantt?.dependenciesField]).toEqual(['object-gantt', 'object-gantt', 'preds']); }); }); 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 d39425a96b9..957dd5410c1 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 @@ -95,30 +95,42 @@ * (`capacity: 'one'` is refused where it used to parse green), that the published * types now TEACH the vocabulary, and that the type-level pins below fail when a * declaration is removed. + * + * ## ⏱ objectstack#25923 — the 24 flattened members LEFT this interface + * + * `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 + * 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. */ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; +import { StrictAnyComponentSchema } from '../zod/index.zod'; import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS, SortConfig } from '../objectql.js'; import type { ViewFilterRule } from '@objectstack/spec/ui'; const MINIMAL = { type: 'object-gantt', objectName: 'task', - startDateField: 'start', - endDateField: 'end', } as const; +/** The block every row below may add, carrying the spec's required trio. */ +const BLOCK = { startDateField: 'start', endDateField: 'end', titleField: 'name' } as const; + /** - * The 27 keys this card declared, each with a value its declared type refuses. - * - * 24 flattened `GanttConfig` members and the three query keys (`staticData` / - * `filter` / `sort`). The 28th measured key, `gantt` (objectui#6475), is pinned - * separately below — it is declared too, but as a PARSED block rather than a - * bare optional scalar, so its refusal shape does not fit this table. + * The 24 flattened `GanttConfig` members objectui#6051 declared here, each with + * a value their type refuses. objectstack#25923 moved them OFF this interface + * and its mirror: the rows below pin their ABSENCE, and the bad values pin that + * the tolerant mirror no longer examines them (it admits what it does not + * declare, objectui#5155). */ -const DECLARED: ReadonlyArray = [ - // — the flattened GanttConfig face — +const MOVED_OFF: ReadonlyArray = [ ['colorField', 5], ['borderColorField', 5], ['dependenciesField', 5], @@ -143,7 +155,10 @@ const DECLARED: ReadonlyArray = [ ['exportFileName', 5], ['timeZone', 5], ['dependencyTypes', 'yes'], - // — the query keys the fetch path reads — +]; + +/** The three query keys the fetch path reads: still DECLARED, each with a refused value. */ +const DECLARED: ReadonlyArray = [ ['staticData', { id: 1 }], ['filter', 'name = 1'], ['sort', 5], @@ -151,34 +166,6 @@ const DECLARED: ReadonlyArray = [ /** One well-typed value per declared key — the counter-probe for the block above. */ const GOOD = { - colorField: 'status', - borderColorField: 'alert', - dependenciesField: 'predecessors', - parentField: 'parent', - typeField: 'kind', - lockField: 'locked', - objectField: 'object_name', - summaryExtent: 'self' as const, - defaultCollapsedDepth: 2, - tooltipFields: ['owner', { field: 'stage', label: 'Stage' }], - baselineStartField: 'plan_start', - baselineEndField: 'plan_end', - groupByField: 'owner', - resourceView: true, - assigneeField: 'owner', - effortField: 'effort', - capacity: 2, - quickFilters: [{ field: 'owner', label: 'Owner' }], - autoZoomToFilter: false, - timeSegments: { - dayStart: '08:00', - bands: [{ key: 'day', label: 'Day shift', start: '08:00', end: '20:00' }], - showMidnight: true, - }, - interactions: { move: true, resize: false, progress: true, link: false }, - exportFileName: 'Shift Plan', - timeZone: 'Asia/Shanghai', - dependencyTypes: false, staticData: [{ id: 1, name: 'Task' }], // objectui#6152 round 10 — the `object-gantt` row's `ViewFilterRule` array; the AST // tuple array this used to carry is the row's refusal now, on both faces. @@ -186,40 +173,42 @@ const GOOD = { sort: [{ field: 'name', order: 'desc' as const }], }; -describe('ObjectGanttSchema — the flattened gantt config is declared (objectui#6051)', () => { - it('the mirror declares every one of the 27', () => { +describe('ObjectGanttSchema — the flattened gantt config is NOT declared (objectstack#25923)', () => { + it('the mirror declares none of the 24, and still declares the three query keys', () => { const shape = Object.keys(ObjectGanttSchema.shape); + for (const [key] of MOVED_OFF) expect(shape, `mirror declares the flat ${key} again`).not.toContain(key); for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); + // CONTROL on the same shape: the block that carries the 24 is declared. + expect(shape).toContain('gantt'); }); - it('the census is the measured 28, not a shorter list that drifted', () => { - // Non-vacuity for the loops below: they iterate DECLARED, so a truncated - // DECLARED would pass everything while checking less. The number is the - // measured residue stated in this file's header. - expect(DECLARED).toHaveLength(27); - expect(new Set(DECLARED.map(([k]) => k)).size).toBe(27); + it('the census is the measured 24 + 3, not a shorter list that drifted', () => { + // Non-vacuity for the loops below: they iterate the two lists, so a + // truncated list would pass everything while checking less. + expect(MOVED_OFF).toHaveLength(24); + expect(new Set(MOVED_OFF.map(([k]) => k)).size).toBe(24); expect(Object.keys(GOOD).sort()).toEqual(DECLARED.map(([k]) => k).sort()); }); - it('declares them all OPTIONAL — none of the 27 may become required', () => { - // Requiredness is the half the zod-mirror-parity ratchet compares against - // `../objectql.ts`, where all 27 are `?:`. A mirror that required one would - // reject every gantt already published. - const result = ObjectGanttSchema.safeParse(MINIMAL); - expect(result.success ? null : result.error.issues).toBe(null); + it('the tolerant mirror no longer examines a flat key: a wrong-typed one rides `.passthrough()`', () => { + // The flip side of the move, stated so nobody reads the tolerant face as + // judging them still. The authoring judgement is the STRICT face's, and for + // `object-gantt` that face already refused a flat key (its arm is the + // `properties` bag, `ObjectGanttBlockSchema`). + for (const [key, bad] of MOVED_OFF) { + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); + expect(result.success ? null : result.error.issues, `${key}`).toBe(null); + } }); - it('materialises NO defaults — an omitted key stays absent after parse', () => { - // `autoZoomToFilter` and `dependencyTypes` default ON *in the renderer*, which - // reads `!== false`. A `.default(true)` here would arrive downstream as an - // explicit author choice; the two spellings are not interchangeable. + it('declares the three query keys OPTIONAL, with no materialised default', () => { const result = ObjectGanttSchema.safeParse(MINIMAL); - expect(result.success).toBe(true); + expect(result.success ? null : result.error.issues).toBe(null); if (!result.success) return; for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false); }); - it('refuses a wrong-typed value on each declared key', () => { + it('refuses a wrong-typed value on each declared query key', () => { for (const [key, bad] of DECLARED) { const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); @@ -229,22 +218,21 @@ describe('ObjectGanttSchema — the flattened gantt config is declared (objectui } }); - it('accepts a well-typed value on every declared key', () => { + it('accepts a well-typed value on every declared query key', () => { // Counter-probe for the assertion above: it must be the VALUE being refused, // not the key. A pin that only ever sees red proves nothing. const result = ObjectGanttSchema.safeParse({ ...MINIMAL, ...GOOD }); expect(result.success ? null : result.error.issues).toBe(null); }); - it('the `gantt` BLOCK face is declared, and the spec trio now enforces at parse time (objectui#6475)', () => { - // The 28th measured key. objectui#6475, Option A: declared as `GanttConfig`, - // so a block is now PARSED against the spec's `GanttConfigSchema` — the - // published CLI's `validate`/`check` refusal this card exists to pin. + it('the `gantt` BLOCK face is declared, and the spec trio enforces at parse time (objectui#6475)', () => { + // The 28th measured key, and since objectstack#25923 the ONE spelling of the + // other 24 on this node. objectui#6475, Option A: declared as `GanttConfig`, + // so a block is PARSED against the spec's `GanttConfigSchema`. expect(Object.keys(ObjectGanttSchema.shape)).toContain('gantt'); // A block missing one of the required trio (startDateField / endDateField / - // titleField) is REFUSED — this is the accept-set narrowing itself, and it - // names the missing field rather than failing silently. + // titleField) is REFUSED, naming the missing field. const missingTitleField = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { startDateField: 'start', endDateField: 'end', lockField: 'locked' }, @@ -261,36 +249,53 @@ describe('ObjectGanttSchema — the flattened gantt config is declared (objectui // A wrong-TYPED block (not even an object) is refused. expect(ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: 'flat' }).success).toBe(false); - // A block carrying the complete trio IS accepted — the narrowing is exactly - // the trio, nothing more. + // A block carrying the complete trio IS accepted, and so is every one of the + // 24 inside it, well-typed — the narrowing is exactly the trio. const complete = ObjectGanttSchema.safeParse({ ...MINIMAL, - gantt: { startDateField: 'start', endDateField: 'end', titleField: 'name', lockField: 'locked' }, + gantt: { ...BLOCK, lockField: 'locked', summaryExtent: 'self', capacity: 2, interactions: { move: true } }, }); expect(complete.success ? null : complete.error.issues).toBe(null); + // …and a wrong-typed one of the 24 inside the block is refused AT its path, + // which is where the value validation the flat members used to carry lives now. + const bad = ObjectGanttSchema.safeParse({ ...MINIMAL, gantt: { ...BLOCK, capacity: 'one' } }); + expect(bad.success).toBe(false); + if (!bad.success) { + expect(bad.error.issues.map((i) => i.path.join('.'))).toContain('gantt.capacity'); + } + }); + + it('the STRICT authoring face refuses each of the 24 written flat, and takes them in the block', () => { + // Not new with objectstack#25923: the strict face's `object-gantt` arm is the + // `properties` bag (`ObjectGanttBlockSchema`, objectui#10859), which refused + // every flat key before this card. Pinned here so the published TS/zod + // narrowing and the strict face cannot drift apart again. + for (const [key] of MOVED_OFF) { + const r = StrictAnyComponentSchema.safeParse({ type: 'object-gantt', [key]: 'x' }); + expect(r.success, `the strict face accepts a flat ${key}`).toBe(false); + } + const ok = StrictAnyComponentSchema.safeParse({ + type: 'object-gantt', + properties: { objectName: 'task', gantt: { ...BLOCK, lockField: 'locked', summaryExtent: 'self' } }, + }); + expect(ok.success ? null : ok.error.issues).toBe(null); }); it('does NOT reject an undeclared key — objectui#5155 ceiling, measured not assumed', () => { - // Declaring the 28 bought validation of DECLARED keys, not rejection of - // undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this card - // as "misspellings now fail" is reading it wrong, and this pin says so in the - // one place that cannot rot. const misspelled = ObjectGanttSchema.safeParse({ ...MINIMAL, colourField: 'status', lockFeild: 'locked' }); expect(misspelled.success).toBe(true); }); }); -/* ── The derived invariant: the two authoring faces are one vocabulary ─────── */ +/* ── The derived invariant, inverted: the flat face carries NO block key ───── */ /** * A declaration's OWN declared members, with any index signature stripped. * * Same construction as `zod-mirror-parity.test.ts` and for the same measured - * reason: `keyof ObjectGanttSchema` resolved to bare `string` while - * `BaseSchema`'s `[key: string]: any` absorbed every literal name (until - * objectui#8347), and `GanttConfig` still carries a signature of its own. A homomorphic - * mapped type maps declared members and index signatures separately, so remapping - * the index-signature keys to `never` leaves the literal members. + * reason: `GanttConfig` carries a signature of its own (the spec's + * `GanttConfigSchema` is `$loose`), and bare `keyof GanttConfig` resolves to + * `string`. */ type WithoutIndexSignature = { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K]; @@ -298,109 +303,37 @@ type WithoutIndexSignature = { type DeclaredKeys = Extract, string>; /** - * Keys of the BLOCK face that the FLAT face does not declare. `never` is the contract. - * - * `DeclaredKeys` is applied to BOTH sides, and that is load-bearing rather than - * symmetry for its own sake: the spec's `GanttConfigSchema` is `$loose`, so - * `GanttConfig` carries `[x: string]: unknown` of its own and bare - * `keyof GanttConfig` resolves to `string` — measured, when this pin was first - * written that way, and it made the `Exclude` unconditionally `string`. Two index - * signatures, two chances for the same vacuity; the non-vacuity test below pins - * both. + * Keys of the BLOCK face that the published interface ALSO declares flat. + * `never` is the contract since objectstack#25923 — the reverse of the + * objectui#6051 invariant this row replaces (which required every one). */ -type FlatFaceGaps = Exclude, DeclaredKeys>; - -describe('ObjectGanttSchema (TS) — the flat face declares the whole block vocabulary', () => { - it('every GanttConfig key is declared at the top level too', () => { - // Derived, with no key list to maintain: add a member to `GanttConfig` (or to - // the spec's `GanttConfigSchema`, which it derives from) without declaring the - // flattened spelling and this line stops compiling, NAMING the missing key. - const noGaps: FlatFaceGaps extends never ? true : FlatFaceGaps = true; - expect(noGaps).toBe(true); +type FlatFaceOverlap = Extract, DeclaredKeys>; + +describe('ObjectGanttSchema (TS) — the published interface declares no block key flat', () => { + it('no GanttConfig key is declared at the top level', () => { + // Derived, with no key list to maintain: re-declare any `GanttConfig` key on + // the interface and this line stops compiling, NAMING the key. + const noOverlap: FlatFaceOverlap extends never ? true : FlatFaceOverlap = true; + expect(noOverlap).toBe(true); }); it('the invariant above is not vacuous', () => { - // Two ways `FlatFaceGaps` could be `never` while proving nothing. - // - // 1. `DeclaredKeys` degenerating to `string` — the exact - // index-signature trap this card is about — would `Exclude` everything. + // 1. `DeclaredKeys` degenerating to `string` on either side would make the + // `Extract` meaningless. const notWidened: string extends DeclaredKeys ? never : true = true; - // 2. the SAME degeneration on the other side would make the `Exclude` source - // `string`, which is what happened before `DeclaredKeys` was applied here. const blockNotWidened: string extends DeclaredKeys ? never : true = true; - // 3. `DeclaredKeys` resolving to `never` would leave nothing to - // exclude, and both the spec's members and objectui's must be in it. + // 2. Both sides must really carry members: the block its spec and local keys, + // the interface its query keys and `gantt`. const blockHasSpecKeys: 'colorField' extends DeclaredKeys ? true : never = true; const blockHasLocalKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; - // 4. ...and the flat face must really carry the derived members, not `any`. - const flatHasKeys: 'summaryExtent' extends DeclaredKeys ? true : never = true; - expect([notWidened, blockNotWidened, blockHasSpecKeys, blockHasLocalKeys, flatHasKeys]) + const flatHasOwnKeys: 'gantt' | 'staticData' extends DeclaredKeys ? true : never = true; + expect([notWidened, blockNotWidened, blockHasSpecKeys, blockHasLocalKeys, flatHasOwnKeys]) .toEqual([true, true, true, true, true]); }); }); -describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () => { - it('refuses a wrong-typed value on every declared key', () => { - // Each directive below failed the build (TS2578, "unused '@ts-expect-error'") - // the moment its key stopped being declared, while the member resolved to - // `any` through `BaseSchema`'s index signature. Since objectui#8347 a removal - // makes the indexed access itself an error, which its directive swallows, so - // these directives guard each member's TYPE only. The deletion guards are - // elsewhere in this file: the derived invariant above catches a deletion of - // any `GanttConfig` key (measured by ablation: deleting `colorField` from - // the interface reddens `noGaps`, naming the key), and the three query keys, - // which `GanttConfig` does not carry, have the `_StaticData…` / `_Filter…` / - // `_Sort…` rows after this block. `tsconfig.test.json` compiles this file, so - // it is real enforcement (#3009). - - // @ts-expect-error — `colorField` is declared `string | undefined`. - const colorField: ObjectGanttSchemaTS['colorField'] = 5; - // @ts-expect-error — `borderColorField` is declared `string | undefined`. - const borderColorField: ObjectGanttSchemaTS['borderColorField'] = 5; - // @ts-expect-error — `dependenciesField` is declared `string | undefined`. - const dependenciesField: ObjectGanttSchemaTS['dependenciesField'] = 5; - // @ts-expect-error — `parentField` is declared `string | undefined`. - const parentField: ObjectGanttSchemaTS['parentField'] = 5; - // @ts-expect-error — `typeField` is declared `string | undefined`. - const typeField: ObjectGanttSchemaTS['typeField'] = 5; - // @ts-expect-error — `lockField` is declared `string | undefined`. - const lockField: ObjectGanttSchemaTS['lockField'] = 5; - // @ts-expect-error — `objectField` is declared `string | undefined`. - const objectField: ObjectGanttSchemaTS['objectField'] = 5; - // @ts-expect-error — `summaryExtent` is declared `'children' | 'self' | undefined`. - const summaryExtent: ObjectGanttSchemaTS['summaryExtent'] = 'parent'; - // @ts-expect-error — `defaultCollapsedDepth` is declared `number | undefined`. - const defaultCollapsedDepth: ObjectGanttSchemaTS['defaultCollapsedDepth'] = '2'; - // @ts-expect-error — `tooltipFields` is declared an ARRAY of field refs. - const tooltipFields: ObjectGanttSchemaTS['tooltipFields'] = 'name'; - // @ts-expect-error — `baselineStartField` is declared `string | undefined`. - const baselineStartField: ObjectGanttSchemaTS['baselineStartField'] = 5; - // @ts-expect-error — `baselineEndField` is declared `string | undefined`. - const baselineEndField: ObjectGanttSchemaTS['baselineEndField'] = 5; - // @ts-expect-error — `groupByField` is declared `string | undefined`. - const groupByField: ObjectGanttSchemaTS['groupByField'] = 5; - // @ts-expect-error — `resourceView` is declared `boolean | undefined`. - const resourceView: ObjectGanttSchemaTS['resourceView'] = 'yes'; - // @ts-expect-error — `assigneeField` is declared `string | undefined`. - const assigneeField: ObjectGanttSchemaTS['assigneeField'] = 5; - // @ts-expect-error — `effortField` is declared `string | undefined`. - const effortField: ObjectGanttSchemaTS['effortField'] = 5; - // @ts-expect-error — `capacity` is declared `number | undefined`. - const capacity: ObjectGanttSchemaTS['capacity'] = 'one'; - // @ts-expect-error — `quickFilters[].field` is required. - const quickFilters: ObjectGanttSchemaTS['quickFilters'] = [{ label: 'Owner' }]; - // @ts-expect-error — `autoZoomToFilter` is declared `boolean | undefined`. - const autoZoomToFilter: ObjectGanttSchemaTS['autoZoomToFilter'] = 'yes'; - // @ts-expect-error — `timeSegments.bands[]` requires `start` and `end`. - const timeSegments: ObjectGanttSchemaTS['timeSegments'] = { bands: [{ label: 'Day' }] }; - // @ts-expect-error — `interactions` is declared an object of switches. - const interactions: ObjectGanttSchemaTS['interactions'] = 'none'; - // @ts-expect-error — `exportFileName` is declared `string | undefined`. - const exportFileName: ObjectGanttSchemaTS['exportFileName'] = 5; - // @ts-expect-error — `timeZone` is declared `string | undefined`. - const timeZone: ObjectGanttSchemaTS['timeZone'] = 5; - // @ts-expect-error — `dependencyTypes` is declared `boolean | undefined`. - const dependencyTypes: ObjectGanttSchemaTS['dependencyTypes'] = 'yes'; +describe('ObjectGanttSchema (TS) — compile-time pin on every declared query key', () => { + it('refuses a wrong-typed value on every declared query key', () => { // @ts-expect-error — `staticData` is declared `any[] | undefined`. const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; // @ts-expect-error — `filter` is declared `ViewFilterRule[] | undefined` (the @@ -410,22 +343,25 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () // string clause was retired in objectui#8221). const sort: ObjectGanttSchemaTS['sort'] = 5; - expect([ - colorField, borderColorField, dependenciesField, parentField, typeField, - lockField, objectField, summaryExtent, defaultCollapsedDepth, tooltipFields, - baselineStartField, baselineEndField, groupByField, resourceView, assigneeField, - effortField, capacity, quickFilters, autoZoomToFilter, timeSegments, - interactions, exportFileName, timeZone, dependencyTypes, - staticData, filter, sort, - ]).toHaveLength(27); + expect([staticData, filter, sort]).toHaveLength(3); }); - it('accepts the well-typed value on every declared key', () => { + it('accepts the well-typed value on every declared key, and the 24 inside the block', () => { // Counter-probe for the directives above: without this, a declaration narrowed // to `never` would satisfy every one of them. - const ok: ObjectGanttSchemaTS = { ...MINIMAL, ...GOOD }; - expect(ok.summaryExtent).toBe('self'); - expect(ok.interactions?.resize).toBe(false); + const ok: ObjectGanttSchemaTS = { + ...MINIMAL, + ...GOOD, + gantt: { ...BLOCK, summaryExtent: 'self', interactions: { move: true, resize: false } }, + }; + expect(ok.gantt?.summaryExtent).toBe('self'); + expect(ok.gantt?.interactions?.resize).toBe(false); + }); + + it('refuses a flat block key on a fresh literal', () => { + // @ts-expect-error — `summaryExtent` is no member of the published interface; write `gantt.summaryExtent` + const flat: ObjectGanttSchemaTS = { ...MINIMAL, summaryExtent: 'self' }; + expect(flat.type).toBe('object-gantt'); }); }); @@ -436,13 +372,9 @@ type Equal = type Expect = T; /** - * `staticData` / `filter` / `sort` are not `GanttConfig` keys, so the derived - * invariant above is blind to their deletion, and the counter-probe literal - * spreads `GOOD`, which excess-property checking does not reach. These rows are + * `staticData` / `filter` / `sort` are not `GanttConfig` keys. These rows are * their deletion guard: an indexed access on a member that does not exist is - * itself a compile error, and `Equal` pins each declared type. Measured by - * ablation on this tree: deleting any one of the three from `ObjectGanttSchema` - * turns its row red while its directive above stays green; restored, green. + * itself a compile error, and `Equal` pins each declared type. */ // The `any[]` below restates the member's own declared type (`staticData?: any[]` in // `objectql.ts`); `Equal` is strict, so a narrower spelling would be red. `filter` is the 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 30d8406f04b..a42f3236828 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 @@ -23,59 +23,47 @@ * resolve to `any` and the unused directive failed `tsc -p tsconfig.test.json` * (TS2578); since objectui#8347 the indexed access itself errors, the directive * swallows that, and the well-typed counter-probe beside it fails instead. + * + * ## ⏱ objectstack#25923 — the pair moved to the hand-off type + * + * Only `ListView`'s toolbar writes the pair, at runtime, and + * `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 + * the absence; the paragraphs above are objectui#10250's reading, kept as written. */ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; import type { ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; -const MINIMAL = { - type: 'object-gantt', - objectName: 'task', - startDateField: 'start', - endDateField: 'end', -} as const; +const MINIMAL = { type: 'object-gantt', objectName: 'task' } as const; -describe('ObjectGanttSchema — the search pair is declared (objectui#10250)', () => { - it('the mirror declares both keys, both optional, with no default', () => { +describe('ObjectGanttSchema — the search pair is NOT published (objectstack#25923)', () => { + it('the mirror declares neither key', () => { const shape = Object.keys(ObjectGanttSchema.shape); - expect(shape).toContain('search'); - expect(shape).toContain('searchableFields'); - const result = ObjectGanttSchema.safeParse(MINIMAL); - expect(result.success).toBe(true); - if (!result.success) return; - expect('search' in result.data).toBe(false); - expect('searchableFields' in result.data).toBe(false); + expect(shape).not.toContain('search'); + expect(shape).not.toContain('searchableFields'); + // CONTROL: the shape read is the real one. + expect(shape).toContain('objectName'); }); - it('accepts a well-typed value on each', () => { - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, search: 'needle', searchableFields: ['name'] }); + it('the tolerant mirror no longer examines them: wrong-typed values ride `.passthrough()`', () => { + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, search: 5, searchableFields: 'name' }); expect(result.success ? null : result.error.issues).toBe(null); }); - - it('refuses a wrong-typed value on each, on that key\'s path', () => { - for (const [key, bad] of [['search', 5], ['searchableFields', 'name']] as const) { - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); - expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); - if (result.success) continue; - expect(result.error.issues.some((i) => i.path[0] === key), `${key} failed off its path`).toBe(true); - } - }); }); -describe('ObjectGanttSchema (TS) — compile-time pin on the same pair', () => { - it('refuses a wrong-typed value on both keys', () => { - // @ts-expect-error — `search` is declared `string | undefined`. - const search: ObjectGanttSchemaTS['search'] = 5; - // @ts-expect-error — `searchableFields` is declared `string[] | undefined`. - const searchableFields: ObjectGanttSchemaTS['searchableFields'] = 'name'; - expect([search, searchableFields]).toHaveLength(2); - }); - - it('accepts the well-typed value on both keys', () => { - // Counter-probe: a declaration narrowed to `never` would satisfy both - // directives above. - const ok: ObjectGanttSchemaTS = { type: 'object-gantt', objectName: 'task', search: 'needle', searchableFields: ['name'] }; - expect(ok.search).toBe('needle'); +describe('ObjectGanttSchema (TS) — the pair is no member of the published interface', () => { + it('refuses both keys on a fresh literal', () => { + // @ts-expect-error — `search` lives on plugin-gantt's hand-off type + const search: ObjectGanttSchemaTS = { ...MINIMAL, search: 'needle' }; + // @ts-expect-error — `searchableFields` lives on plugin-gantt's hand-off type + const fields: ObjectGanttSchemaTS = { ...MINIMAL, searchableFields: ['name'] }; + // Counter-probe: the literal without them compiles. + const ok: ObjectGanttSchemaTS = { ...MINIMAL }; + expect([search.type, fields.type, ok.type]).toEqual(['object-gantt', 'object-gantt', 'object-gantt']); }); }); diff --git a/ui/packages/types/src/__tests__/gantt-view-mode-declared.test.ts b/ui/packages/types/src/__tests__/gantt-view-mode-declared.test.ts index 9e3f5a309f0..b8456a88c5d 100644 --- a/ui/packages/types/src/__tests__/gantt-view-mode-declared.test.ts +++ b/ui/packages/types/src/__tests__/gantt-view-mode-declared.test.ts @@ -33,6 +33,15 @@ * downstream as an explicit author choice and defeat that seeding * (`ObjectGantt.viewmode.test.tsx` pins the wiring half of the same * semantics). + * + * ## ⏱ objectstack#25923 — `viewMode` is pinned on the `gantt` BLOCK now + * + * `ComponentPropsMap['object-gantt']` refuses `viewMode` written flat on the + * node, so the flat member left this interface and its mirror (it lives on + * `plugin-gantt`'s package-local hand-off type, derived from `GanttConfig`). + * The one published spelling is `gantt.viewMode`, and the three properties + * above are pinned there: the block member is the spec's own schema, so the + * derivation holds by construction rather than by a parallel declaration. */ import { describe, it, expect } from 'vitest'; @@ -40,12 +49,10 @@ import { GanttConfigSchema as SpecGanttConfigSchema } from '@objectstack/spec/ui import { ObjectGanttSchema } from '../zod/objectql.zod.js'; import type { ObjectGanttSchema as ObjectGanttSchemaTS, GanttConfig } from '../objectql.js'; -const MINIMAL = { - type: 'object-gantt', - objectName: 'task', - startDateField: 'start', - endDateField: 'end', -} as const; +const MINIMAL = { type: 'object-gantt', objectName: 'task' } as const; +const BLOCK = { startDateField: 'start', endDateField: 'end', titleField: 'name' } as const; +const withViewMode = (viewMode?: unknown) => + ({ ...MINIMAL, gantt: viewMode === undefined ? BLOCK : { ...BLOCK, viewMode } }); /** Unwrap ZodOptional/description wrappers down to the inner enum schema. */ function unwrap(schema: any): any { @@ -54,13 +61,14 @@ function unwrap(schema: any): any { return cur; } -describe('ObjectGanttSchema.viewMode — derived from the spec, by reference', () => { - it('declares the key', () => { - expect(Object.keys(ObjectGanttSchema.shape)).toContain('viewMode'); +describe('ObjectGanttSchema gantt.viewMode — derived from the spec, by reference', () => { + it('declares the key on the block, and no longer flat (objectstack#25923)', () => { + expect(Object.keys(unwrap(ObjectGanttSchema.shape.gantt).shape)).toContain('viewMode'); + expect(Object.keys(ObjectGanttSchema.shape)).not.toContain('viewMode'); }); it('its inner enum IS the spec enum object (reference identity, not a copy)', () => { - const local = unwrap(ObjectGanttSchema.shape.viewMode); + const local = unwrap(unwrap(ObjectGanttSchema.shape.gantt).shape.viewMode); const spec = unwrap(SpecGanttConfigSchema.shape.viewMode); expect(local).toBe(spec); }); @@ -69,38 +77,43 @@ describe('ObjectGanttSchema.viewMode — derived from the spec, by reference', ( const members: string[] = unwrap(SpecGanttConfigSchema.shape.viewMode).options; expect(members.length).toBeGreaterThan(0); for (const m of members) { - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, viewMode: m }); + const result = ObjectGanttSchema.safeParse(withViewMode(m)); expect(result.success).toBe(true); } }); it('refuses an off-enum value on the viewMode path (declared-key validation under passthrough)', () => { - const result = ObjectGanttSchema.safeParse({ ...MINIMAL, viewMode: 'hour' }); + const result = ObjectGanttSchema.safeParse(withViewMode('hour')); expect(result.success).toBe(false); if (result.success) return; - const issue = result.error.issues.find((i) => i.path.join('.') === 'viewMode'); + const issue = result.error.issues.find((i) => i.path.join('.') === 'gantt.viewMode'); expect(issue).toBeTruthy(); expect(issue!.code).toBe('invalid_value'); }); it('materialises NO default — an omitted viewMode stays absent after parse', () => { - const result = ObjectGanttSchema.safeParse(MINIMAL); + const result = ObjectGanttSchema.safeParse(withViewMode()); expect(result.success).toBe(true); if (!result.success) return; - expect('viewMode' in result.data).toBe(false); + expect('viewMode' in (result.data.gantt ?? {})).toBe(false); }); }); -describe('ObjectGanttSchema (TS) viewMode — mirrors the spec type exactly', () => { +type BlockViewMode = NonNullable['viewMode']; + +describe('ObjectGanttSchema (TS) gantt.viewMode — mirrors the spec type exactly', () => { it('is mutually assignable with the spec member (compile-time pin)', () => { // Accepts everything the spec type allows… - const widen = (v: GanttConfig['viewMode']): ObjectGanttSchemaTS['viewMode'] => v; + const widen = (v: GanttConfig['viewMode']): BlockViewMode => v; // …and nothing more (assignable back). - const narrow = (v: ObjectGanttSchemaTS['viewMode']): GanttConfig['viewMode'] => v; + const narrow = (v: BlockViewMode): GanttConfig['viewMode'] => v; expect(widen('quarter')).toBe('quarter'); expect(narrow(undefined)).toBeUndefined(); // @ts-expect-error — a non-member must be refused at compile time. - const _rejected: ObjectGanttSchemaTS['viewMode'] = 'hour'; + const _rejected: BlockViewMode = 'hour'; expect(_rejected).toBe('hour'); + // @ts-expect-error — `viewMode` is no flat member since objectstack#25923 + const flat: ObjectGanttSchemaTS = { ...MINIMAL, viewMode: 'day' }; + expect(flat.type).toBe('object-gantt'); }); }); diff --git a/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts b/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts index 8f99a3d8ba6..5dfe62fcc5c 100644 --- a/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts +++ b/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts @@ -137,14 +137,16 @@ export type assertionEveryRowKeyIsOnTheArm = [ /** * The TypeScript twin is RE-DECLARED, not retired: still published, still the - * post-hoist reading with its flat members, still a member of the TypeScript - * ObjectQL union. A narrowing or a removal here reddens `tsc`. + * post-hoist reading, still a member of the TypeScript ObjectQL union. A + * narrowing or a removal here reddens `tsc`. (objectstack#25923 moved its flat + * `GanttConfig` and search members to `plugin-gantt`'s hand-off type: the + * twin carries the `gantt` block, which is what the two rows below now read.) */ export type assertionTwinStaysPublished = [ Expect>, Expect>, - Expect>, - Expect>, + Expect['startDateField'], string>>, + Expect, never>>, Expect, TsObjectGanttSchema>>, ]; From 5e1ed6972d909911fe76dab6db06414060e39b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:44:03 +0000 Subject: [PATCH 3/8] fix(plugin-list,plugin-view,types): keep refused gantt bindings drawing, point the calendar tombstones at the block - ListView and ObjectView hand a gantt binding the spec's GanttConfigSchema refuses flat, as before, since getGanttConfig validates a gantt block and would show its refusal screen where the flat branch drew. - The object-calendar dateField / endField tombstones prescribe calendar.startDateField / calendar.endDateField. - Drop the unused SpecCalendarConfig import; the plugin-gantt README writes the gantt block. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .../calendarUnionReads-8651.test.tsx | 6 +- ui/packages/plugin-gantt/README.md | 18 +++-- ...ObjectGantt.legacyFlatDraws-25923.test.tsx | 73 +++++++++++++++++++ ui/packages/plugin-list/src/ListView.tsx | 13 +++- .../ListView.gantt-binding-7070.test.tsx | 29 ++++++++ .../ListView.readersRetired-6152.test.tsx | 11 ++- ui/packages/plugin-view/src/ObjectView.tsx | 33 ++++++--- .../ObjectView.ganttBinding-7070.test.tsx | 23 ++++++ .../ObjectView.readersRetired-6152.test.tsx | 11 ++- .../calendar-date-alias-refusal-8355.test.ts | 14 +++- ui/packages/types/src/objectql.ts | 1 - ui/packages/types/src/zod/objectql.zod.ts | 18 ++++- 12 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx 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 336ca7ef8f7..caa86c82b34 100644 --- a/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -478,8 +478,10 @@ describe('objectui#8355 — the `dateField` / `endField` rungs are RETIRED at bo }); it('…and an authored alias is refused BY NAME, at its own path, naming the canonical key', () => { - const node = { type: 'object-calendar', objectName: 'duly_task', titleField: 'nickname' }; - for (const [alias, canonical] of [['dateField', 'startDateField'], ['endField', 'endDateField']] as const) { + const node = { type: 'object-calendar', objectName: 'duly_task', calendar: { titleField: 'nickname' } }; + // The canonical key lives in the `calendar` block since objectstack#25923, + // so the refusal names the block path. + for (const [alias, canonical] of [['dateField', 'calendar.startDateField'], ['endField', 'calendar.endDateField']] as const) { const r = ObjectCalendarMirror.safeParse({ ...node, [alias]: 'kickoff' }); expect(r.success, `${alias} still parses green on the node face`).toBe(false); const issue = r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === alias); diff --git a/ui/packages/plugin-gantt/README.md b/ui/packages/plugin-gantt/README.md index 21a7a5dfad8..5fec633a1d1 100644 --- a/ui/packages/plugin-gantt/README.md +++ b/ui/packages/plugin-gantt/README.md @@ -602,9 +602,7 @@ const interactiveChart = console.log('Task clicked:', record)} @@ -834,7 +832,9 @@ be a CSV string (`"task1, task2"`), an array of ids, or an array of objects — The adapter is **not** a schema key — it reaches the renderer through the renderer context (or as an explicit `dataSource` prop), while the schema names the object and the fields. Mounted directly as below, `ObjectGantt` takes the -node as it reads it, typed `ObjectGanttSchema`, with its props on the node; +node as it reads it, typed `ObjectGanttSchema`, with its props on the node and +the field mapping in its `gantt` block (the flat spelling is no member of that +type since objectstack#25923); through `SchemaRenderer` the same props go in the authored node's `properties` bag: @@ -851,10 +851,12 @@ const dataSource = createObjectStackAdapter({ const schema: ObjectGanttSchema = { type: 'object-gantt', objectName: 'tasks', - titleField: 'task_name', - startDateField: 'start_date', - endDateField: 'end_date', - progressField: 'progress_percent' + gantt: { + titleField: 'task_name', + startDateField: 'start_date', + endDateField: 'end_date', + progressField: 'progress_percent' + } }; const ganttWithAdapter = ; diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx new file mode 100644 index 00000000000..17d58ebe6b5 --- /dev/null +++ b/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx @@ -0,0 +1,73 @@ +/** + * 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 renderer half of the producers' fallback. + * + * `ListView` and `ObjectView` now hand the gantt binding on as the node's + * `gantt` block, and `getGanttConfig` VALIDATES a block against the spec's + * `GanttConfigSchema` (objectui#13459), where its flat branch never validated + * anything. So a stored view whose legacy `options.gantt` carries a value the + * spec refuses would have moved from drawing to the refusal screen. The + * producers therefore hand exactly that block flat, as they did before; this + * file pins why that keeps the view drawing: the same values draw flat and are + * refused nested. The producer halves are pinned in `plugin-list`'s + * `ListView.gantt-binding-7070` and `plugin-view`'s `ObjectView.ganttBinding-7070`. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { GanttConfigSchema } from '@objectstack/spec/ui'; +import { ObjectGantt } from './ObjectGantt'; + +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ {tasks.map((t: any) => ( +
{t.title}
+ ))} +
+ ), +})); + +const ROWS = [{ id: 'r1', name: 'Ada onboarding', start_date: '2024-01-01', end_date: '2024-01-05' }]; + +/** What a legacy `options.gantt` could carry: the axis, plus a value the spec refuses. */ +const LEGACY = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', viewMode: 'hour' }; + +const node = (binding: Record) => + ({ type: 'object-gantt', objectName: 'crm_task', data: { provider: 'value', items: ROWS }, ...binding }) as any; + +let warn: ReturnType; +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); + cleanup(); +}); + +describe('objectstack#25923 — a binding the spec refuses draws flat and is refused nested', () => { + it('the fixture really is refused by the spec (non-vacuity)', () => { + expect(GanttConfigSchema.safeParse(LEGACY).success).toBe(false); + }); + + it('handed FLAT, as the producers hand it, the chart draws', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + expect(screen.getByText('Ada onboarding')).toBeTruthy(); + expect(screen.queryByTestId('gantt-invalid-config')).toBeNull(); + }); + + it('CONTROL: the same values nested in `gantt` reach the refusal screen', async () => { + render(); + expect(await screen.findByTestId('gantt-invalid-config')).toBeTruthy(); + expect(screen.queryByTestId('gantt-view')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index d4b36a10f9e..18d369b3375 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -42,7 +42,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). -import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema, GanttConfigSchema } from '@objectstack/spec/ui'; import type { GroupingConfig } from '@objectstack/spec/ui'; import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; @@ -4434,7 +4434,16 @@ export const ListView = React.forwardRef(({ // block (`object` / `value`) must reach ObjectGantt, whose // getDataConfig prefers schema.data over the objectName fallback. ...(schema.data ? { data: schema.data } : {}), - ...(ganttBlock ? { gantt: ganttBlock } : {}), + // ⚠️ A block the spec's `GanttConfigSchema` refuses goes out FLAT, the + // spelling this branch wrote before objectstack#25923. `getGanttConfig` + // validates a `gantt` block and shows its refusal screen on a bad one, + // but its flat branch never validated anything, so a stored view whose + // legacy `options.gantt` (or an older `gantt` block) carries a value the + // spec now refuses kept drawing. Handing that one block flat keeps it + // drawing exactly as before; every valid block goes out nested. + ...(ganttBlock + ? GanttConfigSchema.safeParse(ganttBlock).success ? { gantt: ganttBlock } : ganttBlock + : {}), }; } case 'map': { diff --git a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx index a43df162474..b935673c597 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -254,6 +254,35 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec }); }); +describe('ListView gantt branch — a block the spec refuses goes out flat, as before (objectstack#25923)', () => { + /** The emitted node itself: these rows read the spelling, so no flat-key guard here. */ + const ganttNode = async (schema: Record) => { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + return captured[captured.length - 1].schema; + }; + + it('a legacy `options.gantt` carrying a refused value is handed flat, so the renderer still draws it', async () => { + // `getGanttConfig` validates a `gantt` block (objectui#13459) and never + // validated the flat branch: nested, this view would move from drawing to the + // refusal screen (`plugin-gantt`'s `ObjectGantt.legacyFlatDraws-25923`). + const node = await ganttNode({ ...BASE, options: { gantt: { ...AXIS, viewMode: 'hour' } } }); + expect(node).not.toHaveProperty('gantt'); + expect([node.startDateField, node.endDateField, node.titleField, node.viewMode]).toEqual(['start_date', 'end_date', 'name', 'hour']); + }); + + it('CONTROL: the same view with a valid value goes out nested', async () => { + const node = await ganttNode({ ...BASE, options: { gantt: { ...AXIS, viewMode: 'week' } } }); + expect(node.gantt).toEqual({ ...AXIS, titleField: 'name', viewMode: 'week' }); + expect(node).not.toHaveProperty('startDateField'); + }); +}); + describe('ListView capability gate — the Gantt toggle follows the binding (objectui#7070)', () => { const GRID = { ...BASE, viewType: 'grid' } as const; diff --git a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx index b985ed311d3..a1d3913183b 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx @@ -193,10 +193,13 @@ describe('2 · the declared keys keep their route (objectui#6152 round 14)', () const block = Object.fromEntries(declared.map((k) => [k, `BLOCK_${k}`])); const bag = Object.fromEntries(declared.map((k) => [k, `BAG_${k}`])); const node = await generatedNode('object-gantt', { gantt: block, options: { gantt: { ...bag, onlyInTheBag: 'x' } } }); - // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. - for (const k of declared) expect(node.gantt?.[k], k).toBe(`BLOCK_${k}`); - for (const k of declared) expect(k in node, `flat ${k}`).toBe(false); - expect('onlyInTheBag' in node.gantt).toBe(false); + // objectstack#25923 — the placeholder strings are values the spec's + // `GanttConfigSchema` refuses (e.g. `capacity`), so the producer hands this + // binding FLAT, as before, rather than as a `gantt` block the renderer would + // refuse; a valid binding goes out nested (`ListView.gantt-binding-7070`). + // Either way every declared key reaches the gantt, the block winning. + expect('gantt' in node).toBe(false); + for (const k of declared) expect(node[k], k).toBe(`BLOCK_${k}`); expect('onlyInTheBag' in node).toBe(false); }); }); diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index 6428b0b95c3..6900c8fbc14 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -76,7 +76,7 @@ import { useObjectTranslation, createSafeTranslation, useDisplayLocale, pickLoca // objectui#7928 — a named view's `label` is the protocol's `I18nLabel` (a plain // string or an inline locale map) since `ObjectViewSchema.listViews` became its // record by reference. Resolved the way `ListView` resolves its own `label`. -import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, GanttConfigSchema } from '@objectstack/spec/ui'; import type { ListView as SpecListView } from '@objectstack/spec/ui'; import { buildExpandFields, @@ -2405,7 +2405,7 @@ export const ObjectView: React.FC = ({ titleField: viewOptions.timeline?.titleField || 'name', }; } - case 'gantt': + case 'gantt': { // `5f4514f7b`: only ever restate a binding the view actually DECLARED // — the same correction objectui#7029 made to the calendar branch above. // `startDateField` / `endDateField` used to be floored at 'start_date' / @@ -2438,21 +2438,30 @@ export const ObjectView: React.FC = ({ // them flat. Handed on under the two conditions the renderer's flat // branch applied: only once BOTH date fields are declared, and with // `titleField` floored at `'name'`, which the block requires. + // + // ⚠️ A block the spec's `GanttConfigSchema` refuses goes out FLAT, the + // spelling this case wrote before objectstack#25923: `getGanttConfig` + // shows its refusal screen for a bad `gantt` block but never validated + // the flat branch, so a stored named view whose legacy `options.gantt` + // carries a value the spec now refuses kept drawing, and still does. + const ganttBlock = + viewOptions.gantt?.startDateField && viewOptions.gantt?.endDateField + ? { + // The block's DECLARED keys, where a raw spread of the whole + // block stood until objectui#6152 round 14. See + // `GANTT_CONFIG_SPELLING`. + ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), + titleField: viewOptions.gantt.titleField || 'name', + } + : undefined; return { type: 'object-gantt', ...baseProps, - ...(viewOptions.gantt?.startDateField && viewOptions.gantt?.endDateField - ? { - gantt: { - // The block's DECLARED keys, where a raw spread of the whole - // block stood until objectui#6152 round 14. See - // `GANTT_CONFIG_SPELLING`. - ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), - titleField: viewOptions.gantt.titleField || 'name', - }, - } + ...(ganttBlock + ? GanttConfigSchema.safeParse(ganttBlock).success ? { gantt: ganttBlock } : ganttBlock : {}), }; + } case 'map': // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_SPELLING`, // which also carries `style` out as `mapStyle` (objectui#9950). diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx index 4f0f695ca1f..117e788e168 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx @@ -188,3 +188,26 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi expect(blockOf(await renderGanttView({ gantt: { ...AXIS } }))).toEqual({ ...AXIS, titleField: 'name' }); }); }); + +describe('ObjectView.generateViewSchema — a block the spec refuses goes out flat, as before (objectstack#25923)', () => { + it('a stored gantt config carrying a refused value is handed flat, so the renderer still draws it', async () => { + // `getGanttConfig` validates a `gantt` block (objectui#13459) and never + // validated the flat branch: nested, this view would move from drawing to + // the refusal screen (`plugin-gantt`'s `ObjectGantt.legacyFlatDraws-25923`). + // The stored row's legacy `options.gantt` reaches this same leg through + // app-shell's ObjectView relay, unvalidated. + const schema = await renderGanttView({ gantt: { ...AXIS, viewMode: 'hour' } }); + expect(schema).not.toHaveProperty('gantt'); + expect([schema.startDateField, schema.endDateField, schema.titleField, schema.viewMode]).toEqual([ + 'planned_start', + 'planned_end', + 'name', + 'hour', + ]); + }); + + it('CONTROL: the same view with a valid value goes out nested', async () => { + const schema = await renderGanttView({ gantt: { ...AXIS, viewMode: 'week' } }); + expect(blockOf(schema)).toEqual({ ...AXIS, titleField: 'name', viewMode: 'week' }); + }); +}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx index b43be2aea1e..76db3b6c8ba 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx @@ -131,11 +131,14 @@ describe('2 · the declared keys keep their route; the undeclared ones do not (o expect(declared.length).toBeGreaterThan(20); const block = Object.fromEntries(declared.map((k) => [k, `V_${k}`])); const node = await generatedNode('gantt', { gantt: { ...block, zzzUndeclared: 1 } }); - // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. - for (const k of declared) expect(node.gantt?.[k], k).toBe(`V_${k}`); - for (const k of declared) expect(node, `flat ${k}`).not.toHaveProperty(k); + // objectstack#25923 — the placeholder strings are values the spec's + // `GanttConfigSchema` refuses (e.g. `capacity`), so the producer hands this + // binding FLAT, as before, rather than as a `gantt` block the renderer would + // refuse; a valid binding goes out nested (`ObjectView.ganttBinding-7070`). + // Either way every declared key reaches the gantt. + expect(node).not.toHaveProperty('gantt'); + for (const k of declared) expect(node[k], k).toBe(`V_${k}`); expect(node).not.toHaveProperty('zzzUndeclared'); - expect(node.gantt).not.toHaveProperty('zzzUndeclared'); }); it('timeline: EVERY key the spec declares on the block reaches the timeline flat; the `dateField` alias does not', async () => { diff --git a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index f96b35dc241..926fbbb01c4 100644 --- a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -245,15 +245,21 @@ describe('objectui#8355 · surface 2 — the legacy `options.calendar` nesting', }); describe('objectui#8355 · surfaces 3 and 4 — the `object-calendar` node', () => { - it.each(RETIRED)('refuses a FLAT `%s` on the node, naming `%s`', (alias, canonical) => { - const result = calendarNode({ startDateField: 'kickoff', [alias]: 'kickoff' }); + it.each(RETIRED)('refuses a FLAT `%s` on the node, naming `calendar.%s` (objectstack#25923)', (alias, canonical) => { + const result = calendarNode({ calendar: { startDateField: 'kickoff' }, [alias]: 'kickoff' }); expect(result.success, `a flat ${alias} still parses green on the node`).toBe(false); const issue = result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === alias); expect(issue?.code).toBe('invalid_type'); expect(issue?.message).toContain(`Unrecognized key(s) on this object-calendar node: \`${alias}\``); - expect(issue?.message).toContain(`Did you mean \`${alias}\` → \`${canonical}\`?`); + // The canonical key is no flat member of the node since objectstack#25923, + // so the pointer and the clause both name the `calendar` block. + expect(issue?.message).toContain(`Did you mean \`${alias}\` → \`calendar.${canonical}\`?`); + expect(issue?.message).toContain(`Write \`calendar.${canonical}\``); + // CONTROL: the bare canonical key is prescribed nowhere in the node's message. + expect(issue?.message).not.toContain(`→ \`${canonical}\``); + expect(issue?.message).not.toContain(`Write \`${canonical}\``); }); it.each(RETIRED)('refuses `%s` inside the element\'s own `calendar` container, naming `%s`', (alias, canonical) => { @@ -267,6 +273,8 @@ describe('objectui#8355 · surfaces 3 and 4 — the `object-calendar` node', () }); it('DARK CONTROL: the canonical node parses GREEN on both nestings', () => { + // The flat node still parses on the TOLERANT mirror (`.passthrough()`), though + // since objectstack#25923 it declares none of the three: the strict face refuses them. const flat = calendarNode({ startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'nickname' }); expect(flat.success, JSON.stringify(flat.error?.issues)).toBe(true); const nested = calendarNode({ calendar: { startDateField: 'kickoff', endDateField: 'wrapup' } }); diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index e910d79812b..49804dbc83e 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -154,7 +154,6 @@ import type { // of `NamedListView` indexes it; see that member for why it cannot take its // type from this package's mirror like the other sixteen. ListView as SpecListView, - CalendarConfig as SpecCalendarConfig, // objectui#11168 slice 3 — the `object-tree` element's `tree` block, so // `ObjectTreeSchema.tree` below takes the protocol's own type. Aliased for // the reason `SpecGanttConfig` above is. diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 78456ae92ca..39002bc84c3 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -1967,12 +1967,22 @@ 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; only - * the surface noun differs from the view-level group. + * ladder actually read — a binding surface, so it 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 + * the prescription names the `calendar` block — `calendar.startDateField` / + * `calendar.endDateField` — in both the pointer and the clause. Pointing at the + * bare key would send an author from one refusal straight into the next. */ +const calendarNodeAliasDetail = (alias: 'dateField' | 'endField') => + calendarAliasDetail(alias, 'binding').replace( + alias === 'dateField' ? 'Write `startDateField`' : 'Write `endDateField`', + alias === 'dateField' ? 'Write `calendar.startDateField`' : 'Write `calendar.endDateField`', + ); const CalendarNodeDateAliasRefusals = { - dateField: aliasKeyRefusal('dateField', 'startDateField', 'this object-calendar node', calendarAliasDetail('dateField', 'binding')), - endField: aliasKeyRefusal('endField', 'endDateField', 'this object-calendar node', calendarAliasDetail('endField', 'binding')), + dateField: aliasKeyRefusal('dateField', 'calendar.startDateField', 'this object-calendar node', calendarNodeAliasDetail('dateField')), + endField: aliasKeyRefusal('endField', 'calendar.endDateField', 'this object-calendar node', calendarNodeAliasDetail('endField')), }; /** From 89e13213a9a9b878e408f0aa83cb12b139b1ac18 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:44:14 +0000 Subject: [PATCH 4/8] docs(changeset): note the refused-gantt flat hand-off and the block tombstone prescription Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .changeset/25923-calendar-gantt-flat-keys-handoff.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/25923-calendar-gantt-flat-keys-handoff.md b/.changeset/25923-calendar-gantt-flat-keys-handoff.md index 3e70cd4f878..220008d4ce8 100644 --- a/.changeset/25923-calendar-gantt-flat-keys-handoff.md +++ b/.changeset/25923-calendar-gantt-flat-keys-handoff.md @@ -15,10 +15,10 @@ Metadata-protocol: narrowed `@object-ui/types` published, on `ObjectCalendarSchema` and `ObjectGanttSchema`, a flat spelling of the field mapping that the spec refuses on the node. A declared flat key is one the strict authoring face derives from, so `objectui validate` accepted the `object-calendar` spelling the spec refuses. The keys only the host writes now live on each renderer package's own hand-off type (ui/AGENTS.md Commandment #0.1), and the producers write the block. -- **`@object-ui/types`**: `ObjectCalendarSchema` (TypeScript and zod) no longer declares `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat; they are written in its `calendar` block. The `dateField` / `endField` tombstones stay. `ObjectGanttSchema` no longer declares the 29 `GanttConfig` keys flat (`startDateField`, `endDateField`, `titleField`, `progressField`, `dependenciesField`, `colorField`, `viewMode`, `summaryExtent`, `interactions` and the rest), nor the legacy `dependencyField` alias or the `search` / `searchableFields` pair; they are written in its `gantt` block (the pair is not authored at all). `staticData`, `filter`, `sort`, `gantt` and the other node keys stay. For `object-gantt` the strict authoring face already refused every flat key (its arm is the `properties` bag), so the narrowing there is the published TypeScript and zod export. +- **`@object-ui/types`**: `ObjectCalendarSchema` (TypeScript and zod) no longer declares `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat; they are written in its `calendar` block. The `dateField` / `endField` tombstones stay and now prescribe `calendar.startDateField` / `calendar.endDateField`. `ObjectGanttSchema` no longer declares the 29 `GanttConfig` keys flat (`startDateField`, `endDateField`, `titleField`, `progressField`, `dependenciesField`, `colorField`, `viewMode`, `summaryExtent`, `interactions` and the rest), nor the legacy `dependencyField` alias or the `search` / `searchableFields` pair; they are written in its `gantt` block (the pair is not authored at all). `staticData`, `filter`, `sort`, `gantt` and the other node keys stay. For `object-gantt` the strict authoring face already refused every flat key (its arm is the `properties` bag), so the narrowing there is the published TypeScript and zod export. - **`@object-ui/plugin-calendar`**: `ObjectCalendarFlatFields` and `ObjectCalendarHandoff` type the flat read `getCalendarConfig` keeps for a host that mounts the renderer directly. The renderer is unchanged: the `calendar` block wins, and the flat spelling is read only without it. - **`@object-ui/plugin-gantt`**: `ObjectGanttFlatFields` (derived from `GanttConfig`, plus the alias and the search pair) and `ObjectGanttHandoff` type the flat read the same way. The renderer is unchanged. -- **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. +- **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. A gantt binding carrying a value the spec's `GanttConfigSchema` refuses (a stored legacy `options.gantt`, say) still goes out flat, because the renderer validates a `gantt` block and would show its refusal screen where the flat branch drew. **BREAKING** for TypeScript code or hand-written nodes that write the flat keys on these two node types. From 4676da49aca9c63eb11f432e79a824b139ef98c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:23:12 +0000 Subject: [PATCH 5/8] revert(plugin-list,plugin-view): emit the gantt block unconditionally, drop the flat fallback The producers hand the gantt binding on as the node's `gantt` block whether or not the spec's GanttConfigSchema accepts it; a silent flat fallback for an off-spec block is consumer-side tolerance. The prose now says what moves: a binding carrying a spec-refused value, which the unvalidated flat branch drew, reaches the renderer's refusal screen. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .../25923-calendar-gantt-flat-keys-handoff.md | 6 +- ...ObjectGantt.legacyFlatDraws-25923.test.tsx | 73 ------------------- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 5 +- ui/packages/plugin-list/src/ListView.tsx | 21 ++---- .../ListView.gantt-binding-7070.test.tsx | 29 -------- .../ListView.readersRetired-6152.test.tsx | 11 +-- ui/packages/plugin-view/src/ObjectView.tsx | 39 +++++----- .../ObjectView.ganttBinding-7070.test.tsx | 23 ------ .../ObjectView.readersRetired-6152.test.tsx | 11 +-- 9 files changed, 40 insertions(+), 178 deletions(-) delete mode 100644 ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx diff --git a/.changeset/25923-calendar-gantt-flat-keys-handoff.md b/.changeset/25923-calendar-gantt-flat-keys-handoff.md index 220008d4ce8..1f7ea57960e 100644 --- a/.changeset/25923-calendar-gantt-flat-keys-handoff.md +++ b/.changeset/25923-calendar-gantt-flat-keys-handoff.md @@ -11,17 +11,17 @@ refactor(types,plugin-calendar,plugin-gantt,plugin-list,plugin-view)!: the flat Clause-②: yes (narrowing) Metadata-protocol: narrowed - + `@object-ui/types` published, on `ObjectCalendarSchema` and `ObjectGanttSchema`, a flat spelling of the field mapping that the spec refuses on the node. A declared flat key is one the strict authoring face derives from, so `objectui validate` accepted the `object-calendar` spelling the spec refuses. The keys only the host writes now live on each renderer package's own hand-off type (ui/AGENTS.md Commandment #0.1), and the producers write the block. - **`@object-ui/types`**: `ObjectCalendarSchema` (TypeScript and zod) no longer declares `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat; they are written in its `calendar` block. The `dateField` / `endField` tombstones stay and now prescribe `calendar.startDateField` / `calendar.endDateField`. `ObjectGanttSchema` no longer declares the 29 `GanttConfig` keys flat (`startDateField`, `endDateField`, `titleField`, `progressField`, `dependenciesField`, `colorField`, `viewMode`, `summaryExtent`, `interactions` and the rest), nor the legacy `dependencyField` alias or the `search` / `searchableFields` pair; they are written in its `gantt` block (the pair is not authored at all). `staticData`, `filter`, `sort`, `gantt` and the other node keys stay. For `object-gantt` the strict authoring face already refused every flat key (its arm is the `properties` bag), so the narrowing there is the published TypeScript and zod export. - **`@object-ui/plugin-calendar`**: `ObjectCalendarFlatFields` and `ObjectCalendarHandoff` type the flat read `getCalendarConfig` keeps for a host that mounts the renderer directly. The renderer is unchanged: the `calendar` block wins, and the flat spelling is read only without it. - **`@object-ui/plugin-gantt`**: `ObjectGanttFlatFields` (derived from `GanttConfig`, plus the alias and the search pair) and `ObjectGanttHandoff` type the flat read the same way. The renderer is unchanged. -- **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. A gantt binding carrying a value the spec's `GanttConfigSchema` refuses (a stored legacy `options.gantt`, say) still goes out flat, because the renderer validates a `gantt` block and would show its refusal screen where the flat branch drew. +- **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. A gantt binding carrying a value the spec's `GanttConfigSchema` refuses (a pre-door stored `options.gantt`, say) goes out nested too, and now shows the renderer's refusal screen where the unvalidated flat branch drew it. **BREAKING** for TypeScript code or hand-written nodes that write the flat keys on these two node types. - **Before.** `const node: ObjectCalendarSchema = { type: 'object-calendar', objectName: 'event', startDateField: 'start' }` compiled, and the `object-calendar` strict face accepted it. -- **Now.** The flat key is a compile error, and `objectui validate` refuses it by name on `object-calendar` (it already did on `object-gantt`). A renderer handed a flat node still draws it. +- **Now.** The flat key is a compile error, and `objectui validate` refuses it by name on `object-calendar` (it already did on `object-gantt`). A renderer handed a flat node by a host still draws it. - **Fix.** Move the keys into the block: FROM `{ type: 'object-calendar', startDateField: 'start', titleField: 'name' }` TO `{ type: 'object-calendar', calendar: { startDateField: 'start', titleField: 'name' } }`, and FROM `{ type: 'object-gantt', startDateField: 's', endDateField: 'e', progressField: 'p' }` TO `{ type: 'object-gantt', gantt: { startDateField: 's', endDateField: 'e', titleField: 'name', progressField: 'p' } }` (the `gantt` block requires `titleField`). Code that hands a flat node straight to `` or `` types it as `ObjectCalendarProps['schema']` or `ObjectGanttProps['schema']`, which carry the flat keys. diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx deleted file mode 100644 index 17d58ebe6b5..00000000000 --- a/ui/packages/plugin-gantt/src/ObjectGantt.legacyFlatDraws-25923.test.tsx +++ /dev/null @@ -1,73 +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 renderer half of the producers' fallback. - * - * `ListView` and `ObjectView` now hand the gantt binding on as the node's - * `gantt` block, and `getGanttConfig` VALIDATES a block against the spec's - * `GanttConfigSchema` (objectui#13459), where its flat branch never validated - * anything. So a stored view whose legacy `options.gantt` carries a value the - * spec refuses would have moved from drawing to the refusal screen. The - * producers therefore hand exactly that block flat, as they did before; this - * file pins why that keeps the view drawing: the same values draw flat and are - * refused nested. The producer halves are pinned in `plugin-list`'s - * `ListView.gantt-binding-7070` and `plugin-view`'s `ObjectView.ganttBinding-7070`. - */ - -import React from 'react'; -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; -import { GanttConfigSchema } from '@objectstack/spec/ui'; -import { ObjectGantt } from './ObjectGantt'; - -vi.mock('./GanttView', () => ({ - GanttView: ({ tasks }: any) => ( -
- {tasks.map((t: any) => ( -
{t.title}
- ))} -
- ), -})); - -const ROWS = [{ id: 'r1', name: 'Ada onboarding', start_date: '2024-01-01', end_date: '2024-01-05' }]; - -/** What a legacy `options.gantt` could carry: the axis, plus a value the spec refuses. */ -const LEGACY = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', viewMode: 'hour' }; - -const node = (binding: Record) => - ({ type: 'object-gantt', objectName: 'crm_task', data: { provider: 'value', items: ROWS }, ...binding }) as any; - -let warn: ReturnType; -beforeEach(() => { - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); -afterEach(() => { - warn.mockRestore(); - cleanup(); -}); - -describe('objectstack#25923 — a binding the spec refuses draws flat and is refused nested', () => { - it('the fixture really is refused by the spec (non-vacuity)', () => { - expect(GanttConfigSchema.safeParse(LEGACY).success).toBe(false); - }); - - it('handed FLAT, as the producers hand it, the chart draws', async () => { - render(); - await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); - expect(screen.getByText('Ada onboarding')).toBeTruthy(); - expect(screen.queryByTestId('gantt-invalid-config')).toBeNull(); - }); - - it('CONTROL: the same values nested in `gantt` reach the refusal screen', async () => { - render(); - expect(await screen.findByTestId('gantt-invalid-config')).toBeTruthy(); - expect(screen.queryByTestId('gantt-view')).toBeNull(); - }); -}); diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index 05f1fa004ec..9f4668a7561 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -633,7 +633,10 @@ function warnOnShadowedFlatGanttKeys(schema: ObjectGanttHandoff): void { * `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 - * exactly as it did when they flattened it. Branch 2 stays for a host that + * 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}). * * The block is taken WHOLE, not merged over the flat keys. That is the ruling's diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 18d369b3375..c9e021a7e66 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -42,7 +42,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). -import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema, GanttConfigSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; import type { GroupingConfig } from '@objectstack/spec/ui'; import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; @@ -4344,12 +4344,16 @@ export const ListView = React.forwardRef(({ // FIRST and validates it against the spec's `GanttConfigSchema`. // // It is handed on under the two conditions the renderer's flat branch - // applied to the flat spelling, so a view draws exactly as it did: - // only once BOTH date fields are declared (otherwise no block, and the + // applied to the flat spelling: only once BOTH date fields are declared (otherwise no block, and the // node reaches the renderer's "configuration required" screen, as a // partial flat spelling did), and with `titleField` floored at `'name'` // (the flat branch's own floor — a display-name default, not a date // axis — which the block, requiring `titleField`, cannot leave to it). + // The one outcome that moves: the flat branch never validated, so a + // binding carrying a value the spec refuses (a pre-door stored + // `options.gantt`, say) used to draw and now reaches the renderer's + // `gantt-invalid-config` refusal screen. Such a row is converted at the + // store (ADR-0087), never tolerated here. const ganttBlock = startDateField && endDateField ? { @@ -4434,16 +4438,7 @@ export const ListView = React.forwardRef(({ // block (`object` / `value`) must reach ObjectGantt, whose // getDataConfig prefers schema.data over the objectName fallback. ...(schema.data ? { data: schema.data } : {}), - // ⚠️ A block the spec's `GanttConfigSchema` refuses goes out FLAT, the - // spelling this branch wrote before objectstack#25923. `getGanttConfig` - // validates a `gantt` block and shows its refusal screen on a bad one, - // but its flat branch never validated anything, so a stored view whose - // legacy `options.gantt` (or an older `gantt` block) carries a value the - // spec now refuses kept drawing. Handing that one block flat keeps it - // drawing exactly as before; every valid block goes out nested. - ...(ganttBlock - ? GanttConfigSchema.safeParse(ganttBlock).success ? { gantt: ganttBlock } : ganttBlock - : {}), + ...(ganttBlock ? { gantt: ganttBlock } : {}), }; } case 'map': { diff --git a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx index b935673c597..a43df162474 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -254,35 +254,6 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec }); }); -describe('ListView gantt branch — a block the spec refuses goes out flat, as before (objectstack#25923)', () => { - /** The emitted node itself: these rows read the spelling, so no flat-key guard here. */ - const ganttNode = async (schema: Record) => { - const dataSource = makeDataSource(); - render( - - - , - ); - await waitFor(() => expect(captured.length).toBeGreaterThan(0)); - return captured[captured.length - 1].schema; - }; - - it('a legacy `options.gantt` carrying a refused value is handed flat, so the renderer still draws it', async () => { - // `getGanttConfig` validates a `gantt` block (objectui#13459) and never - // validated the flat branch: nested, this view would move from drawing to the - // refusal screen (`plugin-gantt`'s `ObjectGantt.legacyFlatDraws-25923`). - const node = await ganttNode({ ...BASE, options: { gantt: { ...AXIS, viewMode: 'hour' } } }); - expect(node).not.toHaveProperty('gantt'); - expect([node.startDateField, node.endDateField, node.titleField, node.viewMode]).toEqual(['start_date', 'end_date', 'name', 'hour']); - }); - - it('CONTROL: the same view with a valid value goes out nested', async () => { - const node = await ganttNode({ ...BASE, options: { gantt: { ...AXIS, viewMode: 'week' } } }); - expect(node.gantt).toEqual({ ...AXIS, titleField: 'name', viewMode: 'week' }); - expect(node).not.toHaveProperty('startDateField'); - }); -}); - describe('ListView capability gate — the Gantt toggle follows the binding (objectui#7070)', () => { const GRID = { ...BASE, viewType: 'grid' } as const; diff --git a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx index a1d3913183b..b985ed311d3 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx @@ -193,13 +193,10 @@ describe('2 · the declared keys keep their route (objectui#6152 round 14)', () const block = Object.fromEntries(declared.map((k) => [k, `BLOCK_${k}`])); const bag = Object.fromEntries(declared.map((k) => [k, `BAG_${k}`])); const node = await generatedNode('object-gantt', { gantt: block, options: { gantt: { ...bag, onlyInTheBag: 'x' } } }); - // objectstack#25923 — the placeholder strings are values the spec's - // `GanttConfigSchema` refuses (e.g. `capacity`), so the producer hands this - // binding FLAT, as before, rather than as a `gantt` block the renderer would - // refuse; a valid binding goes out nested (`ListView.gantt-binding-7070`). - // Either way every declared key reaches the gantt, the block winning. - expect('gantt' in node).toBe(false); - for (const k of declared) expect(node[k], k).toBe(`BLOCK_${k}`); + // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. + for (const k of declared) expect(node.gantt?.[k], k).toBe(`BLOCK_${k}`); + for (const k of declared) expect(k in node, `flat ${k}`).toBe(false); + expect('onlyInTheBag' in node.gantt).toBe(false); expect('onlyInTheBag' in node).toBe(false); }); }); diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index 6900c8fbc14..c8569b9e1d7 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -76,7 +76,7 @@ import { useObjectTranslation, createSafeTranslation, useDisplayLocale, pickLoca // objectui#7928 — a named view's `label` is the protocol's `I18nLabel` (a plain // string or an inline locale map) since `ObjectViewSchema.listViews` became its // record by reference. Resolved the way `ListView` resolves its own `label`. -import { resolveI18nLabel as resolveInlineI18nLabel, GanttConfigSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import type { ListView as SpecListView } from '@objectstack/spec/ui'; import { buildExpandFields, @@ -2405,7 +2405,7 @@ export const ObjectView: React.FC = ({ titleField: viewOptions.timeline?.titleField || 'name', }; } - case 'gantt': { + case 'gantt': // `5f4514f7b`: only ever restate a binding the view actually DECLARED // — the same correction objectui#7029 made to the calendar branch above. // `startDateField` / `endDateField` used to be floored at 'start_date' / @@ -2437,31 +2437,26 @@ export const ObjectView: React.FC = ({ // written flat, so the published `ObjectGanttSchema` no longer declares // them flat. Handed on under the two conditions the renderer's flat // branch applied: only once BOTH date fields are declared, and with - // `titleField` floored at `'name'`, which the block requires. - // - // ⚠️ A block the spec's `GanttConfigSchema` refuses goes out FLAT, the - // spelling this case wrote before objectstack#25923: `getGanttConfig` - // shows its refusal screen for a bad `gantt` block but never validated - // the flat branch, so a stored named view whose legacy `options.gantt` - // carries a value the spec now refuses kept drawing, and still does. - const ganttBlock = - viewOptions.gantt?.startDateField && viewOptions.gantt?.endDateField - ? { - // The block's DECLARED keys, where a raw spread of the whole - // block stood until objectui#6152 round 14. See - // `GANTT_CONFIG_SPELLING`. - ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), - titleField: viewOptions.gantt.titleField || 'name', - } - : undefined; + // `titleField` floored at `'name'`, which the block requires. A binding + // carrying a value the spec refuses, which the unvalidated flat branch + // drew, now reaches the renderer's `gantt-invalid-config` refusal + // screen; a stored row like that is converted at the store (ADR-0087), + // never tolerated here. return { type: 'object-gantt', ...baseProps, - ...(ganttBlock - ? GanttConfigSchema.safeParse(ganttBlock).success ? { gantt: ganttBlock } : ganttBlock + ...(viewOptions.gantt?.startDateField && viewOptions.gantt?.endDateField + ? { + gantt: { + // The block's DECLARED keys, where a raw spread of the whole + // block stood until objectui#6152 round 14. See + // `GANTT_CONFIG_SPELLING`. + ...pickFlatMapConfig(viewOptions.gantt, GANTT_CONFIG_SPELLING), + titleField: viewOptions.gantt.titleField || 'name', + }, + } : {}), }; - } case 'map': // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_SPELLING`, // which also carries `style` out as `mapStyle` (objectui#9950). diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx index 117e788e168..4f0f695ca1f 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx @@ -188,26 +188,3 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi expect(blockOf(await renderGanttView({ gantt: { ...AXIS } }))).toEqual({ ...AXIS, titleField: 'name' }); }); }); - -describe('ObjectView.generateViewSchema — a block the spec refuses goes out flat, as before (objectstack#25923)', () => { - it('a stored gantt config carrying a refused value is handed flat, so the renderer still draws it', async () => { - // `getGanttConfig` validates a `gantt` block (objectui#13459) and never - // validated the flat branch: nested, this view would move from drawing to - // the refusal screen (`plugin-gantt`'s `ObjectGantt.legacyFlatDraws-25923`). - // The stored row's legacy `options.gantt` reaches this same leg through - // app-shell's ObjectView relay, unvalidated. - const schema = await renderGanttView({ gantt: { ...AXIS, viewMode: 'hour' } }); - expect(schema).not.toHaveProperty('gantt'); - expect([schema.startDateField, schema.endDateField, schema.titleField, schema.viewMode]).toEqual([ - 'planned_start', - 'planned_end', - 'name', - 'hour', - ]); - }); - - it('CONTROL: the same view with a valid value goes out nested', async () => { - const schema = await renderGanttView({ gantt: { ...AXIS, viewMode: 'week' } }); - expect(blockOf(schema)).toEqual({ ...AXIS, titleField: 'name', viewMode: 'week' }); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx index 76db3b6c8ba..b43be2aea1e 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx @@ -131,14 +131,11 @@ describe('2 · the declared keys keep their route; the undeclared ones do not (o expect(declared.length).toBeGreaterThan(20); const block = Object.fromEntries(declared.map((k) => [k, `V_${k}`])); const node = await generatedNode('gantt', { gantt: { ...block, zzzUndeclared: 1 } }); - // objectstack#25923 — the placeholder strings are values the spec's - // `GanttConfigSchema` refuses (e.g. `capacity`), so the producer hands this - // binding FLAT, as before, rather than as a `gantt` block the renderer would - // refuse; a valid binding goes out nested (`ObjectView.ganttBinding-7070`). - // Either way every declared key reaches the gantt. - expect(node).not.toHaveProperty('gantt'); - for (const k of declared) expect(node[k], k).toBe(`V_${k}`); + // objectstack#25923 — they reach it as the node's own `gantt` block, never flat. + for (const k of declared) expect(node.gantt?.[k], k).toBe(`V_${k}`); + for (const k of declared) expect(node, `flat ${k}`).not.toHaveProperty(k); expect(node).not.toHaveProperty('zzzUndeclared'); + expect(node.gantt).not.toHaveProperty('zzzUndeclared'); }); it('timeline: EVERY key the spec declares on the block reaches the timeline flat; the `dateField` alias does not', async () => { From d5d316b16f26b80f19c7bf2cf6e4afd5f0502600 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:20:14 +0000 Subject: [PATCH 6/8] docs(spec): the object-calendar / object-gantt flat-key refusals say the producers hand off the block The explanatory clause of OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE and OBJECT_GANTT_FLAT_CONFIG_GUIDANCE said ObjectView / ListView emit the flat spelling; they now hand the binding off in the calendar / gantt block, and the flat spelling is only a stored-document fallback the renderer still reads. The prescription itself is unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .../25923-calendar-gantt-flat-keys-handoff.md | 2 ++ packages/spec/src/ui/component.zod.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.changeset/25923-calendar-gantt-flat-keys-handoff.md b/.changeset/25923-calendar-gantt-flat-keys-handoff.md index 1f7ea57960e..cdb8334a17f 100644 --- a/.changeset/25923-calendar-gantt-flat-keys-handoff.md +++ b/.changeset/25923-calendar-gantt-flat-keys-handoff.md @@ -1,4 +1,5 @@ --- +'@objectstack/spec': patch '@object-ui/types': minor '@object-ui/plugin-calendar': minor '@object-ui/plugin-gantt': minor @@ -18,6 +19,7 @@ Metadata-protocol: narrowed - **`@object-ui/types`**: `ObjectCalendarSchema` (TypeScript and zod) no longer declares `startDateField`, `endDateField`, `titleField`, `colorField` or `allDayField` flat; they are written in its `calendar` block. The `dateField` / `endField` tombstones stay and now prescribe `calendar.startDateField` / `calendar.endDateField`. `ObjectGanttSchema` no longer declares the 29 `GanttConfig` keys flat (`startDateField`, `endDateField`, `titleField`, `progressField`, `dependenciesField`, `colorField`, `viewMode`, `summaryExtent`, `interactions` and the rest), nor the legacy `dependencyField` alias or the `search` / `searchableFields` pair; they are written in its `gantt` block (the pair is not authored at all). `staticData`, `filter`, `sort`, `gantt` and the other node keys stay. For `object-gantt` the strict authoring face already refused every flat key (its arm is the `properties` bag), so the narrowing there is the published TypeScript and zod export. - **`@object-ui/plugin-calendar`**: `ObjectCalendarFlatFields` and `ObjectCalendarHandoff` type the flat read `getCalendarConfig` keeps for a host that mounts the renderer directly. The renderer is unchanged: the `calendar` block wins, and the flat spelling is read only without it. - **`@object-ui/plugin-gantt`**: `ObjectGanttFlatFields` (derived from `GanttConfig`, plus the alias and the search pair) and `ObjectGanttHandoff` type the flat read the same way. The renderer is unchanged. +- **`@objectstack/spec`**: the `object-calendar` and `object-gantt` flat-key refusals no longer say `ObjectView` / `ListView` emit the flat spelling; they hand the binding off in the block, and the flat spelling is only a stored-document fallback the renderer still reads. The prescription itself is unchanged. - **`@object-ui/plugin-list`** and **`@object-ui/plugin-view`**: `ListView` and `ObjectView`'s `generateViewSchema` emit `calendar: { … }` and `gantt: { … }` on the node they build instead of the flat keys. They emit the block only where the renderer's flat branch drew: a calendar once `startDateField` is declared, a gantt once both dates are, with `titleField` floored at `'name'` as that branch floored it. A gantt binding carrying a value the spec's `GanttConfigSchema` refuses (a pre-door stored `options.gantt`, say) goes out nested too, and now shows the renderer's refusal screen where the unvalidated flat branch drew it. **BREAKING** for TypeScript code or hand-written nodes that write the flat keys on these two node types. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 9862b95bb4a..5199cd3820b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6209,9 +6209,9 @@ const OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE: readonly KeySetGuidance[] = [ examples: ['startDateField', 'titleField'], prescription: 'Write this as a key of the `calendar` config object instead — `calendar: { startDateField, ' - + 'endDateField, titleField, colorField, allDayField }`. The flat spelling is the runtime handoff ' - + '`ObjectView`/`ListView` emit and a stored-document fallback the renderer keeps reading; it is ' - + 'not a second authorable spelling (one key per concept, Prime Directive #12).', + + '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).', }, ]; @@ -7561,9 +7561,10 @@ 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, ... }`. The flat top-level spelling is the internal form `ObjectView`/`ListView` ' - + 'produce when they flatten `options.gantt`, 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; 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 ' + 'legacy singular `dependencyField` is `dependenciesField` inside that block.', }, ]; From 2f105661714b1f6658bef5b8bf6e607823961915 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:05:54 +0000 Subject: [PATCH 7/8] chore(changeset): grade @objectstack/spec minor under the narrowed metadata-protocol line Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- .changeset/25923-calendar-gantt-flat-keys-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25923-calendar-gantt-flat-keys-handoff.md b/.changeset/25923-calendar-gantt-flat-keys-handoff.md index cdb8334a17f..1b8a413d6bb 100644 --- a/.changeset/25923-calendar-gantt-flat-keys-handoff.md +++ b/.changeset/25923-calendar-gantt-flat-keys-handoff.md @@ -1,5 +1,5 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor '@object-ui/types': minor '@object-ui/plugin-calendar': minor '@object-ui/plugin-gantt': minor From eba20284903b075d5700d8656870f897b7391cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:52:08 +0000 Subject: [PATCH 8/8] docs(spec,types,plugin-calendar,plugin-gantt): comments say the producers hand off the calendar / gantt block The JSDoc around the object-calendar / object-gantt flat-key guidance, and the comments this PR's files carry, still said ObjectView / ListView emit or flatten into the flat spelling and that ObjectGanttSchema declares it. They now say the producers hand the binding off in the block, the flat spelling is a stored-document fallback the renderer still reads, and the flat keys live on the package-local hand-off types. Comments only. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzetP4CSWdmfU9t887J --- packages/spec/src/ui/component.zod.ts | 32 +++++++++++-------- .../plugin-calendar/src/ObjectCalendar.tsx | 5 +-- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 2 +- .../calendar-flat-color-allday-8466.test.ts | 19 ++++++++--- ...ject-gantt-properties-bag-10859-b6.test.ts | 2 +- ui/packages/types/src/objectql.ts | 6 ++-- ui/packages/types/src/zod/objectql.zod.ts | 6 ++-- 7 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5199cd3820b..145dfb5ed5c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6195,8 +6195,11 @@ export type ObjectKanbanPropsParsed = z.infer; /** * The flat per-field spellings `ObjectCalendar` keeps reading as a - * backward-compat fallback (`getCalendarConfig`; read points recorded in - * `liveness/page.json`, row `regions`) and that `ObjectView`/`ListView` emit on their runtime handoff. Read, but + * 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. @@ -7522,21 +7525,22 @@ export type ObjectMapProps = z.input; export type ObjectMapPropsParsed = z.infer; /** - * The flat `GanttConfig` spellings `getGanttConfig`'s branch 2 reads and that - * `ObjectView` / `ListView` EMIT when they flatten `options.gantt` — objectui's - * own `FLAT_GANTT_CONFIG_KEYS` (read points recorded in `liveness/page.json`, - * row `regions`). Read, but + * 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 branch is the HOT path for this block — a hand-authored `gantt` block - * reaching the renderer through either view layer has already been flattened — - * which is exactly why the prescription matters here: an author who learned the - * flat spelling from a view config (or from `@object-ui/types`' - * `ObjectGanttSchema`, which declares the whole flat face) writes it on an SDUI - * node next, where nothing flattens anything. + * 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. * * Membership is `GanttConfigSchema`'s own shape plus the legacy singular * `dependencyField` alias the flat branch still reads beside `dependenciesField` @@ -7632,7 +7636,7 @@ function objectGanttMarker() { * `mobileReadOnly`. * * Measured and deliberately NOT declared: the flat `GanttConfig` spellings (the - * flatten product — {@link OBJECT_GANTT_FLAT_CONFIG_GUIDANCE}); `title`, which + * stored-document fallback — {@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 @@ -7643,7 +7647,7 @@ function objectGanttMarker() { * 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 flatten product, and not declared here. + * as the flat fallback spellings, and not declared here. * * VALUE posture: `gantt` is the one config block in this family whose value * contract is already the SPEC's — the renderer validates it against diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx index e183f9ed3fa..d0f263c2d2c 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -1470,8 +1470,9 @@ export const ObjectCalendar: React.FC = ({ * `plugin-view`'s `ObjectView` — and app-shell reaches the first from two * faces, `ObjectView` (the object-view door) and `InterfaceListPage` (the * interface-page door). Every one of them hands this component the same - * shape: the shared `baseProps` bag plus whichever of `startDateField` / - * `endDateField` / `titleField` the author declared. NOTHING on the node + * shape: the shared `baseProps` bag plus a `calendar` block carrying whichever + * of `startDateField` / `endDateField` / `titleField` the author declared + * (flat on the node until objectstack#25923). NOTHING on the node * names the door, so there is nothing to infer from, and a door PROP would * have to be declared on the node's published schema and threaded through * four packages. diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index 9f4668a7561..701bd5b1917 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -583,7 +583,7 @@ const warnedShadowedFlatGanttKeys = new Set(); * Silence is what the precedence rule costs if it is not diagnosed: two * spellings of ONE vocabulary (objectui#6051 proved they are one — both derive * from `GanttConfig`) in a single schema, one of them inert. The ruling picks - * the author's block over the flatten product deliberately — maintainer on + * 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. 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 68c401b5066..13f2de84411 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,6 +29,12 @@ * 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 @@ -141,8 +147,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 (the - * runtime handoff), and that the README authors inside the `calendar` block. + * 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. */ const FLAT_KEYS = ['titleField', 'startDateField', 'endDateField', 'allDayField', 'colorField'] as const; /** The three that were already declared — the precedent the two new ones join. */ @@ -469,10 +476,12 @@ describe('objectui#8466 — the renderer reads these keys, which is what the dec it('the README authors the five INSIDE the `calendar` block on `object-calendar`, never flat (objectui#8831)', () => { // This row used to pin the opposite — that the README taught the five flat, // "which is what makes them authorable". objectui#8831 ruled that spelling - // the runtime handoff: `ComponentPropsMap['object-calendar']` refuses it and + // the runtime handoff (since objectstack#25923 only a stored-document + // fallback): `ComponentPropsMap['object-calendar']` refuses it and // its diagnostic prescribes the `calendar` block. So the README teaches the - // block, and the declarations this file pins stay because the renderer - // READS the flat spelling, not because an author should write it. + // block. Since objectstack#25923 `ObjectCalendarSchema` no longer declares + // the five flat at all: the renderer still READS the flat spelling, typed on + // `plugin-calendar`'s own hand-off type, and no author should write it. // // Read off the authored nodes by brace matching, never by line or indent, // so a re-wrap or re-indent cannot move the verdict (objectui#8832). diff --git a/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts b/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts index 5dfe62fcc5c..14255929979 100644 --- a/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts +++ b/ui/packages/types/src/__tests__/object-gantt-properties-bag-10859-b6.test.ts @@ -33,7 +33,7 @@ * ## What is particular to this node * * - The flat mirror also declared the whole `GanttConfig` vocabulary flat on - * the node (`startDateField`, `viewMode`, …, the flatten product + * the node (`startDateField`, `viewMode`, …, the flat spelling * `getGanttConfig` reads when there is no `gantt` block) and the legacy * `dependencyField` alias. Each is refused by name and pointed at the * bag's `gantt` block, the home the row gives them. diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 49804dbc83e..2dd3f8defdf 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -315,8 +315,10 @@ 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 on - // `ObjectGanttSchema`), and a type that lives inside the plugin can be + // (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. // diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 39002bc84c3..460a99353a9 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -1866,8 +1866,10 @@ const CALENDAR_DATE_ALIAS_STEM = /** * WHERE THE AUTHORED VALUE LANDS, which is what decides the consequence clause. * - * - `'binding'` — the value reaches `ObjectCalendar` as a FLAT member of the - * node. Every producer-fed surface is this: the view-level `calendar` block + * - `'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. * - `'container'` — the `object-calendar` element's OWN `calendar` container,