From c7936621d832576f74e7d28b6eaa734893eb8310 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:04:54 +0000 Subject: [PATCH] docs(api): spell the object-view example's default list view at the node level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/api/schema-reference.md`'s `ObjectViewSchema` example authored `listViews["my-deals"].default: true`. `NamedListView` declares no `default` member, and `ObjectView.tsx` picks the opening view from the NODE-level `schema.defaultListView` (falling back to the first key) — nothing reads an entry-level flag. `BaseSchema` is passthrough, so the example validated green while the view it labelled "default" never opened first, twenty rows above the same page's property table that documents `defaultListView` correctly. The example now spells `"defaultListView": "my-deals"` on the node and drops the undeclared key. `my-deals`' two remaining members are reordered so the filter line keeps its trailing comma: `object-view-unmirrored-keys-7779.test.ts` holds that line verbatim, comma included, and that file is held by another PR. A new fixture pin, `schema-reference-named-list-view-keys-7923.test.ts`, holds the example's `listViews` keys to `NamedListView`'s declared members, both sides parsed off disk. A type annotation would catch nothing here: `BaseSchema` closes with `[key: string]: any`, and the fence is JSON so no snippet gate compiles it. The example's filter DIALECT is deliberately untouched — which spelling `mergeFilterNodes` runs is unmeasured, and that is objectui#2890's half. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM7kaS4dPpYHV5BsMyu4tQ --- ...3-schema-reference-named-list-view-keys.md | 10 + content/docs/api/schema-reference.md | 4 +- ...eference-named-list-view-keys-7923.test.ts | 219 ++++++++++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 .changeset/7923-schema-reference-named-list-view-keys.md create mode 100644 packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts diff --git a/.changeset/7923-schema-reference-named-list-view-keys.md b/.changeset/7923-schema-reference-named-list-view-keys.md new file mode 100644 index 0000000000..7fe632e9a5 --- /dev/null +++ b/.changeset/7923-schema-reference-named-list-view-keys.md @@ -0,0 +1,10 @@ +--- +--- + +Documentation and test only. `content/docs/api/schema-reference.md`'s `object-view` +example no longer authors `listViews["my-deals"].default` — a key `NamedListView` +never declared and `ObjectView` never read — and now spells the node-level +`defaultListView` the renderer actually reads. The new pin +`packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts` +holds that example's `listViews` keys to `NamedListView`'s declared members. +No published behaviour changes (objectui#7923). diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 7f2221b0fc..87e58fa528 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -859,11 +859,11 @@ A complete object management interface combining grid, form, search, filters, an "sort": [{ "field": "value", "order": "desc" }] }, "my-deals": { - "label": "My Deals", "filter": [["owner", "=", "${currentUser.id}"]], - "default": true + "label": "My Deals" } }, + "defaultListView": "my-deals", "table": { "columns": ["name", "stage", "value", "owner", "closeDate"], "pageSize": 25 diff --git a/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts b/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts new file mode 100644 index 0000000000..49448ffe5e --- /dev/null +++ b/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts @@ -0,0 +1,219 @@ +/** + * 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#7923 — `content/docs/api/schema-reference.md`'s `ObjectViewSchema` + * example may only author `listViews` entry keys that `NamedListView` declares. + * + * ## The defect this pin closes + * + * The example's `my-deals` entry carried `"default": true`. `NamedListView` + * (`../objectql.ts`) declares no `default` member and + * `packages/plugin-view/src/ObjectView.tsx:701` picks the opening view from the + * NODE-level `ObjectViewSchema.defaultListView`, falling back to the first key — + * nothing anywhere reads an entry-level flag. So the example taught a key that + * does not exist, and the view it labelled "default" never opened first, while + * the SAME page's property table twenty rows below already documented + * `defaultListView` correctly. The page contradicted itself. + * + * ## Why a fixture pin and not a type annotation + * + * Annotating the snippet against `ObjectViewSchema` would catch nothing. + * `BaseSchema` closes with `[key: string]: any` (`../base.ts`), so every + * invented key type-checks through the index signature — measured directly in + * objectui#7927 (probe P5: renaming `titleField` to `titleFieldd` inside an + * annotated block left `check:doc-snippets` at exit 0). The annotation checks a + * key's TYPE and never its NAME. `NamedListView` is a standalone interface that + * does NOT extend `BaseSchema`, so its declared members are a genuinely closed + * list — which is what makes a name-level comparison possible here at all. + * + * Nor does any gate cover the block: `check-doc-snippet-types.mjs` compiles + * `ts` / `tsx` / `typescript` fences only (`TS_FENCE_LANGUAGES`), and this + * example is a ```json fence, so it is collected and never compiled + * (objectui#5250). The page is on that gate's covered surface — it is not in + * `UNGATED_DOCS` — which is precisely why "covered" did not mean "checked". + * + * ## Both sides are parsed off disk, never restated here + * + * The shape is `packages/plugin-calendar/src/readme-calendar-view-schema.test.ts` + * (objectui#7925): the declaration is brace-matched out of the interface source + * and the doc side is parsed out of the markdown on every run, so a red here + * always means "fix the doc (or the declaration)", never "update the test". + * Restating the member list in this file would reproduce the original defect one + * layer up — the doc was itself a confident hand-written restatement. + * Everything that could make this pin pass vacuously — a moved heading, a + * renamed interface, a fence that stopped being JSON — throws LOUDLY instead. + * + * ⛔ Out of scope, deliberately: the example's `filter` DIALECT (the ObjectQL + * tuple form `[["owner", "=", …]]` versus the `{ field, operator, value }` + * objects `packages/plugin-view/README.md` teaches). `NamedListView.filter` is + * an untyped array so both spellings are declared-legal and this pin cannot and + * must not adjudicate them; which one `mergeFilterNodes` runs is unmeasured. + * That is objectui#2890's half. The filter LINE is additionally held verbatim + * by `object-view-unmirrored-keys-7779.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Walk up to the workspace root, so both sources are found by repo layout. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i += 1) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + dir = resolve(dir, '..'); + } + throw new Error('repo root (pnpm-workspace.yaml) not found from this test file'); +} + +const ROOT = repoRoot(); +const DOC = 'content/docs/api/schema-reference.md'; +const DECLARATION = 'packages/types/src/objectql.ts'; +const HEADING = '\n### ObjectViewSchema\n'; +const INTERFACE = 'NamedListView'; + +function readRepo(rel: string): string { + return readFileSync(join(ROOT, rel), 'utf8'); +} + +/** + * The own (not inherited) top-level member names of a TS interface, by brace + * matching rather than a line regex: the body carries JSDoc blocks, nested + * object literals (`sharing`, `addRecord`, `emptyState`, `aria`) and union + * types with their own punctuation, and a nested member must NOT be counted as + * a top-level one. + */ +function interfaceMembers(rel: string, name: string): Set { + const src = readRepo(rel); + const opener = new RegExp(`export interface ${name}\\b[^{]*\\{`).exec(src); + if (!opener) throw new Error(`interface ${name} not found in ${rel} — this pin reads it there`); + + let i = opener.index + opener[0].length; + const start = i; + for (let depth = 1; depth > 0; i += 1) { + if (i >= src.length) throw new Error(`unterminated interface ${name} in ${rel}`); + if (src[i] === '{') depth += 1; + else if (src[i] === '}') depth -= 1; + } + + const body = src + .slice(start, i - 1) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + + const members = new Set(); + let depth = 0; + let buf = ''; + const flush = () => { + const m = /^(?:readonly\s+)?([A-Za-z_$][\w$]*)(?:\?)?\s*:/.exec(buf.trim()); + if (m) members.add(m[1]); + buf = ''; + }; + for (const ch of body) { + if ('{(['.includes(ch)) depth += 1; + if ('})]'.includes(ch)) depth -= 1; + if ((ch === ';' || ch === '\n') && depth === 0) flush(); + else buf += ch; + } + flush(); + + if (members.size === 0) throw new Error(`parsed zero members out of ${name} in ${rel}`); + return members; +} + +/** The `object-view` example under the `### ObjectViewSchema` heading, parsed. */ +function objectViewExample(): Record { + const src = readRepo(DOC); + const heading = src.indexOf(HEADING); + if (heading < 0) throw new Error(`"${HEADING.trim()}" heading not found in ${DOC} — the example moved`); + + const next = src.indexOf('\n### ', heading + HEADING.length); + const section = src.slice(heading, next < 0 ? src.length : next); + const fence = /```json\n([\s\S]*?)\n```/.exec(section); + if (!fence) throw new Error(`no json fence under "${HEADING.trim()}" in ${DOC}`); + + const node = JSON.parse(fence[1]) as Record; + if (node.type !== 'object-view') { + throw new Error(`the first json fence under "${HEADING.trim()}" is not an object-view node (type: ${String(node.type)})`); + } + return node; +} + +function namedListViews(node: Record): Record> { + const views = node.listViews; + if (views == null || typeof views !== 'object') throw new Error(`the ${DOC} object-view example no longer authors \`listViews\``); + const entries = views as Record>; + if (Object.keys(entries).length === 0) throw new Error(`the ${DOC} object-view example authors an EMPTY \`listViews\` — this pin would pass vacuously`); + return entries; +} + +/** + * The predicate. Every key authored across the example's named views that + * `NamedListView` does not declare — `[]` is the passing reading. The positive + * control below runs this same function, so the control cannot drift from it. + */ +function undeclaredKeys(entries: Record>, declared: Set): string[] { + const offending = new Set(); + for (const view of Object.values(entries)) { + for (const key of Object.keys(view)) if (!declared.has(key)) offending.add(key); + } + return [...offending].sort(); +} + +const declared = interfaceMembers(DECLARATION, INTERFACE); +const example = objectViewExample(); +const views = namedListViews(example); + +describe(`objectui#7923 — ${DOC}'s object-view example against \`${INTERFACE}\``, () => { + it('parsed a real declaration and a real example — the live controls, so a zero below is a reading', () => { + // If the brace matcher silently produced junk, `label` (the interface's one + // REQUIRED member) is the first thing to go missing. + expect([...declared], `\`${INTERFACE}\` no longer declares \`label\` — the parse is wrong, not the doc`).toContain('label'); + expect(declared.size, `\`${INTERFACE}\` parsed to ${declared.size} members; it declares dozens (objectui#7924)`).toBeGreaterThan(30); + // And the doc side: two named views, one of which is the one this card fixed. + expect(Object.keys(views).sort()).toEqual(['all', 'my-deals']); + }); + + it(`authors no key \`${INTERFACE}\` does not declare — \`default\` was the offender`, () => { + expect( + undeclaredKeys(views, declared), + `${DOC}'s object-view example authors \`listViews\` key(s) that \`${INTERFACE}\` ` + + '(packages/types/src/objectql.ts) does not declare. An undeclared key here is not ' + + 'caught by anything else: the fence is JSON so no snippet gate compiles it, and ' + + '`BaseSchema`\'s index signature would swallow an annotation anyway (objectui#7927). ' + + 'Fix the doc — or declare the key AND give it a read site.', + ).toEqual([]); + }); + + it('names its opening view at the NODE level, with a key that exists', () => { + // The renderer reads `schema.defaultListView` (ObjectView.tsx:701) and + // otherwise opens the first key. This is where "which view opens first" + // has to be spelled for the example's stated intent to be true. + const key = example.defaultListView; + expect(typeof key, `${DOC}'s object-view example must spell \`defaultListView\` — the entry-level flag it used to carry was read by nothing`).toBe('string'); + expect(Object.keys(views), `\`defaultListView\` names "${String(key)}", which is not one of the example's own listViews`).toContain(key as string); + }); + + it('POSITIVE CONTROL: the same predicate goes red on the key this card removed', () => { + // The control's precondition, stated as an assertion rather than assumed: + // if `default` ever becomes a declared AND read member, this line is the + // tripwire that says so — re-take objectui#7923's decision, do not delete + // this test. + expect( + declared.has('default'), + `\`${INTERFACE}\` now declares \`default\`. This control planted it precisely because it did not; re-take objectui#7923 before touching this file.`, + ).toBe(false); + + const planted: Record> = { ...views, 'my-deals': { ...views['my-deals'], default: true } }; + expect(undeclaredKeys(planted, declared)).toEqual(['default']); + // …and the untouched entry is still clean, so the red above is the plant. + expect(undeclaredKeys({ all: planted.all }, declared)).toEqual([]); + }); +});