diff --git a/.changeset/6152-grid-default-filters-gantt-map-filter.md b/.changeset/6152-grid-default-filters-gantt-map-filter.md new file mode 100644 index 0000000000..3f8d70f130 --- /dev/null +++ b/.changeset/6152-grid-default-filters-gantt-map-filter.md @@ -0,0 +1,48 @@ +--- +'@object-ui/types': minor +'@object-ui/core': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-view': patch +--- + +feat(types)!: `ObjectGridSchema.defaultFilters` and the flat `ObjectGanttSchema` / `ObjectMapSchema` `filter` follow their `@objectstack/spec` rows (objectui#6152, round 10) + +Clause-②: yes + +`@objectstack/spec` has typed `ComponentPropsMap['object-grid'].defaultFilters` as the same +`ViewFilterRule` array as `filter`, `[{ field, operator, value }, ...]`, since 17.6.0: the legacy +fallback `ObjectGrid` reads only when `filter` is absent, refusing the MongoDB-style record, a bare +string and the AST tuple array. The `object-gantt` and `object-map` rows type `filter` the same +way. `@object-ui/types` now takes each row's own member by reference, on the TypeScript interface +and on the zod mirror, with no alias window. + +**Widened.** `ObjectGridSchema.defaultFilters` was `Record` and +`z.record(z.string(), z.any())`, so the zod mirror REFUSED the rule array the row declares. The +flat grid mirror is the source of an `object-view`'s `table` slot, so +`table: { defaultFilters: [{ field: 'status', operator: 'equals', value: 'open' }] }` now parses +there, on the tolerant and the strict face and through `safeValidateSchema`. + +**Narrowed (breaking).** + +- The record form of `defaultFilters` is refused: on the interface (a compile error, in an + `object-view`'s `table` too) and on the zod mirror, at `defaultFilters` (`table.defaultFilters` + in an `object-view`), with the protocol's own message, which computes the rule array from the + record's keys. Respell + `defaultFilters: { status: 'open' }` as + `defaultFilters: [{ field: 'status', operator: 'equals', value: 'open' }]`, or, better, move it + to `filter`, which takes the same array and wins when both are written. +- `ObjectGanttSchema.filter` and `ObjectMapSchema.filter` were `any[]` and `z.array(z.any())`, so + `filter: [['status', '=', 'open']]` type-checked and parsed. Both are the row's rule array now; + respell the tuple as `[{ field: 'status', operator: 'equals', value: 'open' }]`. These two flat + types describe the node as the renderers read it: an authored `object-gantt` / `object-map` + node's `properties` bag is the row itself, which refused the tuple array already. + +What did not move: the renderers' reads. `ObjectGrid` lowers `defaultFilters` through the same +`toFilterNode` sink as `filter`, so a rule array there sends the same `$filter` and draws the same +rows as the same array written as `filter`; the sink still lowers a record or an AST that reaches +the slot at runtime, and `ObjectGantt` / `ObjectMap` still forward an AST a host composes. The +`@object-ui/core`, `@object-ui/plugin-grid` and `@object-ui/plugin-view` entries are comment +repairs to sentences that called the key `Record`. `@object-ui/plugin-grid`, +`@object-ui/plugin-view` and `@object-ui/plugin-map` also carry typed test fixtures re-spelled to +the rule array, and `@object-ui/plugin-grid` a pin of the above through the real renderer. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 7ed7b86bdb..e0bf6f1cd1 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -171,7 +171,7 @@ its legacy half is retired — see the note above.) | --- | --- | | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | -| `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | +| `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: [{ field, operator, value }, …]` — the same rule array; TypeScript and the validator refuse the record form `{ field: value }` (objectui#6152 round 10) | Before objectui#5102, `pagination` / `selection` / `filter` / `sort` had **no read point at all** in this file: an author who wrote the canonical shape diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index ff63bb7dc4..c9cb204c07 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -1397,8 +1397,12 @@ function viewFilterRuleToNode(rule: ViewFilterRuleLike): FilterNode { * * The third is the one that kept getting lost. Renderers tested `source.length * > 0` before using it, which is `undefined > 0` for an object — so a - * `table.defaultFilters` (declared `Record`) was DROPPED and the - * view returned every record. Silently: no error, just a wider answer. + * `table.defaultFilters` (declared `Record` until objectui#6152 + * round 10; the protocol's `ViewFilterRule` array since, and the record no + * longer validates there) was DROPPED and the view returned every record. + * Silently: no error, just a wider answer. That narrowing moved what an author + * may write, not this sink: the object arm below still lowers a record whatever + * caller hands it one. * * The FIRST never worked at all (objectui#3431). The array branch returned * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 2ec85adbb4..444faefb7c 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2629,13 +2629,14 @@ export const ObjectGrid: React.FC = ({ // already go through `toFilterNode`/`mergeFilterNodes`. // // Byte-copying is refused on the wire for BOTH shapes this slot - // carries. `defaultFilters` is declared `Record` - // (the MongoDB-style shape) and `isFilterAST` is false for a plain - // object; `plugin-view` also forwards an active named view's - // `ViewFilterRule[]` into this slot (`ObjectView.tsx`, the - // `gridSchema` memo), and `isFilterAST` is false for an array of - // rule objects too. Either one answers `400 INVALID_FILTER` - // (measured against a real backend in objectui#3431). + // carries. `defaultFilters` was declared `Record` + // (the MongoDB-style shape) until objectui#6152 round 10 — it is the + // row's `ViewFilterRule` array since, the shape `filter` takes — and + // `isFilterAST` is false for a plain object; `plugin-view` also + // forwards an active named view's `ViewFilterRule[]` into this slot + // (`ObjectView.tsx`, the `gridSchema` memo), and `isFilterAST` is + // false for an array of rule objects too. Either one answers `400 + // INVALID_FILTER` (measured against a real backend in objectui#3431). // // `toFilterNode` handles both without new logic: objects route // through `convertFiltersToAST`, rule arrays lower element-wise, diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx new file mode 100644 index 0000000000..5fe230c4e7 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx @@ -0,0 +1,115 @@ +/** + * 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#6152 round 10 — `defaultFilters` takes the `object-grid` row's + * `ViewFilterRule` array, and the real `ObjectGrid` honours it exactly as it honours + * the same array written as `filter`. + * + * `@object-ui/types` now declares `ObjectGridSchema.defaultFilters` as the row's own + * member (it was `Record`). No reader changed: `ObjectGrid` lowers the + * legacy slot through the same `toFilterNode` sink as the canonical one, so a rule + * array there already reached the wire as AST. This file measures that through the + * registered renderer and a `ValueDataSource` that APPLIES the `$filter` it receives, + * so "the same rows" is a reading of what the grid draws, not only of what it sends. + * + * The fixtures are typed `ObjectGridSchema` with no cast: the rule array is what the + * declaration takes. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { ObjectGridSchema } from '@object-ui/types'; +import { ValueDataSource } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-grid`. +import '../index'; + +const ROWS = [ + { id: '1', name: 'Acme', status: 'open' }, + { id: '2', name: 'Beta', status: 'closed' }, + { id: '3', name: 'Cyan', status: 'open' }, +]; +const NAMES = ROWS.map((r) => r.name); + +type Rules = NonNullable; +const OPEN: Rules = [{ field: 'status', operator: 'equals', value: 'open' }]; +const CLOSED: Rules = [{ field: 'status', operator: 'equals', value: 'closed' }]; + +/** An adapter whose `find` answers through a `ValueDataSource`, so `$filter` is applied. */ +function makeAdapter() { + const rows = new ValueDataSource({ items: ROWS, idField: 'id' }); + return { + find: vi.fn((resource: string, params: Record) => rows.find(resource, params as never)), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { id: { type: 'text' }, name: { type: 'text' }, status: { type: 'text' } }, + }), + }; +} + +/** Render the node; return the `$filter` its first query sent and the names it drew. */ +async function draw(node: ObjectGridSchema): Promise<{ $filter: unknown; drawn: string[] }> { + const adapter = makeAdapter(); + const { unmount } = render( + + + , + ); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const params = (adapter.find.mock.calls[0] as [string, Record])[1]; + const answer = await adapter.find.mock.results[0].value; + const expected = (answer as { data: Array<{ name: string }> }).data.map((r) => r.name); + // Wait until the grid has drawn what the source answered, then read the DOM. + if (expected.length > 0) await screen.findByText(expected[0]); + const drawn = NAMES.filter((name) => screen.queryByText(name) !== null); + unmount(); + return { $filter: params.$filter, drawn }; +} + +const node = (extra: Partial): ObjectGridSchema => ({ + type: 'object-grid', + objectName: 'account', + columns: [{ field: 'name' }], + ...extra, +}); + +describe('objectui#6152 round 10 — a `defaultFilters` rule array draws what the same `filter` draws', () => { + it('CONTROL: with neither key, every row is drawn and no `$filter` goes out', async () => { + const { $filter, drawn } = await draw(node({})); + expect($filter).toBeUndefined(); + expect(drawn).toEqual(['Acme', 'Beta', 'Cyan']); + }); + + it('`defaultFilters: [rule]` sends the lowered AST and draws the matching rows', async () => { + const { $filter, drawn } = await draw(node({ defaultFilters: OPEN })); + expect($filter).toEqual([['status', 'equals', 'open']]); + expect(drawn).toEqual(['Acme', 'Cyan']); + }); + + it('the same array written as `filter` sends the same `$filter` and draws the same rows', async () => { + const legacy = await draw(node({ defaultFilters: OPEN })); + const canonical = await draw(node({ filter: OPEN })); + expect(legacy).toEqual(canonical); + }); + + it('LIT CONTROL: a different rule array moves both readings, so the equality above can fail', async () => { + const { $filter, drawn } = await draw(node({ defaultFilters: CLOSED })); + expect($filter).toEqual([['status', 'equals', 'closed']]); + expect(drawn).toEqual(['Beta']); + }); + + it('CONTROL: written both ways, `filter` still wins', async () => { + const { $filter, drawn } = await draw(node({ filter: OPEN, defaultFilters: CLOSED })); + expect($filter).toEqual([['status', 'equals', 'open']]); + expect(drawn).toEqual(['Acme', 'Cyan']); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx index 4b263304d7..bc6c35bdfc 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx @@ -122,7 +122,9 @@ describe('object-grid — the node’s own filter reaches the query resolved (ob it('resolves the deprecated `defaultFilters` alias too, when `filter` is absent', async () => { const adapter = makeAdapter(); - render(ui(adapter, gridNode({ defaultFilters: { owner: '{current_user_id}' } }))); + // objectui#6152 round 10: `defaultFilters` is the row's rule array too (it was a + // `Record`, the `{ owner: '{current_user_id}' }` this cell used to write). + render(ui(adapter, gridNode({ defaultFilters: MINE }))); const sent = JSON.stringify(await queriedFilter(adapter.find)); expect(sent).toContain(USER); expect(sent).not.toContain('{current_user_id}'); @@ -156,7 +158,7 @@ describe('object-grid — the node’s own filter reaches the query resolved (ob it('re-queries with the new id when the user changes and only `defaultFilters` carries the token', async () => { const adapter = makeAdapter(); - const node = gridNode({ defaultFilters: { owner: '{current_user_id}' } }); + const node = gridNode({ defaultFilters: MINE }); const { rerender } = render(ui(adapter, node)); expect(JSON.stringify(await queriedFilter(adapter.find, 0))).toContain(USER); await settle(); diff --git a/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx b/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx index c5c3c91e35..3b4decc6f5 100644 --- a/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx @@ -28,8 +28,10 @@ * is the `400 INVALID_FILTER` measured against a real backend in * objectui#3431: * - * - the declared `Record` (MongoDB-style) — a plain OBJECT - * where the wire expects an AST node; + * - the `Record` (MongoDB-style) the key was declared as until + * objectui#6152 round 10 — a plain OBJECT where the wire expects an AST + * node (the key is the row's `ViewFilterRule` array since, and the record + * no longer validates, but the sink still lowers one that reaches the slot); * - a `ViewFilterRule[]` — an array of rule OBJECTS, which `plugin-view` * forwards into this slot for an active named view (`ObjectView.tsx`, the * `gridSchema` memo: `defaultFilters: viewFilter || …`). @@ -129,10 +131,11 @@ async function findParamsFor(schema: Record) { const BASE = { type: 'object-grid', objectName: 'account', columns: [{ field: 'name' }] }; describe('object-grid — legacy `defaultFilters` is lowered before the wire (objectui#4082)', () => { - it('lowers the declared `Record` shape to AST', async () => { - // The shape the key is actually DECLARED as - // (`packages/types/src/objectql.ts:682`). `isFilterAST` is false for a - // plain object, so this is the case that used to guarantee a 400. + it('lowers the legacy `Record` shape to AST', async () => { + // The shape the key was DECLARED as until objectui#6152 round 10 + // (`ObjectGridSchema.defaultFilters` in `@object-ui/types`; the row's rule + // array since). `isFilterAST` is false for a plain object, so this is the + // case that used to guarantee a 400. const params = await findParamsFor({ ...BASE, defaultFilters: { status: 'active' } }); expect(params.$filter).toEqual(['status', '=', 'active']); }); diff --git a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx index a181af8384..bb4731ffd8 100644 --- a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx +++ b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx @@ -96,7 +96,9 @@ describe('ObjectMapSchema declares what the renderer reads (type pins)', () => { objectName: 'stores', staticData: [{ id: '1' }], data: { provider: 'value', items: [{ id: '1' }] }, - filter: [['status', '=', 'open']], + // objectui#6152 round 10: `filter` is the `object-map` row's `ViewFilterRule` array + // (it was `any[]`; this cell carried the AST tuple array the row refuses). + filter: [{ field: 'status', operator: 'equals', value: 'open' }], sort: [{ field: 'name', order: 'desc' }], map: { latitudeField: 'lat', diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 86b9ad3fb4..fd7a1dbb78 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -246,7 +246,7 @@ just no longer the one to reach for: | --- | --- | | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | -| `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | +| `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: [{ field, operator, value }, …]` — the same rule array; TypeScript and the validator refuse the record form `{ field: value }` (objectui#6152 round 10) | **Precedence when a key is written both ways** — `table: { pagination: { pageSize: 10 }, pageSize: 50 }`, say — the canonical spelling wins. That is diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 5f0a515247..dd6e482fb6 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1392,9 +1392,10 @@ export const ObjectView: React.FC = ({ if (!objectSchemaReady) return; try { - // `mergeFilterNodes` rescues an OBJECT source: `table.defaultFilters` is - // declared `Record`, and the `baseFilter.length > 0` test - // this replaced read false for it — so a view's default filter was + // `mergeFilterNodes` rescues an OBJECT source: `table.defaultFilters` was + // declared `Record` (until objectui#6152 round 10; the + // grid row's `ViewFilterRule` array since), and the `baseFilter.length + // > 0` test this replaced read false for it — so a view's default filter was // dropped and every record came back. The grid path (ObjectGrid) always // assigned it directly and was unaffected, so the same view filtered // correctly as a grid and returned everything as a calendar/kanban/ diff --git a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx index 83ea6ce713..631038ffc2 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx @@ -139,7 +139,9 @@ const RELAYED: Required> = { const BY_NAME: Required>> = { className: 'table-probe', columns: ['subject', 'stage'], - defaultFilters: { stage: 'open' }, + // objectui#6152 round 10: the grid row's rule array (the slot's type was a `Record`). Its + // value differs from `filter`'s below, so a swap of the two slots cannot read as arrival. + defaultFilters: [{ field: 'stage', operator: 'equals', value: 'lost' }], fields: ['subject', 'stage'], filter: [{ field: 'stage', operator: 'equals', value: 'open' }], pageSize: 50, diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index fbf7854600..d39425a96b 100644 --- a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -100,6 +100,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS, SortConfig } from '../objectql.js'; +import type { ViewFilterRule } from '@objectstack/spec/ui'; const MINIMAL = { type: 'object-gantt', @@ -179,7 +180,9 @@ const GOOD = { timeZone: 'Asia/Shanghai', dependencyTypes: false, staticData: [{ id: 1, name: 'Task' }], - filter: [['name', '=', 'Task']], + // objectui#6152 round 10 — the `object-gantt` row's `ViewFilterRule` array; the AST + // tuple array this used to carry is the row's refusal now, on both faces. + filter: [{ field: 'name', operator: 'equals' as const, value: 'Task' }], sort: [{ field: 'name', order: 'desc' as const }], }; @@ -400,7 +403,8 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () const dependencyTypes: ObjectGanttSchemaTS['dependencyTypes'] = 'yes'; // @ts-expect-error — `staticData` is declared `any[] | undefined`. const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; - // @ts-expect-error — `filter` is declared `any[] | undefined`. + // @ts-expect-error — `filter` is declared `ViewFilterRule[] | undefined` (the + // `object-gantt` row's own member, objectui#6152 round 10). const filter: ObjectGanttSchemaTS['filter'] = 'name = 1'; // @ts-expect-error — `sort` is declared `SortConfig[] | undefined` (the legacy // string clause was retired in objectui#8221). @@ -440,10 +444,10 @@ type Expect = T; * ablation on this tree: deleting any one of the three from `ObjectGanttSchema` * turns its row red while its directive above stays green; restored, green. */ -// The two `any[]` below restate the members' own declared type (`staticData?: any[]`, -// `filter?: any[]` in `objectql.ts`); `Equal` is strict, so a narrower spelling would be red. +// The `any[]` below restates the member's own declared type (`staticData?: any[]` in +// `objectql.ts`); `Equal` is strict, so a narrower spelling would be red. `filter` is the +// `object-gantt` row's `ViewFilterRule` array since objectui#6152 round 10 (was `any[]`). // eslint-disable-next-line @typescript-eslint/no-explicit-any export type _StaticDataIsDeclared = Expect>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type _FilterIsDeclared = Expect>; +export type _FilterIsDeclared = Expect>; export type _SortIsDeclared = Expect>; diff --git a/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts b/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts new file mode 100644 index 0000000000..c73668671a --- /dev/null +++ b/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts @@ -0,0 +1,242 @@ +/** + * 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#6152 round 10 — three flat members follow their `@objectstack/spec` rows by + * reference, on both faces of this package. + * + * - `ObjectGridSchema.defaultFilters` is the `object-grid` row's own member: the SAME + * `ViewFilterRule` array as `filter`, which the row has declared since 17.6.0. It was + * `Record` / `z.record(z.string(), z.any())`, so the MongoDB-style record + * the row refuses type-checked and parsed here, and the rule array the row takes was + * REFUSED by the mirror. The flat grid mirror is not an authoring arm + * (objectui#11276), but it is the source of the `object-view` `table` slot, so + * `table.defaultFilters` — objectui's own door — moves with it. + * - `ObjectGanttSchema.filter` / `ObjectMapSchema.filter` are their rows' own member, + * the `ViewFilterRule` array. They were `any[]` / `z.array(z.any())`. Their authored + * arms are the rows themselves (the `properties` bag), so no authored door moves. + * + * The first block re-reads the precondition against the INSTALLED spec, and the + * differential blocks hold every local verdict to the row's on the same values: the local + * faces must never refuse what the installed protocol accepts, nor accept what it refuses. + * `filter` on the grid is the lit control throughout: round 8 moved it, this round does not. + */ +import { describe, it, expect } from 'vitest'; + +import { + ObjectGanttPropsSchema as SpecObjectGanttPropsSchema, + ObjectGridPropsSchema as SpecObjectGridPropsSchema, + ObjectMapPropsSchema as SpecObjectMapPropsSchema, +} from '@objectstack/spec/ui'; +import type { + ObjectGanttProps as SpecObjectGanttProps, + ObjectGridProps as SpecObjectGridProps, + ObjectMapProps as SpecObjectMapProps, + ViewFilterRule, +} from '@objectstack/spec/ui'; +import type { + ObjectGanttSchema as TsObjectGanttSchema, + ObjectGridSchema as TsObjectGridSchema, + ObjectMapSchema as TsObjectMapSchema, + ObjectViewSchema as TsObjectViewSchema, +} from '../objectql'; +import { ObjectGanttSchema, ObjectGridSchema, ObjectMapSchema, ObjectViewSchema } from '../zod/objectql.zod.js'; +import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parsed = { success: boolean; error?: { issues: readonly Issue[] } }; +type Parse = (doc: unknown) => Parsed; + +const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path }))); +const paths = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => i.path.join('.'))); +const firstMessage = (r: Parsed) => (r.success ? '' : r.error!.issues[0].message); + +const GRID = { type: 'object-grid', objectName: 'task' }; +const GANTT = { type: 'object-gantt', objectName: 'task' }; +const MAP = { type: 'object-map', objectName: 'site' }; +const VIEW = { type: 'object-view', objectName: 'task' }; + +const RULES: ViewFilterRule[] = [{ field: 'status', operator: 'equals', value: 'open' }]; +const TUPLES = [['status', '=', 'open']]; +const RECORD = { status: 'open' }; + +const DOORS: ReadonlyArray = [ + ['the tolerant face', (d) => AnyComponentSchema.safeParse(d)], + ['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)], + ['safeValidateSchema', (d) => safeValidateSchema(d)], +]; + +/** The values every differential below is read over: both verdicts occur (asserted). */ +const VALUES: ReadonlyArray = [ + ['the rule array', RULES], + ['an empty array', []], + ['the tuple array', TUPLES], + ['an AST `and` node', ['and', ['status', '=', 'open'], ['amount', '>', 100]]], + ['the record', RECORD], + ['an empty record', {}], + ['a string clause', 'status = open'], + ['a rule with an unknown operator', [{ field: 'status', operator: '=', value: 'open' }]], +]; + +/* ── The precondition, on the installed protocol ─────────────────────────────── */ + +describe('objectui#6152 round 10 — the installed protocol (the precondition)', () => { + it('the `object-grid` row takes the rule array on `defaultFilters` and refuses the record, naming the rule array', () => { + expect(SpecObjectGridPropsSchema.safeParse({ defaultFilters: RULES }).success).toBe(true); + const record = SpecObjectGridPropsSchema.safeParse({ defaultFilters: RECORD }); + expect(codeAndPath(record)).toEqual([{ code: 'invalid_type', path: ['defaultFilters'] }]); + // The refusal names the new shape, computed from the author's own keys. + expect(firstMessage(record)).toContain("[{ field: 'status', operator: 'equals', value: 'open' }]"); + expect(paths(SpecObjectGridPropsSchema.safeParse({ defaultFilters: TUPLES }))).toEqual(['defaultFilters.0']); + }); + + it.each([ + ['object-gantt', SpecObjectGanttPropsSchema], + ['object-map', SpecObjectMapPropsSchema], + ] as const)('the `%s` row takes the rule array on `filter` and refuses the tuple array and the record', (_type, row) => { + expect(row.safeParse({ filter: RULES }).success).toBe(true); + expect(paths(row.safeParse({ filter: TUPLES }))).toEqual(['filter.0']); + const record = row.safeParse({ filter: RECORD }); + expect(paths(record)).toEqual(['filter']); + expect(firstMessage(record)).toContain("[{ field: 'status', operator: 'equals', value: 'open' }]"); + }); +}); + +/* ── The members ARE the rows' ───────────────────────────────────────────────── */ + +describe('objectui#6152 round 10 — the mirror members are the rows\' own, by reference', () => { + it('`ObjectGridSchema.defaultFilters` is the row\'s member (identity, not a copy)', () => { + expect(ObjectGridSchema.shape.defaultFilters).toBe(SpecObjectGridPropsSchema.shape.defaultFilters); + // CONTROL — the identity check can fail: the row's `defaultFilters` is not its `filter`. + expect(ObjectGridSchema.shape.defaultFilters).not.toBe(SpecObjectGridPropsSchema.shape.filter); + // Lit control: round 8's `filter` is still the row's own `filter`. + expect(ObjectGridSchema.shape.filter).toBe(SpecObjectGridPropsSchema.shape.filter); + }); + + it('the flat gantt and map `filter` are their rows\' members', () => { + expect(ObjectGanttSchema.shape.filter).toBe(SpecObjectGanttPropsSchema.shape.filter); + expect(ObjectMapSchema.shape.filter).toBe(SpecObjectMapPropsSchema.shape.filter); + // CONTROL — the gantt row's member is not the map row's. + expect(ObjectGanttSchema.shape.filter).not.toBe(SpecObjectMapPropsSchema.shape.filter); + }); +}); + +/* ── Every local verdict is the row's ────────────────────────────────────────── */ + +describe('objectui#6152 round 10 — every flat mirror verdict is its row\'s', () => { + describe.each([ + ['object-grid `defaultFilters`', ObjectGridSchema as unknown as { safeParse: Parse }, SpecObjectGridPropsSchema, GRID, 'defaultFilters'], + ['object-grid `filter` (lit control, round 8)', ObjectGridSchema as unknown as { safeParse: Parse }, SpecObjectGridPropsSchema, GRID, 'filter'], + ['object-gantt `filter`', ObjectGanttSchema as unknown as { safeParse: Parse }, SpecObjectGanttPropsSchema, GANTT, 'filter'], + ['object-map `filter`', ObjectMapSchema as unknown as { safeParse: Parse }, SpecObjectMapPropsSchema, MAP, 'filter'], + ] as const)('%s', (_label, mirror, row, node, key) => { + it('CONTROL: the node without the key parses', () => { + const r = mirror.safeParse(node); + expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true); + }); + + it.each(VALUES)('%s: the mirror and the installed row agree', (_value, value) => { + const local = mirror.safeParse({ ...node, [key]: value }); + const upstream = row.safeParse({ [key]: value }); + expect(local.success).toBe(upstream.success); + if (!local.success) expect(paths(local).every((p) => p.startsWith(key))).toBe(true); + }); + + it('both verdicts occur, so the agreement above is a reading', () => { + const verdicts = VALUES.map(([, value]) => row.safeParse({ [key]: value }).success); + expect(verdicts).toContain(true); + expect(verdicts).toContain(false); + }); + }); +}); + +/* ── The `object-view` `table` slot: objectui's own door ─────────────────────── */ + +describe('objectui#6152 round 10 — `table.defaultFilters` takes the rule array and refuses the record', () => { + describe.each(DOORS)('%s', (_door, parse) => { + it('the rule array parses', () => { + const r = parse({ ...VIEW, table: { defaultFilters: RULES } }); + expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true); + }); + + it('the record is refused AT `table.defaultFilters`, naming the rule array', () => { + const r = parse({ ...VIEW, table: { defaultFilters: RECORD } }); + expect(codeAndPath(r)).toEqual([{ code: 'invalid_type', path: ['table', 'defaultFilters'] }]); + expect(firstMessage(r)).toContain("[{ field: 'status', operator: 'equals', value: 'open' }]"); + }); + + it('the tuple array is refused at its first element', () => { + expect(paths(parse({ ...VIEW, table: { defaultFilters: TUPLES } }))).toEqual(['table.defaultFilters.0']); + }); + + it('LIT CONTROL: `table.filter` is unchanged — the rule array parses, the tuple array is refused', () => { + expect(parse({ ...VIEW, table: { filter: RULES } }).success).toBe(true); + expect(paths(parse({ ...VIEW, table: { filter: TUPLES } }))).toEqual(['table.filter.0']); + }); + + it.each(VALUES)('%s: the slot\'s verdict is the row\'s', (_value, value) => { + const local = parse({ ...VIEW, table: { defaultFilters: value } }); + expect(local.success).toBe(SpecObjectGridPropsSchema.safeParse({ defaultFilters: value }).success); + }); + }); + + it('the `object-view` mirror\'s own slot agrees with the doors above', () => { + expect(ObjectViewSchema.safeParse({ ...VIEW, table: { defaultFilters: RULES } }).success).toBe(true); + expect(codeAndPath(ObjectViewSchema.safeParse({ ...VIEW, table: { defaultFilters: RECORD } }))) + .toEqual([{ code: 'invalid_type', path: ['table', 'defaultFilters'] }]); + }); + + it('CONTROL: the authored `object-grid` node\'s bag (the row itself) gives the same verdicts', () => { + for (const [, parse] of DOORS) { + const ok = parse({ type: 'object-grid', properties: { objectName: 'task', defaultFilters: RULES } }); + expect(ok.success, JSON.stringify(codeAndPath(ok))).toBe(true); + expect(paths(parse({ type: 'object-grid', properties: { objectName: 'task', defaultFilters: RECORD } }))) + .toEqual(['properties.defaultFilters']); + } + }); +}); + +/* ── The TypeScript face ─────────────────────────────────────────────────────── */ + +describe('objectui#6152 round 10 — the TypeScript face', () => { + it('the record `defaultFilters` and the tuple / record gantt and map `filter` are `tsc` errors; the rule array type-checks', () => { + // Real directives: this package type-checks its tests (`tsconfig.test.json`), so a + // re-widened member fails on the unused directive. + // @ts-expect-error `defaultFilters` is the `ViewFilterRule` array, not the record form + const gridRecord: TsObjectGridSchema = { type: 'object-grid', objectName: 'task', defaultFilters: { status: 'open' } }; + // @ts-expect-error the `table` slot's `defaultFilters` is the grid's: the record is refused there too + const slotRecord: TsObjectViewSchema = { type: 'object-view', objectName: 'task', table: { defaultFilters: { status: 'open' } } }; + // @ts-expect-error `filter` is the `ViewFilterRule` array, not the AST tuple array + const ganttTuples: TsObjectGanttSchema = { type: 'object-gantt', objectName: 'task', filter: [['status', '=', 'open']] }; + // @ts-expect-error `filter` is the `ViewFilterRule` array, not the record form + const mapRecord: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: { status: 'open' } }; + // @ts-expect-error `filter` is the `ViewFilterRule` array, not the AST tuple array + const mapTuples: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: [['status', '=', 'open']] }; + // Lit controls on the same faces. + const grid: TsObjectGridSchema = { type: 'object-grid', objectName: 'task', defaultFilters: RULES }; + const slot: TsObjectViewSchema = { type: 'object-view', objectName: 'task', table: { defaultFilters: RULES } }; + const gantt: TsObjectGanttSchema = { type: 'object-gantt', objectName: 'task', filter: RULES }; + const map: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: RULES }; + expect([gridRecord, slotRecord, ganttTuples, mapRecord, mapTuples]).toHaveLength(5); + expect([grid.defaultFilters, slot.table?.defaultFilters, gantt.filter, map.filter]).toEqual([RULES, RULES, RULES, RULES]); + }); +}); + +/* Read off the members: each is the row's own type. */ +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +export type assertionRound10MembersAreTheRows = [ + Expect>, + Expect>, + Expect['defaultFilters'], ViewFilterRule[] | undefined>>, + Expect>, + Expect>, + Expect>, + Expect>, + // Lit control: round 8's grid `filter` is unchanged. + Expect>, +]; diff --git a/packages/types/src/__tests__/object-gallery-filter-9309.test.ts b/packages/types/src/__tests__/object-gallery-filter-9309.test.ts index cdfd580eda..d4a938ea44 100644 --- a/packages/types/src/__tests__/object-gallery-filter-9309.test.ts +++ b/packages/types/src/__tests__/object-gallery-filter-9309.test.ts @@ -189,10 +189,11 @@ const EXPECTED_CENSUS: readonly FilterRow[] = [ // `ComponentPropsMap` row's own member by reference (the `ViewFilterRule` array). { owner: 'ObjectGridSchema', type: "SpecObjectGridProps['filter']" }, { owner: 'NamedListView', type: 'any[]' }, - { owner: 'ObjectMapSchema', type: 'any[]' }, + // objectui#6152 round 10 — the flat map and gantt mirrors take their rows' member too. + { owner: 'ObjectMapSchema', type: "SpecObjectMapProps['filter']" }, // objectui#9549 — declared in the shape this card settled. { owner: 'ObjectTreeSchema', type: "QueryParams['$filter']" }, - { owner: 'ObjectGanttSchema', type: 'any[]' }, + { owner: 'ObjectGanttSchema', type: "SpecObjectGanttProps['filter']" }, { owner: 'ObjectCalendarSchema', type: "SpecObjectCalendarProps['filter']" }, { owner: 'ObjectKanbanSchema', type: "SpecObjectKanbanProps['filter']" }, { owner: 'ObjectChartSchema', type: 'any[] | Record' }, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index e3867aa4de..8a347c8413 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -170,6 +170,13 @@ import type { // so `ObjectGridSchema.operations` and `.filter` take the row's own members // by reference. Aliased for the reason `SpecObjectCalendarProps` above is. ObjectGridProps as SpecObjectGridProps, + // objectui#6152 round 10 — `ComponentPropsMap['object-gantt']` / + // `['object-map']`'s author states, so the flat `ObjectGanttSchema.filter` / + // `ObjectMapSchema.filter` take their rows' own member by reference. Aliased + // for the reason `SpecObjectCalendarProps` above is (`ObjectGanttProps` is + // also `plugin-gantt`'s own component props name). + ObjectGanttProps as SpecObjectGanttProps, + ObjectMapProps as SpecObjectMapProps, // objectui#11355 — `ComponentPropsMap['object-kanban']`'s author state, so // `ObjectKanbanSchema.swimlaneField` takes the row's own type by reference. ObjectKanbanProps as SpecObjectKanbanProps, @@ -1136,10 +1143,30 @@ export interface ObjectGridSchema extends BaseSchema { defaultSort?: never; /** - * @deprecated Use filter instead - * Legacy default filters - */ - defaultFilters?: Record; + * The legacy base-filter fallback, read only when {@link ObjectGridSchema.filter} + * is absent — the SAME `ViewFilterRule` array, `[{ field, operator, value }, ...]`, + * taken BY REFERENCE from the `object-grid` row + * (`ComponentPropsMap['object-grid'].defaultFilters`), which has declared it so + * since `@objectstack/spec` 17.6.0 and refuses the MongoDB-style record, a bare + * string and the AST tuple array. `ObjectGrid` lowers it through the same + * `toFilterNode` sink as `filter`, so a rule array here sends the same `$filter` + * as the same rule array written as `filter`. + * + * objectui#6152 round 10: this was `Record`, so the record form + * (`{ status: 'open' }`) type-checked here, and through the `object-view` + * `table` slot, while the protocol refused it. Write + * `[{ field: 'status', operator: 'equals', value: 'open' }]` instead — or, better, + * move it to `filter`. ⚠️ The read did not narrow: the sink still lowers a record + * or an AST array that reaches the slot at runtime (`ObjectView` hands it an + * active named view's `filter`, which {@link NamedListView.filter} types + * `any[]`), which is not a shape an author writes here, so this face does not + * publish it (the objectui#10199 split, as {@link ObjectGridSchema.filter} + * makes it). + * + * @deprecated Use filter instead — the same rule array; this key is read only + * when `filter` is absent. + */ + defaultFilters?: SpecObjectGridProps['defaultFilters']; /** * RETIRED (objectui#6152 round 7, ADR-0049) — the legacy second spelling of @@ -3473,10 +3500,22 @@ export interface ObjectMapSchema extends BaseSchema { /** Inline records, wrapped into a `{ provider: 'value' }` data config; read SECOND */ staticData?: any[]; /** - * Query filter, forwarded as `$filter` with its - * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). - */ - filter?: any[]; + * Query filter — the protocol's `ViewFilterRule` array, + * `[{ field, operator, value }, ...]`, taken BY REFERENCE from the `object-map` + * row (`ComponentPropsMap['object-map'].filter`), which refuses the + * MongoDB-style record and the AST tuple array by name. Forwarded as `$filter` + * with its context tokens (`{current_user_id}`, `{current_org_id}`, the date + * macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` + * (objectui#10666). + * + * objectui#6152 round 10: this was `any[]`, so a tuple array type-checked here + * while the row refused it. ⚠️ What did NOT narrow is the read: `ObjectMap` + * still forwards an AST a host composes at runtime (`ElementDataSourceGate`'s + * merged binding), which is not a shape an author writes, so this face does + * not publish it (the objectui#10199 split, as {@link ObjectGridSchema.filter} + * makes it). + */ + filter?: SpecObjectMapProps['filter']; /** Sort configuration, forwarded as `$orderby`. Array only — the legacy string clause is retired (objectui#8221). */ sort?: SortConfig[]; /** @@ -4024,10 +4063,22 @@ export interface ObjectGanttSchema extends BaseSchema { /** Inline records, wrapped into a `{ provider: 'value' }` config by `resolveRecordSourceConfig`. */ staticData?: any[]; /** - * Query filter (JSON Rules format), forwarded as `$filter` with its - * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). - */ - filter?: any[]; + * Query filter — the protocol's `ViewFilterRule` array, + * `[{ field, operator, value }, ...]`, taken BY REFERENCE from the + * `object-gantt` row (`ComponentPropsMap['object-gantt'].filter`), which refuses + * the MongoDB-style record and the AST tuple array by name. Forwarded as + * `$filter` with its context tokens (`{current_user_id}`, `{current_org_id}`, + * the date macros) resolved first through `@object-ui/core`'s + * `resolveFilterPlaceholders` (objectui#10666). + * + * objectui#6152 round 10: this was `any[]`, so a tuple array type-checked here + * while the row refused it. ⚠️ What did NOT narrow is the read: `ObjectGantt` + * still forwards an AST a host composes at runtime (`ElementDataSourceGate`'s + * merged binding, `ListView`'s effective filter), which is not a shape an + * author writes, so this face does not publish it (the objectui#10199 split, as + * {@link ObjectGridSchema.filter} makes it). + */ + filter?: SpecObjectGanttProps['filter']; /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. Array only — the legacy string clause is retired (objectui#8221). */ sort?: SortConfig[]; /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 4e8fcaab5c..616b8ce70d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -671,7 +671,16 @@ export const ObjectGridSchema = BaseSchema.extend({ + 'and nothing reads it any more; the upstream protocol refuses it by name on `object-grid`. ' + 'Rename the key to `sort` and wrap the value in an array: `sort: [{ field, order }]`.', ), - defaultFilters: z.record(z.string(), z.any()).optional(), + // objectui#6152 round 10 — the `object-grid` row's own `defaultFilters` member BY + // REFERENCE: the SAME `ViewFilterRule` array as `filter` above, with the row's + // refusal of the MongoDB-style record, a bare string and the AST tuple array + // (its message names this key as the one written). It was + // `z.record(z.string(), z.any())`, so the `object-view` `table` slot built from + // this mirror accepted the record the row refuses, and refused the rule array + // the row takes. The read is wider on purpose, as for + // `filter`: `ObjectGrid` lowers this slot through the same `toFilterNode` sink + // (see the TS twin's member). + defaultFilters: stripImportedDefaults(SpecObjectGridPropsSchema).shape.defaultFilters, // objectui#6152 round 7 — RETIRED under ADR-0049, in lockstep with the `?: never` // twin on the interface. `@objectstack/spec` 17.7.0 (objectstack#21445) refuses // this key BY NAME on `object-grid` (a retired-key tombstone in the row that @@ -2648,7 +2657,12 @@ export const ObjectMapSchema = BaseSchema.extend({ objectName: z.string().optional().describe('ObjectQL object name — the THIRD record source getDataConfig resolves, after data and staticData; one of the three must be present, unless the node\'s dataSource.object names the object, which ElementDataSourceGate lands here (objectui#11117)'), data: ViewDataSchema.optional().describe('Data source configuration — read FIRST by getDataConfig'), staticData: z.array(z.any()).optional().describe('Inline records — read SECOND by getDataConfig, wrapped into a { provider: value } config'), - filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter'), + // objectui#6152 round 10 — the `object-map` row's own `filter` member BY + // REFERENCE: the `ViewFilterRule` array, with the row's refusal of the + // MongoDB-style record and the AST tuple array. It was `z.array(z.any())`. This + // mirror is not an authoring arm (the authored node's `properties` IS the row), + // so no authored door moves; the read stays wider (see the TS twin's member). + filter: stripImportedDefaults(SpecObjectMapPropsSchema).shape.filter, sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), map: ObjectMapConfigSchema.optional().describe('Map configuration (the author face)'), enableClustering: z.boolean().optional().describe('Group nearby markers into clusters'), @@ -2881,7 +2895,12 @@ export const ObjectGanttSchema = BaseSchema.extend({ // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here. staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by resolveRecordSourceConfig'), - filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'), + // objectui#6152 round 10 — the `object-gantt` row's own `filter` member BY + // REFERENCE: the `ViewFilterRule` array, with the row's refusal of the + // MongoDB-style record and the AST tuple array. It was `z.array(z.any())`. This + // mirror is not an authoring arm (the authored node's `properties` IS the row), + // so no authored door moves; the read stays wider (see the TS twin's member). + filter: stripImportedDefaults(SpecObjectGanttPropsSchema).shape.filter, sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), // objectui#10250 — the full-text pair the record query carries, declared in // step with the twin in `../objectql.ts`. ListView's toolbar Search writes