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
10 changes: 10 additions & 0 deletions .changeset/list-toolbar-filter-fold-to-spec-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

The list toolbar's "Filter" now saves. Saving a filter from the runtime toolbar PUT the FilterBuilder's whole group object (`{ id, logic, conditions }`) into the view's `filter`, where `@objectstack/spec`'s `ListViewSchema.filter` declares `ViewFilterRule[]` — so every save came back `422 invalid_metadata` and the filter was silently never persisted (objectstack#5159).

The producer now folds the builder's group to the spec's flat `{ field, operator, value }` rule list before persisting, sharing one transform with the Studio view inspector (which had the only copy). Operators normalize through the spec's own `normalizeFilterOperator`, so the four builder operators the Studio's local table had drifted behind — `startsWith`, `endsWith`, `isNull`, `isNotNull` — now persist correctly too. The builder's per-row `id` is no longer written: it is a React list key that the read path regenerates, so stored view bodies keep the declared vocabulary only.

A filter whose shape cannot be represented losslessly as a flat rule list — `OR` across several conditions, or nested condition groups — is now refused with a translated message instead of being quietly saved as `AND`, which would have returned a different set of records than the one on screen. Such a filter still applies to the current list; it just does not become part of the saved view.
39 changes: 36 additions & 3 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useParams, useSearchParams, useNavigate, useLocation } from 'react-rout
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage';
import { foldFilterGroupToSpecRules, FILTER_FOLD_REFUSAL_KEYS } from './viewFilterFold';
const ObjectChart = lazy(() =>
import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })),
);
Expand Down Expand Up @@ -265,6 +266,34 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
[dataSource, objectName]
);

/**
* Persist a toolbar filter change (objectstack#5159).
*
* The list toolbar's FilterBuilder emits its own grouped dialect
* (`{ id, logic, conditions }`); `ListViewSchema.filter` declares
* `ViewFilterRule[]`. Handing the group straight to `persistViewPatch` is
* what made every `Filter → Add filter` PUT come back 422 `invalid_union`.
* Fold at the producer (AGENTS.md #0.1) — the spec is not widened.
*
* A group that cannot fold LOSSLESSLY (`logic: 'or'` across several
* conditions, or a nested group) is refused out loud and NOT saved, rather
* than quietly written as AND: an AND rewrite would return a different
* record set than the one the user is looking at. The filter still applies
* to the live grid — `convertFilterGroupToAST` honours `logic` in-session —
* it just does not become part of the stored view.
*/
const persistViewFilter = useCallback(
(viewIdLocal: string, baseViewDef: Record<string, any>, group: unknown) => {
const folded = foldFilterGroupToSpecRules(group);
if (!folded.ok) {
toast.error(t(FILTER_FOLD_REFUSAL_KEYS[folded.reason]));
return;
}
persistViewPatch(viewIdLocal, baseViewDef, { filter: folded.rules });
},
[persistViewPatch, t]
);

const handleViewConfigSave = useCallback((draft: Record<string, any>) => {
setViewDraft(draft);
setRefreshKey(k => k + 1);
Expand Down Expand Up @@ -1335,7 +1364,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
persistViewPatch(viewDef.id, viewDef, { sort });
},
onFilterChange: (filter: any) => {
persistViewPatch(viewDef.id, viewDef, { filter });
persistViewFilter(viewDef.id, viewDef, filter);
},
onHiddenFieldsChange: (hidden: string[]) => {
persistViewPatch(viewDef.id, viewDef, { hiddenFields: hidden });
Expand Down Expand Up @@ -1590,7 +1619,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
persistViewPatch(viewDef.id, viewDef, { sort });
}}
onFilterChange={(filter: any) => {
persistViewPatch(viewDef.id, viewDef, { filter });
persistViewFilter(viewDef.id, viewDef, filter);
// localStorage keeps the BUILDER's group verbatim — it is
// read back into `initialFilters`, which needs `conditions`
// (and the row ids) to rehydrate the toolbar. Only the
// spec-governed view body is folded.
writeListFilterState(listFilterKey, { filters: filter });
}}
onSearchChange={(search: string) => {
Expand All @@ -1612,7 +1645,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
dataSource={ds}
/>
);
}, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, user?.id]);
}, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, persistViewFilter, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, user?.id]);

