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
4 changes: 2 additions & 2 deletions .changeset/11068-grid-declared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ now authors only keys the grid reads.
refused by name.

**Unchanged.** An `object-view`'s `table` slot still withholds `description` and `emptyState`.
Write them on the `object-grid` node itself. `showFilters` and `keyboardNavigation` are still
declared and still not read.
Write them on the `object-grid` node itself. `keyboardNavigation` is still declared and still
not read.
26 changes: 26 additions & 0 deletions .changeset/11068-object-grid-show-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/types': minor
---

`showFilters` is retired on `object-grid` (objectui#11068).

**BREAKING (authoring).** An `object-grid` that carries `showFilters` now fails validation,
and TypeScript code that writes it on an `ObjectGridSchema` no longer compiles. Delete the key.

**What was wrong.** `ObjectGridSchema` declared `showFilters`, but an `object-grid` has no filter
UI and never read the key. An author who wrote `showFilters: true` on a grid got no type error,
no validator refusal and no filter.

**Where filtering lives.** The filter builder is the `list-view` toolbar's. Author a `list-view`
and switch the builder with its `userActions.filter`. To narrow the rows a grid fetches, write
`filter` on the grid.

**How it is refused.** The key is `?: never` on the `ObjectGridSchema` interface and a named
refusal on its Zod twin. On both authoring faces, the node face and the strict authoring face,
the message names the key and `userActions.filter`. This matches the upstream protocol, whose
`object-grid` row does not declare the key either.

**Unchanged.** An `object-view`'s own `showFilters` and a `list-view`'s are different keys, and
both are still accepted. `ObjectView` reads its own, and `ListView` folds its own onto
`userActions.filter`. An `object-view`'s `table` slot already refused `showFilters` and still
does.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ published is not an error: the expression is returned as its own source text.
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```
Expand Down
14 changes: 11 additions & 3 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,9 +766,9 @@ A data grid that auto-fetches from an ObjectQL object definition. Includes searc
```

Every key in this example is one the grid reads (objectui#11068). It used to
also author `showFilters`, `striped` and `operations.read`, which nothing reads,
and `selection: { enabled, mode }` and `pagination.enabled`, which the validator
refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`,
also author `showFilters` (now retired on this node, below), `striped` and
`operations.read`, which nothing reads, and `selection: { enabled, mode }` and
`pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`,
`'single'` or `'multiple'`), and `pagination` declares no on switch — its
presence enables paging.

Expand Down Expand Up @@ -798,6 +798,14 @@ presence enables paging.
> `@object-ui/types` refuse them by name. Write `rowActions`, `bulkActions`, `id`
> or `label`, and `emptyState: { "message": … }` instead.

> **`showFilters` is retired on this node too (objectui#11068).** An `object-grid`
> has no filter UI, and nothing read the key. The filter builder is the
> `list-view` toolbar's: author a `list-view` node and switch it with
> `"userActions": { "filter": true }`. To narrow the rows a grid fetches, write
> `filter`. Both faces of `@object-ui/types` refuse an authored `showFilters` on an
> `object-grid` by name. An [`object-view`](#objectviewschema)'s own `showFilters`,
> below, is a different key and is unchanged.

> **`defaultSort` is retired (objectui#5861).** `ObjectGridSchema` used to accept a
> legacy single-entry `defaultSort: { field, order }` beside `sort`. The installed
> `@objectstack/spec` protocol refuses it by name (a retired-key tombstone), and no
Expand Down
6 changes: 6 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ this node (objectui#11068): nothing ever read them, and both faces of
`@object-ui/types` refuse them by name. Write `id` or `label`,
`emptyState: { message }`, `rowActions` and `bulkActions` instead.

`showFilters` is **retired** on this node too (objectui#11068). The grid has no
filter UI, and nothing read the key. The filter builder is the `list-view`
toolbar's, switched by its `userActions.filter`; to narrow the rows a grid
fetches, write `filter`. An `object-view`'s own `showFilters` is a different key
and is unchanged.

### Column Definition

A column is either a **field name** (`"name"`) or a `ListColumn` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
* Every key `ObjectGridSchema` declares takes effect or is retired
* (objectui#11068) — the RENDERER half. The declaration half (the four
* tombstones and the `emptyState` mirror) is `packages/types`'
* `object-grid-declared-keys-11068.test.ts`.
* `object-grid-declared-keys-11068.test.ts`; the fifth tombstone, `showFilters`,
* is `object-grid-show-filters-retired-11068.test.ts` there.
*
* 1. `description` draws one line of help text above the grid, resolved like
* `label` — a locale map against the display locale.
* 2. `emptyState` draws in place of an empty table — but not when a term in
* the grid's own server-side search box emptied it, where the table and its
* search box must stay.
* 3. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` — the four
* keys the card retired — draw nothing. Asked with a RULER, the same
* document drawn twice and compared as bytes, because `ObjectGridRenderer`
* 3. `name`, `placeholder`, `rowSpecActions`, `bulkSpecActions` and
* `showFilters` — the five keys the card retired — draw nothing. Asked
* with a RULER, the same document drawn twice and compared as bytes,
* because `ObjectGridRenderer`
* hands a node's leftover keys on as props and a source grep cannot see
* that channel (AGENTS.md, "a source grep's zero cannot answer this").
*
Expand Down Expand Up @@ -172,7 +174,7 @@ describe('`emptyState` draws in place of an empty table (objectui#11068)', () =>
});
});

