diff --git a/.changeset/grid-data-input-declares-viewdata-5090.md b/.changeset/grid-data-input-declares-viewdata-5090.md new file mode 100644 index 0000000000..8e4c787beb --- /dev/null +++ b/.changeset/grid-data-input-declares-viewdata-5090.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-grid': patch +--- + +`object-grid` now declares its `data` input as the object its contract actually +accepts, instead of as an array (objectui#5090). + +The declaration published `{ name: 'data', type: 'array', label: 'Static Data', +description: 'Inline rows, …' }` — which is the shape of `staticData`, the +deprecated alias the objectui#4648 carve-out deliberately leaves unpublished, +under the canonical key's name. The contract is +`ObjectGridSchema.data?: ViewData`: the spec's discriminated union on +`provider`, four strict object arms (`object` / `api` / `value` / `schema`), +none of them an array. + +Both halves of that misdeclaration were user-visible. An author following the +designer panel or the generated `sdui-intrinsics.d.ts` wrote `data: [ …rows… ]` +and got a grid that renders but a document `tsc` rejects (TS2322) and spec +parsing refuses; meanwhile the one form that satisfies both, +`{ provider: 'value', items: [...] }`, was reported as `type-mismatch` by the +save gate, because a declared `array` arm accepts only arrays. Writing the +inline-rows form the README already documents now validates clean, and the +designer labels the key `Data Source` with a description that names all four +providers rather than only the deprecated shortcut's shape. + +The renderer is unchanged: a bare array is still honoured as back-compat +(`getDataConfig` folds it to `{ provider: 'value', items }`), it is simply no +longer advertised as authoring surface — the same standing `staticData` has. +Authors who wrote the array shorthand keep working and will now see a +`type-mismatch` hint pointing at the spec-valid spelling. diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index d35ca9e622..0f2527edc2 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -48,9 +48,9 @@ three calls in `src/index.tsx` claim exactly these keys: | `register(…)` call | Namespaced key | Bare fallback | | --- | --- | --- | -| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:181` | `plugin-grid:object-grid` | `object-grid` | -| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` — `src/index.tsx:193` | `view:grid` | **none** — `skipFallback: true` | -| `('import-wizard', ImportWizardRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:216` | `plugin-grid:import-wizard` | `import-wizard` | +| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:202` | `plugin-grid:object-grid` | `object-grid` | +| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` — `src/index.tsx:214` | `view:grid` | **none** — `skipFallback: true` | +| `('import-wizard', ImportWizardRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:237` | `plugin-grid:import-wizard` | `import-wizard` | **Bare `grid` is deliberately not ours.** `skipFallback: true` on the second call keeps this plugin from claiming it, because `grid` belongs to the CSS Grid *layout* @@ -148,7 +148,7 @@ contract rather than this package's component API. A grid node is an `ObjectGridSchema`: one required `objectName`, and keys drawn from the list this package **declares** as its authoring surface -(`GRID_QUERY_INPUTS`, `src/index.tsx:145`) — the same list that feeds the designer +(`GRID_QUERY_INPUTS`, `src/index.tsx:166`) — the same list that feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is authorable here is what the renderer reads. diff --git a/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts b/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts new file mode 100644 index 0000000000..bcff74d40c --- /dev/null +++ b/packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts @@ -0,0 +1,171 @@ +/** + * 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. + */ + +/** + * `object-grid` — the DECLARED coarse type of `data` must be the one its + * contract accepts (objectui#5090). + * + * ## What was wrong + * + * `GRID_QUERY_INPUTS` declared `{ name: 'data', type: 'array', label: 'Static + * Data', description: 'Inline rows, …' }`, but the contract is + * `ObjectGridSchema.data?: ViewData` — the spec's discriminated union on + * `provider`, four strict OBJECT arms (`object` / `api` / `value` / `schema`), + * none of them an array. The declaration published the shape of `staticData`, + * the deprecated alias the objectui#4648 carve-out deliberately refuses to + * publish, under the canonical key's name. + * + * Both halves failed quietly, in opposite directions — objectui#4041's shape, + * 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; + * - 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 + * arrays. The platform reported the only legal write as a type error. + * + * ## What these tests pin, and why it is DERIVED + * + * The subject is the declaration read out of the registry, never a constant + * restated here — a test spelling `'object'` on both sides would keep passing + * through exactly the drift it exists to catch. So the expected arms come from + * `ViewDataSchema`'s own verdicts: one representative value per coarse arm, in + * the vocabulary `armAccepts` uses, filtered by what the schema accepts. Either + * side moving turns the comparison red — a spec release that widens `ViewData` + * to accept an array, or a declaration that grows an arm the contract rejects. + * That derivation is the discipline `component-input-union-specimens.test.ts` + * and `text-input-inputs-spec-parity.test.ts` adopted, and it is the only gate + * on this fact: whether a declared arm matches the contract is otherwise + * nobody's (objectui#4971). + * + * 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 compile-time half needs this package's `tsconfig.test.json` + * (objectui#3181) — without it the `@ts-expect-error` below is erased before + * vitest runs and proves nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { ViewDataSchema } from '@objectstack/spec/ui'; +import type { ObjectGridSchema } from '@object-ui/types'; +// Registers `object-grid` and its `view:grid` alias. +import '../index'; + +/** The two tags this one renderer is published under, sharing one input list. */ +const GRID_TAGS = [ + { label: 'object-grid', type: 'object-grid', namespace: undefined }, + { label: 'view:grid', type: 'grid', namespace: 'view' }, +] as const; + +/** + * One representative value per coarse arm, in the vocabulary `armAccepts` uses + * (`packages/sdui-parser/src/validate.ts`). + * + * The `object` probe is the INLINE ROWS form specifically — the shape the old + * declaration was reaching for and got wrong — so a spec rename of `items` + * empties the derived set and reds this file with a message about the probe + * rather than silently agreeing with a broken declaration. + */ +const COARSE_ARM_PROBES = [ + ['string', 'users'], + ['number', 42], + ['boolean', true], + ['object', { provider: 'value', items: [{ id: 1 }] }], + ['array', [{ id: 1 }]], +] as const; + +/** The coarse arms the CONTRACT accepts, from the schema's own verdicts. */ +const contractAcceptedArms = (): string[] => + COARSE_ARM_PROBES.filter(([, value]) => ViewDataSchema.safeParse(value).success).map( + ([arm]) => arm, + ); + +/** The `data` input as the registration publishes it. */ +function declaredDataInput(type: string, namespace?: string) { + const inputs = (ComponentRegistry.getConfig(type, namespace) as any)?.inputs ?? []; + return inputs.find((i: any) => i?.name === 'data'); +} + +/** Declared arms, normalised across the single-kind and array forms. */ +const declaredArms = (type: string, namespace?: string): string[] => { + const declared = declaredDataInput(type, namespace)?.type; + return Array.isArray(declared) ? [...declared] : [declared]; +}; + +describe('object-grid `data` — declaration matches the contract (objectui#5090)', () => { + it.each(GRID_TAGS)('$label registers and declares a `data` input', ({ type, namespace }) => { + // Reachability before absence: an unregistered or renamed block would + // satisfy every assertion below by never resolving an input at all. + expect(ComponentRegistry.getConfig(type, namespace), `${type} is not registered`).toBeDefined(); + expect(declaredDataInput(type, namespace), `${type} declares no \`data\` input`).toBeDefined(); + }); + + it('the contract accepts exactly the object arm (the derivation, stated)', () => { + // Non-vacuity for everything below: if `ViewDataSchema` stopped resolving + // through `lazySchema`, or the probe went stale, this set would empty out + // and the comparison would pass on nothing. + expect(contractAcceptedArms()).toEqual(['object']); + }); + + it('each of the four providers parses, and none of them is an array', () => { + // What makes "the object arm" a real four-armed union rather than one + // accidental shape, and the direct measurement of the card's premise. + const providers = [ + { provider: 'object', object: 'users' }, + { provider: 'api', read: { url: 'https://example.test/rows' } }, + { provider: 'value', items: [{ id: 1 }] }, + { provider: 'schema', schemaId: 'report' }, + ]; + for (const value of providers) { + expect(ViewDataSchema.safeParse(value).success, `${value.provider} provider`).toBe(true); + expect(Array.isArray(value)).toBe(false); + } + }); + + it.each(GRID_TAGS)('$label declares the arms the contract accepts', ({ type, namespace }) => { + expect(declaredArms(type, namespace)).toEqual(contractAcceptedArms()); + }); + + 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 + // declaring the arm would publish a shape `tsc` and the spec both reject — + // the same reason the objectui#4648 carve-out leaves `staticData` undeclared. + expect(declaredArms(type, namespace)).not.toContain('array'); + expect(ViewDataSchema.safeParse([{ id: 1 }]).success).toBe(false); + }); + + it('declares one shape across both tags, so the alias cannot drift', () => { + const [a, b] = GRID_TAGS.map(({ type, namespace }) => declaredArms(type, namespace)); + expect(a).toEqual(b); + }); + + it('is pinned at compile time too', () => { + // The runtime half above reads a declaration; this half reads the contract + // the declaration is supposed to describe. Erased before vitest runs — its + // value is that `tsc -p tsconfig.test.json` compiles it. + + // The inline-rows form the description now teaches must type-check. + const inlineRows: ObjectGridSchema['data'] = { provider: 'value', items: [{ id: 1 }] }; + expect(inlineRows).toBeDefined(); + + // …and the array shorthand must NOT. If `ViewData` ever widens to accept an + // array, this directive stops suppressing anything and `tsc` fails with + // "Unused '@ts-expect-error' directive" — which is the tripwire firing: + // re-derive the declared arms above instead of deleting this line. + // @ts-expect-error `ViewData` has no array arm — objectui#5090. + const bareArray: ObjectGridSchema['data'] = [{ id: 1 }]; + expect(bareArray).toBeDefined(); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 65564ebcfd..60714017a4 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -141,6 +141,27 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * spellings — `columns`, `data`, `selection`, `pagination`, `searchableFields`, * `sort`, `filter`, `resizable`, `label` — are all declared here, and each * description below says so, so the exemption teaches rather than merely omits. + * + * ## `data` declares the CONTRACT's shape, not the shortcut's (objectui#5090) + * + * The key landed above with `type: 'array'`, labelled "Static Data" and described + * as inline rows — which is the shape of `staticData`, the very alias the + * carve-out above refuses to publish, not the shape of `data`. The contract is + * `ObjectGridSchema.data?: ViewData` (`packages/types/src/objectql.ts`), and + * `ViewData` is the spec's discriminated union on `provider` — four strict object + * arms (`object` / `api` / `value` / `schema`), none of them an array. So the + * declaration published a shape `tsc` rejects (`TS2322`) and `ViewDataSchema` + * refuses, while the one form that satisfies both — `{ provider: 'value', items: + * [...] }` — drew `type-mismatch` from this repo's own save gate, because + * `checkType`'s `'array'` arm accepts only arrays. The platform contradicted + * itself on the only legal write: objectui#4041's shape again, one field over. + * + * `ObjectGrid`'s renderer does still accept a bare array (`getDataConfig` folds + * it to `{ provider: 'value', items }`), and that tolerance is deliberately left + * alone here — it is objectui#5068's family, and declaring an arm for it would + * publish a shape the contract rejects, which is precisely the carve-out's own + * reasoning. An array `data` therefore has the same standing as `staticData`: + * read as back-compat, not advertised as authoring surface. */ const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'objectName', type: 'string', label: 'Object Name', required: true }, @@ -152,7 +173,7 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'sort', type: 'array', label: 'Sort', description: 'Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent.' }, { name: 'pagination', type: 'object', label: 'Pagination', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, { name: 'searchableFields', type: 'array', label: 'Searchable Fields', description: 'Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search.' }, - { name: 'data', type: 'array', label: 'Static Data', description: 'Inline rows, which bypass the object query entirely. For demos and fixtures; the canonical spelling — the deprecated `staticData` is the same thing.' }, + { name: 'data', type: 'object', label: 'Data Source', description: 'Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: "object", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: "api", read, write }`, `{ provider: "value", items: [...] }` for inline rows that bypass the object query, or `{ provider: "schema", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array.' }, // ── presentation ────────────────────────────────────────────────────────── { name: 'rowHeight', type: 'enum', label: 'Row Height', enum: ['compact', 'short', 'medium', 'tall', 'extra_tall'], description: 'Row density. An unrecognised value falls back to `compact` rather than erroring.' }, { name: 'frozenColumns', type: 'number', label: 'Frozen Columns', description: 'How many leading columns stay pinned while the grid scrolls horizontally.' },