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
15 changes: 15 additions & 0 deletions .changeset/8255-docs-spec-shaped-list-view.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions .changeset/object-view-unmirrored-keys-7779.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/building-crud-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
},
},
Expand Down
4 changes: 3 additions & 1 deletion content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -401,6 +401,7 @@ const productViews: ObjectViewSchema = {
cheap: {
label: 'Under 100',
type: 'grid',
columns: ['name', 'price'],
filter: [{ field: 'price', operator: 'less_than', value: 100 }],
},
},
Expand Down Expand Up @@ -525,6 +526,7 @@ const userTabs: ObjectViewSchema = {
admins: {
label: 'Admins',
type: 'grid',
columns: ['name', 'email'],
filter: [{ field: 'role', operator: 'equals', value: 'admin' }],
},
},
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -466,6 +466,7 @@ const schema: ObjectViewSchema = {
cheap: {
label: 'Under 100',
type: 'grid',
columns: ['name', 'price'],
filter: [{ field: 'price', operator: 'less_than', value: 100 }],
},
},
Expand Down Expand Up @@ -624,6 +625,7 @@ const schema: ObjectViewSchema = {
admins: {
label: 'Admins',
type: 'grid',
columns: ['name', 'email'],
filter: [{ field: 'role', operator: 'equals', value: 'admin' }],
},
},
Expand Down
102 changes: 87 additions & 15 deletions packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string, unknown> = {};
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<string, unknown>;
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 } } };
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions scripts/markdown-test-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
Loading