/* ── 3. The four retired keys draw nothing ────────────────────────────────── */
/* ── 3. The five retired keys draw nothing ────────────────────────────────── */

/** Draw one document and return its markup, once the rows are on screen. */
async function draw(schema: Record<string, unknown>): Promise<string> {
Expand All @@ -198,13 +200,17 @@ describe('the ruler', () => {
});
});

describe('the four keys objectui#11068 retired change nothing the grid draws', () => {
describe('the five keys objectui#11068 retired change nothing the grid draws', () => {
it.each([
['name', 'all_contacts'],
['placeholder', 'Nothing here yet'],
['rowSpecActions', ['edit', 'delete']],
['bulkSpecActions', ['delete']],
])('`%s`', async (key, value) => {
// Retired after triage's retriage answer: the grid has no filter UI, so
// neither value draws anything — the filter builder is `list-view`'s.
['showFilters', true],
['showFilters', false],
])('`%s` (%j)', async (key, value) => {
const a = await draw(grid(ROWS));
cleanup();
const b = await draw(grid(ROWS, { [key]: value }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
* draw identical bytes (the ruler is stable — no timestamps, no random ids), the
* second is a LIT CONTROL on a key the renderer demonstrably DOES read, and only
* then is the measurement taken. It is taken twice: once on a plain grid and
* once with the filter surface on, since `operators` is a filtering word and a
* grid without a filter affordance would be the wrong corpus to ask.
* once with the grid's search box on (`searchableFields`), since `operators` is
* a filtering word and a grid without its one query affordance would be the
* wrong corpus to ask. That corpus also used to carry `showFilters: true`, which
* never drew anything: an `object-grid` has no filter UI, and objectui#11068
* retired the key on this node.
*
* ⚠️ The claim is bounded by the ruler: this is what the RENDERED output does
* with the key, on these documents. ⛔ It is not a claim that no code anywhere
Expand Down Expand Up @@ -88,11 +91,11 @@ describe('an authored `operators`', () => {
expect(b).toBe(a);
});

it('changes nothing with the filter surface on either', async () => {
const FILTERS = { ...BASE, showFilters: true, searchableFields: ['name'] };
const a = await draw({ ...FILTERS });
it('changes nothing with the search box on either', async () => {
const SEARCH = { ...BASE, searchableFields: ['name'] };
const a = await draw({ ...SEARCH });
cleanup();
const b = await draw({ ...FILTERS, operators: { name: ['equals', 'contains'] } });
const b = await draw({ ...SEARCH, operators: { name: ['equals', 'contains'] } });
expect(b).toBe(a);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* 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.
*/

/**
* `showFilters` is retired on `object-grid` (objectui#11068, triage's answer to
* the card's retriage) — the DECLARATION half. The renderer half, which shows an
* authored `showFilters` draws nothing, is `plugin-grid`'s
* `ObjectGrid.declaredKeys-11068.test.tsx`.
*
* ## Why a tombstone, and why it points at the list view
*
* `ObjectGrid` has no filter UI. The one filter surface objectui draws is the
* `list-view` toolbar's filter builder, switched by `userActions.filter`, so
* honouring the key on the grid would have meant building a second filter
* surface. The upstream protocol's `object-grid` row does not declare the key
* either. Deleting the member would refuse nothing: the zod twin ends
* `.passthrough()` and the interface inherits `BaseSchema`'s index signature.
* So the key stays DECLARED and unwritable on both faces — `retirementTombstone()`
* and `?: never` — as `rowSpecActions` and `defaultSort` are.
*
* ## The two authoring faces, and the controls beside each refusal
*
* The node face (`safeValidateSchema`) and the strict authoring face
* (`StrictAnyComponentSchema`) are both asked. A red parse proves a by-name
* refusal only beside a document that stays green on the same instrument: the
* grid without the key, a list view's own `showFilters` and `userActions.filter`,
* and an object view's own `showFilters` — which `ObjectView` still reads, so it
* is not retired here.
*/

import { describe, it, expect } from 'vitest';
import type { z } from 'zod';
import { ObjectGridPropsSchema as SpecObjectGridPropsSchema, ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui';
import { ObjectGridSchema } from '../zod/objectql.zod.js';
// Both faces through the barrel: objectui#8345 pins it as the only entry into
// the strict face's module cycle, test files included.
import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js';
import type {
ListViewSchema as ListViewSchemaType,
ObjectGridSchema as ObjectGridSchemaType,
ObjectViewSchema as ObjectViewSchemaType,
} from '../objectql.js';

/** A control key no surface in this package or the protocol declares. */
const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11068';

/** The minimum green `object-grid` document. */
const GRID = { type: 'object-grid' as const, objectName: 'probe' };

type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] };

/** The two authoring faces, each as a `safeParse`. */
const FACES = [
['node face (safeValidateSchema)', (doc: unknown) => safeValidateSchema(doc)],
['strict authoring face (StrictAnyComponentSchema)', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)],
] as const;

type Parse = (doc: unknown) => z.ZodSafeParseResult<unknown>;

/** Every issue in the tree, union arms included, with its full path. */
function flatten(issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] {
const out: Issue[] = [];
for (const issue of issues ?? []) {
const path = [...prefix, ...issue.path];
out.push({ ...issue, path });
for (const arm of issue.errors ?? []) out.push(...flatten(arm, path));
}
return out;
}

function issueAt(parse: Parse, doc: unknown, path: string): Issue | undefined {
const result = parse(doc);
if (result.success) return undefined;
return flatten(result.error.issues as unknown as Issue[]).find((i) => i.path.map(String).join('.') === path);
}

describe('an authored `showFilters` on `object-grid` is refused BY NAME on both authoring faces (objectui#11068)', () => {
it.each(FACES)('%s refuses it at its own path, whatever the value', (_face, parse) => {
for (const value of [true, false]) {
const result = parse({ ...GRID, showFilters: value });
expect(result.success, `showFilters: ${value} parsed green`).toBe(false);
const issue = issueAt(parse, { ...GRID, showFilters: value }, 'showFilters');
expect(issue, `no issue at showFilters for ${value}`).toBeDefined();
// `retirementTombstone()` customises the MESSAGE only; the code is a bare `z.never()`'s.
expect(issue?.code).toBe('invalid_type');
}
});

it.each(FACES)('%s names the card and the list view’s `userActions.filter`', (_face, parse) => {
const message = String(issueAt(parse, { ...GRID, showFilters: true }, 'showFilters')?.message);
expect(message).toContain('objectui#11068');
expect(message).toContain('`userActions.filter`');
expect(message).toContain('`list-view`');
});

it.each(FACES)('LIT CONTROL — %s accepts the same grid without the key', (_face, parse) => {
const result = parse(GRID);
expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true);
});

it('LIT CONTROL — the node face still keeps an unrecognised key on the same document', () => {
// Without this, the refusal above would be consistent with the whole object
// having turned strict on the node face — a far larger change than the one made.
expect(safeValidateSchema({ ...GRID, [UNKNOWN_KEY]: true }).success).toBe(true);
});

it('writes the SAME string into the metadata channel as into the parse message', () => {
const described = (ObjectGridSchema.shape.showFilters as { description?: string }).description;
const issue = ObjectGridSchema.safeParse({ ...GRID, showFilters: true });
expect(issue.success).toBe(false);
const message = issue.success ? undefined : issue.error.issues.find((i) => i.path.join('.') === 'showFilters')?.message;
expect(described).toBe(message);
});

it('the tombstone is a MEMBER, not a deletion — that is what makes the refusal loud', () => {
expect('showFilters' in ObjectGridSchema.shape).toBe(true);
expect(UNKNOWN_KEY in ObjectGridSchema.shape).toBe(false);
});
});

describe('`showFilters` stays live where a renderer reads it (the controls the retirement must not touch)', () => {
const LIST = { type: 'list-view' as const, objectName: 'probe', columns: ['name'] };

it.each(FACES)('%s accepts the list view’s `userActions.filter` — the spelling the refusal names', (_face, parse) => {
const result = parse({ ...LIST, userActions: { filter: true } });
expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true);
});

it.each(FACES)('%s accepts a list view’s own `showFilters`', (_face, parse) => {
const result = parse({ ...LIST, showFilters: true });
expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true);
});

it.each(FACES)('%s accepts an object view’s own `showFilters`', (_face, parse) => {
const result = parse({ type: 'object-view', objectName: 'probe', showFilters: true });
expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true);
});
});

describe('the UPSTREAM half, re-derived from the installed pin', () => {
it('upstream `object-grid` does not declare `showFilters`, and refuses it', () => {
expect('showFilters' in SpecObjectGridPropsSchema.shape).toBe(false);
expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe' }).success).toBe(true);
expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', showFilters: true }).success).toBe(false);
});

it('upstream `list-view` declares the toggle the refusal names: `userActions.filter`', () => {
const userActions = SpecListViewSchema.shape.userActions.unwrap();
expect('filter' in userActions.shape).toBe(true);
});
});

/* ── The TypeScript twin ────────────────────────────────────────────────── */

/**
* The `@ts-expect-error` line below IS the assertion: the member is `?: never`,
* so the literal is a compile error. It fails this package's
* `tsc -p tsconfig.test.json` leg as an UNUSED directive if the member is
* deleted (the key would fall back to `BaseSchema`'s index signature) or
* re-typed as a live value.
*/
export const authoredShowFiltersRefused: ObjectGridSchemaType = {
...GRID,
// @ts-expect-error — `showFilters` is RETIRED on `object-grid` (objectui#11068): a list view's `userActions.filter`.
showFilters: true,
};

/** LIT CONTROL: the spellings that stay live are declared, writable members. */
export const listViewFilterToggleIsDeclared: ListViewSchemaType = {
type: 'list-view',
objectName: 'probe',
columns: ['name'],
userActions: { filter: true },
};

export const objectViewShowFiltersIsDeclared: ObjectViewSchemaType = {
type: 'object-view',
objectName: 'probe',
showFilters: true,
};

describe('the TypeScript twin', () => {
it('is compiled by this package’s type-check leg, which is where the bindings above are read', () => {
expect([authoredShowFiltersRefused, listViewFilterToggleIsDeclared, objectViewShowFiltersIsDeclared]).toHaveLength(3);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ const FORM_IDENTITY_KEYS = ['type', 'objectName', 'mode'] as const;
*/
const TABLE_WITHHELD_BY_REASON = {
/**
* `ObjectGrid` has no read of it, so nothing could draw it. Four of these are
* `ObjectGrid` has no read of it, so nothing could draw it. Five of these are
* retirement tombstones on `ObjectGridSchema` itself since objectui#11068
* (`bulkSpecActions`, `name`, `placeholder`, `rowSpecActions`).
* (`bulkSpecActions`, `name`, `placeholder`, `rowSpecActions`, `showFilters`).
*/
unread: ['bulkSpecActions', 'keyboardNavigation', 'name', 'placeholder', 'rowSpecActions', 'showFilters'],
/**
Expand All @@ -157,7 +157,7 @@ const TABLE_WITHHELD_KEYS: readonly string[] = Object.values(TABLE_WITHHELD_BY_R
/**
* Retirement tombstones `ObjectGridSchema` declares itself (`?: never` on both
* faces) that the slot keeps: they type nothing, and they carry the named
* refusal and its guidance onto this face too. The four the grid retired in
* refusal and its guidance onto this face too. The five the grid retired in
* objectui#11068 are in the withheld set above instead, refused by the slot's
* own message.
*/
Expand Down
Loading
Loading