|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * `showFilters` is retired on `object-grid` (objectui#11068, triage's answer to |
| 11 | + * the card's retriage) — the DECLARATION half. The renderer half, which shows an |
| 12 | + * authored `showFilters` draws nothing, is `plugin-grid`'s |
| 13 | + * `ObjectGrid.declaredKeys-11068.test.tsx`. |
| 14 | + * |
| 15 | + * ## Why a tombstone, and why it points at the list view |
| 16 | + * |
| 17 | + * `ObjectGrid` has no filter UI. The one filter surface objectui draws is the |
| 18 | + * `list-view` toolbar's filter builder, switched by `userActions.filter`, so |
| 19 | + * honouring the key on the grid would have meant building a second filter |
| 20 | + * surface. The upstream protocol's `object-grid` row does not declare the key |
| 21 | + * either. Deleting the member would refuse nothing: the zod twin ends |
| 22 | + * `.passthrough()` and the interface inherits `BaseSchema`'s index signature. |
| 23 | + * So the key stays DECLARED and unwritable on both faces — `retirementTombstone()` |
| 24 | + * and `?: never` — as `rowSpecActions` and `defaultSort` are. |
| 25 | + * |
| 26 | + * ## The two authoring faces, and the controls beside each refusal |
| 27 | + * |
| 28 | + * The node face (`safeValidateSchema`) and the strict authoring face |
| 29 | + * (`StrictAnyComponentSchema`) are both asked. A red parse proves a by-name |
| 30 | + * refusal only beside a document that stays green on the same instrument: the |
| 31 | + * grid without the key, a list view's own `showFilters` and `userActions.filter`, |
| 32 | + * and an object view's own `showFilters` — which `ObjectView` still reads, so it |
| 33 | + * is not retired here. |
| 34 | + */ |
| 35 | + |
| 36 | +import { describe, it, expect } from 'vitest'; |
| 37 | +import type { z } from 'zod'; |
| 38 | +import { ObjectGridPropsSchema as SpecObjectGridPropsSchema, ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; |
| 39 | +import { ObjectGridSchema } from '../zod/objectql.zod.js'; |
| 40 | +// Both faces through the barrel: objectui#8345 pins it as the only entry into |
| 41 | +// the strict face's module cycle, test files included. |
| 42 | +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; |
| 43 | +import type { |
| 44 | + ListViewSchema as ListViewSchemaType, |
| 45 | + ObjectGridSchema as ObjectGridSchemaType, |
| 46 | + ObjectViewSchema as ObjectViewSchemaType, |
| 47 | +} from '../objectql.js'; |
| 48 | + |
| 49 | +/** A control key no surface in this package or the protocol declares. */ |
| 50 | +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11068'; |
| 51 | + |
| 52 | +/** The minimum green `object-grid` document. */ |
| 53 | +const GRID = { type: 'object-grid' as const, objectName: 'probe' }; |
| 54 | + |
| 55 | +type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; |
| 56 | + |
| 57 | +/** The two authoring faces, each as a `safeParse`. */ |
| 58 | +const FACES = [ |
| 59 | + ['node face (safeValidateSchema)', (doc: unknown) => safeValidateSchema(doc)], |
| 60 | + ['strict authoring face (StrictAnyComponentSchema)', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], |
| 61 | +] as const; |
| 62 | + |
| 63 | +type Parse = (doc: unknown) => z.ZodSafeParseResult<unknown>; |
| 64 | + |
| 65 | +/** Every issue in the tree, union arms included, with its full path. */ |
| 66 | +function flatten(issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] { |
| 67 | + const out: Issue[] = []; |
| 68 | + for (const issue of issues ?? []) { |
| 69 | + const path = [...prefix, ...issue.path]; |
| 70 | + out.push({ ...issue, path }); |
| 71 | + for (const arm of issue.errors ?? []) out.push(...flatten(arm, path)); |
| 72 | + } |
| 73 | + return out; |
| 74 | +} |
| 75 | + |
| 76 | +function issueAt(parse: Parse, doc: unknown, path: string): Issue | undefined { |
| 77 | + const result = parse(doc); |
| 78 | + if (result.success) return undefined; |
| 79 | + return flatten(result.error.issues as unknown as Issue[]).find((i) => i.path.map(String).join('.') === path); |
| 80 | +} |
| 81 | + |
| 82 | +describe('an authored `showFilters` on `object-grid` is refused BY NAME on both authoring faces (objectui#11068)', () => { |
| 83 | + it.each(FACES)('%s refuses it at its own path, whatever the value', (_face, parse) => { |
| 84 | + for (const value of [true, false]) { |
| 85 | + const result = parse({ ...GRID, showFilters: value }); |
| 86 | + expect(result.success, `showFilters: ${value} parsed green`).toBe(false); |
| 87 | + const issue = issueAt(parse, { ...GRID, showFilters: value }, 'showFilters'); |
| 88 | + expect(issue, `no issue at showFilters for ${value}`).toBeDefined(); |
| 89 | + // `retirementTombstone()` customises the MESSAGE only; the code is a bare `z.never()`'s. |
| 90 | + expect(issue?.code).toBe('invalid_type'); |
| 91 | + } |
| 92 | + }); |
| 93 | + |
| 94 | + it.each(FACES)('%s names the card and the list view’s `userActions.filter`', (_face, parse) => { |
| 95 | + const message = String(issueAt(parse, { ...GRID, showFilters: true }, 'showFilters')?.message); |
| 96 | + expect(message).toContain('objectui#11068'); |
| 97 | + expect(message).toContain('`userActions.filter`'); |
| 98 | + expect(message).toContain('`list-view`'); |
| 99 | + }); |
| 100 | + |
| 101 | + it.each(FACES)('LIT CONTROL — %s accepts the same grid without the key', (_face, parse) => { |
| 102 | + const result = parse(GRID); |
| 103 | + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); |
| 104 | + }); |
| 105 | + |
| 106 | + it('LIT CONTROL — the node face still keeps an unrecognised key on the same document', () => { |
| 107 | + // Without this, the refusal above would be consistent with the whole object |
| 108 | + // having turned strict on the node face — a far larger change than the one made. |
| 109 | + expect(safeValidateSchema({ ...GRID, [UNKNOWN_KEY]: true }).success).toBe(true); |
| 110 | + }); |
| 111 | + |
| 112 | + it('writes the SAME string into the metadata channel as into the parse message', () => { |
| 113 | + const described = (ObjectGridSchema.shape.showFilters as { description?: string }).description; |
| 114 | + const issue = ObjectGridSchema.safeParse({ ...GRID, showFilters: true }); |
| 115 | + expect(issue.success).toBe(false); |
| 116 | + const message = issue.success ? undefined : issue.error.issues.find((i) => i.path.join('.') === 'showFilters')?.message; |
| 117 | + expect(described).toBe(message); |
| 118 | + }); |
| 119 | + |
| 120 | + it('the tombstone is a MEMBER, not a deletion — that is what makes the refusal loud', () => { |
| 121 | + expect('showFilters' in ObjectGridSchema.shape).toBe(true); |
| 122 | + expect(UNKNOWN_KEY in ObjectGridSchema.shape).toBe(false); |
| 123 | + }); |
| 124 | +}); |
| 125 | + |
| 126 | +describe('`showFilters` stays live where a renderer reads it (the controls the retirement must not touch)', () => { |
| 127 | + const LIST = { type: 'list-view' as const, objectName: 'probe', columns: ['name'] }; |
| 128 | + |
| 129 | + it.each(FACES)('%s accepts the list view’s `userActions.filter` — the spelling the refusal names', (_face, parse) => { |
| 130 | + const result = parse({ ...LIST, userActions: { filter: true } }); |
| 131 | + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); |
| 132 | + }); |
| 133 | + |
| 134 | + it.each(FACES)('%s accepts a list view’s own `showFilters`', (_face, parse) => { |
| 135 | + const result = parse({ ...LIST, showFilters: true }); |
| 136 | + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); |
| 137 | + }); |
| 138 | + |
| 139 | + it.each(FACES)('%s accepts an object view’s own `showFilters`', (_face, parse) => { |
| 140 | + const result = parse({ type: 'object-view', objectName: 'probe', showFilters: true }); |
| 141 | + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe('the UPSTREAM half, re-derived from the installed pin', () => { |
| 146 | + it('upstream `object-grid` does not declare `showFilters`, and refuses it', () => { |
| 147 | + expect('showFilters' in SpecObjectGridPropsSchema.shape).toBe(false); |
| 148 | + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe' }).success).toBe(true); |
| 149 | + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', showFilters: true }).success).toBe(false); |
| 150 | + }); |
| 151 | + |
| 152 | + it('upstream `list-view` declares the toggle the refusal names: `userActions.filter`', () => { |
| 153 | + const userActions = SpecListViewSchema.shape.userActions.unwrap(); |
| 154 | + expect('filter' in userActions.shape).toBe(true); |
| 155 | + }); |
| 156 | +}); |
| 157 | + |
| 158 | +/* ── The TypeScript twin ────────────────────────────────────────────────── */ |
| 159 | + |
| 160 | +/** |
| 161 | + * The `@ts-expect-error` line below IS the assertion: the member is `?: never`, |
| 162 | + * so the literal is a compile error. It fails this package's |
| 163 | + * `tsc -p tsconfig.test.json` leg as an UNUSED directive if the member is |
| 164 | + * deleted (the key would fall back to `BaseSchema`'s index signature) or |
| 165 | + * re-typed as a live value. |
| 166 | + */ |
| 167 | +export const authoredShowFiltersRefused: ObjectGridSchemaType = { |
| 168 | + ...GRID, |
| 169 | + // @ts-expect-error — `showFilters` is RETIRED on `object-grid` (objectui#11068): a list view's `userActions.filter`. |
| 170 | + showFilters: true, |
| 171 | +}; |
| 172 | + |
| 173 | +/** LIT CONTROL: the spellings that stay live are declared, writable members. */ |
| 174 | +export const listViewFilterToggleIsDeclared: ListViewSchemaType = { |
| 175 | + type: 'list-view', |
| 176 | + objectName: 'probe', |
| 177 | + columns: ['name'], |
| 178 | + userActions: { filter: true }, |
| 179 | +}; |
| 180 | + |
| 181 | +export const objectViewShowFiltersIsDeclared: ObjectViewSchemaType = { |
| 182 | + type: 'object-view', |
| 183 | + objectName: 'probe', |
| 184 | + showFilters: true, |
| 185 | +}; |
| 186 | + |
| 187 | +describe('the TypeScript twin', () => { |
| 188 | + it('is compiled by this package’s type-check leg, which is where the bindings above are read', () => { |
| 189 | + expect([authoredShowFiltersRefused, listViewFilterToggleIsDeclared, objectViewShowFiltersIsDeclared]).toHaveLength(3); |
| 190 | + }); |
| 191 | +}); |
0 commit comments