diff --git a/.changeset/8255-docs-spec-shaped-list-view.md b/.changeset/8255-docs-spec-shaped-list-view.md new file mode 100644 index 0000000000..6d439af548 --- /dev/null +++ b/.changeset/8255-docs-spec-shaped-list-view.md @@ -0,0 +1,15 @@ +--- +--- + +Docs and a test only, no package released. The plugin-view README, `content/docs/plugins/plugin-view.mdx` +and `content/docs/api/schema-reference.md` now teach the spec-shaped named list view: every `listViews` +entry carries `columns`, the one key `@objectstack/spec`'s `ObjectListViewSchema` requires, and the +schema reference's `my-deals` view spells its filter as a `{ field, operator, value }` rule object +instead of the ObjectQL tuple, with the spec's own context token `{current_user_id}` as its value +(objectui#8255, the docs half of the objectui#7928 ruling). `content/docs/guide/building-crud-app.md`'s +`TaskSchema.listViews.active` gets the same filter rewrite. + +The `@object-ui/types` pin that held the refused forms as "still what the docs teach" now asserts the +successor: it reads every named view those four pages author off disk with the TypeScript parser and +requires each to parse under `ObjectListViewSchema`, with a positive control that the retired forms +are refused by the same predicate. The spec-side refusal assertions in that pin are unchanged. diff --git a/.changeset/object-view-unmirrored-keys-7779.md b/.changeset/object-view-unmirrored-keys-7779.md index b3d50af093..44221551ed 100644 --- a/.changeset/object-view-unmirrored-keys-7779.md +++ b/.changeset/object-view-unmirrored-keys-7779.md @@ -54,11 +54,13 @@ reach the renderer through an `as any` cast on the named-view config in objectui#8980 ruling declared it by name — objectui#7928's open half, answered. The spec's `ViewSchema.listViews` is a record of the STRICT `ObjectListViewSchema`, which requires `columns` and -refuses `options`, ObjectQL tuple filters and `default` — that is, it refuses +refuses `options`, ObjectQL tuple filters and `default` — that is, it refused the named views this package's own README and -`content/docs/api/schema-reference.md` teach (`{ label: 'All Users' }` fails at -`columns`; `filter: [["owner", "=", "..."]]` fails at `filter.0`). Mirroring the -spec value would lose documented behaviour; mirroring the local value would +`content/docs/api/schema-reference.md` taught when this entry was written +(`{ label: 'All Users' }` fails at `columns`; `filter: [["owner", "=", "..."]]` +fails at `filter.0`), and objectui#8255 has since rewritten them in the spec +shape. Mirroring the +spec value would have lost documented behaviour; mirroring the local value would enforce **43 unread members** (64 declared, minus the 21 that are both declared and read) into the contract — the very thing ruling B refused for the six local keys. The key therefore stays in diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index b2b95ae127..caf261b9f2 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -872,12 +872,14 @@ A complete object management interface combining grid, form, search, filters, an "listViews": { "all": { "label": "All Deals", + "columns": ["name", "stage", "value", "owner", "closeDate"], "filter": [], "sort": [{ "field": "value", "order": "desc" }] }, "my-deals": { - "filter": [["owner", "=", "${currentUser.id}"]], - "label": "My Deals" + "label": "My Deals", + "columns": ["name", "stage", "value", "owner", "closeDate"], + "filter": [{ "field": "owner", "operator": "equals", "value": "{current_user_id}" }] } }, "defaultListView": "my-deals", @@ -893,6 +895,8 @@ A complete object management interface combining grid, form, search, filters, an } ``` +`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the host that renders the list resolves it to the signed-in user's id before the query runs, and resolving it on the `object-view` node path itself is tracked in objectui#10506. + | Property | Type | Description | |----------|------|-------------| | `objectName` | `string` | **Required.** ObjectQL object API name. | diff --git a/content/docs/guide/building-crud-app.md b/content/docs/guide/building-crud-app.md index 534fc67b5d..f390aef0ef 100644 --- a/content/docs/guide/building-crud-app.md +++ b/content/docs/guide/building-crud-app.md @@ -135,7 +135,7 @@ export const TaskSchema = { active: { label: 'Active', columns: ['title', 'status', 'priority', 'assignee', 'due_date'], - filter: [['status', '!=', 'Done']], + filter: [{ field: 'status', operator: 'not_equals', value: 'Done' }], sort: [{ field: 'priority', order: 'asc' }], }, }, diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 8441514a71..f0d92e8efe 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -69,7 +69,7 @@ const shape: ObjectViewSchema = { // --- List surface --- defaultViewType: 'grid', // grid | kanban | gallery | calendar | timeline | gantt | map - listViews: { all: { label: 'All Users' } }, // named views; each needs a `label` + listViews: { all: { label: 'All Users', columns: ['name', 'email'] } }, // named views; each needs `label` and `columns` defaultListView: 'all', table: { columns: ['name', 'email'] }, // grid configuration (see below) @@ -401,6 +401,7 @@ const productViews: ObjectViewSchema = { cheap: { label: 'Under 100', type: 'grid', + columns: ['name', 'price'], filter: [{ field: 'price', operator: 'less_than', value: 100 }], }, }, @@ -525,6 +526,7 @@ const userTabs: ObjectViewSchema = { admins: { label: 'Admins', type: 'grid', + columns: ['name', 'email'], filter: [{ field: 'role', operator: 'equals', value: 'admin' }], }, }, diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 76a244922f..e7f0e491f2 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -151,7 +151,7 @@ const shape: ObjectViewSchema = { // --- List surface --- defaultViewType: 'grid', // grid | kanban | gallery | calendar | timeline | gantt | map - listViews: { all: { label: 'All Users' } }, // named views; each needs a `label` + listViews: { all: { label: 'All Users', columns: ['name', 'email'] } }, // named views; each needs `label` and `columns` defaultListView: 'all', table: { columns: ['name', 'email'] }, // grid configuration (see below) @@ -466,6 +466,7 @@ const schema: ObjectViewSchema = { cheap: { label: 'Under 100', type: 'grid', + columns: ['name', 'price'], filter: [{ field: 'price', operator: 'less_than', value: 100 }], }, }, @@ -624,6 +625,7 @@ const schema: ObjectViewSchema = { admins: { label: 'Admins', type: 'grid', + columns: ['name', 'email'], filter: [{ field: 'role', operator: 'equals', value: 'admin' }], }, }, diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index b6c7369c54..a0b1182232 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -60,10 +60,13 @@ * ⛔ Do not quote 47 / 6 / 41 from anywhere: they are this file's history, not * its reading. The spec's * `ViewSchema.listViews` is a record of the STRICT `ObjectListViewSchema`, and - * the spec value refuses the named views this package's docs teach. Both facts - * are asserted against the SPEC schema here, so the day the spec relaxes (or - * the renderer's read set moves) the measurement — and the stop — is re-taken - * rather than remembered. + * the spec value refuses the local named-view dialect this package's docs + * taught until objectui#8255 (a view with no `columns`, an ObjectQL tuple + * filter). Both facts are asserted against the SPEC schema here, so the day the + * spec relaxes (or the renderer's read set moves) the measurement — and the + * stop — is re-taken rather than remembered. objectui#8255 rewrote those docs in + * the spec shape, and the `listViews` block below parses every named view they + * author through that same schema, so the docs cannot drift back unseen. * * ## objectui#7924 — the same measurement, now PER MEMBER * @@ -1037,6 +1040,59 @@ describe('objectui#7779 — the zod mirror REFUSES `viewTabBar` by name', () => /* ── `listViews`: the measurement that keeps it in the ledger ─────────────── */ +/** + * The pages that author named list views (objectui#8255): three on an + * `object-view` node, plus the CRUD guide's object definition, whose + * `listViews` the spec types with the same `ObjectListViewSchema` value. + */ +const NAMED_VIEW_DOCS = [README, DOC, 'content/docs/api/schema-reference.md', 'content/docs/guide/building-crud-app.md'] as const; + +/** A literal expression's value. Anything else THROWS, so a reading is never silently partial. */ +function literalValue(node: ts.Expression, where: string): unknown { + const e = unwrapExpr(node); + if (ts.isObjectLiteralExpression(e)) { + const out: Record = {}; + for (const p of e.properties) { + if (!ts.isPropertyAssignment(p) || !(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) { + throw new Error(`${where}: \`${p.getText()}\` is not a literal property, so this reading cannot judge it`); + } + out[p.name.text] = literalValue(p.initializer, where); + } + return out; + } + if (ts.isArrayLiteralExpression(e)) return e.elements.map((x) => literalValue(x, where)); + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; + if (ts.isNumericLiteral(e)) return Number(e.text); + if (e.kind === ts.SyntaxKind.TrueKeyword) return true; + if (e.kind === ts.SyntaxKind.FalseKeyword) return false; + if (e.kind === ts.SyntaxKind.NullKeyword) return null; + throw new Error(`${where}: \`${e.getText()}\` is not a literal, so this reading cannot judge it`); +} + +/** + * Every named view a page authors, off disk: each fenced block that mentions + * `listViews` is parsed with the TypeScript parser (a `json` body inside an + * initializer), and the entries of every `listViews` property come back by name. + */ +function docNamedViews(rel: string): Array<{ where: string; view: unknown }> { + const text = readRepo(rel); + const out: Array<{ where: string; view: unknown }> = []; + for (const m of text.matchAll(/^```(\w*)\n([\s\S]*?)^```$/gm)) { + const lang = m[1] ?? ''; + const body = m[2] ?? ''; + if (!body.includes('listViews')) continue; + const at = `${rel}:${text.slice(0, m.index ?? 0).split('\n').length}`; + const code = /^jsonc?$/.test(lang) ? `const doc = ${body};` : body; + const sf = ts.createSourceFile(`${at}.tsx`, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + for (const n of allNodes(sf)) { + if (!ts.isPropertyAssignment(n) || !(ts.isIdentifier(n.name) || ts.isStringLiteral(n.name)) || n.name.text !== 'listViews') continue; + const views = literalValue(n.initializer, at) as Record; + for (const [name, view] of Object.entries(views)) out.push({ where: `${at} listViews.${name}`, view }); + } + } + return out; +} + describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallback clause; the measurement is pinned against the SPEC', () => { it('the spec slot `ViewSchema.listViews` is a record whose value is the strict `ObjectListViewSchema`', () => { const slot = shapeMember(SpecViewSchema, 'listViews') as { unwrap(): { def?: { type?: string; valueType?: unknown }; _def?: { type?: string; valueType?: unknown } } }; @@ -1051,19 +1107,19 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba if (!r.success) expect((r.error.issues as readonly Issue[]).some((i) => i.code === 'unrecognized_keys')).toBe(true); }); - it('the spec value REFUSES the named views this package\'s docs teach — the behaviour a by-reference mirror would lose', () => { - // README / plugin-view.mdx: `listViews: { all: { label: 'All Users' } }` — - // "each needs a `label`", nothing else. The spec requires `columns`. + it('the spec value REFUSES the named-view dialect this package\'s docs taught until objectui#8255 — the behaviour a by-reference mirror would have lost', () => { + // README / plugin-view.mdx taught `listViews: { all: { label: 'All Users' } }` + // — "each needs a `label`", nothing else. The spec requires `columns`. const labelOnly = SpecObjectListViewSchema.safeParse({ label: 'All Users' }); expect(labelOnly.success).toBe(false); if (!labelOnly.success) expect(refusedAt(labelOnly.error.issues as readonly Issue[], 'columns')).toBe(true); - // README: a `filter`-only view with `type: 'grid'` and no `columns`. + // README taught a `filter`-only view with `type: 'grid'` and no `columns`. const filtered = SpecObjectListViewSchema.safeParse({ label: 'Under 100', type: 'grid', filter: [{ field: 'price', operator: 'less_than', value: 100 }] }); expect(filtered.success).toBe(false); if (!filtered.success) expect(refusedAt(filtered.error.issues as readonly Issue[], 'columns')).toBe(true); - // schema-reference.md: an ObjectQL tuple filter. Only `tuple` still has a - // doc side, and that side is HELD — the "still what the docs teach" test - // below pins this exact filter string off disk. + // schema-reference.md taught an ObjectQL tuple filter. None of these three + // forms has a doc side any more: objectui#8255 rewrote every one in the spec + // shape, and the next test parses what those pages author now. const tuple = SpecObjectListViewSchema.safeParse({ label: 'My Deals', columns: ['name'], filter: [['owner', '=', '${currentUser.id}']] }); expect(tuple.success).toBe(false); if (!tuple.success) expect(refusedAt(tuple.error.issues as readonly Issue[], 'filter')).toBe(true); @@ -1083,10 +1139,26 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba expect(SpecObjectListViewSchema.safeParse({ label: 'Directory', columns: ['name', 'email'] }).success).toBe(true); }); - it('the documented shapes the spec refuses are still what the docs teach (the measurement\'s inputs, off disk)', () => { - expect(readRepo(README)).toContain("listViews: { all: { label: 'All Users' } }"); - expect(readRepo(DOC)).toContain("listViews: { all: { label: 'All Users' } }"); - expect(readRepo('content/docs/api/schema-reference.md')).toContain('"filter": [["owner", "=", "${currentUser.id}"]],'); + it('the docs now teach the spec shape — every named view the four pages author parses under `ObjectListViewSchema` (objectui#8255, off disk)', () => { + // The predicate, and the SAME function the positive control below runs. + const refused = (entries: ReadonlyArray<{ where: string; view: unknown }>): string[] => + entries.filter((e) => !SpecObjectListViewSchema.safeParse(e.view).success).map((e) => e.where); + for (const doc of NAMED_VIEW_DOCS) { + const entries = docNamedViews(doc); + expect(entries.length, `${doc} authors no \`listViews\` entry any more — this reading would pass vacuously`).toBeGreaterThan(0); + expect( + refused(entries), + `${doc} teaches a named view the spec's ObjectListViewSchema refuses. Every entry needs \`columns\`, and \`filter\` is \`{ field, operator, value }\` rule objects, never an ObjectQL tuple.`, + ).toEqual([]); + } + // POSITIVE CONTROL: the predicate goes red on the dialect these pages taught + // before objectui#8255, and stays quiet on the spec shape, so the empty + // readings above are readings. + expect(refused([ + { where: 'label-only', view: { label: 'All Users' } }, + { where: 'tuple', view: { label: 'My Deals', columns: ['name'], filter: [['owner', '=', '${currentUser.id}']] } }, + { where: 'spec', view: { label: 'Directory', columns: ['name', 'email'] } }, + ])).toEqual(['label-only', 'tuple']); }); it('the renderer reads twenty-one keys off a named view — every one of them a declared `NamedListView` member since objectui#8980 — of a declaration with 64, the reason a local key-for-key mirror is still not the answer', () => { diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 4a422e4ce5..91cde502cf 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -456,6 +456,7 @@ export const ADJUDICATED = new Map([ { reads: [ 'content/docs/api/schema-reference.md', + 'content/docs/guide/building-crud-app.md', 'content/docs/plugins/plugin-view.mdx', 'packages/plugin-view/README.md', ],