Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/grid-data-input-declares-viewdata-5090.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions packages/plugin-grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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.

Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 22 additions & 1 deletion packages/plugin-grid/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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.' },
Expand Down
Loading