Skip to content

Commit 21d34d5

Browse files
docs(guide),console: write the react-tier ListView binding as data + type, not the retired objectName / viewType (#10600)
Fixes #8510 Clause-②: no The react-tier ListView block's `objectName` / `viewType` aliases are retired from the published contract (objectstack#14791, ruling B, no deprecation window). This PR rewrites every in-repo author site of those spellings to the contract's only spelling, `data={{ provider: 'object', object: '…' }}` for the binding and `type` for the visualization, on the ListView block only. ## Premise check (the director seat's first act on claim) The retirement ships in what this repo installs. After `pnpm install` in this worktree, `@objectstack/spec` and `@objectstack/lint` both resolve to `17.4.0` (the lockfile's only resolution for each). Both packages' `17.4.0` CHANGELOG sections name `react-prop-retired`. The installed lint exports `REACT_PROP_RETIRED = "react-prop-retired"` and fires it from `validateReactPageProps`. The installed `REACT_BLOCKS` ListView entry declares neither alias: its `dataProps` and `interactions` carry `data` and `type`, and the only `objectName` / `viewType` text left in the entry is the summary sentence saying they are retired. ## Sites rewritten `content/docs/guide/react-pages.md`, five sites (the card named four; the fifth is a census hit): 1. `kind:'react'` section, the worked JSON page's `source` string: ListView `objectName=\"showcase_project\"` becomes `data={{ provider: 'object', object: 'showcase_project' }}`. 2. **Census hit, not in the card:** the "Written out, that `source` is:" JSX block right below it. It is the same source on several lines and wrote `objectName="showcase_project"` on its own line, so leaving it would contradict the rewritten JSON. It post-dates the card's census. 3. "Two tiers" prose: a kanban / calendar / gantt / timeline / map of an object is now `ListView type="kanban" …` (was `viewType="kanban"`). The sentence's meaning is unchanged. 4. The `RecordRelatedList` replacement row keeps the related-list shape: `ListView data={{ provider: 'object', object: 'child_object' }} filters={['lookup_field', '=', parentId]}`. 5. "Blocks take flat props" sample: `ListView data={{ provider: 'object', object: 'showcase_project' }} fields={['name', 'status']} pagination={{ pageSize: 25 }}`. `apps/console/src/sdui-workbench-preview.tsx`: the `crm_workbench` react page source's ListView, `objectName="showcase_project"` becomes `data={{ provider: 'object', object: 'showcase_project' }}`. Out of scope and untouched, per the card: ObjectForm's own `objectName`, the `record:*` blocks' prose, `Block type="object-tree" objectName=…`, the metadata-tier `objectName` the host stamps, and the component-schema type `ListViewSchema['viewType']` in `packages/plugin-list/README.md`. Census method: a `git grep` for the ListView JSX opening tag over `HEAD` in `content/docs/**` and `apps/**`, every hit read by hand. Live control: all five of the card's sites hit. Only the six sites above write a retired prop on a react-tier ListView. ## Proof **The installed validator, before and after.** A one-time probe runs `validateReactPageProps` from the installed `@objectstack/lint@17.4.0` over the preview's page source (cooked), the doc's JSON page, its written-out copy, and the three inline doc snippets. The snippet's `…` elision is filled with a data binding, so the only variable is the retired prop. - base `f5178a272`: 6 findings, all `react-prop-retired` (`objectName` x5, `viewType` x1). - head `1bc5466b5`: 0 findings of any rule. **The preview still renders `showcase_project`.** A new pin, `apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx`, pulls the `crm_workbench` source out through the shared preview extractor (`helpers/preview-page-sources.ts`). It mounts that source through the real `kind:'react'` tier and the real `list-view`. - ListView hands its view `schema.objectName === 'showcase_project'`. It calls `getObjectSchema('showcase_project')`, and it queries `showcase_project` selecting the preview's columns. That query is distinct from the page's own `{ $top: 200 }` stats query. - Control: the same source with the `data` binding stripped. Once ListView reports `data-state="idle"`, neither `getObjectSchema` nor any non-stats `find` has happened. - `validateReactPageProps` finds no `react-prop-retired` on any preview page. Control: with the retired spelling put back, it yields exactly `[react-prop-retired]`. **Ablation (one-time, not kept).** Predicted before running: disabling the `data.provider === 'object'` leg in `normalizeListViewSchema` turns only the positive render case red. Observed: `Tests 1 failed | 4 passed (5)`. The failing case is "renders the ListView bound to showcase_project through the data-source fold", because the view is never handed a bound object. The strip control and both lint cases stay green. - The mutation landed by anchor: anchor count 1, marker count 1, 2 diff lines. - It was restored with `git checkout HEAD --`, and the restore is proven by a blob hash equal to HEAD's. - `@object-ui/core` and `@object-ui/plugin-list` resolve to `src` in the console project, so no rebuild was involved. ## Gates (local, at `1bc5466b5`) - `pnpm exec vitest run apps/console/`: Test Files 117 passed (117), Tests 1277 passed (1277). 117 is the count of tracked console test files, the pin included. - `pnpm --filter @object-ui/console type-check`: exit 0, after `turbo run build --filter='@object-ui/console^...'`. Its first run flagged the new test file (an unused import, since removed), so it covers that file. - eslint over the two touched `.tsx` files: 0 errors. The pin has 0 warnings. The preview's 7 `no-explicit-any` warnings are on untouched lines (7 at base too). - `pnpm check:control-bytes` OK. `pnpm check:new-line-citations`: 0 new. `node scripts/check-changeset-presence.mjs` OK (empty-frontmatter declaration). `pnpm check:changeset-claims`: exit 0. It lists 5 pending changesets that name a touched file; each was read and none is falsified. - Docs gates, all exit 0: `check:doc-types`, `check:doc-snippets` (674/674 judged, 0 failed, after its own `--build-filter` build), `check:doc-examples`, `check:doc-fences`, `check:doc-example-ids`, `check:doc-example-readers`, `docs:check-links`, `check:spec-symbols`, `check:docs-route-closure`. - NOT MEASURED: a grep of a built console `dist` for the preview. Reason: no built console exists here, and a full console build is out of proportion for this check. The reading is static instead: the console's Vite `rollupOptions` declares no `input` (single entry `index.html`), and the preview module's only importer is `apps/console/sdui-workbench-preview.html`. ## Changeset `.changeset/8510-react-listview-spelling.md`, with an empty frontmatter (releases nothing). The gate counts `apps/console/src/**`, but the preview is a dev-server-only harness, and the rest of the change is a test and docs. ## Acceptance notes - The card's second half is untouched because it is "Not decided here". `packages/plugin-list/src/index.tsx` still declares `objectName` (required) and `viewType` as `inputs` of `list-view` and of its `list` alias, plus `defaultProps` `objectName: ''` / `viewType: 'grid'`. This is reported to the dispatch seat. - Three test fixtures in `packages/components` still mount a react page whose ListView writes `objectName="showcase_project"`: `react-page-adapter.test.tsx`, and `react-page-scope.test.tsx` twice. They exercise scope and adapter plumbing, which renders either spelling. They sit outside the census the card set (`content/docs/**`, `apps/**`). Noted, not filed. - The claim's file surface named the four doc sites "only". The fifth (the written-out copy) was added under the dispatch's census instruction, because leaving it would contradict the rewritten JSON just above it. --- _Generated by [Claude Code](https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 58b7b3d commit 21d34d5

4 files changed

Lines changed: 211 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
---
3+
4+
The SDUI workbench preview's react page source (`apps/console/src/sdui-workbench-preview.tsx`)
5+
now binds its ListView with `data={{ provider: 'object', object: 'showcase_project' }}` instead of
6+
the `objectName` spelling that `@objectstack/spec` 17.4.0 retired from the react-tier contract
7+
(`react-prop-retired` in `@objectstack/lint`), and a console test pins that binding. The preview is a
8+
dev-server-only harness page — the console build's only entry is `index.html` — so no published
9+
behaviour changes. The same rewrite in `content/docs/guide/react-pages.md` is documentation only.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* objectui#8510 — the workbench preview's react-tier `<ListView>` is written in
3+
* the contract's only spelling, and it still binds `showcase_project`.
4+
*
5+
* `REACT_BLOCKS` in `@objectstack/spec/ui` retired the ListView block's
6+
* `objectName` / `viewType` overlay props with no deprecation window
7+
* (objectstack#14791, ruling B); `@objectstack/lint` refuses a leftover alias on
8+
* a `kind:'react'` page as `react-prop-retired`. The spellings that remain are
9+
* ListViewSchema's own `data={{ provider: 'object', object: '…' }}` and `type`.
10+
* `sdui-workbench-preview.tsx` was the one in-repo react page still writing
11+
* `objectName="showcase_project"`, so it was rewritten to the `data` binding.
12+
*
13+
* Two facts are pinned, each by the real instrument rather than a copy of it:
14+
*
15+
* 1. RENDER — the rewritten binding still reaches the data layer. The page
16+
* source is extracted from the harness (the same extractor the styling and
17+
* query-params pins use, so it is the string the page really receives) and
18+
* mounted through the real `kind:'react'` page tier and the real
19+
* `list-view` registration. The object reaches `ListView` through
20+
* `normalizeListViewSchema`'s `data.provider === 'object'` fold
21+
* (objectui#7477) — nothing on the page writes `objectName` any more.
22+
* 2. CONTRACT — `validateReactPageProps` from `@objectstack/lint`, the same
23+
* check `os validate` runs, reports no `react-prop-retired` on any preview
24+
* page.
25+
*
26+
* NON-VACUITY IS PINNED, NOT ARGUED. The page itself calls
27+
* `adapter.find('showcase_project', { $top: 200 })` for its stats strip, so "a
28+
* find carried the object" would pass with the ListView binding removed. The
29+
* render case therefore reads what only `ListView` produces — the
30+
* `schema.objectName` its view is handed, the object-definition fetch
31+
* (`getObjectSchema`, which nothing else on the page calls while the form is
32+
* closed) and its own query, which selects the preview's columns — and a
33+
* control renders the SAME extracted source with the `data` binding stripped:
34+
* once ListView reports its fetch effect settled (`data-state="idle"` on its
35+
* root), neither of its data calls may have happened and the stats query must
36+
* be the only one. The contract case carries a control too: the same source
37+
* with the retired spelling put back must go red.
38+
*/
39+
import { describe, it, expect, vi, beforeEach } from 'vitest';
40+
import { render, waitFor } from '@testing-library/react';
41+
import { ComponentRegistry } from '@object-ui/core';
42+
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
43+
// The page renderers (`type:'home'`), which dispatch `kind:'react'` to the
44+
// react page tier.
45+
import '@object-ui/components';
46+
// The REAL `list-view`, which the react page's `<ListView>` resolves to.
47+
import '@object-ui/plugin-list';
48+
import { validateReactPageProps, REACT_PROP_RETIRED } from '@objectstack/lint';
49+
import { pagesOf, previewHarnessFiles, readHarness } from './helpers/preview-page-sources';
50+
51+
const HARNESS = 'sdui-workbench-preview.tsx';
52+
const OBJECT = 'showcase_project';
53+
54+
/** The binding the harness writes today, exactly as the page receives it. */
55+
const DATA_BINDING = `data={{ provider: 'object', object: '${OBJECT}' }}`;
56+
/** The retired spelling the contract refuses. */
57+
const RETIRED_BINDING = `objectName="${OBJECT}"`;
58+
/** The columns the harness's ListView asks for — its query selects them. */
59+
const COLUMNS = ['name', 'status', 'health', 'budget', 'owner'];
60+
/** The page's OWN query (its stats strip), which is not the ListView's. */
61+
const STATS_QUERY = { $top: 200 };
62+
63+
const [workbench] = pagesOf(readHarness(HARNESS), HARNESS);
64+
65+
/** Every page in every preview harness, for the contract sweep. */
66+
const allPages = previewHarnessFiles.flatMap((file) =>
67+
pagesOf(readHarness(file), file).map((page) => ({ file, ...page })),
68+
);
69+
70+
const rows = [
71+
{ id: '1', name: 'Apollo Migration', status: 'active' },
72+
{ id: '2', name: 'Billing Revamp', status: 'planned' },
73+
];
74+
75+
const objectDef = {
76+
name: OBJECT,
77+
label: 'Project',
78+
fields: {
79+
name: { name: 'name', type: 'text', label: 'Project Name' },
80+
status: { name: 'status', type: 'text', label: 'Status' },
81+
},
82+
};
83+
84+
/** The part of a view's props this file reads: the schema ListView hands down. */
85+
interface ViewSpyProps {
86+
schema?: { objectName?: unknown };
87+
}
88+
89+
/** Props the view spy received, in order. */
90+
let gridProps: ViewSpyProps[] = [];
91+
92+
// The default kind is `grid` (the page authors no `type`), so the view ListView
93+
// hands its binding to is `object-grid`. A spy stands in for the real grid: the
94+
// question here is what ListView resolved, not how a grid draws.
95+
ComponentRegistry.register(
96+
'object-grid',
97+
(props: ViewSpyProps) => {
98+
gridProps.push(props);
99+
return <div data-testid="grid-spy" />;
100+
},
101+
{ namespace: 'test', label: 'Grid spy', category: 'view' },
102+
);
103+
104+
const makeDataSource = () => ({
105+
find: vi.fn<(object: string, params?: unknown) => Promise<typeof rows>>(async () => rows),
106+
findOne: vi.fn(async () => null),
107+
create: vi.fn(),
108+
update: vi.fn(),
109+
delete: vi.fn(),
110+
count: vi.fn(async () => rows.length),
111+
getObjectSchema: vi.fn<(object: string) => Promise<typeof objectDef>>(async () => objectDef),
112+
getObjects: vi.fn(async () => []),
113+
onMutation: () => () => {},
114+
});
115+
116+
/** Mount a page source through the real `kind:'react'` tier, as the harness does. */
117+
function renderWorkbench(source: string) {
118+
const dataSource = makeDataSource();
119+
const { container } = render(
120+
<AdapterCtx.Provider value={dataSource as never}>
121+
<SchemaRendererProvider dataSource={dataSource as never}>
122+
<SchemaRenderer schema={{ type: 'home', kind: 'react', name: workbench.name, source } as never} />
123+
</SchemaRendererProvider>
124+
</AdapterCtx.Provider>,
125+
);
126+
return { dataSource, container };
127+
}
128+
129+
/** True for the page's own stats query — the one find call that is not ListView's. */
130+
function isStatsQuery(params: unknown): boolean {
131+
return JSON.stringify(params) === JSON.stringify(STATS_QUERY);
132+
}
133+
134+
beforeEach(() => {
135+
gridProps = [];
136+
});
137+
138+
describe('sdui-workbench-preview — the react-tier ListView binding (objectui#8510)', () => {
139+
it('the extracted page is the workbench, and it binds ListView with the data source only', () => {
140+
expect(`${workbench.name}:${workbench.kind}`).toBe('crm_workbench:react');
141+
// The binding the render case relies on is really in the source …
142+
expect(workbench.source).toContain(`<ListView key={reloadKey} ${DATA_BINDING} `);
143+
// … and the retired spelling is not, anywhere on the page's ListView.
144+
expect(workbench.source).not.toMatch(/<ListView\b[^>]*\b(objectName|viewType)=/);
145+
});
146+
147+
it('renders the ListView bound to showcase_project through the data-source fold', async () => {
148+
const { dataSource } = renderWorkbench(workbench.source);
149+
150+
await waitFor(() => expect(gridProps.length).toBeGreaterThan(0));
151+
// The object the view was handed came from `data={{ provider: 'object' }}`.
152+
expect(gridProps[gridProps.length - 1]?.schema?.objectName).toBe(OBJECT);
153+
// And it is live: ListView fetched that object's definition — a call no
154+
// other code on the page makes while the form is closed …
155+
expect(dataSource.getObjectSchema).toHaveBeenCalledWith(OBJECT);
156+
// … and queried the object for its columns, which the stats strip's own
157+
// `{ $top: 200 }` query does not.
158+
expect(dataSource.find).toHaveBeenCalledWith(
159+
OBJECT,
160+
expect.objectContaining({ $select: expect.arrayContaining(COLUMNS) }),
161+
);
162+
});
163+
164+
it('control — the same source with the data binding stripped binds ListView to nothing', async () => {
165+
const stripped = workbench.source.replace(`${DATA_BINDING} `, '');
166+
expect(stripped).not.toBe(workbench.source);
167+
168+
const { dataSource, container } = renderWorkbench(stripped);
169+
170+
// ListView's root reports `idle` only after its fetch effect has run —
171+
// the branch a query would have started from.
172+
await waitFor(() =>
173+
expect(container.querySelector('[role="region"][data-state="idle"]')).not.toBeNull(),
174+
);
175+
expect(dataSource.getObjectSchema).not.toHaveBeenCalled();
176+
expect(dataSource.find.mock.calls.filter(([, params]) => !isStatsQuery(params))).toEqual([]);
177+
expect(gridProps.filter((p) => p.schema?.objectName)).toEqual([]);
178+
});
179+
180+
it('no preview page writes a retired react-tier prop (react-prop-retired)', () => {
181+
expect(allPages.map((p) => `${p.file}:${p.name}`)).toContain(`${HARNESS}:${workbench.name}`);
182+
const retired = validateReactPageProps({ pages: allPages as unknown as Record<string, unknown>[] })
183+
.filter((f) => f.rule === REACT_PROP_RETIRED);
184+
expect(retired.map((f) => `${f.where}: ${f.message}`)).toEqual([]);
185+
});
186+
187+
it('control — putting the retired spelling back turns that check red', () => {
188+
const regressed = workbench.source.replace(DATA_BINDING, RETIRED_BINDING);
189+
expect(regressed).not.toBe(workbench.source);
190+
191+
const findings = validateReactPageProps({
192+
pages: [{ type: 'home', kind: 'react', name: workbench.name, source: regressed }],
193+
});
194+
expect(findings.map((f) => f.rule)).toEqual([REACT_PROP_RETIRED]);
195+
});
196+
});

‎apps/console/src/sdui-workbench-preview.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function Page() {
108108
</div>
109109
<div className="grid grid-cols-5 gap-6">
110110
<section className="col-span-3 rounded-xl border border-slate-200 bg-white p-2">
111-
<ListView key={reloadKey} objectName="showcase_project" fields={['name','status','health','budget','owner']} navigation={{ mode: 'none' }} onRowClick={onRowClick} />
111+
<ListView key={reloadKey} data={{ provider: 'object', object: 'showcase_project' }} fields={['name','status','health','budget','owner']} navigation={{ mode: 'none' }} onRowClick={onRowClick} />
112112
</section>
113113
<section className="col-span-2 rounded-xl border border-slate-200 bg-white p-5">
114114
{editing ? (

‎content/docs/guide/react-pages.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ another, custom data fetching. It runs **without a sandbox**.
3939
"type": "home",
4040
"name": "project_console",
4141
"kind": "react",
42-
"source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>\n <ListView objectName=\"showcase_project\" fields={['name', 'status']} onRowClick={(r) => setSelected(r._id)} />\n {selected && <ObjectForm objectName=\"showcase_project\" mode=\"edit\" recordId={selected} />}\n </div>\n );\n}"
42+
"source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>\n <ListView data={{ provider: 'object', object: 'showcase_project' }} fields={['name', 'status']} onRowClick={(r) => setSelected(r._id)} />\n {selected && <ObjectForm objectName=\"showcase_project\" mode=\"edit\" recordId={selected} />}\n </div>\n );\n}"
4343
}
4444
```
4545

@@ -51,7 +51,7 @@ function Page() {
5151
return (
5252
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
5353
<ListView
54-
objectName="showcase_project"
54+
data={{ provider: 'object', object: 'showcase_project' }}
5555
fields={['name', 'status']}
5656
onRowClick={(r) => setSelected(r._id)}
5757
/>
@@ -105,7 +105,7 @@ repo. **Treat that table as the prop authority, not this page.**
105105
Everything in the runtime scope but outside the contract still resolves and
106106
renders — its props simply are not part of the react-tier contract. Reach those
107107
through the contract instead: a kanban / calendar / gantt / timeline / map of an
108-
object is `<ListView viewType="kanban" …>`, or `<Block type="object-kanban" …>`.
108+
object is `<ListView type="kanban" …>`, or `<Block type="object-kanban" …>`.
109109

110110
#### The `record:*` family is excluded from this tier
111111

@@ -131,7 +131,7 @@ the same way. On a react page, bind the record yourself:
131131
|---|---|
132132
| `<RecordDetails>` | `<ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />` — it binds by its own props. |
133133
| `<RecordHighlights>` | `<ObjectForm … mode="view" />`, or read the record with `useAdapter().findOne` and lay the strip out in JSX. |
134-
| `<RecordRelatedList>` | `<ListView objectName="child_object" filters={['lookup_field', '=', parentId]} />` — the parent binding is an ordinary filter here. |
134+
| `<RecordRelatedList>` | `<ListView data={{ provider: 'object', object: 'child_object' }} filters={['lookup_field', '=', parentId]} />` — the parent binding is an ordinary filter here. |
135135
| `<RecordPath>` | Read the record with `useAdapter().findOne` and render the stage bar in JSX. |
136136

137137
If you want the whole record-page composition, author the page as `type:'record'`
@@ -204,7 +204,7 @@ An injected block folds its JSX props into the block's schema, so you write
204204
flat props rather than a nested `schema` object:
205205

206206
```jsx
207-
<ListView objectName="showcase_project" fields={['name', 'status']} pagination={{ pageSize: 25 }} />
207+
<ListView data={{ provider: 'object', object: 'showcase_project' }} fields={['name', 'status']} pagination={{ pageSize: 25 }} />
208208
```
209209

210210
Use the **canonical** spelling of each prop — the one the contract publishes.

0 commit comments

Comments
 (0)