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
48 changes: 48 additions & 0 deletions .changeset/6152-grid-default-filters-gantt-map-filter.md
Original file line number Diff line number Diff line change
@@ -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<string, any>` 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<string, any>`. `@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.
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<field, value>` (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
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/utils/filter-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>`) was DROPPED and the
* view returned every record. Silently: no error, just a wider answer.
* `table.defaultFilters` (declared `Record<string, any>` 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
Expand Down
15 changes: 8 additions & 7 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2629,13 +2629,14 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// already go through `toFilterNode`/`mergeFilterNodes`.
//
// Byte-copying is refused on the wire for BOTH shapes this slot
// carries. `defaultFilters` is declared `Record<string, any>`
// (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<string, any>`
// (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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>`). 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<ObjectGridSchema['defaultFilters']>;
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<string, unknown>) => 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(
<SchemaRendererProvider dataSource={adapter as never}>
<SchemaRenderer schema={node} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const params = (adapter.find.mock.calls[0] as [string, Record<string, unknown>])[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>): 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']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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}');
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
* is the `400 INVALID_FILTER` measured against a real backend in
* objectui#3431:
*
* - the declared `Record<string, any>` (MongoDB-style) — a plain OBJECT
* where the wire expects an AST node;
* - the `Record<string, any>` (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 || …`).
Expand Down Expand Up @@ -129,10 +131,11 @@ async function findParamsFor(schema: Record<string, unknown>) {
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<string, any>` 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<string, any>` 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']);
});
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<field, value>` (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
Expand Down
7 changes: 4 additions & 3 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1392,9 +1392,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
if (!objectSchemaReady) return;

try {
// `mergeFilterNodes` rescues an OBJECT source: `table.defaultFilters` is
// declared `Record<string, any>`, 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<string, any>` (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/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ const RELAYED: Required<Pick<TableSlot, RelayKey>> = {
const BY_NAME: Required<Pick<TableSlot, Exclude<ByNameKey, 'operations'>>> = {
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,
Expand Down
Loading
Loading