// Memoize the merged views array so PluginObjectView doesn't get a new
// reference on every render (which would trigger unnecessary data refetches).
Expand Down
36 changes: 24 additions & 12 deletions packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ import {
} from '@object-ui/components';
import { ChevronDown, ChevronsUpDown, ChevronUp, Eye, EyeOff, Plus, Search, Trash2 } from 'lucide-react';
import { iconNames } from 'lucide-react/dynamic.mjs';
import { toast } from 'sonner';
import { useObjectTranslation } from '@object-ui/i18n';
import { useMetadataLocale, t, tFormat } from './i18n';
import { foldFilterGroupToSpecRules, FILTER_FOLD_REFUSAL_KEYS } from '../viewFilterFold';
import { ColorVariantPicker } from './color-variant-field';
import { ConditionBuilder } from './inspectors/ConditionBuilder';
import { expressionSource, writeExpressionSource } from './inspectors/expression-envelope';
Expand Down Expand Up @@ -1675,14 +1678,13 @@ function ActionMultiWidget({ id, value, onChange, readOnly, context }: WidgetPro
/* by ViewFilterRuleSchema); reads also accept legacy shorthand/camelCase */
/* spellings still present in already-stored view metadata (gt / eq / isNull). */
/* -------------------------------------------------------------------------- */
const FB_TO_SPEC: Record<string, string> = {
equals: 'equals', notEquals: 'not_equals', contains: 'contains', notContains: 'not_contains',
isEmpty: 'is_empty', isNotEmpty: 'is_not_empty',
greaterThan: 'greater_than', lessThan: 'less_than',
greaterOrEqual: 'greater_than_or_equal', lessOrEqual: 'less_than_or_equal',
before: 'before', after: 'after', between: 'between',
in: 'in', notIn: 'not_in',
};
/* The WRITE direction (builder group → spec `ViewFilterRule[]`) now lives in */
/* `../viewFilterFold`, shared with the runtime list toolbar — this file used */
/* to hold the only copy, which is why the toolbar had none and PUT its raw */
/* FilterGroup (objectstack#5159). Its local `FB_TO_SPEC` table went with it: */
/* the shared fold normalizes through the spec's own */
/* `normalizeFilterOperator`, which covers the four builder operators this */
/* table had drifted behind (startsWith / endsWith / isNull / isNotNull). */
/** Spec operator → FilterBuilder camelCase. Keys cover both the canonical
* vocabulary and legacy spellings (shorthand + snake/camel) so stored view
* metadata written before canonicalization still seeds the builder. */
Expand All @@ -1707,6 +1709,10 @@ function FilterBuilderField({ value, onChange, fields, readOnly }: {
fields: Array<{ name: string; label?: string; type?: string }>;
readOnly?: boolean;
}) {
// The metadata-admin `t` above is a static engine-string table; refusal
// copy lives in the shared console locale packs, so it resolves through the
// platform translator (same one ObjectView's toolbar toasts use).
const { t: tr } = useObjectTranslation();
const rules = Array.isArray(value) ? value : [];
const group = {
id: 'g',
Expand All @@ -1725,10 +1731,16 @@ function FilterBuilderField({ value, onChange, fields, readOnly }: {
? rules.map((r) => `${fields.find((f) => f.name === r.field)?.label || r.field}`).filter(Boolean).join(', ')
: '';
const handle = (g: any) => {
const next = (g?.conditions ?? [])
.filter((c: any) => c?.field)
.map((c: any) => ({ field: c.field, operator: FB_TO_SPEC[c.operator] ?? c.operator, value: c.value }));
onChange(next);
// Shared with the runtime list toolbar (objectstack#5159). A group that
// cannot fold losslessly — `logic: 'or'` over several rows, or a nested
// group — is refused rather than silently written as AND, which is what
// this callback used to do by dropping `g.logic` on the floor.
const folded = foldFilterGroupToSpecRules(g);
if (!folded.ok) {
toast.error(tr(FILTER_FOLD_REFUSAL_KEYS[folded.reason]));
return;
}
onChange(folded.rules as FilterRuleLite[]);
};
return (
<Popover>
Expand Down
104 changes: 104 additions & 0 deletions packages/app-shell/src/views/view-filter-fold.ratchet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* objectstack#5159 ratchet — the FilterBuilder's `FilterGroup` must never
* reach a persisted view `filter` again.
*
* The bug: the runtime list toolbar handed `onFilterChange`'s payload straight
* to `persistViewPatch({ filter })`. That payload is the builder's grouped
* dialect (`{ id, logic, conditions }`); `ListViewSchema.filter` declares
* `z.array(ViewFilterRuleSchema)`. Every `Filter → Add filter → save` came back
* 422 `invalid_union` — and because objectstack#5014 flattens union errors to a
* bare "Invalid input" that never names `filter`, it lived on `main` unnoticed.
*
* `viewFilterFold.test.ts` pins what the fold PRODUCES. This pins that the
* persist path still GOES THROUGH it: a shape assertion on the transform is
* worth nothing if a future edit routes a raw group around it. The two together
* close the issue's replay matrix at the persist-call boundary — variant ① (the
* captured `FilterGroup`) is unreachable, variant ③ (declared keys only) is
* what the PUT carries.
*
* If this fails: do not re-add `persistViewPatch(…, { filter })`. Route the
* group through `persistViewFilter` (ObjectView) / `foldFilterGroupToSpecRules`
* (`./viewFilterFold`), which folds to `ViewFilterRule[]` and refuses — out
* loud — the shapes that cannot fold losslessly.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const here = path.dirname(fileURLToPath(import.meta.url));
const objectViewPath = path.join(here, 'ObjectView.tsx');
const widgetsPath = path.join(here, 'metadata-admin/widgets.tsx');

const objectViewSrc = readFileSync(objectViewPath, 'utf8');
const widgetsSrc = readFileSync(widgetsPath, 'utf8');

/**
* Every `persistViewPatch(…, { filter … })` in the file — the call that shipped
* the group. Exactly ONE is legitimate: the one inside `persistViewFilter`,
* whose value is the fold's output. Any other is the bug returning.
*/
const FILTER_PERSIST_CALLS = /persistViewPatch\s*\([^;]*?\{\s*filter\s*(?::\s*([A-Za-z0-9_.]+))?/gs;

describe('objectstack#5159 ratchet — no raw FilterGroup on the view persist path', () => {
it('the ratchet is reading real source, not an empty string', () => {
expect(objectViewSrc.length).toBeGreaterThan(10_000);
expect(widgetsSrc.length).toBeGreaterThan(10_000);
// Sanity: the guarded symbol still exists under these names.
expect(objectViewSrc).toContain('persistViewPatch');
});

it('the only `filter` patch ObjectView persists is the FOLD’s output', () => {
// `m[1]` is undefined for the shorthand `{ filter }` — the exact form
// the bug shipped — so it can never satisfy the expectation below.
const values = [...objectViewSrc.matchAll(FILTER_PERSIST_CALLS)].map((m) => m[1] ?? '{ filter } shorthand');
// Exactly one such call, and it writes the folded rules — never the raw
// `filter` argument the toolbar handed in.
expect(values, 'a `persistViewPatch(…, { filter })` call appeared that does not write the fold’s output')
.toEqual(['folded.rules']);
});

it('every toolbar filter change routes through the fold', () => {
// Look at the code immediately following each `onFilterChange` binding:
// it must reach `persistViewFilter`, not `persistViewPatch`.
const sites = [...objectViewSrc.matchAll(/onFilterChange[=:]/g)];
expect(sites.length, 'expected the list-toolbar onFilterChange handlers to be found')
.toBeGreaterThanOrEqual(2);
for (const site of sites) {
// Window = this handler only. It ends where the NEXT `onXxx`
// binding starts, so the sibling handlers (which legitimately call
// `persistViewPatch` for sort / hiddenFields / columnState) stay
// out of the assertion.
const rest = objectViewSrc.slice(site.index! + 1, site.index! + 401);
const next = rest.search(/\bon[A-Z]\w*\s*[=:]/);
const body = next === -1 ? rest : rest.slice(0, next);
expect(body, `this onFilterChange bypasses the fold:\n${body}`).toContain('persistViewFilter');
expect(body, `this onFilterChange persists a raw group:\n${body}`).not.toContain('persistViewPatch');
}
});

it('ObjectView imports the shared fold rather than re-deriving one', () => {
expect(objectViewSrc).toMatch(/import\s*\{[^}]*foldFilterGroupToSpecRules[^}]*\}\s*from\s*'\.\/viewFilterFold'/);
});

it('the Studio inspector folds through the SAME module — one dialect, not two', () => {
expect(widgetsSrc).toMatch(/import\s*\{[^}]*foldFilterGroupToSpecRules[^}]*\}\s*from\s*'\.\.\/viewFilterFold'/);
// The local operator table it used to carry is gone: a second table is
// how the runtime toolbar ended up with no fold at all. (Matched on the
// DECLARATION so the comment explaining the removal doesn't trip it.)
expect(widgetsSrc).not.toMatch(/const\s+FB_TO_SPEC\b/);
});

it('the refusal reaches the user — both fold call sites surface it, none swallow it', () => {
for (const [name, src] of [['ObjectView.tsx', objectViewSrc], ['widgets.tsx', widgetsSrc]] as const) {
expect(src, `${name} calls the fold`).toContain('foldFilterGroupToSpecRules');
expect(src, `${name} surfaces the refusal as a toast`).toMatch(
/toast\.error\(\s*tr?\(\s*FILTER_FOLD_REFUSAL_KEYS/,
);
}
});
});
Loading
Loading