diff --git a/packages/app-shell/src/views/metadata-admin/FilterModeWidget.test.tsx b/packages/app-shell/src/views/metadata-admin/FilterModeWidget.test.tsx index 0b1e65b53a..db1c99ab27 100644 --- a/packages/app-shell/src/views/metadata-admin/FilterModeWidget.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/FilterModeWidget.test.tsx @@ -103,7 +103,11 @@ describe('filter-mode widget', () => { ); expect(screen.getByTestId('tab-preset-0')).toBeInTheDocument(); expect((screen.getByTestId('tab-label-0') as HTMLInputElement).value).toBe('Urgent'); - expect(screen.getByTestId('tab-0-rule-0')).toBeInTheDocument(); + // The per-tab filter now uses the same unified FilterBuilder as the list + // toolbar; its trigger summarizes the existing condition by field label. + const tabFilter = screen.getByTestId('tab-0-filter'); + expect(tabFilter).toBeInTheDocument(); + expect(tabFilter.textContent).toContain('Priority'); }); it('normalizes a legacy { id, filters } tab into the canonical shape on edit', () => { diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index bbbf48db10..11739448ea 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -32,6 +32,10 @@ import { Switch, LazyIcon, toKebabIconName, + Popover, + PopoverTrigger, + PopoverContent, + FilterBuilder, } from '@object-ui/components'; import { ChevronDown, ChevronsUpDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react'; // @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs @@ -1195,20 +1199,6 @@ const FILTER_MODES: Array<{ key: 'none' | UFMode; label: string }> = [ { key: 'dropdown', label: 'Dropdown' }, ]; -// Common predicate operators offered in the tab rule builder. Free-form -// operators authored elsewhere still round-trip; this is the curated set that -// keeps AI/Studio authoring consistent (ADR-0053). -const TAB_OPERATORS = [ - 'equals', - 'not_equals', - 'contains', - 'in', - 'greater_than', - 'less_than', - 'is_empty', - 'is_not_empty', -]; - const slugifyTabName = (s: string): string => (s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'tab'; @@ -1286,12 +1276,6 @@ function FilterModeWidget({ value, onChange, readOnly, context }: WidgetProps) { }; const patchTab = (ti: number, patch: Partial) => writeTabs(tabs.map((t, i) => (i === ti ? { ...t, ...patch } : t))); - const addRule = (ti: number) => - patchTab(ti, { filter: [...(tabs[ti].filter ?? []), { field: objectFields[0]?.name ?? '', operator: 'equals', value: '' }] }); - const patchRule = (ti: number, ri: number, patch: Partial) => - patchTab(ti, { filter: (tabs[ti].filter ?? []).map((r, j) => (j === ri ? { ...r, ...patch } : r)) }); - const removeRule = (ti: number, ri: number) => - patchTab(ti, { filter: (tabs[ti].filter ?? []).filter((_, j) => j !== ri) }); const setShowAllRecords = (c: boolean) => onChange({ ...(uf ?? {}), element: 'tabs', showAllRecords: c }); return ( @@ -1413,44 +1397,14 @@ function FilterModeWidget({ value, onChange, readOnly, context }: WidgetProps) { )} - {/* Per-tab filter rules ({ field, operator, value }) */} -
- {(tab.filter ?? []).map((rule, ri) => ( -
- - - patchRule(ti, ri, { value: e.target.value })} - /> - {!readOnly && ( - - )} -
- ))} - {!readOnly && ( - - )} + {/* Per-tab filter — unified runtime FilterBuilder (popover). */} +
+ patchTab(ti, { filter: f as any })} + fields={objectFields} + readOnly={readOnly} + />
))} @@ -1550,6 +1504,86 @@ 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. */ +/* -------------------------------------------------------------------------- */ +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', + in: 'in', notIn: 'not_in', +}; +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', +}; + +interface FilterRuleLite { field: string; operator: string; value?: unknown } + +function FilterBuilderField({ value, onChange, fields, readOnly }: { + value?: FilterRuleLite[]; + onChange: (rules: FilterRuleLite[]) => void; + fields: Array<{ name: string; label?: string; type?: string }>; + readOnly?: boolean; +}) { + const rules = Array.isArray(value) ? value : []; + const group = { + id: 'g', + logic: 'and' as const, + conditions: rules.map((r, i) => ({ + id: `c${i}`, + field: r.field, + operator: SPEC_TO_FB[r.operator] ?? r.operator ?? 'equals', + value: (r.value as any) ?? '', + })), + }; + const fbFields = fields.map((f) => ({ value: f.name, label: f.label || f.name, type: f.type })); + const summary = rules.length + ? 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); + }; + return ( + + + + + + {fields.length === 0 ? ( +

Bind a source object to add filter conditions.

+ ) : ( + + )} +
+
+ ); +} + +function FilterBuilderWidget({ value, onChange, readOnly, context }: WidgetProps) { + return ( + onChange(rules.length ? rules : undefined)} + fields={context?.objectFields ?? []} + readOnly={readOnly} + /> + ); +} + export const WIDGETS: Record = { 'ref:object': RefObjectWidget, 'filter-mode': FilterModeWidget, @@ -1558,14 +1592,13 @@ export const WIDGETS: Record = { 'field-ref': FieldRefWidget, 'field-multi': FieldRefMultiWidget, 'action-multi': ActionMultiWidget, + 'filter-builder': FilterBuilderWidget, 'view-ref': ViewRefWidget, 'icon': IconPickerWidget, 'master-detail': MasterDetailWidget, 'string-tags': StringTagsWidget, 'multiselect': MultiSelectWidget, 'code': CodeWidget, - // Reasonable fallbacks until dedicated builders ship: - 'filter-builder': MasterDetailWidget, }; /* -------------------------------------------------------------------------- */