diff --git a/.changeset/28370-view-filter-or-group-console.md b/.changeset/28370-view-filter-or-group-console.md new file mode 100644 index 00000000000..c25da838047 --- /dev/null +++ b/.changeset/28370-view-filter-or-group-console.md @@ -0,0 +1,21 @@ +--- +'@object-ui/core': patch +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-view': patch +'@object-ui/i18n': patch +--- + +feat(console): a stored view filter's OR group filters the list, and the filter builder saves and reads it + +Clause-②: yes (widening: `@object-ui/core` adds the export `builderRulesOfSpecFilter` and its result type `BuilderFilterRules`; no schema, accept set or error code changes) +Metadata-protocol: none +Skills-docs: follow-up #28476 — skills/objectstack-ui/rules/list-views.md, skills/objectstack-ui/evals/views-apps-actions-pages.json (the any-of group is not taught there yet); `content/docs/ui/views.mdx` gains a Base filter section that states it + +A view filter list may hold an any-of group beside its rules, `{ or: [rule, ...] }` (`ViewFilterOrGroupSchema` in `@objectstack/spec/ui`): the list ANDs its elements and the group ORs its rules. Until now no console reader applied it. The list query passed the group through, the server refused the query as a malformed filter (400 `INVALID_FILTER`), and the view showed no rows. + +- **Query.** `toFilterNode` (`@object-ui/core`) lowers a group to an `or` node of its rules, one child of the list's AND, so the list, the export, kanban, calendar, gallery, timeline, related lists, line items, the grid and the in-memory `ValueDataSource` all select the records the group names. Each member is lowered exactly like a top-level rule, so the same operator fold and refusals apply inside a group. A group the spec refuses (an empty `or`, a blank-field member, a group inside a group) is not repaired: the server still refuses it. +- **Adapter.** `@object-ui/data-objectstack` translates a group in an unlowered view filter list on both `find()` routes, whether it opens the list, follows a rule or is spread into an `and`. A group that does not translate is refused whole with `MalformedFilterError` (`INVALID_FILTER`, 400), never dropped. +- **Save.** The filter builder's OR group over two or more rules is saved as one group element, `[{ or: rules }]`, instead of being refused with "a saved view cannot store OR". The `console.objectView.filterOrNotSavable` message is retired in all ten packs. +- **Read back.** A stored list that is exactly one group opens in the Studio filter field, and in `toFilterGroup`, as an OR group of its rules. A list with a group beside other conditions cannot be shown by the one-level builder, so the Studio filter field shows it read-only with the reason (`console.objectView.filterOrGroupNotEditable`, in all ten packs) and never saves part of it. +- **Override recovery.** The pass that drops unfinished conditions from a stored view override keeps a group, drops only its unfinished members, and drops a group with none left. It used to drop every group as an entry with no `field`, which widened the view. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 254b323f41d..4f376004d55 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -98,7 +98,7 @@ A List View controls how a collection of records is presented. It supports multi | `label` | `string` | optional | Display label shown in the view switcher | | `type` | `enum` | optional | View type (see table above; default `'grid'`) | | `data` | `ViewData` | optional | Data source configuration (defaults to the `object` provider) | -| `filter` | `array` | optional | Base filter criteria | +| `filter` | `array` | optional | Base filter: rules that all must match, any of which may be an any-of group `{ or: [...] }` — see [Base filter](#base-filter-filter) below | | `sort` | `array` | optional | Sort configuration | | `searchableFields` | `string[]` | optional | Fields the toolbar search scans — **narrows** the set the object allows, never widens it (ADR-0061). Every entry must be in that allowed set, or every toolbar search on the list returns `400 INVALID_FIELD` (#4254) — see [Toolbar search](#toolbar-search-searchablefields) below | | `grouping` | `object` | optional | Row grouping configuration | @@ -115,6 +115,30 @@ The view's machine name is its **key** in the container (`listViews.urgent` on object `task` becomes `task.urgent`); the default `list` claims `task.default`. List and form views share that one namespace — don't reuse a key. +### Base filter (`filter`) + +A list view's `filter` is a list of conditions that **all** must match. Each +element is a rule `{ field, operator, value }`, or an **any-of group** +`{ or: [rule, ...] }` that matches a record when **any** of its rules does. A +group holds flat rules only (no group inside a group), and the list still ANDs +the group with the elements beside it: + +```typescript +// open, or completed in the last 7 days — and owned by me +filter: [ + { or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }, + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, +], +``` + +The Console's filter builder saves its **OR** toggle as one such group, and +opens a filter that is exactly one group as an OR group. A filter whose group +sits beside other conditions is applied to the list as written, but the +builder shows it read-only, because it edits one AND or OR over flat rows. + ### Toolbar search (`searchableFields`) The toolbar's search box scans a set the **object** owns. A list view's 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..6b15d9dbc68 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 @@ -59,15 +59,18 @@ function useFieldOptions(objectName: string | undefined): Array<{ value: string; ); } +/** The elements `FilterBuilderField` reads and writes. */ +type FilterElements = NonNullable['value']>; + /** - * 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. */ 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`. */} -