From e96ae036e0a4d6606ca0a8d8cffe6b781fce79bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:15:36 +0000 Subject: [PATCH] feat(SchemaForm): field-type-aware operators + values for view filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the Studio view-config filter editor with the canonical @objectstack/spec ViewFilterRule operator vocabulary and reuse the runtime FilterBuilder (which already provides per-field-type operator lists, value inputs, and i18n) instead of raw repeater rows. - ResourceEditPage: resolve the source object from `data.object` so view filter rows get the object field catalog that drives operator/value widgets. - widgets.tsx FilterBuilderWidget: write the canonical operator vocabulary (greater_than, not_equals, before/after, …); reads still accept legacy shorthand/camelCase spellings from already-stored metadata and round-trip unknown operators instead of rewriting them. - Runtime operator->AST maps (ListView.mapOperator, UserFilters.specOperatorToAst) now accept the canonical long-form operators the builder writes, alongside the existing shorthand spellings. Depends on the paired @objectstack/spec change (ViewFilterRule operator enum + `widget: 'filter-builder'` on the view filter field) for the operator dropdown. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PwbhoMeqh33xFWq5M77QTJ --- .../views/metadata-admin/ResourceEditPage.tsx | 1 + .../src/views/metadata-admin/widgets.tsx | 36 +++++++++++++------ packages/plugin-list/src/ListView.tsx | 11 +++--- packages/plugin-list/src/UserFilters.tsx | 15 ++++++-- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index bee39e99b..6667e0731 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -584,6 +584,7 @@ function MetadataResourceEditPageImpl({ // `object`; other types fall back to their own `object`/`objectName`. const sourceObjectName: string | undefined = ((draft as any)?.interfaceConfig?.source as string | undefined) || + ((draft as any)?.data?.object as string | undefined) || ((draft as any)?.object as string | undefined) || ((draft as any)?.objectName as string | undefined); const [objectFields, setObjectFields] = React.useState>([]); diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 560a851ec..7ba3cb469 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -1668,22 +1668,34 @@ function ActionMultiWidget({ id, value, onChange, readOnly, context }: WidgetPro /* -------------------------------------------------------------------------- */ /* filter-builder — the SAME runtime FilterBuilder used by the list toolbar, */ -/* reused in Studio for tab presets and the page base filter (unified UX). */ -/* Stored format stays spec ViewFilterRule[] ({field,operator,value}); the */ -/* builder's camelCase operators are mapped at the boundary so the runtime */ -/* (specOperatorToAst) keeps working unchanged. */ +/* reused in Studio for view/tab filters and the page base filter (unified UX).*/ +/* Stored format stays spec ViewFilterRule[] ({field,operator,value}). Writes */ +/* use the CANONICAL @objectstack/spec operator vocabulary (the enum enforced */ +/* by ViewFilterRuleSchema); reads also accept legacy shorthand/camelCase */ +/* spellings still present in already-stored view metadata (gt / eq / isNull). */ /* -------------------------------------------------------------------------- */ const FB_TO_SPEC: Record = { equals: 'equals', notEquals: 'not_equals', contains: 'contains', notContains: 'not_contains', - isEmpty: 'is_empty', isNotEmpty: 'is_not_empty', greaterThan: 'gt', lessThan: 'lt', - greaterOrEqual: 'gte', lessOrEqual: 'lte', before: 'lt', after: 'gt', between: 'between', + 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', }; +/** 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. */ const SPEC_TO_FB: Record = { - equals: 'equals', eq: 'equals', not_equals: 'notEquals', ne: 'notEquals', neq: 'notEquals', - contains: 'contains', not_contains: 'notContains', is_empty: 'isEmpty', is_not_empty: 'isNotEmpty', - gt: 'greaterThan', greater_than: 'greaterThan', lt: 'lessThan', less_than: 'lessThan', - gte: 'greaterOrEqual', lte: 'lessOrEqual', in: 'in', not_in: 'notIn', nin: 'notIn', + equals: 'equals', eq: 'equals', + not_equals: 'notEquals', ne: 'notEquals', neq: 'notEquals', notEquals: 'notEquals', + contains: 'contains', not_contains: 'notContains', notContains: 'notContains', + is_empty: 'isEmpty', isEmpty: 'isEmpty', is_not_empty: 'isNotEmpty', isNotEmpty: 'isNotEmpty', + greater_than: 'greaterThan', gt: 'greaterThan', greaterThan: 'greaterThan', + less_than: 'lessThan', lt: 'lessThan', lessThan: 'lessThan', + greater_than_or_equal: 'greaterOrEqual', gte: 'greaterOrEqual', greaterOrEqual: 'greaterOrEqual', + less_than_or_equal: 'lessOrEqual', lte: 'lessOrEqual', lessOrEqual: 'lessOrEqual', + before: 'before', after: 'after', between: 'between', + in: 'in', not_in: 'notIn', nin: 'notIn', notIn: 'notIn', }; interface FilterRuleLite { field: string; operator: string; value?: unknown } @@ -1700,8 +1712,10 @@ function FilterBuilderField({ value, onChange, fields, readOnly }: { logic: 'and' as const, conditions: rules.map((r, i) => ({ id: `c${i}`, - field: r.field, + // Keep the raw operator verbatim if the builder has no camelCase + // equivalent, so it round-trips on save instead of being rewritten. operator: SPEC_TO_FB[r.operator] ?? r.operator ?? 'equals', + field: r.field, value: (r.value as any) ?? '', })), }; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index b880d45d9..3b7e5bde0 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -64,11 +64,12 @@ function mapOperator(op: string) { case 'equals': case 'eq': return '='; case 'notEquals': case 'not_equals': case 'ne': case 'neq': return '!='; case 'contains': return 'contains'; - case 'notContains': case 'notcontains': return 'notcontains'; - case 'greaterThan': case 'gt': return '>'; - case 'greaterOrEqual': case 'gte': return '>='; - case 'lessThan': case 'lt': return '<'; - case 'lessOrEqual': case 'lte': return '<='; + case 'notContains': case 'not_contains': case 'notcontains': return 'notcontains'; + case 'startsWith': case 'starts_with': return 'startswith'; + case 'greaterThan': case 'greater_than': case 'gt': return '>'; + case 'greaterOrEqual': case 'greater_than_or_equal': case 'gte': return '>='; + case 'lessThan': case 'less_than': case 'lt': return '<'; + case 'lessOrEqual': case 'less_than_or_equal': case 'lte': return '<='; case 'in': return 'in'; case 'notIn': case 'not_in': case 'nin': return 'not in'; case 'before': return '<'; diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index a00113702..877110bf2 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -72,13 +72,22 @@ export interface UserFiltersProps { onSelectionsChange?: (selections: Record>) => void; } -/** Map @objectstack/spec ViewFilterRule operators to ObjectQL AST operators. */ +/** + * Map @objectstack/spec ViewFilterRule operators to ObjectQL AST operators. + * Accepts the canonical vocabulary (`greater_than`, `not_equals`, `before`, …) + * that the Studio filter builder now writes, plus legacy shorthand spellings + * (`gt`, `eq`, `nin`) still present in already-stored view metadata. + */ function specOperatorToAst(op: string | undefined): string { switch (op) { case undefined: case 'equals': case 'eq': return '='; case 'not_equals': case 'ne': case 'neq': return '!='; - case 'gte': return '>='; case 'lte': return '<='; - case 'gt': return '>'; case 'lt': return '<'; + case 'greater_than_or_equal': case 'gte': return '>='; + case 'less_than_or_equal': case 'lte': return '<='; + case 'greater_than': case 'gt': case 'after': return '>'; + case 'less_than': case 'lt': case 'before': return '<'; + case 'not_contains': return 'notcontains'; + case 'starts_with': return 'startswith'; case 'not_in': case 'nin': return 'not in'; default: return op; }