From 6a39f52abd81590d22d105e8aae220e7d3d11be3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:24:40 +0000 Subject: [PATCH 1/5] feat(console): read a stored view filter's or group in the list query and the filter builder A view filter list element { or: [rule, ...] } now lowers to an or node of its rules inside the list's AND (core toFilterNode and the data-objectstack translator), the filter builder saves an OR group as one such element instead of refusing it, and the Studio filter field and parseSpecFilter read a lone group back as an OR group. A group beside other elements is shown read-only in the one-level builder, and the view-override recovery pass keeps a group instead of dropping it as a field-less entry. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgXa2W2ZCA4huS5hVVx --- .../inspectors/PageBlockInspector.tsx | 11 +- .../src/views/metadata-admin/widgets.tsx | 64 +++++-- .../app-shell/src/views/objectViewTabs.ts | 39 +++-- .../studio-design/ObjectListViewInspector.tsx | 9 +- .../src/views/viewFilterFold.test.ts | 27 ++- .../app-shell/src/views/viewFilterFold.ts | 33 ++-- .../view-filter-or-group-28370.test.ts | 157 ++++++++++++++++++ .../core/src/utils/filter-converter.ts | 88 +++++++++- ui/packages/data-objectstack/src/index.ts | 43 ++++- ui/packages/i18n/src/locales/ar.ts | 2 +- ui/packages/i18n/src/locales/de.ts | 2 +- ui/packages/i18n/src/locales/en.ts | 2 +- ui/packages/i18n/src/locales/es.ts | 2 +- ui/packages/i18n/src/locales/fr.ts | 2 +- ui/packages/i18n/src/locales/ja.ts | 2 +- ui/packages/i18n/src/locales/ko.ts | 2 +- ui/packages/i18n/src/locales/pt.ts | 2 +- ui/packages/i18n/src/locales/ru.ts | 2 +- ui/packages/i18n/src/locales/zh.ts | 2 +- .../src/config/view-config-utils.ts | 17 +- 20 files changed, 429 insertions(+), 79 deletions(-) create mode 100644 ui/packages/core/src/utils/__tests__/view-filter-or-group-28370.test.ts diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx index 4885e6448e4..fa2e60ffea3 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -60,14 +60,17 @@ function useFieldOptions(objectName: string | undefined): Array<{ value: string; } /** - * A block prop that is a spec `ViewFilterRule[]` (`record:related_list.filter`), - * edited with the list toolbar's own filter builder — the one the + * A block prop that is a spec view filter list (`record:related_list.filter`: + * flat rules, any of which may be an any-of group `{ or: [rule, ...] }`), edited with the list toolbar's own filter builder — the one the * `filter-builder` form widget renders for a view's and a page's filters — over * the fields of `objectName`. Clearing the last rule removes the key. */ +/** The elements `FilterBuilderField` reads and writes. */ +type FilterElements = NonNullable['value']>; + function FilterRulesField({ label, objectName, value, onChange, disabled }: { label: string; objectName: string | undefined; value: unknown; - onChange: (v: Array<{ field: string; operator: string; value?: unknown }> | undefined) => void; + onChange: (v: FilterElements | undefined) => void; disabled?: boolean; }) { const { fields, error } = useObjectFields(objectName); @@ -81,7 +84,7 @@ function FilterRulesField({ label, objectName, value, onChange, disabled }: { ) : undefined} + value={Array.isArray(value) ? (value as FilterElements) : undefined} onChange={(rules) => onChange(rules.length > 0 ? rules : undefined)} fields={builderFields} readOnly={disabled} diff --git a/ui/packages/app-shell/src/views/metadata-admin/widgets.tsx b/ui/packages/app-shell/src/views/metadata-admin/widgets.tsx index be60ad2802c..705370d5dba 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -42,6 +42,7 @@ import { DialogTitle, } from '@object-ui/components'; import type { RegistryComponentMeta } from '@object-ui/core'; +import { builderRulesOfSpecFilter } from '@object-ui/core'; import { AlertTriangle, ChevronDown, ChevronsUpDown, ChevronUp, Eye, EyeOff, Plus, Search, Trash2 } from 'lucide-react'; import { iconNames } from 'lucide-react/dynamic.mjs'; import { toast } from 'sonner'; @@ -2586,7 +2587,7 @@ function FilterModeWidget({ value, onChange, readOnly, context, ariaLabelledBy } {/* Per-tab filter — unified runtime FilterBuilder (popover). */}
patchTab(ti, { filter: f as any })} fields={objectFields} loadError={loadError} @@ -2737,11 +2738,31 @@ function ActionMultiWidget({ value, onChange, readOnly, context, ariaLabelledBy interface FilterRuleLite { field: string; operator: string; value?: unknown } -/** The builder's row group for a stored rule list. */ -function groupOfStoredRules(rules: FilterRuleLite[]) { +/** + * One element of a stored view filter list: a flat rule, or an any-of group + * `{ or: [rule, ...] }` (`ViewFilterOrGroupSchema`), which is what the fold + * writes for an OR group. + */ +type FilterElementLite = FilterRuleLite | { or: FilterRuleLite[] }; + +/** Every flat rule of a stored list, a group's members included, for the summary. */ +function rulesOfStoredElements(elements: FilterElementLite[]): FilterRuleLite[] { + return elements.flatMap((element) => { + const members = (element as { or?: unknown }).or; + return Array.isArray(members) ? (members as FilterRuleLite[]) : [element as FilterRuleLite]; + }); +} + +/** + * The builder's row group for a stored list the builder can show + * (`builderRulesOfSpecFilter`): its ONE logic over its flat rules, so an OR + * group the fold wrote reads back as the OR group it was. + */ +function groupOfStoredRules(shown: { logic: 'and' | 'or'; rules: unknown[] } | null) { + const rules = (shown?.rules ?? []) as FilterRuleLite[]; return { id: 'g', - logic: 'and' as const, + logic: shown?.logic ?? ('and' as const), conditions: rules.map((r, i) => ({ id: `c${i}`, // Handed over as stored: the builder reads it through the spec's fold @@ -2755,8 +2776,8 @@ function groupOfStoredRules(rules: FilterRuleLite[]) { } export function FilterBuilderField({ value, onChange, fields, readOnly, id, ariaLabelledBy, loadError }: { - value?: FilterRuleLite[]; - onChange: (rules: FilterRuleLite[]) => void; + value?: FilterElementLite[]; + onChange: (rules: FilterElementLite[]) => void; fields: Array<{ name: string; label?: string; type?: string; options?: Array<{ value: string; label: string }> }>; readOnly?: boolean; /** @@ -2786,6 +2807,12 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria // platform translator (same one ObjectView's toolbar toasts use). const { t: tr } = useObjectTranslation(); const rules = Array.isArray(value) ? value : []; + // The stored list as the builder's one logic over flat rows, or `null` when + // it holds an any-of group beside other elements: the builder edits one + // logic, so loading that list would drop part of it on the next save. Such a + // list is shown, never edited, here. + const shown = builderRulesOfSpecFilter(rules); + const notEditable = shown === null; // The stored rules by CONTENT, so a re-render handing over an equal array is // not a change (ui/AGENTS.md #10: never key on an identity). const rulesKey = JSON.stringify(rules); @@ -2796,7 +2823,7 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria // the rows from the stored rules therefore erased the new row on the next // render, so no filter could be authored here at all. The held group keeps // such a row on screen until it is complete; only the fold's rules leave. - const [draft, setDraft] = React.useState(() => groupOfStoredRules(rules)); + const [draft, setDraft] = React.useState(() => groupOfStoredRules(shown)); const [seededFrom, setSeededFrom] = React.useState(rulesKey); if (seededFrom !== rulesKey) { // The stored rules moved. When they are the echo of this builder's own @@ -2804,7 +2831,7 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria // editor, a reload, an undo) re-seeds the rows from what is stored. setSeededFrom(rulesKey); const held = foldFilterGroupToSpecRules(draft); - if (!held.ok || JSON.stringify(held.rules) !== rulesKey) setDraft(groupOfStoredRules(rules)); + if (!held.ok || JSON.stringify(held.rules) !== rulesKey) setDraft(groupOfStoredRules(shown)); } // `options` travel with the column (objectstack#27515): the builder draws a // picklist column's value as a picker over them and stores the option's @@ -2817,14 +2844,16 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria type: f.type, ...(f.options ? { options: f.options } : {}), })); - const summary = rules.length - ? rules.map((r) => `${fields.find((f) => f.name === r.field)?.label || r.field}`).filter(Boolean).join(', ') + const storedRules = rulesOfStoredElements(rules); + const summary = storedRules.length + ? storedRules.map((r) => `${fields.find((f) => f.name === r.field)?.label || r.field}`).filter(Boolean).join(', ') : ''; const handle = (g: any) => { - // 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. + // Shared with the runtime list toolbar (objectstack#5159). An OR group + // folds to one any-of element (`{ or: rules }`); a group that cannot fold + // losslessly — a nested group — is refused rather than silently written as + // something else, 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])); @@ -2834,7 +2863,7 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria // A row added or edited without completing it folds to the rules already // stored: the view has not changed, so nothing is written. if (JSON.stringify(folded.rules) === rulesKey) return; - onChange(folded.rules as FilterRuleLite[]); + onChange(folded.rules as FilterElementLite[]); }; return ( @@ -2843,7 +2872,8 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria role `button` — axe-core reports it as `aria-allowed-attr`. The other ten `'control'` widgets carry it through `controlNaming`. */} -