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
21 changes: 21 additions & 0 deletions .changeset/28370-view-filter-or-group-console.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 25 additions & 1 deletion content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,18 @@ function useFieldOptions(objectName: string | undefined): Array<{ value: string;
);
}

/** The elements `FilterBuilderField` reads and writes. */
type FilterElements = NonNullable<React.ComponentProps<typeof FilterBuilderField>['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);
Expand All @@ -81,7 +84,7 @@ function FilterRulesField({ label, objectName, value, onChange, disabled }: {
<Label htmlFor={id} className="text-xs text-muted-foreground">{label}</Label>
<FilterBuilderField
id={id}
value={Array.isArray(value) ? (value as Array<{ field: string; operator: string; value?: unknown }>) : undefined}
value={Array.isArray(value) ? (value as FilterElements) : undefined}
onChange={(rules) => onChange(rules.length > 0 ? rules : undefined)}
fields={builderFields}
readOnly={disabled}
Expand Down
64 changes: 47 additions & 17 deletions ui/packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2586,7 +2587,7 @@ function FilterModeWidget({ value, onChange, readOnly, context, ariaLabelledBy }
{/* Per-tab filter — unified runtime FilterBuilder (popover). */}
<div className="pl-1" data-testid={`tab-${ti}-filter`}>
<FilterBuilderField
value={tab.filter as FilterRuleLite[] | undefined}
value={tab.filter as FilterElementLite[] | undefined}
onChange={(f) => patchTab(ti, { filter: f as any })}
fields={objectFields}
loadError={loadError}
Expand Down Expand Up @@ -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
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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);
Expand All @@ -2796,15 +2823,15 @@ 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
// write, the held rows already fold to them; anything else (another
// 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
Expand All @@ -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]));
Expand All @@ -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 (
<Popover>
Expand All @@ -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`. */}
<PopoverTrigger asChild>
<Button {...controlNaming({ id, ariaLabelledBy })} variant="outline" size="sm" disabled={readOnly}
<Button {...controlNaming({ id, ariaLabelledBy })} variant="outline" size="sm" disabled={readOnly || notEditable}
title={notEditable ? tr('console.objectView.filterOrGroupNotEditable') : undefined}
className="h-8 w-full justify-between text-xs font-normal" data-testid="filter-builder-trigger">
<span className="truncate text-left">{summary || <span className="text-muted-foreground">+ Add filter…</span>}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
Expand Down Expand Up @@ -2875,7 +2905,7 @@ function FilterBuilderWidget({ id, ariaLabelledBy, value, onChange, readOnly, co
<FilterBuilderField
id={id}
ariaLabelledBy={ariaLabelledBy}
value={value as FilterRuleLite[] | undefined}
value={value as FilterElementLite[] | undefined}
onChange={(rules) => onChange(rules.length ? rules : undefined)}
fields={fields}
loadError={loadError}
Expand Down
39 changes: 29 additions & 10 deletions ui/packages/app-shell/src/views/objectViewTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { leadWithNameField } from '@object-ui/core';
import { isFilterValueComplete } from '@object-ui/components';
import { isSystemManagedField } from '@object-ui/types';
import { narrowPersonalizationOverlay } from '@object-ui/data-objectstack';
import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui';
import { isViewFilterOrGroup, ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui';
import { VALUELESS_FILTER_OPERATORS } from './viewFilterFold.js';
import { viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js';
import { buildNewViewSpec } from './newViewSpec.js';
Expand Down Expand Up @@ -246,19 +246,38 @@ export function sanitizeViewOverride(override: any): any {
// Folded before the lookup — see the docblock (objectui#9306).
const isValueless = (operator: unknown): boolean =>
VALUELESS_FILTER_OPERATORS.has(normalizeFilterOperator(String(operator)));
const kept = override.filter.filter((entry: any) => {
if (Array.isArray(entry)) {
// Legacy runtime triple: [field, operator, value]
if (entry.length < 2) return false;
const [, operator, value] = entry;
if (isValueless(operator)) return true;
return isFilterValueComplete(String(operator), value);
}
const isEffectiveRule = (entry: any): boolean => {
if (!entry || typeof entry !== 'object') return false;
if (typeof entry.field !== 'string' || entry.field === '') return false;
if (isValueless(entry.operator)) return true;
const value = entry.value;
return isFilterValueComplete(String(entry.operator), value);
};
let changed = false;
const kept = override.filter.flatMap((entry: any) => {
if (Array.isArray(entry)) {
// Legacy runtime triple: [field, operator, value]
const effective = entry.length >= 2
&& (isValueless(entry[1]) || isFilterValueComplete(String(entry[1]), entry[2]));
if (!effective) changed = true;
return effective ? [entry] : [];
}
// An any-of group (`{ or: [rule, ...] }`, objectstack#28020) is judged
// by its members, the way the live query reads an OR group's rows: an
// unfinished member is not a condition, so it leaves the group, and a
// group with no effective member left carries no condition at all. A
// group whose `or` is not an array is kept as it is, so the server's
// refusal of that shape is still what the author sees.
if (isViewFilterOrGroup(entry)) {
if (!Array.isArray(entry.or)) return [entry];
const members = entry.or.filter(isEffectiveRule);
if (members.length === entry.or.length) return [entry];
changed = true;
return members.length > 0 ? [{ ...entry, or: members }] : [];
}
const effective = isEffectiveRule(entry);
if (!effective) changed = true;
return effective ? [entry] : [];
});

// The empty-array case is checked FIRST: `filter: []` (Clear all's write)
Expand All @@ -273,7 +292,7 @@ export function sanitizeViewOverride(override: any): any {
const { filter: _dropped, ...rest } = override;
return rest;
}
if (kept.length === override.filter.length) return override;
if (!changed) return override;
return { ...override, filter: kept };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,14 @@ function fieldNamesHeldBy(body: Row): string[] {
if (typeof name === 'string' && name) held.add(name);
};
if (Array.isArray(body.columns)) for (const column of body.columns) add(colFieldName(column));
if (Array.isArray(body.filter)) for (const rule of body.filter) if (isPlainObject(rule)) add(rule.field);
if (Array.isArray(body.filter)) {
for (const element of body.filter) {
if (!isPlainObject(element)) continue;
// An any-of group's members are rules too (`{ or: [rule, ...] }`).
if (Array.isArray(element.or)) element.or.forEach((rule: unknown) => { if (isPlainObject(rule)) add(rule.field); });
else add(element.field);
}
}
for (const entry of sortEntriesOf(body.sort)) add(entry.field);
if (isPlainObject(body.kanban)) {
if (Array.isArray(body.kanban.columns)) body.kanban.columns.forEach(add);
Expand Down
Loading
Loading