diff --git a/.changeset/8348-record-source-data-arm-per-spec-row.md b/.changeset/8348-record-source-data-arm-per-spec-row.md new file mode 100644 index 0000000000..f45ae1d33d --- /dev/null +++ b/.changeset/8348-record-source-data-arm-per-spec-row.md @@ -0,0 +1,24 @@ +--- +'@object-ui/core': minor +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-grid': minor +'@object-ui/plugin-map': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-tree': minor +--- + +**Breaking behaviour change — a view block now honours only the `data` spelling its published row declares.** + +Maintainer ruling, decision batch #83 (2026-09-08), verbatim 「8348 以协议为准」: the contract decides. What `os validate` and the save gate refuse under `data`, the renderer refuses too. The shared record-source ladder (`resolveRecordSourceConfig` in `@object-ui/core`) now takes the arm the calling block's row declares, and rung 1 applies only on that arm. + +⚠️ **`object-calendar` documents authored against the tolerated spelling stop rendering those rows.** The ladder falls past `data` to `staticData`, then to `objectName` — so such a calendar queries its object instead, or draws nothing when it names neither. This is accepted, with no transition window and no staged deprecation (the standing 2026-08-27 posture). + +**Per block — what the published row says, and what actually moves:** + +- **`object-calendar`** — row: `ComponentPropsMap['object-calendar'].data` is `z.array(z.unknown())` ("Pre-fetched records — skips the internal fetch"). A record-source **config object** under `data` — `{ provider: 'value', items }`, `{ provider: 'object', object }`, `{ provider: 'api', … }` — is no longer honoured, and this is a real end-to-end change: that object had exactly one carrier into the block, so an authored calendar written that way now queries its object (or draws nothing) instead of drawing the authored rows. There is no longer an api-provider branch to reach here. An **array** under `data` is unchanged, as are `staticData` and `objectName`. +- **`object-grid`** — row: the `ViewData` union, whose own description says "the bare-array shortcut is refused". `getDataConfig`'s `Array.isArray(schema.data)` head is removed, so the array is no longer a record source at the ladder. ⛔ Measured: this is **not** an end-to-end change for a node rendered through `SchemaRenderer`. An authored `data` array also arrives on the **props channel** (`SchemaRenderer` spreads node keys as props; `ObjectGrid`'s `passedData` lifts an array at higher priority), so such a grid still draws its rows. The removal takes the second read, not the last one. +- **`object-map`** — no `ComponentPropsMap` row exists; the governing row is this repo's own `ObjectMapSchema.data`, `ViewDataSchema.optional()`. Its array-shorthand head is removed on the same terms, with the same measured caveat: through `SchemaRenderer` the props channel still draws an authored array. `data: { provider: 'value', items }` and `staticData` are unchanged. +- **`object-gantt`** — no `ComponentPropsMap` row; the governing row is `ObjectGanttSchema.data`, `ViewDataSchema.optional()`. A bare array under `data` is no longer a record source. Nothing observable moves: this block never lifted one, the array carried no `provider` and matched no fetch branch, and its renderer forwards no host props. +- **`object-tree`** — **unchanged.** No published face declares a `data` row for this block: not `ComponentPropsMap`, not `ObjectTreeSchema` (which declares `objectName` required and no `data`), not its registration `inputs`. Neither arm of the ruling reaches it, so its rung 1 keeps its previous behaviour and the block is reported rather than guessed at. + +The `data` **prop** — the pre-fetched rows a host such as `ObjectView` or `ListView` passes down — is a different carrier and is untouched on every block. That it is also reachable from an authored node key, because `SchemaRenderer` spreads node keys as props, is what limits the grid and map halves above; it is reported on objectui#8348 rather than changed here. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 1777606840..fbff304912 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -242,7 +242,10 @@ const schema: ObjectCalendarSchema = { type: 'object-calendar', objectName?: string, // ObjectQL object name staticData?: Array, // Static data array - data?: ViewData, // Advanced data configuration + data?: Array, // Pre-fetched records, drawn in place of the + // calendar's own query. An ARRAY — this block's + // published row refuses the `{ provider, items }` + // config object (objectui#8348) calendar?: CalendarConfig, // Calendar-specific configuration filter?: ViewFilterRule[], // Query filter, lowered to $filter sort?: SortConfig[], // Query sort, lowered to $orderby @@ -344,17 +347,25 @@ const valueProviderCalendar: ObjectCalendarSchema = { }; ``` -#### API Provider — not implemented - -`data.provider: 'api'` has no fetch implementation in `ObjectCalendar`. A schema -that reaches this branch logs `API provider not yet implemented for -ObjectCalendar`, sets the record set to empty and renders a calendar with no -events; `endpoint` and `method` have no read point anywhere in the package. -Without a `DataSource` it fails one step earlier, with `DataSource required for -object/api providers`. - -Read from the database with the **Object Provider** above, or pass events you -already hold with the **Value Provider**. +#### `data: { provider: … }` — refused on this block + +⚠️ Since **objectui#8348** a calendar authored with a record-source *config +object* under `data` — `{ provider: 'value', items: [...] }`, +`{ provider: 'object', object: … }`, `{ provider: 'api', … }` — is not honoured +here. `object-calendar`'s published `data` row is an **array of pre-fetched +records**, so the config object is refused by kind: `os validate` and the save +gate reject it, and as of that card the renderer does too. + +That is a behaviour change for stored documents. A calendar written that way no +longer draws those rows; the record-source ladder falls past `data` to +`staticData`, then to `objectName`, so it queries its object instead — or draws +nothing when it names neither. + +Read from the database with the **Object Provider** above (`objectName`), pass +events you already hold with the **Value Provider** (`staticData`), or hand the +block rows it already has as an array under `data`. There is no api-provider +branch to reach on this block; `endpoint` and `method` have no read point +anywhere in the package. ## Comparison: CalendarView vs ObjectCalendar diff --git a/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts b/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts index ac944db793..f5a926b50b 100644 --- a/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts +++ b/packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts @@ -29,10 +29,27 @@ * `{ provider: 'value', items }`, which the other three do NOT have. This is * a REAL divergence on off-contract input: those three return the array * verbatim, so `dataConfig.provider` is `undefined` downstream and the block - * draws nothing. It is preserved, not flattened — the two sites keep the - * head locally and the shared rung stays contract-strict (AGENTS.md #0.1), - * the same way objectui#7627 left the off-contract `{ provider: 'object' }` - * tails at their sites. + * draws nothing. + * + * ## ⭐ objectui#8348 — what this file now pins, and what it still pins + * + * The divergence above was PRESERVED by objectui#7632 and is RULED on by + * decision batch #83 (2026-09-08, maintainer verbatim 「8348 以协议为准」): rung 1 + * honours `data` only on the arm the block's PUBLISHED row declares. So this + * file keeps its original job — the shared rung moves nothing at any site — for + * every input the ruling leaves alone, and pins the ruled MOVES explicitly + * where it does not: + * + * - `object-calendar` (row: `z.array(z.unknown())`) no longer takes a + * `{ provider, items }` object as a record source; + * - `object-grid`, `object-map`, `object-gantt` (row: `ViewData`) no longer + * take a bare array, and grid's and map's normalizing heads are gone; + * - `object-tree` publishes no `data` row on any face, so nothing about it + * moves and it passes the `'undeclared'` arm. + * + * The pre-collapse bodies transcribed below stay as the reference for both + * halves: they are what "unchanged" means, and they are the ⛔ CONTROL that + * makes "changed" a measurement instead of a restatement. */ import { describe, it, expect } from 'vitest'; import { resolveRecordSourceConfig } from '../record-source.js'; @@ -81,20 +98,41 @@ const beforeMap = (schema: Schema): Cfg => { return null; }; -/** The post-collapse spelling now compiled into grid and map: head, then shared rung. */ -const afterArrayHead = (schema: Schema): Cfg => { - if (Array.isArray(schema.data)) return { provider: 'value', items: schema.data }; - return resolveRecordSourceConfig(schema); +/** + * The ladder with rung 1 REMOVED — where an authored `data` that is off its + * block's declared arm now lands (objectui#8348). Not a third hand-copy: it is + * the expected value this file compares against, written out so a reader can + * see that "refused at rung 1" means "falls through to `staticData`, then + * `objectName`" and not "returns null". + */ +const ladderBelowRungOne = (schema: Schema): Cfg => { + if (schema.staticData) return { provider: 'value', items: schema.staticData }; + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; }; -const SITES: { id: string; before: (s: Schema) => Cfg; after: (s: Schema) => Cfg }[] = [ - { id: 'ObjectGantt:321', before: beforeBare, after: resolveRecordSourceConfig }, - { id: 'ObjectTree:93', before: beforeBare, after: resolveRecordSourceConfig }, - { id: 'ObjectCalendar:118', before: beforeCalendar, after: resolveRecordSourceConfig }, - { id: 'ObjectGrid:428', before: beforeGrid, after: afterArrayHead }, - { id: 'ObjectMap:128', before: beforeMap, after: afterArrayHead }, +/** + * The five sites, each with the arm its block's PUBLISHED `data` row declares + * (objectui#8348, decision batch #83 — 「8348 以协议为准」). The arm is what the + * site passes today; `before` is what the site resolved before the collapse + * (objectui#7632), which is still the reference for every input the ruling does + * not move. + */ +const SITES: { id: string; arm: 'view-data' | 'array' | 'undeclared'; before: (s: Schema) => Cfg }[] = [ + { id: 'ObjectGantt', arm: 'view-data', before: beforeBare }, + { id: 'ObjectTree', arm: 'undeclared', before: beforeBare }, + { id: 'ObjectCalendar', arm: 'array', before: beforeCalendar }, + { id: 'ObjectGrid', arm: 'view-data', before: beforeGrid }, + { id: 'ObjectMap', arm: 'view-data', before: beforeMap }, ]; +/** What the site resolves TODAY. */ +const after = (site: (typeof SITES)[number], schema: Schema): Cfg => + resolveRecordSourceConfig(schema as any, site.arm) as Cfg; + +/** Is the authored `data` a `ViewData` OBJECT (the arm calendar's row refuses)? */ +const hasObjectData = (s: Schema): boolean => !!s.data && !Array.isArray(s.data); + /** * Contract-valid by construction: `ViewDataSchema` is a * `z.discriminatedUnion('provider', [...])` over object variants whose `object` @@ -121,8 +159,19 @@ const CONTRACT_VALID: [string, Schema][] = [ describe('resolveRecordSourceConfig — behaviour neutrality on contract-valid input (objectui#7632)', () => { for (const [name, schema] of CONTRACT_VALID) { for (const site of SITES) { - it(`${site.id} is unchanged for "${name}"`, () => { - expect(site.after(schema)).toEqual(site.before(schema)); + // ⭐ objectui#8348 moves exactly one cell family of this matrix, and the + // branch below is what keeps the rest a neutrality claim rather than a + // rewritten expectation. `object-calendar`'s published row is + // `z.array(z.unknown())`, so a `ViewData` OBJECT under `data` is no + // longer a record source THERE — and nowhere else. + const ruledAway = site.arm === 'array' && hasObjectData(schema); + it(`${site.id} ${ruledAway ? 'refuses the off-arm `data` (objectui#8348)' : 'is unchanged'} for "${name}"`, () => { + if (ruledAway) { + expect(after(site, schema)).not.toEqual(site.before(schema)); + expect(after(site, schema)).toEqual(ladderBelowRungOne(schema)); + } else { + expect(after(site, schema)).toEqual(site.before(schema)); + } }); } } @@ -130,7 +179,7 @@ describe('resolveRecordSourceConfig — behaviour neutrality on contract-valid i it('the matrix is a LIT control: every rung of the ladder is actually exercised', () => { const reached = new Set( CONTRACT_VALID.map(([, s]) => { - const cfg = resolveRecordSourceConfig(s); + const cfg = resolveRecordSourceConfig(s as any, 'view-data'); if (cfg === null) return 'null'; if (s.data) return 'data'; if (s.staticData) return 'staticData'; @@ -140,15 +189,39 @@ describe('resolveRecordSourceConfig — behaviour neutrality on contract-valid i // A matrix that never reaches a rung cannot prove that rung neutral. expect([...reached].sort()).toEqual(['data', 'null', 'objectName', 'staticData']); }); + + it('⛔ NON-VACUITY: the ruled-away family is not empty', () => { + // Without this, a `hasObjectData` that answered `false` everywhere would + // turn the branch above into "everything is unchanged" and the file would + // pass while asserting nothing about the ruling. + const ruled = CONTRACT_VALID.filter(([, s]) => hasObjectData(s)); + expect(ruled.length).toBeGreaterThan(0); + expect(SITES.filter((site) => site.arm === 'array')).toHaveLength(1); + }); }); /** - * The OFF-CONTRACT fork: a bare array under `data`. `ViewData` admits no array - * variant, so this cannot be published — but grid and map normalize it anyway - * and the other three do not. The collapse deliberately does NOT unify them; - * these cases pin BOTH sides of the fork, so neither a "fold the head into the - * shared reader" simplification nor a "drop the redundant head" cleanup can - * happen silently. + * The bare array under `data` — the fork objectui#7632 preserved and + * objectui#8348 RESOLVES, by the row rather than by convention. + * + * Before this card: `ObjectGrid` and `ObjectMap` lifted `data: [...]` to + * `{ provider: 'value', items }` at their own sites; gantt, tree and calendar + * returned the array verbatim (`provider` `undefined` downstream). Decision + * batch #83 rules by each block's published row, and the rows are not the same + * shape, so the fork does not close onto ONE answer — it closes onto the + * declared one per block: + * + * - `object-grid` — row is `ViewData`, and its own spec description says "the + * bare-array shortcut is refused". ⇒ the lift is gone; the array falls + * through rung 1. + * - `object-map`, `object-gantt` — no `ComponentPropsMap` row; the governing + * row is `ObjectMapSchema.data` / `ObjectGanttSchema.data`, + * `ViewDataSchema.optional()`. ⇒ same verdict. + * - `object-calendar` — row IS `z.array(z.unknown())` + * ("Pre-fetched records — skips the internal fetch"), so the array is the + * DECLARED spelling and rung 1 still returns it verbatim. Unmoved. + * - `object-tree` — no published `data` row at all, on any face, so neither + * arm of the ruling reaches it and rung 1 keeps its pre-8348 behaviour. */ const ARRAY_SHORTHAND: [string, Schema][] = [ ['array-shorthand', { objectName: 'Y', data: [1, 2] }], @@ -157,33 +230,83 @@ const ARRAY_SHORTHAND: [string, Schema][] = [ ['array-shorthand+staticData', { staticData: [9], data: [1] }], ]; -describe('the off-contract bare-array `data` shorthand (objectui#7632)', () => { +describe('the bare-array `data` shorthand, judged by the row (objectui#8348)', () => { for (const [name, schema] of ARRAY_SHORTHAND) { - it(`grid and map still normalize it for "${name}"`, () => { - expect(afterArrayHead(schema)).toEqual({ provider: 'value', items: schema.data }); - expect(afterArrayHead(schema)).toEqual(beforeGrid(schema)); - expect(afterArrayHead(schema)).toEqual(beforeMap(schema)); + it(`grid, map and gantt no longer honour it for "${name}"`, () => { + for (const site of SITES.filter((s) => s.arm === 'view-data')) { + expect(after(site, schema), site.id).toEqual(ladderBelowRungOne(schema)); + // The named regression: the lift is gone, not relocated. + expect(after(site, schema), site.id).not.toEqual({ + provider: 'value', + items: schema.data, + }); + } + // ⛔ CONTROL: the pre-8348 grid/map bodies transcribed at the top of this + // file DID lift it — so the rows above are a change in behaviour, not a + // restatement of what was already true. + expect(beforeGrid(schema)).toEqual({ provider: 'value', items: schema.data }); + expect(beforeMap(schema)).toEqual({ provider: 'value', items: schema.data }); }); - it(`gantt, tree and calendar still return it verbatim for "${name}"`, () => { - expect(resolveRecordSourceConfig(schema)).toBe(schema.data); - expect(resolveRecordSourceConfig(schema)).toEqual(beforeBare(schema)); - expect(resolveRecordSourceConfig(schema)).toEqual(beforeCalendar(schema)); + it(`calendar and tree still return it verbatim for "${name}"`, () => { + for (const site of SITES.filter((s) => s.arm !== 'view-data')) { + expect(after(site, schema), site.id).toBe(schema.data); + } + // Unmoved against the pre-collapse bodies, which is the objectui#7632 + // neutrality claim still holding for these two sites. + expect(beforeBare(schema)).toBe(schema.data); + expect(beforeCalendar(schema)).toBe(schema.data); }); } - it('an empty array is truthy, which is why hoisting the head is neutral', () => { - // The whole neutrality of the hoist rests on this: `if (schema.data)` could - // never let an array fall through to rung 2 or 3, so checking the array - // FIRST cannot change which rung is taken. + it('an empty array is still truthy — so this is an ARM verdict, not a falsiness one', () => { + // `[]` was the case that made the old head's hoist neutral. It is also the + // case that proves the new rung 1 discriminates on SHAPE rather than on + // truthiness: a `view-data` site drops `data: []` even though it is truthy, + // and an `array` site keeps it. expect(Boolean([])).toBe(true); - expect(beforeGrid({ objectName: 'Y', staticData: [9], data: [] })).toEqual({ + const schema: Schema = { objectName: 'Y', staticData: [9], data: [] }; + expect(resolveRecordSourceConfig(schema as any, 'view-data')).toEqual({ provider: 'value', - items: [], + items: [9], }); + expect(resolveRecordSourceConfig(schema as any, 'array')).toBe(schema.data); }); }); +/** + * The other direction of the same ruling: the `{ provider, items }` config + * object on the block whose row is the ARRAY arm — the exact spelling + * objectui#8348 was filed about. + * + * `ComponentPropsMap['object-calendar'].data.safeParse({ provider: 'value', + * items: [] })` fails with `invalid_type … expected: 'array'`, so `os validate` + * and the save gate refuse it. As of this card the renderer refuses it too. + */ +describe('the `{ provider, items }` object on an ARRAY-armed block (objectui#8348)', () => { + const OFF_ARM: [string, Schema][] = [ + ['value-provider + objectName', { objectName: 'Y', data: { provider: 'value', items: [{ id: 1 }] } }], + ['value-provider alone', { data: { provider: 'value', items: [{ id: 1 }] } }], + ['value-provider + staticData', { staticData: [9], data: { provider: 'value', items: [{ id: 1 }] } }], + ['object-provider + objectName', { objectName: 'Y', data: { provider: 'object', object: 'X' } }], + ]; + + for (const [name, schema] of OFF_ARM) { + it(`is not a record source for "${name}"`, () => { + expect(resolveRecordSourceConfig(schema as any, 'array')).toEqual( + ladderBelowRungOne(schema), + ); + expect(resolveRecordSourceConfig(schema as any, 'array')).not.toBe(schema.data); + }); + + it(`⛔ CONTROL: the same document IS a record source on a view-data arm for "${name}"`, () => { + // Without this leg, a rung 1 that had simply stopped working would satisfy + // every assertion above. + expect(resolveRecordSourceConfig(schema as any, 'view-data')).toBe(schema.data); + }); + } +}); + /** * `ObjectCalendar`'s `in` guards, on the schema shape that motivated them: * `CalendarSchema` declares neither `data` nor `staticData`, so the guard is a @@ -200,7 +323,7 @@ describe('the `in`-guard divergence is type-level, not behavioural (objectui#763 for (const [name, schema] of CALENDAR_IN_GUARD) { it(`the guarded and unguarded ladders agree for "${name}"`, () => { - expect(resolveRecordSourceConfig(schema)).toEqual(beforeCalendar(schema)); + expect(resolveRecordSourceConfig(schema as any, 'array')).toEqual(beforeCalendar(schema)); expect(beforeCalendar(schema)).toEqual(beforeBare(schema)); }); } diff --git a/packages/core/src/utils/record-source.ts b/packages/core/src/utils/record-source.ts index ca4e209a4e..7cdbbe7638 100644 --- a/packages/core/src/utils/record-source.ts +++ b/packages/core/src/utils/record-source.ts @@ -68,7 +68,7 @@ import type { ViewData } from '@object-ui/types'; * * @example * ```ts - * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]); + * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema, 'view-data'), [schema]); * const objectName = resolveRecordSourceObjectName(schema, dataConfig); * ``` */ @@ -79,9 +79,54 @@ export function resolveRecordSourceObjectName( return dataConfig?.provider === 'object' ? dataConfig.object : schema?.objectName; } +/** + * Which arm of `data` a block's PUBLISHED row declares — the only shape rung 1 + * of the ladder honours for that block (objectui#8348). + * + * Maintainer ruling, decision batch #83 (2026-09-08), verbatim: 「8348 以协议为准」 + * — the contract decides. A renderer honours the `data` spelling its block's + * published row declares and no other: what `os validate` and the save gate + * refuse, the renderer refuses too. + * + * - `'view-data'` — the row is the spec's `ViewData` discriminated union, four + * strict OBJECT arms on `provider`. A bare array under `data` is NOT a record + * source for such a block. + * - `'array'` — the row is `z.array(...)`: an array of PRE-FETCHED RECORDS. The + * `{ provider, items }` config object is NOT a record source for such a + * block. + * - `'undeclared'` — no published face declares a `data` row for the block at + * all, so neither arm of the ruling reaches it and rung 1 keeps its pre-8348 + * verbatim behaviour. ⛔ NOT a tolerance to copy: it is the honest answer for + * a block the ruling does not decide, and it is reported rather than guessed. + * + * The arm is passed BY THE CALL SITE rather than looked up from `schema.type` + * on purpose. Every one of these renderers is registered twice — `object-grid` + * and the `view:grid` alias `grid`, `object-calendar` and `calendar`, and so on + * — so a node reaches the same component under either spelling, and a table + * keyed by `type` would answer for one tag and silently miss the other. A + * REQUIRED parameter makes the arm a compile-time obligation at each of the + * five sites instead. + */ +export type RecordSourceDataArm = 'view-data' | 'array' | 'undeclared'; + +/** + * Does the authored `data` match the arm this block's published row declares? + * + * Falsy `data` is never a record source — the pre-8348 `if (schema.data)` + * truthiness test, kept, so `data: null` and `data: undefined` still fall + * through to `staticData`. + */ +function authoredDataIsOnTheDeclaredArm(authored: unknown, arm: RecordSourceDataArm): boolean { + if (!authored) return false; + if (arm === 'array') return Array.isArray(authored); + if (arm === 'view-data') return !Array.isArray(authored); + return true; +} + /** * The block's record source, resolved from the ruled three-rung ladder - * (objectui#7632). + * (objectui#7632), with rung 1 judged against the block's own published `data` + * row (objectui#8348). * * ## The ruled contract this is the ONE implementation of * @@ -89,9 +134,9 @@ export function resolveRecordSourceObjectName( * published contract and pinned by * `objectql-record-source-refinement-6939.test.ts`: * - * 1. **`data`** — *"Data source configuration. Read FIRST by `getDataConfig`"*. - * Returned verbatim, so an `api`/`value`/`object` provider config reaches - * the caller exactly as the author wrote it. + * 1. **`data`** — *"Data source configuration. Read FIRST by `getDataConfig`"*, + * honoured ONLY on the arm `dataArm` names. Returned verbatim, so a config + * on the declared arm reaches the caller exactly as the author wrote it. * 2. **`staticData`** — *"Inline records — read SECOND by `getDataConfig`, * wrapped into a `{ provider: value }` config"*. * 3. **`objectName`** — *"the THIRD record source `getDataConfig` resolves, @@ -107,22 +152,42 @@ export function resolveRecordSourceObjectName( * together, which is the AGENTS.md #0.1 drift class: a change to the ruled * order had five edit sites and nothing noticed a missed one. * + * ## Rung 1 is judged against the block's own row (objectui#8348) + * + * ⛔ This docblock used to state, as a fact about the whole ladder, that *"an + * array under `data` cannot be published"*. That is true of the blocks whose row + * is `ViewData` and FALSE of `object-calendar`, whose published row + * (`ComponentPropsMap['object-calendar'].data` on `@objectstack/spec` 17.4.0) is + * `z.array(z.unknown()).optional()` — *"Pre-fetched records — skips the internal + * fetch"*. Both directions of that disagreement were live at once: the renderers + * honoured the `{ provider, items }` object on a block whose row refuses it by + * kind, and `ObjectGrid` / `ObjectMap` lifted a bare array on blocks whose row + * refuses THAT by kind. Decision batch #83 settled it — the row decides — and + * `dataArm` is where each block says which row it has. + * + * MEASURED, per block, at the version this repo resolves: + * + * - `object-grid` — `ComponentPropsMap['object-grid'].data` is the `ViewData` + * union, and its own description names the refusal: *"the bare-array shortcut + * is refused — see migration `object-grid-data-view-data-converged`"*. + * ⇒ `'view-data'`, and the site's normalizing head is gone. + * - `object-calendar` — `z.array(z.unknown()).optional()`, and the registration + * publishes the same arm (`{ name: 'data', type: 'array' }`). ⇒ `'array'`. + * - `object-map`, `object-gantt` — no `ComponentPropsMap` row exists for either + * block; the published row that governs them is this repo's own + * `ObjectMapSchema.data` / `ObjectGanttSchema.data`, both + * `ViewDataSchema.optional()`. ⇒ `'view-data'`, and `ObjectMap`'s normalizing + * head is gone too. + * - `object-tree` — NO published face declares a `data` row: not + * `ComponentPropsMap`, not `ObjectTreeSchema` (which declares `objectName` + * REQUIRED and no `data`), not the registration's `inputs`. Neither arm of + * the ruling reaches it, so it passes `'undeclared'` and nothing about it + * changes here. + * * ## No lenient rung was added (AGENTS.md #0.1) * - * Two things the hand-copies did are deliberately NOT folded in here: - * - * - **The bare-array `data` shorthand.** `ObjectGrid` and `ObjectMap` normalize - * `data: [...]` to `{ provider: 'value', items }`; calendar, gantt and tree - * do not, and return the array verbatim. That shorthand is off-contract — - * `ViewData` is a `z.discriminatedUnion('provider', [...])` over OBJECT - * variants, so an array under `data` cannot be published — and the two sites - * that accept it keep it as their own documented head, exactly as the - * objectui#7627 collapse left `ObjectGrid`'s and `ObjectTree`'s off-contract - * `{ provider: 'object' }` tails at the site. Hoisting their check is - * behaviour-neutral because an array is ALWAYS truthy, `[]` included, so it - * could never have reached rung 2 or 3. - * - **Null tolerance.** All five copies dereference `schema` unguarded and - * would throw on `null`; no site passes one, so no `?.` was added. + * **Null tolerance** stays out: all five copies dereference `schema` unguarded + * and would throw on `null`; no site passes one, so no `?.` was added. * * `ObjectCalendar`'s copy guarded with `'data' in schema && schema.data` * because its parameter is the union `ObjectGridSchema | CalendarSchema` and @@ -135,21 +200,27 @@ export function resolveRecordSourceObjectName( * * @param schema - The block's schema; only `data`, `staticData` and * `objectName` are read. + * @param dataArm - The arm the CALLING BLOCK's published `data` row declares. + * Required: there is no repo-wide default, because the answer differs per + * block and a default is how the second de-facto contract got in. * @returns The resolved data config, or `null` when nothing is bound. * * @example * ```ts - * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]); + * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema, 'view-data'), [schema]); * const objectName = resolveRecordSourceObjectName(schema, dataConfig); * ``` */ -export function resolveRecordSourceConfig(schema: { - objectName?: string; - data?: ViewData; - staticData?: any[]; -}): ViewData | null { - if (schema.data) { - return schema.data; +export function resolveRecordSourceConfig( + schema: { + objectName?: string; + data?: ViewData; + staticData?: any[]; + }, + dataArm: RecordSourceDataArm, +): ViewData | null { + if (authoredDataIsOnTheDeclaredArm(schema.data, dataArm)) { + return schema.data as ViewData; } if (schema.staticData) { diff --git a/packages/plugin-calendar/src/ObjectCalendar.discardedConfigMemo.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.discardedConfigMemo.test.tsx index 8d071e58aa..3c253b5e1f 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.discardedConfigMemo.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.discardedConfigMemo.test.tsx @@ -30,6 +30,7 @@ import React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { resolveRecordSourceConfig } from '@object-ui/core'; import { ObjectCalendar } from './ObjectCalendar'; afterEach(cleanup); @@ -51,13 +52,31 @@ const CALENDAR = { startDateField: 'starts_at', titleField: 'name' }; describe('ObjectCalendar — record-fetch effect survives a discarded `dataConfig` memo (objectui#6592)', () => { it('does not re-fire the fetch when `dataConfig` recomputes to a new identity with the SAME primitive fields', async () => { const dataSource = makeDataSource(); - // Two different `data` object references, byte-identical content — forces - // `dataConfig`'s own memo to recompute to a NEW object (its `data` dep is - // compared by reference) while `provider`/`object` stay unchanged. - const schemaA: any = { type: 'object-calendar', calendar: CALENDAR, data: { provider: 'object', object: 'visit' } }; - const schemaB: any = { type: 'object-calendar', calendar: CALENDAR, data: { provider: 'object', object: 'visit' } }; - expect(schemaA.data).not.toBe(schemaB.data); - expect(schemaA.data).toEqual(schemaB.data); + // Two different SCHEMA object references, byte-identical content — enough to + // force `dataConfig`'s own memo to recompute to a NEW config object while + // `provider`/`object` stay unchanged, which is the condition this file is + // about. + // + // Spelled through `objectName` rather than `data: { provider: 'object', + // object: 'visit' }` since objectui#8348: `ComponentPropsMap`'s + // `object-calendar.data` row is `z.array(...)`, so the config OBJECT is no + // longer a record source on this block and the ladder resolves rung 3 + // instead. Rung 3 builds `{ provider: 'object', object: 'visit' }` FRESH on + // every call, so the discarded-memo condition is reproduced exactly as + // before — the resolved config is identical and only its identity moves. + const schemaA: any = { type: 'object-calendar', calendar: CALENDAR, objectName: 'visit' }; + const schemaB: any = { type: 'object-calendar', calendar: CALENDAR, objectName: 'visit' }; + // ⛔ The instrument check this file rests on: the two schemas really are + // distinct references carrying equal content, so a re-render with the second + // one really does hand the memo a changed dependency. + expect(schemaA).not.toBe(schemaB); + expect(schemaA).toEqual(schemaB); + expect(resolveRecordSourceConfig(schemaA, 'array')).not.toBe( + resolveRecordSourceConfig(schemaB, 'array'), + ); + expect(resolveRecordSourceConfig(schemaA, 'array')).toEqual( + resolveRecordSourceConfig(schemaB, 'array'), + ); const { rerender } = render(); await waitFor(() => expect(screen.getByText('Site visit')).toBeTruthy()); diff --git a/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx index 6146d87ea4..ecf9cf755d 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx @@ -84,10 +84,12 @@ async function openDrawer(navigation?: Record) { type: 'object-calendar', objectName: 'events', calendar: { startDateField: 'starts_at', titleField: 'name' }, - data: { - provider: 'value', - items: [{ id: '1', name: 'On the calendar', starts_at: eventInCurrentMonth() }], - }, + // `staticData`, not `data: { provider: 'value', items }`: since + // objectui#8348 this block's published `data` row is `z.array(...)`, + // so the config object is not a record source here. `staticData` is + // the block's declared door for inline rows and resolves to the same + // `{ provider: 'value', items }` config one rung down. + staticData: [{ id: '1', name: 'On the calendar', starts_at: eventInCurrentMonth() }], ...(navigation ? { navigation } : {}), } as never} />, diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 5ede88b21f..a2bf529316 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -300,7 +300,20 @@ export const ObjectCalendar: React.FC = ({ enabled: !!dataSource && !!schema.objectName, }); - const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [ + // `'array'` — the arm `object-calendar`'s published `data` row declares + // (objectui#8348, decision batch #83, 「8348 以协议为准」). MEASURED on + // `@objectstack/spec` 17.4.0: `ComponentPropsMap['object-calendar'].data` is + // `z.array(z.unknown()).optional()`, *"Pre-fetched records — skips the + // internal fetch"*, and this package's own registration publishes the same + // arm (`{ name: 'data', type: 'array' }` in `index.tsx`). So the + // `{ provider, items }` config object — which that row refuses by KIND, and + // which `os validate` and the save gate therefore refuse — is no longer a + // record source here either. An authored ARRAY is unchanged: it still reaches + // this ladder verbatim (so `staticData` and `objectName` stay unreached, as + // the registration's description promises) AND reaches the component as the + // `data` PROP through `index.tsx`'s `resolveExternalData`, which is what + // actually draws it. + const dataConfig = useMemo(() => resolveRecordSourceConfig(schema, 'array'), [ (schema as any).data, (schema as any).staticData, schema.objectName, diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.dataObjectArmRefused-8348.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.dataObjectArmRefused-8348.test.tsx new file mode 100644 index 0000000000..b4972c095c --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.dataObjectArmRefused-8348.test.tsx @@ -0,0 +1,212 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8348 — `object-calendar` honours ONLY the `data` spelling its + * published row declares. + * + * ## The ruling this pins + * + * Decision batch #83 (2026-09-08), maintainer verbatim: 「8348 以协议为准」 — the + * CONTRACT decides. A block whose `ComponentPropsMap[...].data` row is + * `z.array(...)` stops honouring the `{ provider, items }` record-source config + * object under `data`: what `os validate` and the save gate refuse, the renderer + * refuses too. + * + * MEASURED on the `@objectstack/spec` this repo resolves (17.4.0): + * + * ComponentPropsMap['object-calendar'].data + * -> z.array(z.unknown()).optional() "Pre-fetched records — skips the internal fetch" + * ...safeParse({ objectName: 'visit', data: { provider: 'value', items: [] } }) + * -> success=false [{ code: 'invalid_type', path: ['data'], expected: 'array' }] + * + * ## What this file adds that the shared-ladder pin cannot + * + * `record-source-config.behaviourNeutrality-7632.test.ts` measures the resolver + * in isolation — the right place for the arm verdict itself, and the wrong place + * for "does the block still draw". This file is the card's OWN harness: a real + * `object-calendar` node, through the real `SchemaRenderer`, with a recording + * data source. objectui#8348 was filed on exactly this instrument, and its + * finding read + * + * OBJECT-DATA grid present: true + * OBJECT-DATA titles: ['Authored member one', 'Authored member two'] + * OBJECT-DATA find calls: 0 + * + * — rows drawn from a spelling the contract refuses, and no query. Rows 1 and 2 + * below are that same measurement after the ruling. + * + * ## ⛔ Why the `data` arm and the PROPS channel are BOTH exercised here + * + * `SchemaRenderer` spreads every non-metadata node key as a React prop, so an + * authored `data` ARRAY arrives twice: as `rest.data` at this package's renderer + * boundary (`index.tsx`'s `resolveExternalData`, which keeps it only when + * `Array.isArray`) AND as `schema.data` inside the component. The + * `{ provider, items }` OBJECT arrives only on the second carrier — the boundary + * drops it — which is why removing the ladder's off-arm rung removes it + * outright. Rows 3-5 hold the declared carriers still, so a change that broke + * inline rows generally could not pass this file off as the ruling. + * + * ⚠️ THE ACCEPTED COST, stated as row 2: a stored calendar authored + * `data: { provider: 'value', items: [...] }` stops drawing those rows and + * queries its object instead. Batch #83 accepts that under the standing + * 2026-08-27 posture — no transition windows, no staged deprecation. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; + +// The month grid is orthogonal to what this file observes (which records became +// events, and whether a query went out). Same stub idiom as the +// `recordSourceMembers-8314` pin next door. +vi.mock('../CalendarView', () => ({ + CalendarView: ({ events }: any) => ( +
e.title).join('|')} + /> + ), +})); + +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module scope, not a hook: this import IS the registration this file renders +// through (AGENTS.md's test-discipline section). +import '../index'; + +afterEach(cleanup); + +const OBJECT = 'visit'; +const CALENDAR = { startDateField: 'starts_at', titleField: 'name' }; + +const today = new Date(); +const inThisMonth = (d: number) => + new Date(today.getFullYear(), today.getMonth(), Math.min(d, 28), 9, 0, 0, 0).toISOString(); + +/** The rows an author put in the metadata. */ +const AUTHORED = [ + { id: 'r1', name: 'Authored member one', starts_at: inThisMonth(10) }, + { id: 'r2', name: 'Authored member two', starts_at: inThisMonth(12) }, +]; + +/** The row only a real query can put on screen — the discriminator. */ +const FETCHED = [{ id: 'q1', name: 'Fetched by the query', starts_at: inThisMonth(20) }]; + +function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: FETCHED }), + getObjectSchema: vi.fn().mockResolvedValue({ + name: OBJECT, + fields: { id: { type: 'text' }, name: { type: 'text' }, starts_at: { type: 'datetime' } }, + }), + } as any; +} + +/** + * A node for the `plugin-calendar:object-calendar` registration, always carrying + * a live `objectName` so every reading below is about the authored record source + * rather than about a calendar with nothing to fetch. + */ +const node = (extra: Record) => + ({ + type: 'plugin-calendar:object-calendar', + id: 'n', + objectName: OBJECT, + calendar: CALENDAR, + ...extra, + }) as never; + +function renderNode(schema: unknown, dataSource: any) { + return render( + + + , + ); +} + +async function titles(): Promise { + await waitFor(() => expect(screen.getByTestId('calendar-view')).toBeTruthy()); + return (screen.getByTestId('calendar-view').getAttribute('data-event-titles') ?? '') + .split('|') + .filter(Boolean); +} + +/** The config object the spec row refuses by kind. */ +const OFF_ARM = { provider: 'value', items: AUTHORED }; + +describe('object-calendar refuses the off-arm `{ provider, items }` under `data` (objectui#8348)', () => { + it('1. the authored rows are NOT drawn — the card’s own measurement, inverted', async () => { + const ds = makeDataSource(); + renderNode(node({ data: OFF_ARM }), ds); + + expect(await titles()).not.toContain('Authored member one'); + expect(await titles()).not.toContain('Authored member two'); + }); + + it('2. …and the block queries its object instead — the accepted cost, on screen', async () => { + // Before this card the same document drew the authored rows and issued ZERO + // queries. The ladder now falls past the off-arm `data` to `objectName`, so + // the calendar resolves the `object` provider and fetches. + const ds = makeDataSource(); + renderNode(node({ data: OFF_ARM }), ds); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(await titles()).toEqual(['Fetched by the query']); + expect(ds.find.mock.calls[0][0]).toBe(OBJECT); + }); + + it('3. ⛔ CONTROL: the DECLARED array under `data` still draws, and still issues no query', async () => { + // The arm the spec row names — "Pre-fetched records — skips the internal + // fetch". If this row went dark, rows 1-2 would be measuring a broken + // calendar rather than the ruling. It is also what keeps the registration's + // published promise true: with an array `data`, `objectName` is unused and + // `staticData` is never reached. + const ds = makeDataSource(); + renderNode(node({ data: AUTHORED }), ds); + + expect(await titles()).toEqual(['Authored member one', 'Authored member two']); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('4. ⛔ CONTROL: `staticData` — the declared inline-rows door — still draws', async () => { + const ds = makeDataSource(); + renderNode(node({ staticData: AUTHORED }), ds); + + expect(await titles()).toEqual(['Authored member one', 'Authored member two']); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('5. ⛔ CONTROL: the off-arm `data` does not suppress `staticData` either', async () => { + // The ladder does not merely drop the rows: rung 1 no longer APPLIES on this + // block, so rung 2 is reached. A reader who assumed "refused" meant "resolves + // null" would predict an empty calendar here. + const ds = makeDataSource(); + renderNode(node({ data: OFF_ARM, staticData: AUTHORED }), ds); + + expect(await titles()).toEqual(['Authored member one', 'Authored member two']); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('6. the spec row is what all of the above is judged against — read, not restated', async () => { + // Derived rather than asserted as a constant: a spec release that widened + // `object-calendar.data` to accept the object arm would turn this red, which + // is the tripwire that says "re-derive the arm", not "delete this line". + const { ComponentPropsMap } = await import('@objectstack/spec/ui'); + const row = ComponentPropsMap['object-calendar']; + + expect(row.safeParse({ objectName: OBJECT, data: AUTHORED }).success).toBe(true); + + const refused = row.safeParse({ objectName: OBJECT, data: OFF_ARM }); + expect(refused.success).toBe(false); + if (!refused.success) { + const issue = refused.error.issues.find((i: any) => i.path[0] === 'data'); + expect(issue?.code).toBe('invalid_type'); + expect((issue as any)?.expected).toBe('array'); + } + }); +}); diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx index b8874d53b8..f3f1cdbc37 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx @@ -374,7 +374,11 @@ describe('ObjectCalendar gates its standalone query on the object schema (object // resolution nothing was going to produce. This is the deadlock pin. const adapter = resolvesSchema(); const { findAllByTestId } = renderCalendar(adapter, { - data: { provider: 'value', items: [{ ...ROW, _from: 'inline' }] }, + // `staticData` since objectui#8348 — this block's published `data` row is + // `z.array(...)`, so `{ provider: 'value', items }` is no longer a record + // source here. Rung 2 wraps `staticData` into that same config, so the + // inline-`value` branch this test is about is reached exactly as before. + staticData: [{ ...ROW, _from: 'inline' }], }); const events = await findAllByTestId('event'); diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.navigationRecordSource-7638.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.navigationRecordSource-7638.test.tsx index 0d238c8f92..552e87cc01 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.navigationRecordSource-7638.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.navigationRecordSource-7638.test.tsx @@ -105,28 +105,58 @@ describe('ObjectCalendar navigation URL follows the record source (objectui#7638 expect(open).toHaveBeenCalledWith(`/${DECOY}/record/e1`, '_blank'); }); - it('navigates to the object the ROWS came from, not the top-level key', async () => { + it('⭐ objectui#8348 — an off-arm `data` no longer re-points this block, so rung three answers', async () => { + // ⚠️ THE RULED CHANGE, and the accepted cost stated as a row. Decision batch + // #83 (2026-09-08, maintainer verbatim 「8348 以协议为准」): a block honours the + // `data` spelling its published row declares, and + // `ComponentPropsMap['object-calendar'].data` is `z.array(z.unknown())` — + // it refuses `{ provider, object }` by KIND, with `os validate` and the save + // gate refusing it too. So on THIS block the config object is not a record + // source at all, and the URL names the top-level key. + // + // ⛔ Before the ruling this same document answered `/clinic_visit/record/e1`. + // objectui#7638's finding is untouched — the URL still follows whatever the + // ladder RESOLVES; what moved is what this block's ladder resolves. renderCalendar({ objectName: DECOY, data: { provider: 'object', object: RECORD_SOURCE }, }); const url = await clickEventAndReadUrl(open); - expect(url).toBe(`/${RECORD_SOURCE}/record/e1`); - // The whole finding in one line: before objectui#7638 this was the answer, - // while the drawer on the very same click resolved `clinic_visit`. - expect(url).not.toBe(`/${DECOY}/record/e1`); + expect(url).toBe(`/${DECOY}/record/e1`); + // The named regression, in the direction the ruling moved it: the object the + // off-arm `data` block names is NOT what the URL resolves any more. + expect(url).not.toBe(`/${RECORD_SOURCE}/record/e1`); }); - it('builds a routed URL for a data-only block, which previously had no name to use', async () => { - // No top-level `objectName` at all, so `schema.objectName` was `undefined` - // and the hook took its `/${encodedId}` leg — an unrouted path that paints - // a blank page. + it('⭐ objectui#8348 — a `data`-only block has NO record source here, and the URL says so', async () => { + // The second half of the accepted cost, and the sharper one. A calendar + // whose only binding is `data: { provider, object }` resolves NOTHING once + // the off-arm rung is gone: no `staticData`, no `objectName`, so + // `resolveRecordSourceConfig` returns `null` and the hook takes its + // `/${encodedId}` leg again — the unrouted path objectui#7638 removed for + // this shape. + // + // ⛔ NOT a regression of objectui#7638: that card's fix reads whatever the + // ladder resolves, and this document no longer resolves anything. It is + // also not publishable — `ComponentPropsMap['object-calendar']` refuses the + // object under `data` by kind, so `os validate` and the save gate reject it + // — which is exactly the reasoning batch #83 accepted the cost on. Rows + // still draw, because they arrive on the props channel. renderCalendar({ data: { provider: 'object', object: RECORD_SOURCE } }); const url = await clickEventAndReadUrl(open); - expect(url).toBe(`/${RECORD_SOURCE}/record/e1`); - expect(url).not.toBe('/e1'); + expect(url).toBe('/e1'); + expect(url).not.toBe(`/${RECORD_SOURCE}/record/e1`); + }); + + it('…and the DECLARED spellings still route: `objectName`, and `staticData` beside it', async () => { + // ⛔ The control that stops the two rows above from reading as "navigation + // broke". Both of this block's published record-source doors still build a + // routed URL. + renderCalendar({ objectName: RECORD_SOURCE, staticData: ROWS }); + + expect(await clickEventAndReadUrl(open)).toBe(`/${RECORD_SOURCE}/record/e1`); }); it('keeps the `?? schema.objectName` tail for the OFF-CONTRACT `{ provider: "object" }`', async () => { diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 21bb86c7bd..7d99caa88f 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -578,7 +578,17 @@ export const ObjectGantt: React.FC = ({ }); }, [t]); - const rawDataConfig = resolveRecordSourceConfig(schema); + // `'view-data'` — the arm `object-gantt`'s published `data` row declares + // (objectui#8348). MEASURED: `@objectstack/spec` 17.4.0 has NO + // `ComponentPropsMap['object-gantt']` row at all, so the published row that + // governs this block is this repo's own `ObjectGanttSchema.data` + // (`@object-ui/types`), `ViewDataSchema.optional()` — the discriminated union + // over four strict OBJECT arms. A bare array under `data` is not on it, so it + // is no longer a record source and the ladder falls through to `staticData` / + // `objectName`; it was inert before (it carried no `provider`, so no fetch + // branch below ever matched it), which is why nothing a published document + // can express moves here. + const rawDataConfig = resolveRecordSourceConfig(schema, 'view-data'); // Memoize dataConfig using deep comparison to prevent infinite loops const dataConfig = useMemo(() => { return rawDataConfig; diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 9d9d3fceae..f3a82145b7 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -524,28 +524,45 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr * implementation of a contract published on both faces (objectui#6939), which * this file used to hand-copy (objectui#7632). * - * What stays here is the head above it: the bare-array `data` shorthand. It is - * OFF-CONTRACT — `ViewData` is a `z.discriminatedUnion('provider', [...])` over - * object variants, so an array under `data` cannot be published — and only this - * block and `ObjectMap` normalize it inside their ladder; calendar, gantt and - * tree return the array verbatim. So it is kept at the site rather than folded - * into the shared rung, exactly as the objectui#7627 collapse left this file's - * off-contract `{ provider: 'object' }` tail at the site (AGENTS.md #0.1). + * What used to stay here was the head above it: the bare-array `data` + * shorthand, which lifted `data: [...]` to `{ provider: 'value', items }`. + * ⛔ IT IS GONE (objectui#8348, decision batch #83, maintainer verbatim + * 「8348 以协议为准」 — the contract decides). * - * Hoisting the check above the shared call is behaviour-neutral: an array is - * ALWAYS truthy, `[]` included, so `if (schema.data)` could never have let one - * fall through to `staticData` or `objectName`. + * MEASURED on `@objectstack/spec` 17.4.0: + * `ComponentPropsMap['object-grid'].data` is the `ViewData` union, and its own + * description names the refusal — *"Static inline rows live at + * `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — + * see migration `object-grid-data-view-data-converged`"*. This block's own + * registration publishes the same arm (`{ name: 'data', type: 'object' }` in + * `index.tsx`), and `gridDataInputContract.test.ts` has pinned that declaration + * since objectui#5090. The head was the last carrier of a spelling every one of + * those faces refuses, so `data` is honoured here on the OBJECT arm only and + * the shared rung is passed `'view-data'`. + * + * ⛔ WHAT THIS REACHES, measured per CARRIER — do NOT read it as "the array is + * gone". An authored `data` array reaches this component TWICE: as + * `schema.data`, which this function used to lift, and as the `data` PROP, + * because `SchemaRenderer` spreads every non-metadata node key and + * `index.tsx` forwards `{...rest}`. That prop is `passedData` below, and it + * lifts an array to `{ provider: 'value', items }` at HIGHER priority than this + * ladder — it is the channel a host such as `ListView` uses to hand down rows it + * already fetched, and it is indistinguishable here from an authored key. + * + * ⇒ at the ladder the array is no longer a record source; through + * `SchemaRenderer` an authored `data: [ …rows… ]` still draws, from the props + * channel. Both halves are pinned in + * `__tests__/gridBareArrayDataRefused-8348.test.tsx`, which had to correct its + * own first draft on exactly this point. Collapsing the two carriers would take + * the host path with it and is outside objectui#8348's scope — reported on the + * card, not changed in passing. + * + * The declared spelling for inline rows is + * `data: { provider: 'value', items: [...] }`, and the deprecated `staticData` + * array still works as before. */ function getDataConfig(schema: ObjectGridSchema): ViewData | null { - // Array shorthand -> the declared `value` provider (see docblock above). - if (Array.isArray(schema.data)) { - return { - provider: 'value', - items: schema.data, - }; - } - - return resolveRecordSourceConfig(schema); + return resolveRecordSourceConfig(schema, 'view-data'); } /** diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.unmaterializedSort.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.unmaterializedSort.test.tsx index 4d0d86e9d4..3b344ae983 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.unmaterializedSort.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.unmaterializedSort.test.tsx @@ -177,10 +177,17 @@ describe('ObjectGrid — the sort header is withheld from an unmaterialized colu it('keeps the formula header live where the sort never leaves the browser', async () => { const ds = makeDataSource(); const { container } = renderGrid(ds, { - data: [ - { id: 'a', name: 'A', amount: 10, probability: 20, expected_revenue: 2 }, - { id: 'b', name: 'B', amount: 30, probability: 40, expected_revenue: 12 }, - ], + // objectui#8348 — the DECLARED inline-rows spelling. `object-grid`'s + // published `data` row is the `ViewData` union ("the bare-array shortcut + // is refused"), so `getDataConfig` no longer lifts a bare array; this form + // resolves to the same config and this row still sorts in the browser. + data: { + provider: 'value', + items: [ + { id: 'a', name: 'A', amount: 10, probability: 20, expected_revenue: 2 }, + { id: 'b', name: 'B', amount: 30, probability: 40, expected_revenue: 12 }, + ], + }, }); await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument()); diff --git a/packages/plugin-grid/src/__tests__/columnDeclaredSpellingOnly.test.tsx b/packages/plugin-grid/src/__tests__/columnDeclaredSpellingOnly.test.tsx index 60ec3adcad..a23f540d7c 100644 --- a/packages/plugin-grid/src/__tests__/columnDeclaredSpellingOnly.test.tsx +++ b/packages/plugin-grid/src/__tests__/columnDeclaredSpellingOnly.test.tsx @@ -64,7 +64,16 @@ const ROWS = [ function renderGrid(columns: unknown[]) { return render( - + {/* + objectui#8348 — the DECLARED inline-rows spelling. `object-grid`'s + published `data` row is the `ViewData` union ("the bare-array shortcut + is refused"), so `getDataConfig` no longer lifts a bare array; + `{ provider: 'value', items }` resolves to the same config this file has + always exercised. + */} + , ); } diff --git a/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx b/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx index c3aea5e6b5..518ec6e73c 100644 --- a/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx +++ b/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx @@ -77,13 +77,29 @@ function renderGrid( }; } +/** + * ⭐ objectui#8348 — the SCHEMA-carried inline rows below are spelled + * `data: { provider: 'value', items: ROWS }`, not the bare `data: ROWS` this + * file used to write. `object-grid`'s published `data` row is the `ViewData` + * union, whose own spec description says "the bare-array shortcut is refused", + * so `getDataConfig` no longer lifts an array. The declared form resolves to the + * identical config, which is why nothing this file measures moves. + * + * ⛔ TWO ROWS DELIBERATELY KEEP THE BARE ARRAY, and they are not oversights: + * the host-prop row passes rows through the `data` REACT PROP, a different + * carrier that requires an array (`passedData`'s `Array.isArray`) and is + * untouched by the ruling; and the row named for objectui#8348 keeps the bare + * array under the schema key precisely to assert that it no longer carries + * rows. + */ + afterEach(() => cleanup()); describe('objectui#5349 — the four-way matrix, rendering and diagnostic together', () => { it('field-only: renders both columns, says nothing', () => { const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ field: 'name', label: 'Name' }, { field: 'amount', label: 'Amount' }], }); expect(headers).toEqual(['#', 'Name', 'Amount']); @@ -97,7 +113,7 @@ describe('objectui#5349 — the four-way matrix, rendering and diagnostic togeth // columns, and no error, no warning, no empty state. const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ accessorKey: 'name', header: 'Name' }, { accessorKey: 'amount', header: 'Amount' }], }); expect(headers).toEqual(['#']); @@ -115,7 +131,7 @@ describe('objectui#5349 — the four-way matrix, rendering and diagnostic togeth it('mixed, declared first: drops the second — and names the second', () => { const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ field: 'name', label: 'Name' }, { accessorKey: 'amount', header: 'Amount' }], }); expect(headers).toEqual(['#', 'Name']); @@ -131,7 +147,7 @@ describe('objectui#5349 — the four-way matrix, rendering and diagnostic togeth // diagnostic inherits the per-column judgement, not the sniff. const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ accessorKey: 'amount', header: 'Amount' }, { field: 'name', label: 'Name' }], }); expect(headers).toEqual(['#', 'Name']); @@ -145,7 +161,7 @@ describe('objectui#5349 — the column-side fallbacks that keep their silence', it('a `string[]` column list is the other declared spelling: renders, says nothing', () => { const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: ['name', 'amount'], }); expect(headers).toEqual(['#', 'Name', 'Amount']); @@ -153,13 +169,13 @@ describe('objectui#5349 — the column-side fallbacks that keep their silence', }); it('no `columns` at all: the grid auto-derives them and is not scolded for it', () => { - const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', data: ROWS }); + const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', data: { provider: 'value', items: ROWS } }); expect(headers).toEqual(['#', 'Id', 'Name', 'Amount']); expect(diagnostics).toEqual([]); }); it('an empty `columns` array: same auto-derivation, same silence', () => { - const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', data: ROWS, columns: [] }); + const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', data: { provider: 'value', items: ROWS }, columns: [] }); expect(headers).toEqual(['#', 'Id', 'Name', 'Amount']); expect(diagnostics).toEqual([]); }); @@ -170,7 +186,7 @@ describe('objectui#5349 — the column-side fallbacks that keep their silence', // columns". The author asked for this one. const { headers, diagnostics } = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ field: 'name', label: 'Name', hidden: true }], }); expect(headers).toEqual(['#']); @@ -180,7 +196,7 @@ describe('objectui#5349 — the column-side fallbacks that keep their silence', cleanup(); const probe = renderGrid({ objectName: 'opportunities', - data: ROWS, + data: { provider: 'value', items: ROWS }, columns: [{ accessorKey: 'name', header: 'Name' }], }); expect(probe.headers).toEqual(['#']); @@ -198,10 +214,20 @@ describe('objectui#5349 — the row-side fallbacks a `needs columns` predicate w const GOOD = [{ field: 'name', label: 'Name' }]; const BAD = [{ accessorKey: 'name', header: 'Name' }]; - it('inline rows as a bare `data` array', () => { + it('⭐ objectui#8348 — a bare `data` array no longer carries inline rows, and the diagnostic is unmoved', () => { + // This row used to read "inline rows as a bare `data` array" and assert that + // `Ada` was on screen. `object-grid`'s published `data` row is the `ViewData` + // union — its own spec description says "the bare-array shortcut is refused" + // — so `getDataConfig` no longer lifts the array and this grid resolves its + // `objectName` instead (with no dataSource here, that draws no rows). + // + // The file's own subject is unaffected, which is the point of keeping the + // row: the column-spelling diagnostic is a statement about `columns`, not + // about the row carrier, so it stays silent for GOOD and fires once for BAD + // on a grid with no rows at all. const ok = renderGrid({ objectName: 'opportunities', data: ROWS, columns: GOOD }); expect(ok.headers).toEqual(['#', 'Name']); - expect(screen.getByText('Ada')).toBeInTheDocument(); + expect(screen.queryByText('Ada')).toBeNull(); expect(ok.diagnostics).toEqual([]); cleanup(); diff --git a/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx b/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx index 3cfc0d68b8..e365bc0f5e 100644 --- a/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx +++ b/packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx @@ -56,7 +56,11 @@ function renderTyped(type: string) { ) { + const adapter = makeAdapter(); + render( + + + , + ); + return adapter; +} + +describe('object-grid refuses the bare-array `data` shorthand (objectui#8348)', () => { + it('1. at the LADDER: a bare array under `data` is no longer a record source', async () => { + // The component directly, with NO `data` prop — the only lens through which + // the ladder's verdict is observable at all (see the two-carrier note above). + const adapter = makeAdapter(); + render( + , + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(screen.queryByText('Authored row')).toBeNull(); + }); + + it('2. …and the ladder falls through to `objectName`, so the block queries', async () => { + // Before this card `getDataConfig`'s `Array.isArray(schema.data)` head lifted + // the array to `{ provider: 'value', items }` and this render issued ZERO + // queries. + const adapter = makeAdapter(); + render( + , + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(adapter.find.mock.calls[0][0]).toBe('account'); + expect(await screen.findByText('Fetched by the query')).toBeTruthy(); + }); + + it('3. ⛔ REPORTED, NOT CHANGED: through `SchemaRenderer` the array still draws, from the PROPS channel', async () => { + // The measurement that corrected this file's own first draft. The authored + // key reaches the component twice, and the surviving carrier is the one a + // host legitimately uses — so the node still renders its rows and issues no + // query. Removing THAT carrier would take the host path with it and is + // outside objectui#8348's scope; it is reported on the card. + const adapter = renderGrid({ data: AUTHORED }); + + expect(await screen.findByText('Authored row')).toBeTruthy(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('4. ⛔ CONTROL: the DECLARED `{ provider: value, items }` form still draws, with no query', async () => { + const adapter = renderGrid({ data: { provider: 'value', items: AUTHORED } }); + + expect(await screen.findByText('Authored row')).toBeTruthy(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('5. ⛔ CONTROL: the deprecated `staticData` array still draws, with no query', async () => { + // `staticData` is this block's DECLARED array-shaped door for inline rows + // ("Deprecated bare-array static-rows shortcut the renderer still reads" on + // the spec row). The ruling retires the array under `data`, not this key — + // and without this control, rows 1-2 would also pass on a grid that had + // simply stopped drawing inline rows at all. + const adapter = renderGrid({ staticData: AUTHORED }); + + expect(await screen.findByText('Authored row')).toBeTruthy(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('6. the spec row is what all of the above is judged against — read, not restated', async () => { + // Derived rather than asserted as a constant: a spec release that widened + // `object-grid.data` to accept an array would turn this red, which is the + // tripwire that says "re-derive the arm", not "delete this line". + const { ComponentPropsMap } = await import('@objectstack/spec/ui'); + const row = ComponentPropsMap['object-grid']; + + expect(row.safeParse({ objectName: 'account', data: { provider: 'value', items: [] } }).success) + .toBe(true); + + const refused = row.safeParse({ objectName: 'account', data: AUTHORED }); + expect(refused.success).toBe(false); + if (!refused.success) { + const issue = refused.error.issues.find((i: any) => i.path[0] === 'data'); + expect(issue?.code).toBe('invalid_type'); + expect((issue as any)?.expected).toBe('object'); + } + }); +}); diff --git a/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts b/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts index 12f416c954..263210a50c 100644 --- a/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts +++ b/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts @@ -24,8 +24,9 @@ * one field over: * * - an author following the published declaration wrote `data: [ …rows… ]`, - * which the renderer does honour (`getDataConfig`'s `Array.isArray` branch) - * but which `tsc` refuses (`TS2322`, measured) and `ViewDataSchema` refuses; + * which the renderer honoured at the time (`getDataConfig`'s `Array.isArray` + * branch, removed on objectui#8348) but which `tsc` refuses (`TS2322`, + * measured) and `ViewDataSchema` refuses; * - the one form that satisfies both — `{ provider: 'value', items: [...] }` — * drew `type-mismatch` from this repo's own save gate, because * `checkType`'s `'array'` arm (`sdui-parser/src/validate.ts`) accepts only @@ -43,17 +44,30 @@ * That derivation is the discipline `component-input-union-specimens.test.ts` * and `text-input-inputs-spec-parity.test.ts` adopted. It used to be the only * gate on this fact; objectui#4971 has since added the repo-wide arm direction to - * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. That one - * judges against `ComponentPropsMap['object-grid'].data`, which is `z.array()` + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. + * + * ⛔ CORRECTED on objectui#8348 — this docblock used to say that the parity gate + * "judges against `ComponentPropsMap['object-grid'].data`, which is `z.array()` * and DISAGREES with the `ViewDataSchema` this file measures — two spec - * authorities, opposite kinds, filed as objectui#6207 and carried there as a - * reasoned exemption. So this file stays the authority for the contract - * `ObjectGridSchema` actually resolves to, and the two are not redundant. + * authorities, opposite kinds, filed as objectui#6207". MEASURED on the + * `@objectstack/spec` this repo now resolves (17.4.0), that is no longer true: + * the two authorities CONVERGED. `ComponentPropsMap['object-grid'].data` is the + * `ViewData` union, and its own description names the refusal — "Static inline + * rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is + * refused — see migration `object-grid-data-view-data-converged`". The two gates + * agree and are still not redundant: this file measures the contract + * `ObjectGridSchema` resolves to, the parity gate measures the repo-wide arm + * direction. * - * The renderer's array tolerance is NOT asserted away here. It stays as - * back-compat and is objectui#5068's family; what this file forbids is - * ADVERTISING it, which is the carve-out's own reasoning applied to an arm - * instead of a key. + * ⭐ The renderer's array tolerance is GONE as of objectui#8348 (decision batch + * #83, 2026-09-08, maintainer verbatim 「8348 以协议为准」 — the contract decides). + * This paragraph used to read "NOT asserted away here… it stays as back-compat + * and is objectui#5068's family; what this file forbids is ADVERTISING it". The + * ruling removed the thing being un-advertised: `getDataConfig` no longer lifts + * a bare array, so the declaration and the read now say the same thing. This + * file still owns the DECLARATION half; the behaviour half — a bare array draws + * nothing and the block queries its object instead — is pinned next door in + * `gridBareArrayDataRefused-8348.test.tsx`. * * The compile-time half needs this package's `tsconfig.test.json` * (objectui#3181) — without it the `@ts-expect-error` below is erased before @@ -143,10 +157,13 @@ describe('object-grid `data` — declaration matches the contract (objectui#5090 }); it.each(GRID_TAGS)('$label does not declare the array shorthand', ({ type, namespace }) => { - // The regression named. `data: [ …rows… ]` is honoured by the renderer as - // back-compat (objectui#5068's family) but refused by `ViewData`, so + // The regression named. `data: [ …rows… ]` is refused by `ViewData`, so // declaring the arm would publish a shape `tsc` and the spec both reject — // the same reason the objectui#4648 carve-out leaves `staticData` undeclared. + // + // ⭐ objectui#8348 — this row used to carry "…is honoured by the renderer as + // back-compat". It no longer is: the read was corrected to the declaration, + // not the other way round, so declaration and behaviour agree. expect(declaredArms(type, namespace)).not.toContain('array'); expect(ViewDataSchema.safeParse([{ id: 1 }]).success).toBe(false); }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx index fdc180ea1c..df7e2575d3 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx @@ -132,7 +132,11 @@ async function renderAndCaptureMeta(schemaExtra: Record) { const schema: any = { type: 'object-grid', objectName: OBJECT, - data: ROWS, + // objectui#8348 — the DECLARED inline-rows spelling. `object-grid`'s published + // `data` row is the `ViewData` union ("the bare-array shortcut is refused"), so + // `getDataConfig` no longer lifts a bare array; `{ provider: 'value', items }` + // resolves to the same config this file has always exercised. + data: { provider: 'value', items: ROWS }, pagination: { pageSize: 50 }, ...schemaExtra, }; diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx index 1b4ee8fd14..fd7cc689d7 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx @@ -143,7 +143,11 @@ async function renderAndCaptureMeta(schemaExtra: Record) { const schema: any = { type: 'object-grid', objectName: OBJECT, - data: ROWS, + // objectui#8348 — the DECLARED inline-rows spelling. `object-grid`'s published + // `data` row is the `ViewData` union ("the bare-array shortcut is refused"), so + // `getDataConfig` no longer lifts a bare array; `{ provider: 'value', items }` + // resolves to the same config this file has always exercised. + data: { provider: 'value', items: ROWS }, pagination: { pageSize: 50 }, ...schemaExtra, }; diff --git a/packages/plugin-map/src/ObjectMap.configMemo.test.tsx b/packages/plugin-map/src/ObjectMap.configMemo.test.tsx index 4851e1416f..87ba8ef1ef 100644 --- a/packages/plugin-map/src/ObjectMap.configMemo.test.tsx +++ b/packages/plugin-map/src/ObjectMap.configMemo.test.tsx @@ -98,7 +98,13 @@ const ROWS = [ const baseSchema = (map: Record): any => ({ type: 'object-map', map: { latitudeField: 'latitude', longitudeField: 'longitude', ...map }, - data: ROWS, + // The DECLARED inline-rows spelling, not the bare array this file used to + // write. Since objectui#8348 `getDataConfig` honours `data` only on the arm + // this block's published row declares — `ObjectMapSchema.data` is + // `ViewDataSchema.optional()` — so the array shorthand is no longer lifted + // and would leave this map empty. The resolved config is identical, which is + // why this file's subject (memo identity) is untouched. + data: { provider: 'value', items: ROWS }, }); /** `Array.prototype.at` is outside this package's configured `lib` target. */ diff --git a/packages/plugin-map/src/ObjectMap.schemaDataShorthand.test.tsx b/packages/plugin-map/src/ObjectMap.schemaDataShorthand.test.tsx index 61ae9cd20c..c33a5af000 100644 --- a/packages/plugin-map/src/ObjectMap.schemaDataShorthand.test.tsx +++ b/packages/plugin-map/src/ObjectMap.schemaDataShorthand.test.tsx @@ -5,31 +5,64 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * objectui#5305 — the fetch effect used to carry a SECOND short-circuit beside - * the `props.data` one #5003/#5297 fixed: it read `schema.data` directly and - * tested whether that value was itself an array. eslint reported the read as - * `missing dependency: schema.data`. + * objectui#5305 / ⭐ objectui#8348 — the bare-array `data` shorthand on + * `object-map`: first normalized, now RETIRED, and this file is the record of + * both halves. * - * The measurement that chose the fix: `schema.data` was never actually stale. - * `getDataConfig(schema)` already returns `schema.data` verbatim, and the - * result is memoized on `JSON.stringify(rawDataConfig)` into `dataConfig` — - * which IS one of the effect's declared dependencies. So the effect re-ran on - * every authored-rows change even before this change; the direct read was a - * DUPLICATE of a value already threaded, not an un-threaded one. + * ## What objectui#5305 left here * - * That is why the array handling moved into `getDataConfig` rather than being - * deleted (it is a live convention in six sibling blocks — ObjectGrid's own - * `getDataConfig`, ListView, ObjectTree, ObjectChart, ObjectDataTable, - * calendar-view-renderer) or being papered over with a redundant dependency. + * The fetch effect used to carry a SECOND short-circuit beside the `props.data` + * one #5003/#5297 fixed: it read `schema.data` directly and tested whether that + * value was itself an array. #5305 moved that handling into `getDataConfig`, so + * the array was lifted to `{ provider: 'value', items }` at one boundary. The + * reason given for keeping it at all was that the shorthand is "a live + * convention in six sibling blocks" — an argument from what the CODE does. * - * These tests pin the behaviour the normalization must preserve, so a later - * "simplification" of `getDataConfig` cannot quietly turn the shorthand into a - * silently empty map. + * ## What objectui#8348 rules + * + * Decision batch #83 (2026-09-08), maintainer verbatim 「8348 以协议为准」 — the + * CONTRACT decides, not the convention. A renderer honours the `data` spelling + * its block's published row declares and no other. MEASURED: `@objectstack/spec` + * 17.4.0 publishes no `ComponentPropsMap['object-map']` row at all, so the row + * that governs this block is this repo's own `ObjectMapSchema.data` — + * `ViewDataSchema.optional()`, a `z.discriminatedUnion('provider', [...])` over + * OBJECT variants with no array arm. ⇒ the lift is gone. + * + * ⛔ WHAT THE REMOVAL DOES AND DOES NOT REACH — measured, per CARRIER + * + * At the LADDER (rows 1-2, driving the component directly with no `data` prop): + * the array is no longer a record source, so the ladder falls through to + * `staticData`, then to `objectName`, and such a map queries its object instead + * — or draws nothing when it names neither. + * + * Through `SchemaRenderer` (row 3): the array STILL DRAWS. `SchemaRenderer` + * spreads every non-metadata node key as a React prop and + * `plugin-map/src/index.tsx` forwards `{...props}`, so an authored `data` array + * also arrives on the props channel — the one a host such as `ListView` + * legitimately uses for pre-fetched rows, and which outranks the schema + * (objectui#5003 order, row 6). Collapsing the two carriers would take the host + * path with it and is outside objectui#8348; it is reported on the card. + * + * ⇒ the ruling's accepted cost lands squarely on `object-calendar`, where the + * off-arm spelling had exactly ONE carrier. On this block the ruling removes the + * second read, not the last one. + * + * ## Why the file keeps its name and its controls + * + * The subject is the same fact, with the verdict reversed, so the #5305 rows + * survive as the ⛔ CONTROLS that keep the retirement a measurement: the + * DECLARED `{ provider: 'value', items }` form still paints, still takes the + * no-fetch path, and still repaints on a changed row set; and the `data` PROP + * still outranks the schema. If the retirement had broken inline rows outright + * rather than just the shorthand, those rows go red. */ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { ObjectMap } from './ObjectMap'; +// Registers `object-map` and its `view:map` alias — row 3 renders through it. +import './index'; import type { DataSource } from '@object-ui/types'; vi.mock('react-map-gl/maplibre', () => ({ @@ -63,12 +96,57 @@ const makeDataSource = (): DataSource => getObjectSchema: vi.fn().mockResolvedValue({ fields: {} }), }) as unknown as DataSource; -describe('ObjectMap — array-shorthand `schema.data` (objectui#5305)', () => { - it('renders rows authored as a bare array under `data`', async () => { +describe('ObjectMap — the bare-array `schema.data` shorthand is retired (objectui#8348)', () => { + it('⭐ a bare array under `data` no longer draws markers — the accepted cost', async () => { const schema: any = { type: 'object-map', map: MAP_CONFIG, data: ROWS }; render(); + await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull()); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + }); + + it('⭐ …and with an `objectName` beside it, the ladder falls through and QUERIES', async () => { + // The other face of the same removal, and the one an author actually feels: + // the rows are not merely dropped, the block goes and asks its object + // instead. Before objectui#8348 this schema issued no `find` at all. + const dataSource = makeDataSource(); + const schema: any = { + type: 'object-map', + objectName: 'locations', + map: MAP_CONFIG, + data: ROWS, + }; + + render(); + + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + }); + + it('3. ⛔ REPORTED, NOT CHANGED: through `SchemaRenderer` the array still draws, from the PROPS channel', async () => { + const dataSource = makeDataSource(); + render( + + + , + ); + + await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(2)); + expect(dataSource.find).not.toHaveBeenCalled(); + }); + + it('⛔ CONTROL: the declared `{ provider: value, items }` form still paints', async () => { + const schema: any = { + type: 'object-map', + map: MAP_CONFIG, + data: { provider: 'value', items: ROWS }, + }; + + render(); + await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull()); await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(2)); @@ -77,19 +155,16 @@ describe('ObjectMap — array-shorthand `schema.data` (objectui#5305)', () => { expect(markers[1]).toHaveAttribute('data-lng', '-75'); }); - it('treats the shorthand as inline data — no `find`, no `getObjectSchema`', async () => { - // The shorthand normalizes to `{ provider: 'value' }`, so it must take the - // same no-fetch path the declared value provider takes. Before this change - // the array left `dataConfig.provider` undefined, which made - // `hasInlineData` false and sent the sibling effect off to fetch object - // metadata whose only read site is the object-provider fetch branch this - // schema never reaches. + it('⛔ CONTROL: the declared form is still inline data — no `find`, no `getObjectSchema`', async () => { + // objectui#5305's substance, kept: `{ provider: 'value' }` must take the + // no-fetch path, and must not send the sibling effect off for object + // metadata whose only read site is the object-provider fetch branch. const dataSource = makeDataSource(); const schema: any = { type: 'object-map', objectName: 'locations', map: MAP_CONFIG, - data: ROWS, + data: { provider: 'value', items: ROWS }, }; render(); @@ -101,37 +176,37 @@ describe('ObjectMap — array-shorthand `schema.data` (objectui#5305)', () => { expect(dataSource.getObjectSchema).not.toHaveBeenCalled(); }); - it('does not go stale — new authored rows re-run the effect', async () => { - // The substance behind the `missing dependency` report. `schema.data` is - // read only by `getDataConfig` now, and reaches the effect through the + it('⛔ CONTROL: the declared form does not go stale — new authored rows re-run the effect', async () => { + // The substance behind the original `missing dependency` report. `schema.data` + // is read only by `getDataConfig`, and reaches the effect through the // `dataConfig` dependency, so a changed row set must still repaint. - const schema: any = { type: 'object-map', map: MAP_CONFIG, data: ROWS }; + const schema: any = { + type: 'object-map', + map: MAP_CONFIG, + data: { provider: 'value', items: ROWS }, + }; const { rerender } = render(); await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(2)); - rerender(); + rerender( + , + ); await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(1)); expect(screen.getAllByTestId('map-marker')[0]).toHaveAttribute('data-lat', '51'); }); - it('leaves the declared `{ provider: value, items }` form untouched', async () => { + it('⛔ CONTROL: the `data` PROP still outranks the schema (objectui#5003 order)', async () => { + // Unchanged by the ruling, and the reason a HOSTED map (ObjectView / + // ListView pre-fetching rows) is untouched by any of the above: the props + // channel is a different carrier from the authored `data` key. const schema: any = { type: 'object-map', map: MAP_CONFIG, data: { provider: 'value', items: ROWS }, }; - render(); - - await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull()); - await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(2)); - }); - - it('still prefers the `data` PROP over an array-shorthand schema (objectui#5003 order)', async () => { - const schema: any = { type: 'object-map', map: MAP_CONFIG, data: ROWS }; - render(); await waitFor(() => expect(screen.getAllByTestId('map-marker')).toHaveLength(1)); diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 2eb19f5d85..2f9e06d10a 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -130,49 +130,42 @@ const FLAT_MAP_CONFIG_KEYS = (Object.keys(ObjectMapConfigSchema.shape) as (keyof * The ruled three-rung ladder itself (`data`, then `staticData`, then * `objectName`) is `resolveRecordSourceConfig` in `@object-ui/core` — ONE * implementation of a contract published on both faces (objectui#6939), which - * this file used to hand-copy (objectui#7632). What stays here is the head - * above it, unchanged: the array shorthand. + * this file used to hand-copy (objectui#7632). + * + * What used to stay here was the head above it: the array shorthand, which + * lifted `data: [...]` to `{ provider: 'value', items }`. + * ⛔ IT IS GONE (objectui#8348, decision batch #83, maintainer verbatim + * 「8348 以协议为准」 — the contract decides). Its old justification was that the + * shorthand is "a deliberate, commented convention across this block family", + * i.e. that the other blocks accept it too. The ruling replaces that argument + * with the row: a renderer honours the `data` spelling its block's PUBLISHED row + * declares and no other. + * + * MEASURED: `@objectstack/spec` 17.4.0 has NO `ComponentPropsMap['object-map']` + * row, so the published row that governs this block is this repo's own + * `ObjectMapSchema.data` (`@object-ui/types`), `ViewDataSchema.optional()` — + * @objectstack/spec's `z.discriminatedUnion('provider', [...])` over OBJECT + * variants, whose `value` member additionally declares + * `aliases: { data: 'items', rows: 'items', records: 'items' }`. A bare array + * is off that row twice over, and this block's registration declares no `data` + * input that could say otherwise. + * + * ⛔ WHAT THIS REACHES, measured per CARRIER — do NOT read it as "the array is + * gone". `SchemaRenderer` spreads every non-metadata node key as a React prop + * and `index.tsx` forwards `{...props}`, so an authored `data` array also + * arrives on the props channel, which outranks the schema (objectui#5003 + * order). At the ladder the array is no longer a record source; through + * `SchemaRenderer` an authored `data: [ …rows… ]` still draws, from that prop. + * Both halves are pinned in `ObjectMap.schemaDataShorthand.test.tsx`. + * Collapsing the two carriers would take the host path with it and is outside + * objectui#8348's scope — reported on the card, not changed in passing. + * + * The declared spellings for inline rows are + * `data: { provider: 'value', items: [...] }` and `staticData: [...]`, both + * unchanged. */ function getDataConfig(schema: ObjectMapSchema): ViewData | null { - // Array shorthand -> the declared `value` provider. - // - // `ObjectMapSchema.data` is declared `ViewData`, and `ViewData` resolves to - // @objectstack/spec's `ViewDataSchema` — a `z.discriminatedUnion('provider', - // [...])` over OBJECT variants, whose `value` member additionally declares - // `aliases: { data: 'items', rows: 'items', records: 'items' }`. So a bare - // array under `data` is off-contract twice over, and `staticData` is this - // schema's declared door for inline rows. - // - // It is normalized rather than rejected because the array shorthand is a - // deliberate, commented convention across this block family — ObjectGrid's - // own `getDataConfig` ("Check if data is an array (shorthand format)"), - // ListView ("Also support schema.data as a plain array (shorthand for value - // provider)"), ObjectChart, ObjectDataTable and calendar-view-renderer all - // accept it. An author (or a generator) that learned the shorthand from - // `object-grid` writes it for `object-map` next; dropping it HERE alone would - // leave the one block in the family that answers the shorthand with a - // silently empty map. - // - // ObjectTree was named in this list until objectui#7632 measured it: it has - // no `Array.isArray(schema.data)` anywhere, so it answers the shorthand with - // a silently empty tree today. ObjectGantt and ObjectCalendar do not accept - // it either. That divergence is NOT resolved here — it is the reason this - // head stays at the site instead of being folded into the shared rung - // (AGENTS.md #0.1), and it is filed separately rather than fixed in passing. - // - // Normalizing at this single boundary — instead of a second short-circuit - // inside the fetch effect below — is what lets that effect read `dataConfig` - // only, which is already one of its dependencies (objectui#5305). - // - // Hoisting this check above the shared call is behaviour-neutral: an array is - // ALWAYS truthy, `[]` included, so the `if (schema.data)` that used to wrap - // it could never have let one fall through to `staticData` or `objectName`. - const authored: unknown = schema.data; - if (Array.isArray(authored)) { - return { provider: 'value', items: authored }; - } - - return resolveRecordSourceConfig(schema); + return resolveRecordSourceConfig(schema, 'view-data'); } const isDev = (): boolean => diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 9a0fc311cf..0ba00b9616 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -487,7 +487,18 @@ export const ObjectTree: React.FC = ({ }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]); + // `'undeclared'` — and that is a finding, not a shrug (objectui#8348). + // MEASURED: NO published face declares a `data` row for `object-tree`. + // `@objectstack/spec` 17.4.0 has no `ComponentPropsMap['object-tree']` entry; + // `ObjectTreeSchema` (`@object-ui/types`) declares `objectName` REQUIRED and + // no `data` / `staticData`; and this package's registration declares no + // `data` input. Decision batch #83 rules by reference to the block's own + // published row — "the row decides" — so with no row on any face, neither arm + // of that ruling reaches this block and rung 1 keeps its pre-8348 verbatim + // behaviour here. ⛔ Do NOT copy this arm to a block that HAS a row: it is + // the honest answer for an unruled block, reported on the card rather than + // guessed at. + const dataConfig = useMemo(() => resolveRecordSourceConfig(schema, 'undeclared'), [schema]); /** * The object THIS render is bound to, as a plain string — so the resolution diff --git a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts index 6af84d2f71..fdacc613d9 100644 --- a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts +++ b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts @@ -295,20 +295,37 @@ describe('objectui#7313 — `data` and `staticData` are DECLARED, not passthroug }); describe('objectui#7313 — the declaration names a live read, in the declared order', () => { - it('the renderer resolves its records through the shared ladder', () => { + it('the renderer resolves its records through the shared ladder, on the ARRAY arm', () => { const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8'); - expect(src, `${RENDERER} no longer calls resolveRecordSourceConfig(schema)`).toContain('resolveRecordSourceConfig(schema)'); + // ⭐ objectui#8348 — the arm is part of the call now, and asserting it here + // is what keeps this row honest. The previous spelling looked for the bare + // `resolveRecordSourceConfig(schema)`, which this file's own renderer + // satisfies from a DOCBLOCK line that merely names the function — so it + // would have stayed green through a call site that had stopped existing. + expect(src, `${RENDERER} no longer calls the shared ladder with its declared arm`).toContain( + "resolveRecordSourceConfig(schema, 'array')", + ); + // The arm is the one `ComponentPropsMap['object-calendar'].data` declares + // (`z.array(z.unknown())`, "Pre-fetched records"), which is why it is + // `'array'` here and `'view-data'` on `object-grid` / `object-map` / + // `object-gantt`. + expect(src).not.toContain("resolveRecordSourceConfig(schema, 'view-data')"); }); it('the ladder reads `data`, then `staticData`, then `objectName` — the order the refinement rests on', () => { const src = readFileSync(join(REPO_ROOT, LADDER), 'utf8'); const body = src.slice(src.indexOf('export function resolveRecordSourceConfig')); - const data = body.indexOf('if (schema.data)'); + // Rung 1 is no longer a bare `if (schema.data)`: objectui#8348 gates it on + // the arm the calling block's published `data` row declares. The ORDER — the + // thing `requireRecordSource` actually rests on — is unchanged, and is what + // this row still measures. + const data = body.indexOf('schema.data'); const staticData = body.indexOf('if (schema.staticData)'); const objectName = body.indexOf('if (schema.objectName)'); expect(data).toBeGreaterThan(-1); expect(staticData).toBeGreaterThan(data); expect(objectName).toBeGreaterThan(staticData); + expect(body).toContain('authoredDataIsOnTheDeclaredArm(schema.data, dataArm)'); }); it('the two static-data examples on the plugin page carry the annotation (the card\'s completion signal)', () => {