From 0deaebba0043fe4d10f7bf7cf28051c532a3acbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:42:30 +0000 Subject: [PATCH 01/10] wip(spec): any-of group element in the view filter list Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/lint/src/filter-walk.ts | 28 ++ .../lint/src/validate-page-field-bindings.ts | 24 +- .../lint/src/validate-preset-comparands.ts | 10 +- .../src/metadata-diagnostics.ts | 13 +- .../spec/src/ai/solution-blueprint.zod.ts | 6 + packages/spec/src/conversions/registry.ts | 6 +- packages/spec/src/ui/component.zod.ts | 24 +- .../src/ui/filter-rule-array-guidance.test.ts | 7 +- packages/spec/src/ui/filter-rule-array.ts | 17 +- packages/spec/src/ui/page.zod.ts | 8 +- packages/spec/src/ui/view.zod.ts | 250 +++++++++++++++++- 11 files changed, 361 insertions(+), 32 deletions(-) diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 9bdbdcbfa61..84cd3065366 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -49,6 +49,7 @@ */ import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { isViewFilterAnyGroup } from '@objectstack/spec/ui'; import { recordsOf } from './object-graph.js'; /** Any plain metadata record. */ @@ -279,6 +280,15 @@ export function walkFilterFieldKeys( // Group ['and'|'or', ...members] or a bare list — recurse the members. node.forEach((member, i) => { if (typeof member === 'string') return; // the leading keyword + // [#28020] An any-of group element of a view filter list. It names no + // field itself, so it is stepped INTO: read as a condition object it + // would report its own key `any` as a field, and its rules would go + // unchecked. + const rules = filterGroupRules(member); + if (rules) { + rules.forEach((rule, j) => walkFilterFieldKeys(rule, `${path}[${i}].any[${j}]`, visit, depth + 2)); + return; + } walkFilterFieldKeys(member, `${path}[${i}]`, visit, depth + 1); }); return; @@ -294,3 +304,21 @@ export function walkFilterFieldKeys( conditionFieldKeys(node, path, '', visit, depth); } + +/** + * [#28020] The rules of an any-of group element of a view filter list + * (`{ any: [rule, ...] }`, `ViewFilterAnyGroupSchema`), or `undefined` when + * `member` is not one. + * + * Asked only of a MEMBER of a filter array — the position a group element + * occupies — so a field-keyed condition object whose field happens to be named + * `any` (`{ any: ['a'] }` as a whole filter) is still read as one. Which value + * is a group is the spec's own predicate, `isViewFilterAnyGroup`, never a + * restated test. Exported for the other walks of a filter list in this package, + * which must step into a group the same way. + */ +export function filterGroupRules(member: unknown): unknown[] | undefined { + if (!isViewFilterAnyGroup(member)) return undefined; + const rules = member.any; + return Array.isArray(rules) ? rules : undefined; +} diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 4cc9c3c43fe..26e79d1e8bc 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -107,6 +107,7 @@ export interface PageFieldFinding { } import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { filterGroupRules } from './filter-walk.js'; // Real pages DO reference registry-injected columns — e.g. `sys_user.page.ts` // lists `created_at` in a related-list's columns — so the shared set is load- // bearing here, not merely defensive. @@ -169,6 +170,25 @@ export function fieldRefsFrom(value: unknown, basePath: string): FieldRef[] { return out; } +/** + * [#28020] Field references in a view filter LIST — the `ViewFilterRule` array + * a list page's `filterBy` and a related list's `filter` carry. + * + * Plain rules are ordinary {@link fieldRefsFrom} work. An any-of group element + * (`{ any: [rule, ...] }`) names no field of its own, so its rules are read in + * its place: {@link fieldRefsFrom} alone finds neither `field` nor `name` on the + * group and would skip every field the group's rules filter by. + */ +function filterFieldRefs(value: unknown, basePath: string): FieldRef[] { + if (!Array.isArray(value)) return fieldRefsFrom(value, basePath); + const out: FieldRef[] = []; + for (let i = 0; i < value.length; i++) { + const rules = filterGroupRules(value[i]); + out.push(...(rules ? fieldRefsFrom(rules, `${basePath}[${i}].any`) : fieldRefsFrom(value[i], `${basePath}[${i}]`))); + } + return out; +} + /** * Field references in a `sort` value. * @@ -419,7 +439,7 @@ export function relatedListFieldRefs( related: [ ...fieldRefsFrom(props.columns, at('columns')), ...sortFieldRefs(props.sort, at('sort')), - ...fieldRefsFrom(props.filter, at('filter')), + ...filterFieldRefs(props.filter, at('filter')), ...fieldRefsFrom(props.relationshipField, at('relationshipField')), ...(add ? fieldRefsFrom(add.linkField, at('add.linkField')) : []), ], @@ -673,7 +693,7 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const refs: FieldRef[] = [ ...fieldRefsFrom(cfg.columns, `${base}.columns`), ...sortFieldRefs(cfg.sort, `${base}.sort`), - ...fieldRefsFrom(cfg.filterBy, `${base}.filterBy`), + ...filterFieldRefs(cfg.filterBy, `${base}.filterBy`), ]; const userFilters = isRec(cfg.userFilters) ? cfg.userFilters : undefined; if (userFilters) { diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index d3700e1f9d1..a57212bc8a6 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -10,7 +10,7 @@ import { } from '@objectstack/spec/data'; import { normalizeFilterOperator } from '@objectstack/spec/ui'; -import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; +import { filterGroupRules, walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from './object-graph.js'; /** @@ -705,6 +705,14 @@ function judgeFilterValue( // Group ['and'|'or', ...members] or bare list — recurse the members. node.forEach((member, i) => { if (typeof member === 'string') return; // the leading keyword + // [#28020] An any-of group element: its rules are judged exactly as + // top-level rules are — a preset in one is the same refusal. + const rules = filterGroupRules(member); + if (rules) { + rules.forEach((rule, j) => + judgeFilterValue(rule, `${path}[${i}].any[${j}]`, where, out, depth + 2, isTemporal)); + return; + } judgeFilterValue(member, `${path}[${i}]`, where, out, depth + 1, isTemporal); }); return; diff --git a/packages/metadata-protocol/src/metadata-diagnostics.ts b/packages/metadata-protocol/src/metadata-diagnostics.ts index 6eb8ac1e30e..db8a9a64308 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.ts @@ -28,6 +28,7 @@ import type { z } from 'zod'; import { getMetadataTypeSchema, stripReadDecorations } from '@objectstack/spec/kernel'; import type { MetadataValidationResult } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { isViewFilterAnyGroup } from '@objectstack/spec/ui'; // [#5598] The READ path's share of the #5364 expansion. `zodIssuesToMetadataIssues` // is the ONE ranking this package speaks; the save path's 422 calls the same // function, so a document's verdict cannot depend on whether it was being saved @@ -486,7 +487,17 @@ export function computeViewReferenceDiagnostics( | undefined; userFilters?.fields?.forEach((f, i) => requireField(f?.field, `userFilters.fields.${i}.field`)); userFilters?.tabs?.forEach((t, i) => - t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`))); + t?.filter?.forEach((r, j) => { + // [#28020] An any-of group element names no field itself; each of + // its rules names one, and each is checked like a top-level rule. + if (isViewFilterAnyGroup(r)) { + const rules = Array.isArray(r.any) ? (r.any as Array<{ field?: string }>) : []; + rules.forEach((rule, k) => + requireField(rule?.field, `userFilters.tabs.${i}.filter.${j}.any.${k}.field`)); + return; + } + requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`); + })); // A list view's OWN `tabs` and `filterableFields` are not walked: each is a // `retiredKey` tombstone on every list-view shape. The write door refuses diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 48a4827d38e..943b6366465 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -170,6 +170,12 @@ export const BlueprintViewSchema = lazySchema(() => z.object({ .describe('Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), groupBy: z.string().regex(SNAKE_CASE).optional() .describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows).'), + // [#28020] Flat rules only: the any-of group element `ListView.filter` takes + // (`ViewFilterElementSchema`) is deliberately NOT admitted here. The design + // model writes this key through the strict mirror below, which cannot carry a + // union, and the applier that turns it into a built view lives outside this + // repository; widening the lenient side alone would accept a shape no producer + // emits and no reader here was measured on. filters: z.array(ViewFilterRuleSchema).optional() .describe('Which records this view shows: the list-view filter rules [{ field, operator, value }] the built view carries, ANDed. REQUIRED whenever the view\'s name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [{ field: "due_date", operator: "less_than", value: "{today}" }, { field: "status", operator: "not_equals", value: "completed" }]; my open → [{ field: "owner", operator: "equals", value: "{current_user_id}" }, { field: "status", operator: "equals", value: "open" }]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record.'), sort: z.array(SortItemSchema).optional() diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0879ab5d1bd..8d09cb430f4 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13620,8 +13620,10 @@ function dollarKeysReason(keys: readonly string[]): string { ? '' : ` (its top-level ${quoteKeys(others)} ${others.length === 1 ? 'is' : 'are'} not a field either)`; return `carries the combinator${combinators.length === 1 ? '' : 's'} ${quoteKeys(combinators)}` - + `${alsoNotFields}: a rule array's rules only AND, so \`$or\` and \`$not\` have no rule ` - + 'spelling and `$and` only the separate rules it joins, and a combinator is never flattened — ' + + `${alsoNotFields}: a rule array's elements AND, so \`$and\` is only the separate rules it ` + + 'joins, `$or` over flat rules only an any-of group element `{ any: [rule, ...] }` (declared, ' + + 'not yet applied by any console) that this conversion does not write, and `$not` has no rule ' + + 'spelling; a combinator is never flattened — ' + 'that would change which rows the filter selects. Decide which rows it should select, and ' + 'write the rules that select exactly those'; } diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f4deacf10fa..fb9ec8c6fac 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { - ViewFilterRuleSchema, + ViewFilterElementSchema, ViewDataSchema, GanttConfigSchema, TreeConfigSchema, @@ -1713,7 +1713,7 @@ export const RecordRelatedListProps = strictObject({ })) ]).optional().describe('Sort order for related records'), limit: z.number().int().positive().default(5).describe('Number of records to display initially'), - filter: z.array(ViewFilterRuleSchema).optional().describe('Additional filter criteria for related records'), + filter: z.array(ViewFilterElementSchema).optional().describe('Additional filter criteria for related records'), title: I18nLabelSchema.optional().describe('Custom title for the related list'), showViewAll: z.boolean().default(true).describe('Show "View All" link to see all related records'), actions: z.array(z.string()).optional().describe('Action IDs available for related records'), @@ -1749,7 +1749,7 @@ export const RecordRelatedListProps = strictObject({ object: z.string().describe('Object to pick records from (the far side of an m2m, or the child object for a 1:m re-parent).'), valueField: z.string().default('id').describe('Field on the picked record used as the link value (default `id`).'), labelField: z.string().optional().describe('Field shown in the picker rows (defaults to the object title field).'), - filter: z.array(ViewFilterRuleSchema).optional().describe('Restrict which records the picker offers.'), + filter: z.array(ViewFilterElementSchema).optional().describe('Restrict which records the picker offers.'), }).describe('Where the Add affordance sources records from.'), linkField: z.string().optional().describe('Field on `objectName` that stores the picked record id (junction case). Omit for a 1:m re-parent.'), label: I18nLabelSchema.optional().describe('Label for the Add button (default "Add").'), @@ -2533,7 +2533,7 @@ export const RecordLineItemsProps = lazySchema(() => strictObject({ .describe('Render the grid read-only: cells locked, no Save button, and no adding or removing lines (renderer default: editable)'), minRows: z.number().optional().describe('Minimum number of rows'), maxRows: z.number().optional().describe('Maximum number of rows'), - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterElementSchema).optional() .describe('Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record\'s lines'), sort: z.array(SortItemSchema).optional() .describe('Load order for the child rows — the SortItem array form `[{ field, order }, ...]` (renderer default: storage order)'), @@ -4999,7 +4999,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `filter`; the migration prescription is the * `element-data-source-and-object-block-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-grid`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -5737,7 +5737,7 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ * at `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-metric`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -5920,7 +5920,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-kanban`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -6220,7 +6220,7 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-calendar`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7493,7 +7493,7 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ * does for the kanban and the calendar. The record form is refused at * `filter`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-map`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7694,7 +7694,7 @@ export const ObjectGanttPropsSchema = lazySchema(() => strictObject({ staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-gantt`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7903,7 +7903,7 @@ export const ObjectTreePropsSchema = lazySchema(() => strictObject({ staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-tree`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -8273,7 +8273,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ timeline: TimelineConfigSchema.optional() .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-timeline`', migration: 'element-data-source-and-object-block-filter-rule-array', diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts index 9688b2c53a2..5abdbe2c47a 100644 --- a/packages/spec/src/ui/filter-rule-array-guidance.test.ts +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -164,7 +164,12 @@ describe('§1 the record form is refused WITH the new spelling', () => { ); expect(issue.code).toBe('invalid_type'); expect(issue.message).not.toContain("field: '$or'"); - expect(issue.message).toContain('`$or` is a combinator, not a field, and the rule array has no spelling for it'); + expect(issue.message).toContain('`$or` is a combinator, not a field, and the rule array spells a combinator only as its own elements'); + // [#28020] The one combinator the rule array can spell is named, with its + // status: the any-of group is declared but no console applies it yet. + expect(issue.message).toContain('`$or` over flat rules is one any-of group element'); + expect(issue.message).toContain('no console applies it yet'); + expect(issue.message).toContain('`$not` cannot be written at all'); expect(issue.message).toContain('flattening one changes which rows the filter selects'); expect(issue.message).toContain('decide which rows this filter should select'); expect(issue.message).toContain('migration `element-data-source-and-object-block-filter-rule-array`'); diff --git a/packages/spec/src/ui/filter-rule-array.ts b/packages/spec/src/ui/filter-rule-array.ts index ad2405c8c8b..7991990909e 100644 --- a/packages/spec/src/ui/filter-rule-array.ts +++ b/packages/spec/src/ui/filter-rule-array.ts @@ -130,7 +130,7 @@ function ruleKeys(): readonly string[] { * * @example * ```ts - * filter: z.array(ViewFilterRuleSchema, { + * filter: z.array(ViewFilterElementSchema, { * error: ruleArrayFilterError({ * surface: 'this `object-grid`', * migration: 'element-data-source-and-object-block-filter-rule-array', @@ -187,14 +187,19 @@ export function ruleArrayFilterError(options: RuleArrayFilterErrorOptions): z.co lead = ''; } const named = combinators.map((key) => `\`${key}\``).join(' and '); + // [#28020] The rule array gained ONE combinator spelling, the any-of group + // element, and this sentence names it — with its status, because no console + // applies it yet: prescribing it bare would send an author to a filter that + // does not narrow the rows (`ViewFilterAnyGroupSchema`'s PLANNED note). const combinatorSentence = combinators.length === 0 ? '' : ` ${named} ${combinators.length === 1 ? 'is a combinator, not a field' : 'are combinators, not fields'}, ` - + `and the rule array has no spelling for ${combinators.length === 1 ? 'it' : 'them'}: its rules ` - + `only AND, so \`$or\` and \`$not\` cannot be written as rules at all, and \`$and\` only as the ` - + `separate rules it joins. Nothing rewrites a combinator for you — flattening one changes which ` - + `rows the filter selects — so decide which rows this filter should select, and write the rules ` - + `that select exactly those.`; + + `and the rule array spells a combinator only as its own elements: they AND, so \`$and\` is ` + + `the separate rules it joins, \`$or\` over flat rules is one any-of group element ` + + `\`{ any: [{ field, operator, value }, ...] }\` (declared, but no console applies it yet, so it ` + + `does not narrow the rows today), and \`$not\` cannot be written at all. Nothing rewrites a ` + + `combinator for you — flattening one changes which rows the filter selects — so decide which ` + + `rows this filter should select, and write the rules that select exactly those.`; return ( `\`filter\` on ${surface} takes the ViewFilterRule ARRAY form \`${ruleForm}\`, ` diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 12d1dfbfa36..1b8eacc1520 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -12,7 +12,7 @@ import { UserActionsConfigSchema, AppearanceConfigSchema, UserFiltersSchema, - ViewFilterRuleSchema, + ViewFilterElementSchema, AddRecordConfigSchema, ListColumnSchema, EmptyStateSchema, @@ -327,7 +327,7 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ * form is refused at `filter`; the migration prescription is the * `element-data-source-and-object-block-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this element data source', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -872,7 +872,7 @@ export const PageRecordBindingSchema = lazySchema(() => strictObject({ }, { object: z.string().describe('Object the page row is read from'), view: z.string().optional().describe('Named view to apply'), - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterElementSchema).optional() .describe('Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view'), sort: z.array(SortItemSchema).optional().describe('Sort order — the page row is the FIRST row of the bound set under it'), })); @@ -1055,7 +1055,7 @@ export const InterfacePageConfigSchema = lazySchema(() => strictObject({ .describe('Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance).'), sort: z.array(SortItemSchema).optional() .describe('Default sort order for the page, defined directly on the page.'), - filterBy: z.array(ViewFilterRuleSchema).optional().describe('Always-on page filter (base filter).'), + filterBy: z.array(ViewFilterElementSchema).optional().describe('Always-on page filter (base filter).'), levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'), /** @deprecated Back-compat only. Pre-revision pages inherited columns/filter/sort diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 50b103ffe45..17bd83c3d9c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -498,6 +498,42 @@ const VIEW_CONSOLE_ROW_ID_GUIDANCE = + 'per row for React) — it is not part of the authoring contract, and the write path ' + 'removes it before validating. Delete it from authored metadata.'; +/** + * [#28020] The one key of an any-of group element of a view filter list + * ({@link ViewFilterAnyGroupSchema}). Its value is an array of builder rows, so + * the wire door strips row decorations inside it too. + */ +const VIEW_FILTER_ANY_GROUP_KEY = 'any' as const; + +/** [#28020] What a rule INSIDE an any-of group is told when it carries `any` itself. */ +const VIEW_FILTER_NESTED_GROUP_GUIDANCE = + 'an any-of group cannot hold another group — its members are flat rules `{ field, operator, value }`. ' + + 'Move the inner group\'s rules into this group (any of them may match), or write the inner group as its ' + + 'own element of the filter list, where it ANDs with the others.'; + +/** + * [#28020] The spellings an author reaching for "OR" writes on a rule — the + * console builder's `{ logic, conditions }`, the Query-DSL `$or`, and the + * natural near-misses — each answered with the one any-of shape this list has. + */ +const VIEW_FILTER_OR_SPELLING: string = + 'a view filter writes "any of" as its own element of the filter list, `{ any: [rule, ...] }`, whose ' + + 'flat rules `{ field, operator, value }` match when ANY of them does; the list still ANDs its elements. ' + + 'There is no `$or`, `or`, `logic` or `conditions` key on a filter rule.'; +const VIEW_FILTER_OR_SPELLING_GUIDANCE: Readonly> = { + $or: VIEW_FILTER_OR_SPELLING, + or: VIEW_FILTER_OR_SPELLING, + anyOf: VIEW_FILTER_OR_SPELLING, + logic: VIEW_FILTER_OR_SPELLING, + conditions: VIEW_FILTER_OR_SPELLING, +}; + +/** [#28020] What an any-of group is told when it carries a rule's own key. */ +const VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE = + 'an any-of group holds only `any`, the array of its rules — a rule key beside it belongs to a rule. Put ' + + 'the rule inside `any` (any of the group\'s rules may match), or write it as its own element of the ' + + 'filter list, where it ANDs with the group.'; + function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unknown { if (depth > VIEW_DECORATION_MAX_DEPTH || !value || typeof value !== 'object') return value; @@ -524,7 +560,12 @@ function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unk } for (const [k, v] of Object.entries(next ?? dict)) { - const out = stripRowDecorations(v, VIEW_DECORATED_ROW_CARRIERS.includes(k), depth + 1); + // [#28020] The rules of an any-of group are builder rows of the same + // filter, one level down: `any` carries rows only on a row (a group + // element of `filter[]`), so a key spelled `any` anywhere else in a view + // body is left alone. + const carriesRows = VIEW_DECORATED_ROW_CARRIERS.includes(k) || (isRow && k === VIEW_FILTER_ANY_GROUP_KEY); + const out = stripRowDecorations(v, carriesRows, depth + 1); if (out !== v) { next ??= { ...dict }; next[k] = out; @@ -1005,6 +1046,12 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ history: VIEW_HISTORY, guidance: { id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + // [#28020] A rule is reached two ways: as a plain element of a filter list, + // and as a member of an any-of group. An element carrying `any` is routed + // to the group before it gets here, so this key only ever arrives on a + // rule INSIDE a group — a nested group, which the one-level shape refuses. + [VIEW_FILTER_ANY_GROUP_KEY]: VIEW_FILTER_NESTED_GROUP_GUIDANCE, + ...VIEW_FILTER_OR_SPELLING_GUIDANCE, }, }, { /** Field name to filter on */ @@ -1050,6 +1097,203 @@ export type ViewFilterRule = z.input; /** Post-parse shape of {@link ViewFilterRule} — defaults applied, transforms run (ADR-0122). */ export type ViewFilterRuleParsed = z.infer; +/** + * [#28020] True when `value` is an any-of group element of a view filter list + * ({@link ViewFilterAnyGroupSchema}): a plain object carrying its own + * `any` key. + * + * It answers WHICH element kind a value is, not whether it is a valid one: the + * schema routes every element this predicate claims to the group shape, which + * then judges it (a non-array `any`, an empty one, a stray key). Exported for + * the readers that walk a stored filter list and must step INTO a group rather + * than misread it as a rule or a field-keyed condition — the lint filter walks + * and the view reference diagnostics. ⛔ Never restate the test at a reader: + * this predicate is the one place the element kinds are told apart. + */ +export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_ANY_GROUP_KEY]: unknown } { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, VIEW_FILTER_ANY_GROUP_KEY); +} + +/** + * [#28020] View Filter Any-Of Group Schema — one element of a view filter list + * that holds flat rules of which ANY may match. + * + * ## The stored shape, and why it is this one + * + * A view filter list (`ListView.filter` and every other carrier of + * {@link ViewFilterElementSchema}) is an AND list. Until this element, that was + * all it could say: an "open, or completed in the last 7 days" view had no + * spelling, although the console's filter builder already offers an OR toggle + * (it refuses to save one, `viewFilterFold.ts`'s `or_logic`). This element is + * the one way to say "any of": it sits in the list beside the plain rules, the + * list still ANDs its elements, and the group matches a record when at least + * one of its rules does. + * + * - **A member of the rule list, not a second filter vocabulary.** + * `FilterConditionSchema`'s `$or` is the Query-DSL dialect, and the seven + * `filter` doors of the objectui#6206 family converged away from it onto the + * rule array (`filter-rule-array.ts`); admitting it here would put two + * shapes in storage and make every reader accept both (PD#12). The group's + * members ARE {@link ViewFilterRuleSchema}, so the operator vocabulary, the + * alias fold, the value-shape coupling and the `icontains` comparand check + * apply inside a group exactly as at the top level — by construction, not by + * a second copy. + * - **One level.** A group holds flat rules only; a group inside a group is + * refused by name. The console builder edits one logic over flat rows and has + * no control that creates a sub-group (`FilterBuilderRowGroup`), so nothing + * it can produce needs more. + * - **`any`, not the builder's `{ logic, conditions }`.** No stored spelling of + * an any-of exists to mirror: the builder's group is its whole editing state, + * and its `logic: 'and'` arm would be a second spelling of the AND the list + * already is, leaving `logic` a key with one legal value. One key named for + * its meaning is the smaller contract. The builder's spellings, and `$or`, + * are answered with this shape when written on a rule + * ({@link VIEW_FILTER_OR_SPELLING_GUIDANCE}). + * - **Not empty.** An any-of over zero rules matches no record — a view that is + * permanently empty — so `{ any: [] }` is refused rather than stored. + * + * Every filter stored before this element still parses, byte for byte: a plain + * rule never carries `any`, so it is routed to {@link ViewFilterRuleSchema} + * exactly as before. + * + * ## [PLANNED — not yet rendered] + * + * No reader applies the group yet. The query a list view runs is built by the + * console (`ui/packages/plugin-list`'s filter-to-AST conversion), which reads + * flat rules only; until it reads this element, a view that stores one does not + * filter by it. The describe below says so, so no author is told it works. + * + * @example + * ```ts + * // status is "open", OR completed within the last 7 days — and owned by me + * filter: [ + * { any: [ + * { field: 'status', operator: 'equals', value: 'open' }, + * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + * ] }, + * { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + * ] + * ``` + */ +export const ViewFilterAnyGroupSchema = lazySchema(() => strictObject({ + surface: 'this any-of filter group', + history: VIEW_HISTORY, + guidance: { + // The console's group carries a React key too; the wire door removes it + // from a group element as from a rule (`stripViewConsoleDecorations`). + id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + field: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + operator: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + value: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + }, +}, { + /** + * The rules of which ANY may match — at least one, each a flat + * {@link ViewFilterRuleSchema}. + */ + any: z.array(ViewFilterRuleSchema) + .min(1, { + error: () => + 'An any-of group needs at least one rule: `{ any: [] }` is a disjunction of zero rules, which ' + + 'matches no record, so the view would be permanently empty. Write the rules of which any may ' + + 'match, or delete the group.', + }) + .describe( + 'The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, ' + + 'each judged exactly as a rule at the top level of the filter. A group cannot hold a group.', + ), +}).describe( + '[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its ' + + 'rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, ' + + 'so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", ' + + 'operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the ' + + 'last 7 days. One level only: its members are flat rules. No console reads it yet: until the list ' + + 'query build reads the group, a view that stores one does not filter by it.', +)); + +export type ViewFilterAnyGroup = z.input; +/** Post-parse shape of {@link ViewFilterAnyGroup} — defaults applied, transforms run (ADR-0122). */ +export type ViewFilterAnyGroupParsed = z.infer; + +/** + * [#28020] Report ONE element kind's verdict, never both. + * + * {@link ViewFilterElementSchema} is a union, and a union that fails reports + * every branch: `invalid_union`, with the per-branch truth nested in `errors[]` + * at paths relative to the element. On this union that would bury the rule's + * own refusals — a value-shape issue at `filter.0.value`, an unknown key named + * with its guidance — one level down, beside a group branch's complaint about + * a missing `any` that the author never meant to write. (zod returns a branch's + * issues unwrapped only when exactly one branch failed non-fatally, which an + * unknown key or a bad operator never is.) + * + * The element kind is not a guess: {@link isViewFilterAnyGroup} decides it, so + * the claimed branch's issues replace the union's own issue, flat, and every + * path and message an author read before the group existed is the one they + * read now. The same shape {@link focusClaimedBranch} takes on + * `ViewMetadataSchema`, applied where the branch decision is a single key. + * + * It changes no verdict: it only runs when the union already failed, adds no + * issue of its own, and removes none from the claimed branch. + */ +function reportClaimedFilterElementBranch(): z.core.$ZodCheck { + const check = new z.core.$ZodCheck({ check: 'custom', when: () => true }) as z.core.$ZodCheck; + check._zod.check = (payload) => { + for (let index = 0; index < payload.issues.length; index += 1) { + const issue = payload.issues[index] as { + code?: string; + path?: readonly PropertyKey[]; + errors?: readonly z.core.$ZodIssue[][]; + }; + if (issue?.code !== 'invalid_union' || !Array.isArray(issue.errors)) continue; + // Only THIS union's own issue: raised at the element itself, one error + // list per branch. A nested union's issue carries a path. + if ((issue.path?.length ?? 0) !== 0 || issue.errors.length !== 2) continue; + const claimed = issue.errors[isViewFilterAnyGroup(payload.value) ? 1 : 0] ?? []; + payload.issues.splice(index, 1, ...(claimed as unknown as typeof payload.issues)); + return; + } + }; + return check; +} + +/** + * [#28020] One element of a view filter list: a flat + * {@link ViewFilterRuleSchema}, or an any-of {@link ViewFilterAnyGroupSchema}. + * + * This is what every filter-list carrier holds — `ListView.filter`, a view + * tab's filter, `Page.filterBy`, an element data source's filter, the + * related-list and picker filters and the component filter doors — so the + * group reaches all of them at once and none can drift. The list ANDs its + * elements; a group ORs its rules. + * + * Which branch judges an element is decided by {@link isViewFilterAnyGroup} + * (an own `any` key), and only that branch's issues are reported — see + * {@link reportClaimedFilterElementBranch}. A stored filter of plain rules is + * therefore judged, and refused, exactly as before. + * + * @example + * ```ts + * filter: [ + * { any: [ + * { field: 'status', operator: 'equals', value: 'open' }, + * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + * ] }, + * { field: 'archived_at', operator: 'is_empty' }, + * ] + * ``` + */ +export const ViewFilterElementSchema = lazySchema(() => + z.union([ViewFilterRuleSchema, ViewFilterAnyGroupSchema]) + .check(reportClaimedFilterElementBranch()) + .describe('One view filter element: a flat rule, or an any-of group of flat rules'), +); + +export type ViewFilterElement = z.input; +/** Post-parse shape of {@link ViewFilterElement} — defaults applied, transforms run (ADR-0122). */ +export type ViewFilterElementParsed = z.infer; + /** * Column Summary Function Schema * Aggregation function for column footer (Airtable-style column summaries) @@ -1698,7 +1942,7 @@ export const ViewTabSchema = lazySchema(() => strictObject({ label: I18nLabelSchema.optional().describe('Display label').meta({ title: 'Label' }), icon: z.string().optional().describe('Tab icon name').meta({ title: 'Icon' }), view: z.string().optional().describe('Referenced list view name from listViews').meta({ title: 'List View' }), - filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria').meta({ title: 'Filter' }), + filter: z.array(ViewFilterElementSchema).optional().describe('Tab-specific filter criteria').meta({ title: 'Filter' }), order: z.number().int().min(0).optional().describe('Tab display order').meta({ title: 'Display Order' }), pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)').meta({ title: 'Pinned' }), isDefault: z.boolean().default(false).describe('Set as the default active tab').meta({ title: 'Default Tab' }), @@ -3069,7 +3313,7 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ + 'no projection, so neither of them applies: which columns show is then left to the ' + 'renderer (objectui\'s `ListView` grid derives the object\'s default columns).', ), - filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), + filter: z.array(ViewFilterElementSchema).optional().describe('Filter criteria (JSON Rules)'), /** * Sort order — the structured `{ field, order }[]` array, and only that. * From 5d332d67f1cb84cf691e66973653b9aa60b9ef8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:45:21 +0000 Subject: [PATCH 02/10] wip(spec): declare the element union's dropped refinements Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/spec/authorable-surface/ui.json | 1 + .../spec/dropped-refinements.baseline.json | 66 +++++++++++++++---- packages/spec/json-schema.manifest/ui.json | 2 + 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 057a463d806..6e5b26c650c 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1362,6 +1362,7 @@ "ui/View:name", "ui/View:object", "ui/View:protection", + "ui/ViewFilterAnyGroup:any", "ui/ViewFilterRule:field", "ui/ViewFilterRule:operator", "ui/ViewFilterRule:value", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 9a3aeb656ab..e6bdbecd6ed 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -133,6 +133,7 @@ "manifest.views.element.list.bulkActionDefs.element", "manifest.views.element.list.emptyState.link.url", "manifest.views.element.list.filter.element", + "manifest.views.element.list.filter.element.options[0]", "manifest.views.element.list.gantt.groupByField", "manifest.views.element.list.grouping.fields.element.field", "manifest.views.element.list.kanban.groupByField", @@ -261,6 +262,7 @@ "data.options[1].manifest.views.element.list.bulkActionDefs.element", "data.options[1].manifest.views.element.list.emptyState.link.url", "data.options[1].manifest.views.element.list.filter.element", + "data.options[1].manifest.views.element.list.filter.element.options[0]", "data.options[1].manifest.views.element.list.gantt.groupByField", "data.options[1].manifest.views.element.list.grouping.fields.element.field", "data.options[1].manifest.views.element.list.kanban.groupByField", @@ -297,6 +299,7 @@ "list.bulkActionDefs.element", "list.emptyState.link.url", "list.filter.element", + "list.filter.element.options[0]", "list.gantt.groupByField", "list.grouping.fields.element.field", "list.kanban.groupByField", @@ -394,6 +397,7 @@ "options[1].manifest.views.element.list.bulkActionDefs.element", "options[1].manifest.views.element.list.emptyState.link.url", "options[1].manifest.views.element.list.filter.element", + "options[1].manifest.views.element.list.filter.element.options[0]", "options[1].manifest.views.element.list.gantt.groupByField", "options[1].manifest.views.element.list.grouping.fields.element.field", "options[1].manifest.views.element.list.kanban.groupByField", @@ -463,6 +467,7 @@ "data.packages.element.options[1].manifest.views.element.list", "data.packages.element.options[1].manifest.views.element.list.bulkActionDefs.element", "data.packages.element.options[1].manifest.views.element.list.filter.element", + "data.packages.element.options[1].manifest.views.element.list.filter.element.options[0]", "data.packages.element.options[1].manifest.views.element.list.gantt.groupByField", "data.packages.element.options[1].manifest.views.element.list.kanban.groupByField", "data.packages.element.options[1].manifest.views.element.list.timeline.groupByField" @@ -502,6 +507,7 @@ "data.listViews.valueType.bulkActionDefs.element", "data.listViews.valueType.emptyState.link.url", "data.listViews.valueType.filter.element", + "data.listViews.valueType.filter.element.options[0]", "data.listViews.valueType.gantt.groupByField", "data.listViews.valueType.grouping.fields.element.field", "data.listViews.valueType.kanban.groupByField", @@ -963,6 +969,7 @@ "listViews.valueType.bulkActionDefs.element", "listViews.valueType.emptyState.link.url", "listViews.valueType.filter.element", + "listViews.valueType.filter.element.options[0]", "listViews.valueType.gantt.groupByField", "listViews.valueType.grouping.fields.element.field", "listViews.valueType.kanban.groupByField", @@ -1298,6 +1305,7 @@ "operations.element.options[3].object.listViews.valueType.bulkActionDefs.element", "operations.element.options[3].object.listViews.valueType.emptyState.link.url", "operations.element.options[3].object.listViews.valueType.filter.element", + "operations.element.options[3].object.listViews.valueType.filter.element.options[0]", "operations.element.options[3].object.listViews.valueType.gantt.groupByField", "operations.element.options[3].object.listViews.valueType.grouping.fields.element.field", "operations.element.options[3].object.listViews.valueType.kanban.groupByField", @@ -1325,6 +1333,7 @@ "object.listViews.valueType.bulkActionDefs.element", "object.listViews.valueType.emptyState.link.url", "object.listViews.valueType.filter.element", + "object.listViews.valueType.filter.element.options[0]", "object.listViews.valueType.gantt.groupByField", "object.listViews.valueType.grouping.fields.element.field", "object.listViews.valueType.kanban.groupByField", @@ -1374,6 +1383,7 @@ "options[3].object.listViews.valueType.bulkActionDefs.element", "options[3].object.listViews.valueType.emptyState.link.url", "options[3].object.listViews.valueType.filter.element", + "options[3].object.listViews.valueType.filter.element.options[0]", "options[3].object.listViews.valueType.gantt.groupByField", "options[3].object.listViews.valueType.grouping.fields.element.field", "options[3].object.listViews.valueType.kanban.groupByField", @@ -1578,7 +1588,8 @@ }, "ui/ElementDataSource": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/EmptyState": { @@ -1634,7 +1645,8 @@ "ui/InterfacePageConfig": { "sites": [ "emptyState.link.url", - "filterBy.element" + "filterBy.element", + "filterBy.element.options[0]" ] }, "ui/JoinedReportBlock": { @@ -1655,6 +1667,7 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "gantt.groupByField", "grouping.fields.element.field", "kanban.groupByField", @@ -1681,7 +1694,8 @@ }, "ui/ObjectCalendarProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectFormProps": { @@ -1696,6 +1710,7 @@ "ui/ObjectGanttProps": { "sites": [ "filter.element", + "filter.element.options[0]", "gantt.groupByField" ] }, @@ -1704,12 +1719,14 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "grouping.fields.element.field" ] }, "ui/ObjectKanbanProps": { "sites": [ "filter.element", + "filter.element.options[0]", "grouping.fields.element.field" ] }, @@ -1719,6 +1736,7 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "gantt.groupByField", "grouping.fields.element.field", "kanban.groupByField", @@ -1727,7 +1745,8 @@ }, "ui/ObjectMapProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectMasterDetailFormProps": { @@ -1746,19 +1765,22 @@ "drillDown.report.blocks.element.runtimeFilter", "drillDown.report.runtimeFilter", "drillDown.report.runtimeFilter.lazy", - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectTimelineProps": { "sites": [ "", "filter.element", + "filter.element.options[0]", "timeline.groupByField" ] }, "ui/ObjectTreeProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/Page": { @@ -1766,23 +1788,27 @@ "", "interfaceConfig.emptyState.link.url", "record.filter.element", + "record.filter.element.options[0]", "slots.header.options[0].type" ] }, "ui/PageComponent": { "sites": [ "dataSource.options[0].filter.element", + "dataSource.options[0].filter.element.options[0]", "type" ] }, "ui/PageRecordBinding": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/PageRegion": { "sites": [ "components.element.lazy.dataSource.options[0].filter.element", + "components.element.lazy.dataSource.options[0].filter.element.options[0]", "components.element.lazy.type" ] }, @@ -1800,7 +1826,8 @@ "ui/RecordLineItemsProps": { "sites": [ "columns.element", - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/RecordQuickActionsProps": { @@ -1810,7 +1837,8 @@ }, "ui/RecordRelatedListProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/Report": { @@ -1829,7 +1857,8 @@ }, "ui/UserFilters": { "sites": [ - "tabs.element.filter.element" + "tabs.element.filter.element", + "tabs.element.filter.element.options[0]" ] }, "ui/View": { @@ -1842,12 +1871,24 @@ "list.bulkActionDefs.element", "list.emptyState.link.url", "list.filter.element", + "list.filter.element.options[0]", "list.gantt.groupByField", "list.grouping.fields.element.field", "list.kanban.groupByField", "list.timeline.groupByField" ] }, + "ui/ViewFilterAnyGroup": { + "sites": [ + "any.element" + ] + }, + "ui/ViewFilterElement": { + "sites": [ + "", + "options[0]" + ] + }, "ui/ViewFilterRule": { "sites": [ "" @@ -1859,6 +1900,7 @@ "options[0].config.bulkActionDefs.element", "options[0].config.emptyState.link.url", "options[0].config.filter.element", + "options[0].config.filter.element.options[0]", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", "options[0].config.kanban.groupByField", @@ -1875,6 +1917,7 @@ "options[0].config.bulkActionDefs.element", "options[0].config.emptyState.link.url", "options[0].config.filter.element", + "options[0].config.filter.element.options[0]", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", "options[0].config.kanban.groupByField", @@ -1887,7 +1930,8 @@ }, "ui/ViewTab": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] } } diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 151713915b5..ac4c5fff208 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -163,6 +163,8 @@ "ui/UserFilters", "ui/View", "ui/ViewData", + "ui/ViewFilterAnyGroup", + "ui/ViewFilterElement", "ui/ViewFilterRule", "ui/ViewItem", "ui/ViewItemName", From 2650129c2464e6e1c16ebdc9ddcdae76edacc739 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:46:45 +0000 Subject: [PATCH 03/10] test(spec): pin the any-of filter group Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../spec/src/ui/view-filter-any-group.test.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 packages/spec/src/ui/view-filter-any-group.test.ts diff --git a/packages/spec/src/ui/view-filter-any-group.test.ts b/packages/spec/src/ui/view-filter-any-group.test.ts new file mode 100644 index 00000000000..e779cc638c7 --- /dev/null +++ b/packages/spec/src/ui/view-filter-any-group.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] A view filter list can hold an any-of group: `{ any: [rule, ...] }`. + * + * The list stays an AND list of flat `ViewFilterRule`s; one new element kind + * sits beside the rule and holds a non-empty list of flat rules of which ANY + * may match. These pins hold the four halves of that contract: + * + * 1. a group is ACCEPTED on every carrier of the filter list; + * 2. every filter stored before the group parses exactly as it did — same + * output for an accepted rule, same issues (code, path, message) for a + * refused one, because the element union reports only the claimed branch; + * 3. the rule's own refinements apply INSIDE a group exactly as at top level; + * 4. an empty group, a nested group and a non-rule member are REFUSED, each + * with its own code at its own path. + * + * Every rejection pin asserts the issue code and path, and the message's + * leading words where the wording is the contract (a prescription an author + * acts on), never a bare throw. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + ListViewSchema, + ViewFilterAnyGroupSchema, + ViewFilterElementSchema, + ViewFilterRuleSchema, + ViewMetadataSchema, + ViewTabSchema, + isViewFilterAnyGroup, +} from './view.zod'; +import { ElementDataSourceSchema, InterfacePageConfigSchema } from './page.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SPEC_SRC = resolve(HERE, '..'); + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +function issuesOf(result: { success: boolean; error?: { issues: unknown[] } }): Issue[] { + expect(result.success).toBe(false); + return (result.error!.issues as Issue[]).map(({ code, path, message }) => ({ code, path, message })); +} + +const listView = (filter: unknown) => ({ type: 'grid', columns: ['name'], filter }); + +/** "open, or completed in the last 7 days" — the view the card names. */ +const OPEN_OR_RECENT = { + any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ], +}; + +describe('a group is accepted on every carrier of the filter list', () => { + const carriers: Array<[string, (filter: unknown) => { success: boolean; data?: unknown }]> = [ + ['ListView.filter', (f) => ListViewSchema.safeParse(listView(f))], + ['ViewTab.filter', (f) => ViewTabSchema.safeParse({ name: 'recent', filter: f })], + ['ElementDataSource.filter', (f) => ElementDataSourceSchema.safeParse({ object: 'task', filter: f })], + ['InterfacePageConfig.filterBy', (f) => InterfacePageConfigSchema.safeParse({ source: 'task', filterBy: f })], + ]; + + it.each(carriers)('%s takes a group beside a plain rule', (_name, parse) => { + const filter = [OPEN_OR_RECENT, { field: 'owner', operator: 'equals', value: '{current_user_id}' }]; + const result = parse(filter); + expect(result.success).toBe(true); + }); + + it('the group parses to itself — nothing added, dropped or reordered', () => { + const parsed = ListViewSchema.parse(listView([OPEN_OR_RECENT])); + expect(parsed.filter).toEqual([OPEN_OR_RECENT]); + }); + + it('every filter-list carrier in the spec holds the ELEMENT, not the bare rule', () => { + // A source scan: a carrier added later with the bare rule array would take + // no group. Two bare-rule arrays are expected and named: the group's own + // members (one level only) and the solution-blueprint view filters, which + // deliberately stay flat (their docblock says why). + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + else if (entry.endsWith('.zod.ts')) files.push(path); + } + }; + walk(SPEC_SRC); + const bare: string[] = []; + for (const file of files) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + const code = line.trim(); + if (code.startsWith('*') || code.startsWith('//')) return; + if (code.includes('z.array(ViewFilterRuleSchema')) bare.push(`${file.slice(SPEC_SRC.length + 1)}:${code.split(':')[0]}`); + }); + } + expect(bare.sort()).toEqual(['ai/solution-blueprint.zod.ts:filters', 'ui/view.zod.ts:any']); + }); +}); + +describe('a filter stored before the group parses exactly as it did', () => { + // Accepted rules, alias spellings among them: the element parses each to + // what the rule alone parses it to. + const accepted: unknown[] = [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'stage', operator: 'notIn', value: ['won', 'lost'] }, + { field: 'amount', operator: 'gte', value: 100 }, + { field: 'close_date', operator: 'between', value: ['2024-01-01', '2024-12-31'] }, + { field: 'archived_at', operator: 'isNull' }, + { field: 'name', operator: 'icontains', value: 'acme' }, + ]; + + it.each(accepted)('accepts %j with the rule\'s own output', (rule) => { + expect(ViewFilterElementSchema.parse(rule)).toEqual(ViewFilterRuleSchema.parse(rule)); + }); + + // Refused rules: the element reports the RULE's issues, flat — never an + // `invalid_union` with the group branch's complaint beside them. + const refused: unknown[] = [ + { field: 'stage', operator: 'in', value: 'won' }, + { field: 'name', operator: 'icontains', value: '' }, + { field: 'status', operator: 'equals' }, + { field: 'status', operator: 'nope', value: 'x' }, + { field: 'status', operator: 'equals', value: 'x', id: 'row-1' }, + { operator: 'equals', value: 'x' }, + ]; + + it.each(refused)('refuses %j with the rule\'s own issues', (rule) => { + const asElement = issuesOf(ViewFilterElementSchema.safeParse(rule)); + const asRule = issuesOf(ViewFilterRuleSchema.safeParse(rule)); + expect(asElement).toEqual(asRule); + expect(asElement.some((issue) => issue.code === 'invalid_union')).toBe(false); + }); + + it('a refused rule is located at its own path through a carrier', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ field: 'stage', operator: 'in', value: 'won' }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'value']); + expect(issues[0]!.message.startsWith('Operator "in" on field "stage" requires an ARRAY of values.')).toBe(true); + }); +}); + +describe('the rule\'s refinements apply inside a group exactly as at top level', () => { + it('the value shape is coupled to the operator', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [{ field: 'stage', operator: 'in', value: 'won' }] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0, 'value']); + expect(issues[0]!.message.startsWith('Operator "in" on field "stage" requires an ARRAY of values.')).toBe(true); + }); + + it('the `icontains` comparand is judged', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [{ field: 'name', operator: 'icontains', value: '' }] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0, 'value']); + }); + + it('legacy operator spellings fold to canonical inside a group too', () => { + const parsed = ListViewSchema.parse(listView([{ any: [{ field: 'amount', operator: 'gt', value: 1 }] }])); + expect(parsed.filter).toEqual([{ any: [{ field: 'amount', operator: 'greater_than', value: 1 }] }]); + }); +}); + +describe('what a group refuses', () => { + it('an EMPTY group — it would match no record', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('too_small'); + expect(issues[0]!.path).toEqual(['filter', 0, 'any']); + expect(issues[0]!.message.startsWith('An any-of group needs at least one rule')).toBe(true); + }); + + it('a NESTED group — one level only, refused by name', () => { + const nested = { any: [{ any: [{ field: 'status', operator: 'equals', value: 'open' }] }] }; + const issues = issuesOf(ListViewSchema.safeParse(listView([nested]))); + const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['filter', 0, 'any', 0]); + expect(unknown?.message).toContain('an any-of group cannot hold another group'); + }); + + it('a member that is not a rule', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: ['status = open'] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0]); + }); + + it('a non-array `any`', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: { field: 'status' } }]))); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual(['filter', 0, 'any']); + }); + + it('a rule key written beside `any` — it belongs to a rule', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ ...OPEN_OR_RECENT, field: 'owner' }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual(['filter', 0]); + expect(issues[0]!.message).toContain('an any-of group holds only `any`'); + }); + + it.each(['$or', 'or', 'logic', 'conditions'])('the OR spelling `%s` on a rule is answered with the group', (key) => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ [key]: [{ field: 'status', operator: 'equals', value: 'open' }] }]))); + const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['filter', 0]); + expect(unknown?.message).toContain('`{ any: [rule, ...] }`'); + }); +}); + +describe('the wire door and the published shape', () => { + it('the console row `id` is stripped inside a group as on a plain rule', () => { + const body = { + name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], + filter: [{ id: 'root', any: [{ id: 'r1', field: 'status', operator: 'equals', value: 'open' }] }], + }; + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect((parsed.data as { filter: unknown }).filter).toEqual([ + { any: [{ field: 'status', operator: 'equals', value: 'open' }] }, + ]); + }); + + it('…while the AUTHORING door still refuses an authored `id` on a group, by name', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ id: 'root', ...OPEN_OR_RECENT }]))); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.message).toContain('`id` is a console row key'); + }); + + it('the element publishes both kinds, and the group says it is not yet rendered', () => { + const json = z.toJSONSchema(ViewFilterElementSchema, { io: 'input', unrepresentable: 'any' }) as { + anyOf?: Array<{ properties?: Record }>; + }; + expect(json.anyOf?.map((branch) => Object.keys(branch.properties ?? {}).sort())).toEqual([ + ['field', 'operator', 'value'], + ['any'], + ]); + expect(ViewFilterAnyGroupSchema.description?.startsWith('[PLANNED — not yet rendered]')).toBe(true); + }); + + it('`isViewFilterAnyGroup` tells the element kinds apart by the own `any` key', () => { + expect(isViewFilterAnyGroup(OPEN_OR_RECENT)).toBe(true); + expect(isViewFilterAnyGroup({ any: 'not yet valid' })).toBe(true); + expect(isViewFilterAnyGroup({ field: 'status', operator: 'equals', value: 'open' })).toBe(false); + expect(isViewFilterAnyGroup(Object.create({ any: [] }))).toBe(false); + expect(isViewFilterAnyGroup([OPEN_OR_RECENT])).toBe(false); + expect(isViewFilterAnyGroup(null)).toBe(false); + }); +}); From 8d2ef4252265ea30cdc83b8a53e8530fca1ce357 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:58:52 +0000 Subject: [PATCH 04/10] feat(spec,lint,metadata-protocol): readers step into the any-of filter group; regenerate artefacts Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/28020-view-filter-any-group.md | 33 ++++ content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/index.mdx | 10 +- content/docs/references/ui/component.mdx | 126 ++++++++++++--- content/docs/references/ui/page.mdx | 50 ++++-- content/docs/references/ui/view.mdx | 143 ++++++++++++++++-- .../ui.md | 12 +- .../lint/src/filter-walk-any-group.test.ts | 77 ++++++++++ .../src/validate-page-field-bindings.test.ts | 27 ++++ .../src/validate-preset-comparands.test.ts | 20 +++ ...-diagnostics.view-filter-any-group.test.ts | 62 ++++++++ packages/spec/api-surface/ui.json | 7 + packages/spec/declaration-map/ui.json | 4 + .../spec/dropped-refinements.baseline.json | 4 +- packages/spec/export-origins/ui.json | 7 + packages/spec/src/ui/page.test.ts | 4 +- .../spec/src/ui/view-filter-any-group.test.ts | 2 +- 18 files changed, 527 insertions(+), 67 deletions(-) create mode 100644 .changeset/28020-view-filter-any-group.md create mode 100644 packages/lint/src/filter-walk-any-group.test.ts create mode 100644 packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts diff --git a/.changeset/28020-view-filter-any-group.md b/.changeset/28020-view-filter-any-group.md new file mode 100644 index 00000000000..d5790cf59ff --- /dev/null +++ b/.changeset/28020-view-filter-any-group.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec): a stored view filter list can hold an any-of group, `{ any: [rule, ...] }` + +Clause-②: yes (widening) +Metadata-protocol: added + +A view filter list (`ListView.filter`, a view tab's `filter`, `Page.interfaceConfig.filterBy`, an element data source's `filter`, the related-list and picker filters and the component `filter` doors) was an AND list of flat `{ field, operator, value }` rules, so a view whose natural filter is an any-of ("open, or completed in the last 7 days") could not be stored. The list now takes one more element kind beside the rule: `{ any: [rule, ...] }`, a non-empty array of flat rules of which ANY may match. The list still ANDs its elements. + +```ts +filter: [ + { any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }, + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, +] +``` + +- New exports from `@objectstack/spec/ui`: `ViewFilterAnyGroupSchema`, `ViewFilterElementSchema` (rule or group — what every carrier holds now), their `ViewFilterAnyGroup` / `ViewFilterElement` types and `...Parsed` twins, and `isViewFilterAnyGroup(value)`, the one test that tells the two element kinds apart. +- A group's members are `ViewFilterRuleSchema` itself, so the operator vocabulary, the legacy-alias fold, the operator/value-shape coupling and the `icontains` comparand check apply inside a group exactly as at the top level. +- Refused at parse: an empty group (`too_small` at `filter.N.any`), a group inside a group (one level only; `unrecognized_keys` naming `any` on the inner element), a member that is not a rule, and a rule key written beside `any`. `$or`, `or`, `anyOf`, `logic` and `conditions` written on a rule are answered with the group shape. +- Every filter stored before this change parses exactly as it did: the same output for an accepted rule, and the same issues (code, path, message) for a refused one — the element union reports only the branch the element claims. +- The wire door strips the console's row `id` inside a group as on a plain rule. +- `ViewFilterRule` (the type) is unchanged and still means one flat rule. A reader typed against a carrier's element now sees `ViewFilterElement`, and must step into a group (`isViewFilterAnyGroup`) rather than read `field` off every element. +- `solution-blueprint` view `filters` deliberately stay flat rules: the model writes them through a strict mirror that cannot carry a union. +- `[PLANNED — not yet rendered]`: no console applies the group yet. The list query build reads flat rules only, so until it reads this element a view that stores a group does not filter by it; the group's describe says so. + +Readers updated: `@objectstack/lint`'s filter field-key walk (`filter-walk.ts`, used by the list-view, dataset and widget field-reference rules), the preset-comparand rule, and the page field-binding rule (`filterBy` and a related list's `filter`) step into a group, so a field inside one is checked and `any` is never reported as a field. `@objectstack/metadata-protocol`'s `computeViewReferenceDiagnostics` checks the fields inside a group in a preset tab's filter. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3c6ed44e69f..d1c97a29d86 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1856,7 +1856,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1935,7 +1935,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 864b75599f6..33d37e9224c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -375,7 +375,7 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 5e46543ac0e..ceab129fcfa 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1572 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **203** | **1570** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **203** | **1572** | 14 protocol modules | --- @@ -370,7 +370,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 172 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 174 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -391,7 +391,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterAnyGroup`, `ViewFilterElement`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index eb9940acf7d..e6b87b61346 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -486,7 +486,7 @@ const result = ActionButtonPropsSchema.parse(data); | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar config `{ startDateField, endDateField?, titleField?, colorField?, allDayField?, initialDate? }` — the same block a list view's `calendar` declares; `startDateField` is required | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched events — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | | **staticData** | `any[]` | optional | Static inline records | @@ -505,7 +505,7 @@ const result = ActionButtonPropsSchema.parse(data); | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | | **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | -### Nested Shape: `ObjectCalendarProps.filter[number]` +### Nested Shape: `ObjectCalendarProps.filter[number][option 1]` View filter rule @@ -515,6 +515,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectCalendarProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectCalendarProps.sort[number]` Sort field and direction pair @@ -675,7 +683,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default). The renderer's own default is `{ mode: 'drawer' }` when the key is absent | @@ -712,7 +720,7 @@ Sort field and direction pair | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectGanttProps.filter[number]` +### Nested Shape: `ObjectGanttProps.filter[number][option 1]` View filter rule @@ -722,6 +730,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectGanttProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectGanttProps.sort[number]` Sort field and direction pair @@ -800,7 +816,7 @@ Sort field and direction pair | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | What the grid draws instead of an empty table: `{ title, message, icon, link }` — the list view's own empty-state shape | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -862,7 +878,7 @@ Sort field and direction pair | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ObjectGridProps.filter[number]` +### Nested Shape: `ObjectGridProps.filter[number][option 1]` View filter rule @@ -872,6 +888,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectGridProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectGridProps.sort[number]` Sort field and direction pair @@ -997,7 +1021,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `string[] \| { id: string; title: string; cards?: object[]; limit?: integer; … }[]` | optional | Swimlane definitions — all lane objects `{ id, title, cards?, limit?, className?, collapsed? }` (one per `groupBy` value), or all bare value strings (drawn only on a board with no `groupBy`). NOT a field projection; one spelling per list | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | @@ -1021,7 +1045,7 @@ Sort field and direction pair | **className** | `string` | optional | CSS class names applied to the lane | | **collapsed** | `boolean` | optional | Whether the lane first renders collapsed — a title spine with its cards withheld, which the viewer can reopen | -### Nested Shape: `ObjectKanbanProps.filter[number]` +### Nested Shape: `ObjectKanbanProps.filter[number][option 1]` View filter rule @@ -1031,6 +1055,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectKanbanProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectKanbanProps.grouping` | Property | Type | Required | Description | @@ -1067,7 +1099,7 @@ View filter rule | **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | @@ -1096,7 +1128,7 @@ View filter rule | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectMapProps.filter[number]` +### Nested Shape: `ObjectMapProps.filter[number][option 1]` View filter rule @@ -1106,6 +1138,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectMapProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectMapProps.sort[number]` Sort field and direction pair @@ -1211,7 +1251,7 @@ Sort field and direction pair | **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | | **aggregate** | `{ field?: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; groupBy?: string \| object }` | optional | Aggregation run against the object: `{ field?, function, groupBy? }` — `function` one of count / sum / avg / min / max / count_distinct (`field` needed for all but count), `groupBy` a field name or a `{ field, dateGranularity }` node, absent for one number over every row | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | | **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | | **prefix** | `string` | optional | Static prefix before the formatted value | @@ -1232,7 +1272,7 @@ Sort field and direction pair | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function — `count`, `sum`, `avg`, `min`, `max` or `count_distinct` | | **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }` | optional | Field the rows are grouped by, or a `{ field, dateGranularity }` date-bucket node. Omit it for the one number over every row (`_all`) | -### Nested Shape: `ObjectMetricProps.filter[number]` +### Nested Shape: `ObjectMetricProps.filter[number][option 1]` View filter rule @@ -1242,6 +1282,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectMetricProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectMetricProps.trend` | Property | Type | Required | Description | @@ -1278,7 +1326,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | | **data** | `any[]` | optional | Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written | @@ -1303,7 +1351,7 @@ View filter rule | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -### Nested Shape: `ObjectTimelineProps.filter[number]` +### Nested Shape: `ObjectTimelineProps.filter[number][option 1]` View filter rule @@ -1313,6 +1361,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectTimelineProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectTimelineProps.sort[number]` Sort field and direction pair @@ -1368,7 +1424,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default) | @@ -1394,7 +1450,7 @@ Sort field and direction pair | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectTreeProps.filter[number]` +### Nested Shape: `ObjectTreeProps.filter[number][option 1]` View filter rule @@ -1404,6 +1460,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectTreeProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectTreeProps.tree` | Property | Type | Required | Description | @@ -1824,7 +1888,7 @@ Type: `string` | **readonly** | `boolean` | optional | Render the grid read-only: cells locked, no Save button, and no adding or removing lines (renderer default: editable) | | **minRows** | `number` | optional | Minimum number of rows | | **maxRows** | `number` | optional | Maximum number of rows | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Load order for the child rows — the SortItem array form `[{ field, order }, ...]` (renderer default: storage order) | | **limit** | `integer` | optional | Row cap for the child fetch (renderer default: 500). The grid has no pagination, so this is the window of rows that are editable and saved together | @@ -1853,7 +1917,7 @@ Type: `string` | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | -### Nested Shape: `RecordLineItemsProps.filter[number]` +### Nested Shape: `RecordLineItemsProps.filter[number][option 1]` View filter rule @@ -1863,6 +1927,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `RecordLineItemsProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `RecordLineItemsProps.sort[number]` Sort field and direction pair @@ -1963,11 +2035,11 @@ Sort field and direction pair | **objectName** | `string` | ✅ | Related object name (e.g., "task", "opportunity") | | **relationshipField** | `string` | ✅ | Field on related object that points to this record (e.g., "account_id") | | **relationshipValueField** | `string` | optional (default: `"id"`) | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | -| **parentDataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. Read by the single-record rule: the FIRST row of the bound set by its `sort` is the parent (declare `limit: 1` to say one row is meant), and when no row answers, the block renders a loud absence, never an empty list, which would read as "this record has no related records". `relationshipValueField` is read on that row. When this key is absent, the record page's record context remains the parent. The node-level `dataSource` on this block keeps its published meaning, the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`), and is never read as the parent. | +| **parentDataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … }` | optional | The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. Read by the single-record rule: the FIRST row of the bound set by its `sort` is the parent (declare `limit: 1` to say one row is meant), and when no row answers, the block renders a loud absence, never an empty list, which would read as "this record has no related records". `relationshipValueField` is read on that row. When this key is absent, the record page's record context remains the parent. The node-level `dataSource` on this block keeps its published meaning, the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`), and is never read as the parent. | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | @@ -1983,7 +2055,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -2006,7 +2078,7 @@ Sort field and direction pair | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `RecordRelatedListProps.filter[number]` +### Nested Shape: `RecordRelatedListProps.filter[number][option 1]` View filter rule @@ -2016,11 +2088,19 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `RecordRelatedListProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `RecordRelatedListProps.add` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **picker** | `{ object: string; valueField?: string; labelField?: string; filter?: object[] }` | ✅ | Where the Add affordance sources records from. | +| **picker** | `{ object: string; valueField?: string; labelField?: string; filter?: (object \| object)[] }` | ✅ | Where the Add affordance sources records from. | | **linkField** | `string` | optional | Field on `objectName` that stores the picked record id (junction case). Omit for a 1:m re-parent. | | **label** | `string \| Record` | optional | Label for the Add button (default "Add"). | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 26119a323dd..28593469927 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -52,11 +52,11 @@ API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | -### Nested Shape: `ElementDataSource.filter[number]` +### Nested Shape: `ElementDataSource.filter[number][option 1]` View filter rule @@ -66,6 +66,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ElementDataSource.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ElementDataSource.sort[number]` Sort field and direction pair @@ -89,7 +97,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -130,7 +138,7 @@ Sort field and direction pair | **field** | `string` | ✅ | Field name to sort by | | **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | -### Nested Shape: `InterfacePageConfig.filterBy[number]` +### Nested Shape: `InterfacePageConfig.filterBy[number][option 1]` View filter rule @@ -140,6 +148,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `InterfacePageConfig.filterBy[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `InterfacePageConfig.appearance` | Property | Type | Required | Description | @@ -207,12 +223,12 @@ View filter rule | **type** | `Enum<'record' \| 'home' \| 'app' \| 'utility' \| 'list'>` | optional (default: `"record"`) | Page type | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array' \| 'record_id'>; defaultValue?: any; source?: string }[]` | optional | Local page state, exposed to expressions as `page.` and writable by interactive elements via `source` (master/detail, filtered dashboards). | | **object** | `string` | optional | Bound object (for Record pages) | -| **record** | `{ object: string; view?: string; filter?: object[]; sort?: object[] }` | optional | The row an app page is about — a one-row query `{ object, view?, filter?, sort? }` whose FIRST row (by `sort`, read under the caller's identity) the page's components read as `record` in `visibleWhen`. A count or a plan is a field on that row: a `summary` roll-up (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). No row, no `record`: the predicate faults and the component renders. Only on `type: 'app'` — refused on every other page type (a record page's row is the record it opens). Planned in the renderer: until the console mounts it, `record` stays unbound on an app page. | +| **record** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[] }` | optional | The row an app page is about — a one-row query `{ object, view?, filter?, sort? }` whose FIRST row (by `sort`, read under the caller's identity) the page's components read as `record` in `visibleWhen`. A count or a plan is a field on that row: a `summary` roll-up (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). No row, no `record`: the predicate faults and the component renders. Only on `type: 'app'` — refused on every other page type (a record page's row is the record it opens). Planned in the renderer: until the console mounts it, `record` stays unbound on an app page. | | **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. | | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | +| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Applied by the console's browser print rendering, which prints the page through the browser's own print dialog (where it can be saved as PDF); the server-side PDF renderer is to follow. A list view's print button is `allowPrinting`, not this. | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults OFF and is turned on server-side via the OS_PAGE_REACT_ENABLED=true env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | @@ -242,7 +258,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | ### Nested Shape: `Page.regions[number]` @@ -260,7 +276,7 @@ View filter rule | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -325,7 +341,7 @@ View filter rule | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | -| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | +| **dataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -344,7 +360,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -448,10 +464,10 @@ Print declaration: paper size, orientation, margins, the running header and foot | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | -### Nested Shape: `PageRecordBinding.filter[number]` +### Nested Shape: `PageRecordBinding.filter[number][option 1]` View filter rule @@ -461,6 +477,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `PageRecordBinding.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `PageRecordBinding.sort[number]` Sort field and direction pair @@ -497,7 +521,7 @@ Sort field and direction pair | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | -| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | +| **dataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 725e2c00af9..f3641f24522 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -59,8 +59,8 @@ nothing. The one item-name grammar itself lives in ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterAnyGroupSchema, ViewFilterElementSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterAnyGroup, ViewFilterElement, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -772,7 +772,7 @@ Map view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -855,7 +855,7 @@ Map view configuration | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ListView.filter[number]` +### Nested Shape: `ListView.filter[number][option 1]` View filter rule @@ -865,6 +865,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ListView.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ListView.userFilters` | Property | Type | Required | Description | @@ -1153,7 +1161,7 @@ View filter rule | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1236,7 +1244,7 @@ View filter rule | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ObjectListView.filter[number]` +### Nested Shape: `ObjectListView.filter[number][option 1]` View filter rule @@ -1246,6 +1254,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectListView.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectListView.selection` | Property | Type | Required | Description | @@ -1687,7 +1703,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1727,7 +1743,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1806,7 +1822,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1928,6 +1944,99 @@ This schema accepts one of the following structures: --- +--- + +## ViewFilterAnyGroup + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + +### Nested Shape: `ViewFilterAnyGroup.any[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + + +--- + +## ViewFilterElement + +One view filter element: a flat rule, or an any-of group of flat rules + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +View filter rule + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Allowed Values: `ViewFilterElement[option 1].operator` + +* `equals` +* `not_equals` +* `contains` +* `not_contains` +* `icontains` +* `starts_with` +* `ends_with` +* `greater_than` +* `less_than` +* `greater_than_or_equal` +* `less_than_or_equal` +* `in` +* `not_in` +* `is_empty` +* `is_not_empty` +* `is_null` +* `is_not_null` +* `before` +* `after` +* `between` + +--- + +#### Option 2 + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + +### Nested Shape: `ViewFilterElement[option 2].any[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +--- + + --- ## ViewFilterRule @@ -2008,7 +2117,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2180,7 +2289,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2368,13 +2477,13 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | | **visible** | `boolean` | optional (default: `true`) | Tab visibility | -### Nested Shape: `ViewTab.filter[number]` +### Nested Shape: `ViewTab.filter[number][option 1]` View filter rule @@ -2384,6 +2493,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ViewTab.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 4e2536b9446..725fcd426a4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 216 | 207 | 4 | 0 | 5 | +| `ui/` | 217 | 208 | 4 | 0 | 5 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 4 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 58 | +| `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **216** | +| **total** | **217** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**5 strip of 216**, in 4 file(s). +**5 strip of 217**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 2 | 58 | +| `view.zod.ts` | 2 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **5** | **216** | +| **total** | **5** | **217** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/filter-walk-any-group.test.ts b/packages/lint/src/filter-walk-any-group.test.ts new file mode 100644 index 00000000000..27912bc017b --- /dev/null +++ b/packages/lint/src/filter-walk-any-group.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] The filter field-key walk steps INTO an any-of group element of a + * view filter list (`{ any: [rule, ...] }`). + * + * Before the walk knew the element, a group read as a field-keyed condition + * object: its own key `any` was reported as the field it filters by, and the + * rules inside it — the fields the query really filters by — were never seen. + * So a group cost a false finding AND hid a true one. These pins hold both + * halves, on the walk itself and through the list-view rule that reads it. + */ + +import { describe, expect, it } from 'vitest'; +import { walkFilterFieldKeys } from './filter-walk.js'; +import { LIST_VIEW_FIELD_UNKNOWN, validateListViewFieldRefs } from './validate-list-view-field-refs.js'; + +const keysOf = (filter: unknown) => { + const out: Array<{ field: string; path: string }> = []; + walkFilterFieldKeys(filter, 'filter', (key) => out.push(key)); + return out; +}; + +describe('walkFilterFieldKeys and the any-of group', () => { + it('reports the rules of a group, at their own paths, and never `any` itself', () => { + expect(keysOf([ + { any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }, + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + ])).toEqual([ + { field: 'status', path: 'filter[0].any[0].field' }, + { field: 'completed_at', path: 'filter[0].any[1].field' }, + { field: 'owner', path: 'filter[1].field' }, + ]); + }); + + it('CONTROL: a whole-filter condition object whose field is named `any` is still a condition', () => { + // Only a MEMBER of a filter list is an element; a Mongo-style record that + // is the whole filter keeps its field-keyed reading. + expect(keysOf({ any: 'x' })).toEqual([{ field: 'any', path: 'filter.any' }]); + }); +}); + +describe('validateListViewFieldRefs through a group', () => { + const stack = (filter: unknown) => ({ + name: 'probe', + objects: [{ + name: 'duly_task', + fields: [ + { name: 'title', type: 'text' }, + { name: 'status', type: 'select' }, + { name: 'completed_at', type: 'datetime' }, + ], + }], + views: [{ name: 'duly_task', object: 'duly_task', list: { type: 'grid', columns: ['title'], filter } }], + }); + + it('a group of real fields is silent', () => { + const findings = validateListViewFieldRefs(stack([{ any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }])); + expect(findings).toEqual([]); + }); + + it('an unknown field inside a group is an error at its own path', () => { + const findings = validateListViewFieldRefs(stack([{ any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'no_such_field', operator: 'equals', value: 'x' }, + ] }])); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [LIST_VIEW_FIELD_UNKNOWN, 'views[0].list.filter[0].any[1].field'], + ]); + }); +}); diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 77b85c37341..8d6d2360bc0 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -616,6 +616,33 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => 'pages[0].interfaceConfig.filterBy[0].field', ]); }); + + // [#28020] An any-of group element names no field itself — its rules do, and + // each is checked like a top-level rule. Read through `fieldRefsFrom` alone, + // the group yielded nothing and every rule inside it went unchecked. + it('checks the rules inside an any-of group of interfaceConfig.filterBy', () => { + const findings = validatePageFieldBindings({ + ...baseStack(), + pages: [ + { + name: 'list_page', + type: 'list', + object: 'crm_lead', + interfaceConfig: { + source: 'crm_lead', + filterBy: [ + { any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'ghost_in_group', operator: 'equals', value: 'x' }, + ] }, + { field: 'amount', operator: 'greater_than', value: 1 }, + ], + }, + }, + ], + }); + expect(findings.map((f) => f.path)).toEqual(['pages[0].interfaceConfig.filterBy[0].any[1].field']); + }); }); describe('validatePageFieldBindings — false-positive floor', () => { diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 71238684b25..dab598ba4da 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -55,6 +55,26 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => expect(f.hint).toContain('{date-macro}'); }); + // [#28020] A rule inside an any-of group element of a view filter list is + // judged exactly as a top-level rule — the group is stepped into, not read as + // a field-keyed condition whose one "field" is `any`. + it('judges the rules inside an any-of group of a view filter list', () => { + const findings = validatePresetComparands({ + views: [{ + name: 'recent', + filter: [ + { any: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'created_at', operator: 'after', value: 'last_7_days' }, + ] }, + ], + }], + }); + expect(findings.map((f) => f.path)).toEqual(['views[0].filter[0].any[1].value']); + expect(findings[0]!.rule).toBe(FILTER_PRESET_COMPARAND); + expect(findings[0]!.message).toContain('last_7_days'); + }); + it('judges all three authored filter shapes', () => { const findings = validatePresetComparands({ views: [{ diff --git a/packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts new file mode 100644 index 00000000000..ffe0f88a904 --- /dev/null +++ b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] Two readers of a stored view filter list, and the any-of group + * element (`{ any: [rule, ...] }`) the list can now hold. + * + * - `computeViewReferenceDiagnostics` checks every field a list view's preset + * tabs filter by. A group names no field itself, so read as a rule it was + * skipped whole and the fields its rules filter by went unchecked. + * - `graftNormalizedOperators` copies the spec's operator fold back onto an + * authored body by STRUCTURE, with no key list, so it reaches the rules + * inside a group without knowing the group exists. Pinned so that stays true. + */ + +import { describe, expect, it } from 'vitest'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { computeViewReferenceDiagnostics } from './metadata-diagnostics.js'; +import { graftNormalizedOperators } from './protocol.js'; + +const objectDef = { fields: { status: { type: 'select' }, completed_at: { type: 'datetime' } } }; + +describe('computeViewReferenceDiagnostics and an any-of group in a preset tab', () => { + const view = (rules: unknown[]) => ({ + userFilters: { tabs: [{ name: 'recent', filter: [{ any: rules }] }] }, + }); + + it('a group of real fields is valid', () => { + expect(computeViewReferenceDiagnostics(view([ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ]), objectDef)).toEqual({ valid: true }); + }); + + it('an unknown field inside a group is reported at its own path', () => { + const result = computeViewReferenceDiagnostics(view([ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'ghost', operator: 'equals', value: 'x' }, + ]), objectDef); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([{ + path: 'userFilters.tabs.0.filter.0.any.1.field', + message: 'Field "ghost" does not exist on the source object', + code: 'reference_not_found', + }]); + }); +}); + +describe('graftNormalizedOperators reaches the rules inside a group', () => { + it('folds an alias operator inside a group and changes nothing else', () => { + const authored = { + name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], + filter: [{ any: [{ field: 'amount', operator: 'gt', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }], + }; + const parsed = ViewMetadataSchema.safeParse(authored); + expect(parsed.success).toBe(true); + const out = graftNormalizedOperators(authored, parsed.data) as typeof authored; + expect(out.filter).toEqual([ + { any: [{ field: 'amount', operator: 'greater_than', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }, + ]); + expect(authored.filter[0]!.any[0]!.operator).toBe('gt'); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index fb64982ab8f..0dba9f40d6a 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -468,6 +468,12 @@ "ViewData (type)", "ViewDataParsed (type)", "ViewDataSchema (const)", + "ViewFilterAnyGroup (type)", + "ViewFilterAnyGroupParsed (type)", + "ViewFilterAnyGroupSchema (const)", + "ViewFilterElement (type)", + "ViewFilterElementParsed (type)", + "ViewFilterElementSchema (const)", "ViewFilterOperator (type)", "ViewFilterRule (type)", "ViewFilterRuleParsed (type)", @@ -555,6 +561,7 @@ "isKnownComponentType (function)", "isRecordContextBlockType (function)", "isViewContainerShaped (function)", + "isViewFilterAnyGroup (function)", "listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index ad203b4f624..785b0ada609 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -287,6 +287,10 @@ "View": "ui/View", "ViewData": "ui/ViewData", "ViewDataSchema": "ui/ViewData", + "ViewFilterAnyGroup": "ui/ViewFilterAnyGroup", + "ViewFilterAnyGroupSchema": "ui/ViewFilterAnyGroup", + "ViewFilterElement": "ui/ViewFilterElement", + "ViewFilterElementSchema": "ui/ViewFilterElement", "ViewFilterRule": "ui/ViewFilterRule", "ViewFilterRuleSchema": "ui/ViewFilterRule", "ViewItem": "ui/ViewItem", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index e6bdbecd6ed..58f60210b6c 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 244, - "droppedRefinementSites": 906, + "publishedSchemasWithDroppedRefinements": 246, + "droppedRefinementSites": 942, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index db3ec47a59c..f3bd0ead607 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -451,6 +451,12 @@ "ViewData": "src/ui/view.zod.ts#ViewData (type)", "ViewDataParsed": "src/ui/view.zod.ts#ViewDataParsed (type)", "ViewDataSchema": "src/ui/view.zod.ts#ViewDataSchema (const)", + "ViewFilterAnyGroup": "src/ui/view.zod.ts#ViewFilterAnyGroup (type)", + "ViewFilterAnyGroupParsed": "src/ui/view.zod.ts#ViewFilterAnyGroupParsed (type)", + "ViewFilterAnyGroupSchema": "src/ui/view.zod.ts#ViewFilterAnyGroupSchema (const)", + "ViewFilterElement": "src/ui/view.zod.ts#ViewFilterElement (type)", + "ViewFilterElementParsed": "src/ui/view.zod.ts#ViewFilterElementParsed (type)", + "ViewFilterElementSchema": "src/ui/view.zod.ts#ViewFilterElementSchema (const)", "ViewFilterOperator": "src/ui/view.zod.ts#ViewFilterOperator (type)", "ViewFilterRule": "src/ui/view.zod.ts#ViewFilterRule (type)", "ViewFilterRuleParsed": "src/ui/view.zod.ts#ViewFilterRuleParsed (type)", @@ -538,6 +544,7 @@ "isKnownComponentType": "src/ui/component-type-vocabulary.ts#isKnownComponentType (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", + "isViewFilterAnyGroup": "src/ui/view.zod.ts#isViewFilterAnyGroup (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn": "src/ui/view-grouping-query.ts#listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 798327fbe5e..b2c36424030 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -726,7 +726,9 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w filter: [{ field: 'status', operator: 'ne', value: 'done' }], }); expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('not_equals'); + // [#28020] An element is a rule OR an any-of group, so the folded rule is + // asserted whole rather than read off the element union. + expect(legacy.data!.filter![0]).toEqual({ field: 'status', operator: 'not_equals', value: 'done' }); // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's // own superRefine — the value-shape check rides in with the schema. const scalarIn = ElementDataSourceSchema.safeParse({ diff --git a/packages/spec/src/ui/view-filter-any-group.test.ts b/packages/spec/src/ui/view-filter-any-group.test.ts index e779cc638c7..9cd4cd73ca1 100644 --- a/packages/spec/src/ui/view-filter-any-group.test.ts +++ b/packages/spec/src/ui/view-filter-any-group.test.ts @@ -92,7 +92,7 @@ describe('a group is accepted on every carrier of the filter list', () => { walk(SPEC_SRC); const bare: string[] = []; for (const file of files) { - readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + readFileSync(file, 'utf8').split('\n').forEach((line) => { const code = line.trim(); if (code.startsWith('*') || code.startsWith('//')) return; if (code.includes('z.array(ViewFilterRuleSchema')) bare.push(`${file.slice(SPEC_SRC.length + 1)}:${code.split(':')[0]}`); From 1bf43c4246e5845ed885b6cb76fb4f5ce7e637d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:01:27 +0000 Subject: [PATCH 05/10] docs(spec): name the console follow-up on the group's PLANNED marker and liveness row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/spec/liveness/view.json | 2 +- packages/spec/src/ui/view.zod.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index a74f14e4c23..87fbe921031 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -49,7 +49,7 @@ }, "filter": { "status": "live", - "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less." + "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less. One element kind rides this live verdict at PLANNED strength (#28020, 2026-10-31): the any-of group `{ any: [rule, ...] }` (ViewFilterAnyGroupSchema, its describe marked `[PLANNED — not yet rendered]`) has no console reader yet — the list query build (ui/packages/plugin-list/src/ListView.tsx, buildEffectiveFilter; ui/packages/core/src/utils/filter-converter.ts, convertFiltersToAST) and the builder save fold (ui/packages/app-shell/src/views/viewFilterFold.ts, which refuses an OR group) read flat rules only, read on this tree at 22ce3d13fe. The console half is #28370; amend this note, citing the group read, when it lands." }, "sort": { "status": "live", diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 17bd83c3d9c..2bca4242409 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1163,6 +1163,7 @@ export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_AN * console (`ui/packages/plugin-list`'s filter-to-AST conversion), which reads * flat rules only; until it reads this element, a view that stores one does not * filter by it. The describe below says so, so no author is told it works. + * The console half is #28370; the marker leaves when it lands. * * @example * ```ts From cbfae94922c49f9ce3780f0d3e47a8fb20d558f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:31:03 +0000 Subject: [PATCH 06/10] test(ui-types): the filter mirror pins read the element union; the related-list face withholds the planned group Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../gantt-flat-config-declared-keys.test.ts | 5 +++-- ...filters-gantt-map-filter-round10-6152.test.ts | 6 ++++-- ...d-kanban-calendar-members-round8-6152.test.ts | 4 +++- .../kanban-calendar-filter-sort-8174.test.ts | 7 ++++--- ...ed-list-add-picker-filter-mirror-9964.test.ts | 16 ++++++++++++++-- .../related-list-filter-rule-array-10199.test.ts | 16 ++++++++++++++-- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index 06a475e03fb..6a9009a7d3d 100644 --- a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -113,7 +113,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; import { StrictAnyComponentSchema } from '../zod/index.zod'; import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS, SortConfig } from '../objectql.js'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; const MINIMAL = { type: 'object-gantt', @@ -381,5 +381,6 @@ type Expect = T; // `object-gantt` row's `ViewFilterRule` array since objectui#6152 round 10 (was `any[]`). // eslint-disable-next-line @typescript-eslint/no-explicit-any export type _StaticDataIsDeclared = Expect>; -export type _FilterIsDeclared = Expect>; +// objectstack#28020: the row's array element is a flat rule OR an any-of group. +export type _FilterIsDeclared = Expect>; export type _SortIsDeclared = Expect>; diff --git a/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts b/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts index f81252b027e..814c4326bf0 100644 --- a/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts +++ b/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts @@ -42,6 +42,7 @@ import type { ObjectGanttProps as SpecObjectGanttProps, ObjectGridProps as SpecObjectGridProps, ObjectMapProps as SpecObjectMapProps, + ViewFilterElement, ViewFilterRule, } from '@objectstack/spec/ui'; import type { @@ -259,8 +260,9 @@ export type assertionRound10MembersAreTheRows = [ Expect['defaultFilters'], SpecObjectGridProps['defaultFilters']>>, Expect>, Expect>, - Expect>, - Expect>, + // objectstack#28020: the element is a flat rule OR an any-of group (`ViewFilterElement`). + Expect>, + Expect>, // Lit control: round 8's grid `filter` is unchanged. Expect>, ]; diff --git a/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts b/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts index c8d1dec460a..acd478e754f 100644 --- a/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts +++ b/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts @@ -42,6 +42,7 @@ import type { ObjectCalendarProps as SpecObjectCalendarProps, ObjectGridProps as SpecObjectGridProps, ObjectKanbanProps as SpecObjectKanbanProps, + ViewFilterElement, ViewFilterRule, } from '@objectstack/spec/ui'; import type { @@ -278,7 +279,8 @@ export type assertionRound8MembersAreTheRows = [ Expect>, Expect>, Expect>, - Expect>, + // objectstack#28020: the element is a flat rule OR an any-of group (`ViewFilterElement`). + Expect>, Expect>, Expect>, Expect>, diff --git a/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts b/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts index 14acb7c23a3..71aefb35892 100644 --- a/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts +++ b/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts @@ -77,7 +77,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { ComponentPropsMap } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement, ViewFilterRule } from '@objectstack/spec/ui'; import { ObjectCalendarSchema, ObjectKanbanSchema, safeValidateSchema } from '../zod/index.zod'; import type { @@ -134,10 +134,11 @@ type IsOptional = Record extends Pick // either member made the indexed access fall back to `[key: string]: any`, // making `IsAny` true and the `Equal` row false; since objectui#8347 the // indexed access stops compiling instead. -export type _KanbanFilterIsArray = Expect>; +// objectstack#28020: the array element is a flat rule OR an any-of group. +export type _KanbanFilterIsArray = Expect>; export type _KanbanFilterIsNotAny = Expect, false>>; export type _KanbanFilterIsOptional = Expect>; -export type _CalendarFilterIsArray = Expect>; +export type _CalendarFilterIsArray = Expect>; export type _CalendarFilterIsNotAny = Expect, false>>; export type _CalendarFilterIsOptional = Expect>; // `sort` on the CALENDAR only: declared `SortConfig[]`, optional, not `any`. diff --git a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts index 7aee35782e6..d6dfce5111a 100644 --- a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts +++ b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts @@ -64,7 +64,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterAnyGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -94,7 +94,19 @@ type SpecPickerFilter = NonNullable['add' * ⭐ The card, in type form: the two declarations of one key agree, and the * expectation comes from the protocol rather than from this file. */ -export type _MirrorAgreesWithTheContract = Expect>; +/** + * objectstack#28020: the contract's element is now a flat rule OR an any-of + * group (`{ any: [rule, ...] }`), and the group is `[PLANNED — not yet + * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) + * types and lowers flat rules only. So this face carries the contract MINUS + * the group until the console reads it (objectstack#28370), and the two legs + * below hold exactly that gap — widen this face and the second leg's + * companion together, never one alone. + */ +type FlatRulesOf = Exclude[number], ViewFilterAnyGroup>[] | undefined; +export type _MirrorAgreesWithTheContract = Expect>>; +/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterAnyGroup>>; /** * …and the shape they agree on is the rule ARRAY. Stated separately from the diff --git a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts index ee509dae557..41e2c538bf0 100644 --- a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts +++ b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts @@ -67,7 +67,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterAnyGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -94,7 +94,19 @@ type MirrorFilter = RecordRelatedListComponentProps['filter']; type SpecFilter = z.input['filter']; /** ⭐ The ruling, in type form: the authoring face carries the protocol's declaration. */ -export type _MirrorAgreesWithTheContract = Expect>; +/** + * objectstack#28020: the contract's element is now a flat rule OR an any-of + * group (`{ any: [rule, ...] }`), and the group is `[PLANNED — not yet + * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) + * types and lowers flat rules only. So this face carries the contract MINUS + * the group until the console reads it (objectstack#28370), and the two legs + * below hold exactly that gap — widen this face and the second leg's + * companion together, never one alone. + */ +type FlatRulesOf = Exclude[number], ViewFilterAnyGroup>[] | undefined; +export type _MirrorAgreesWithTheContract = Expect>>; +/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterAnyGroup>>; /** * …and that declaration is the rule ARRAY. Stated separately so a spec that From 68b811655830245edb343e45ed08b7b2590d131a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:35:58 +0000 Subject: [PATCH 07/10] chore(spec): regenerate reference docs on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 33d37e9224c..671aa422728 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,7 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -606,7 +606,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | -| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e6b87b61346..55e3dd92115 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -503,7 +503,7 @@ const result = ActionButtonPropsSchema.parse(data); | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ObjectCalendarProps.filter[number][option 1]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f3641f24522..aca1e6f707f 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -109,7 +109,7 @@ Appearance and visualization configuration | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | --- @@ -926,7 +926,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ListView.gantt` @@ -1305,7 +1305,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ObjectListView.gantt` From 912c4ac2106de717b60cb838176cae6b666bfb95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:21:32 +0000 Subject: [PATCH 08/10] docs(spec): state that a stored any-of group fails closed until the console reads it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/28020-view-filter-any-group.md | 2 +- content/docs/references/ui/component.mdx | 20 ++++++++++---------- content/docs/references/ui/page.mdx | 6 +++--- content/docs/references/ui/view.mdx | 10 +++++----- packages/spec/src/ui/view.zod.ts | 12 ++++++++---- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.changeset/28020-view-filter-any-group.md b/.changeset/28020-view-filter-any-group.md index d5790cf59ff..984cc54883e 100644 --- a/.changeset/28020-view-filter-any-group.md +++ b/.changeset/28020-view-filter-any-group.md @@ -28,6 +28,6 @@ filter: [ - The wire door strips the console's row `id` inside a group as on a plain rule. - `ViewFilterRule` (the type) is unchanged and still means one flat rule. A reader typed against a carrier's element now sees `ViewFilterElement`, and must step into a group (`isViewFilterAnyGroup`) rather than read `field` off every element. - `solution-blueprint` view `filters` deliberately stay flat rules: the model writes them through a strict mirror that cannot carry a union. -- `[PLANNED — not yet rendered]`: no console applies the group yet. The list query build reads flat rules only, so until it reads this element a view that stores a group does not filter by it; the group's describe says so. +- `[PLANNED — not yet rendered]`: no console applies the group yet. The list query build reads flat rules only, so until it reads this element a view that stores a group is refused when it loads (the server answers the passed-through group with a malformed-filter error and the list shows no rows); it fails closed, and the group's describe says so. Readers updated: `@objectstack/lint`'s filter field-key walk (`filter-walk.ts`, used by the list-view, dataset and widget field-reference rules), the preset-comparand rule, and the page field-binding rule (`filterBy` and a related list's `filter`) step into a group, so a field inside one is checked and `any` is never reported as a field. `@objectstack/metadata-protocol`'s `computeViewReferenceDiagnostics` checks the fields inside a group in a preset tab's filter. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 55e3dd92115..93ce9829b0d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -517,7 +517,7 @@ View filter rule ### Nested Shape: `ObjectCalendarProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -732,7 +732,7 @@ View filter rule ### Nested Shape: `ObjectGanttProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -890,7 +890,7 @@ View filter rule ### Nested Shape: `ObjectGridProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1057,7 +1057,7 @@ View filter rule ### Nested Shape: `ObjectKanbanProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1140,7 +1140,7 @@ View filter rule ### Nested Shape: `ObjectMapProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1284,7 +1284,7 @@ View filter rule ### Nested Shape: `ObjectMetricProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1363,7 +1363,7 @@ View filter rule ### Nested Shape: `ObjectTimelineProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1462,7 +1462,7 @@ View filter rule ### Nested Shape: `ObjectTreeProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1929,7 +1929,7 @@ View filter rule ### Nested Shape: `RecordLineItemsProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2090,7 +2090,7 @@ View filter rule ### Nested Shape: `RecordRelatedListProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 28593469927..06a70c4a145 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -68,7 +68,7 @@ View filter rule ### Nested Shape: `ElementDataSource.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -150,7 +150,7 @@ View filter rule ### Nested Shape: `InterfacePageConfig.filterBy[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -479,7 +479,7 @@ View filter rule ### Nested Shape: `PageRecordBinding.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index aca1e6f707f..f9be4c8d3d0 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -867,7 +867,7 @@ View filter rule ### Nested Shape: `ListView.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1256,7 +1256,7 @@ View filter rule ### Nested Shape: `ObjectListView.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1948,7 +1948,7 @@ This schema accepts one of the following structures: ## ViewFilterAnyGroup -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). ### Properties @@ -2016,7 +2016,7 @@ View filter rule #### Option 2 -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). ### Properties @@ -2495,7 +2495,7 @@ View filter rule ### Nested Shape: `ViewTab.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one does not filter by it. +[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1288473874e..96daefbca9c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1160,9 +1160,12 @@ export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_AN * ## [PLANNED — not yet rendered] * * No reader applies the group yet. The query a list view runs is built by the - * console (`ui/packages/plugin-list`'s filter-to-AST conversion), which reads - * flat rules only; until it reads this element, a view that stores one does not - * filter by it. The describe below says so, so no author is told it works. + * console (`ui/packages/core/src/utils/filter-converter.ts#toFilterNode`), which + * lowers flat rules only and passes a group through untouched, so the server + * refuses the query as a malformed filter (400 `INVALID_FILTER`) and the list + * shows that error and no rows. It fails closed: a stored group never widens + * what a view, a related list or a picker shows. The describe below says so, + * so no author is told it works. * The console half is #28370; the marker leaves when it lands. * * @example @@ -1210,7 +1213,8 @@ export const ViewFilterAnyGroupSchema = lazySchema(() => strictObject({ + 'so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", ' + 'operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the ' + 'last 7 days. One level only: its members are flat rules. No console reads it yet: until the list ' - + 'query build reads the group, a view that stores one does not filter by it.', + + 'query build reads the group, a view that stores one is refused when it loads (the list reports a ' + + 'malformed filter and shows no rows).', )); export type ViewFilterAnyGroup = z.input; From 9d54c7f359bc8b1cee85612daf92dc7a37430b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:48:45 +0000 Subject: [PATCH 09/10] chore(spec): regenerate the authorable surface and references after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 2 +- content/docs/references/index.mdx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 671aa422728..80e950e3cc3 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -490,7 +490,7 @@ const result = ApiMethod.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | -| **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | +| **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ceab129fcfa..f2acd243832 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1572 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1573 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 33 | 451 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 452 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **203** | **1572** | 14 protocol modules | +| **Total** | **204** | **1573** | 14 protocol modules | --- @@ -63,13 +63,14 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **33 pages, 451 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 452 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. | File | Schemas | | :--- | :--- | | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | +| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | | [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | From 399231dcab526848b0d819f63b7d8039da4dd549 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:10:03 +0000 Subject: [PATCH 10/10] feat(spec): rename the view filter any-of group key from any to or The maintainer ruled the stored shape stands and its key is `or`, matching the and/or wording of Notion, monday.com and Dataverse filters; a nested level added later would pair with `and`. - `{ any: [rule, ...] }` becomes `{ or: [rule, ...] }` in the schema, the row-id strip, describes, TSDoc and examples, the lint readers, the view reference diagnostics, tests, changeset and liveness note. - Symbols follow: ViewFilterOrGroupSchema, ViewFilterOrGroup(Parsed), isViewFilterOrGroup, VIEW_FILTER_OR_GROUP_KEY. Unreleased, so no aliases; the manifest and authorable-surface baselines rename their branch-added lines in place. - `or` leaves the rule's OR-spelling guidance (it is the real key now); `any` joins `$or`, `anyOf`, `logic` and `conditions` there. A rule carrying `or` inside a group keeps the nested-group refusal. - Test files renamed to *-or-group; references regenerated. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- ...group.md => 28020-view-filter-or-group.md} | 14 +-- content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/index.mdx | 2 +- content/docs/references/ui/component.mdx | 62 ++++++------ content/docs/references/ui/page.mdx | 24 ++--- content/docs/references/ui/view.mdx | 84 ++++++++-------- ...p.test.ts => filter-walk-or-group.test.ts} | 22 ++--- packages/lint/src/filter-walk.ts | 16 ++-- .../src/validate-page-field-bindings.test.ts | 4 +- .../lint/src/validate-page-field-bindings.ts | 4 +- .../src/validate-preset-comparands.test.ts | 6 +- .../lint/src/validate-preset-comparands.ts | 2 +- .../src/metadata-diagnostics.ts | 8 +- ...-diagnostics.view-filter-or-group.test.ts} | 12 +-- packages/spec/api-surface/ui.json | 8 +- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/declaration-map/ui.json | 4 +- .../spec/dropped-refinements.baseline.json | 4 +- packages/spec/export-origins/ui.json | 8 +- packages/spec/json-schema.manifest/ui.json | 2 +- packages/spec/liveness/view.json | 2 +- packages/spec/src/conversions/registry.ts | 2 +- packages/spec/src/ui/filter-rule-array.ts | 4 +- ...p.test.ts => view-filter-or-group.test.ts} | 71 +++++++------- packages/spec/src/ui/view.zod.ts | 95 ++++++++++--------- ...list-add-picker-filter-mirror-9964.test.ts | 8 +- ...lated-list-filter-rule-array-10199.test.ts | 8 +- 28 files changed, 245 insertions(+), 239 deletions(-) rename .changeset/{28020-view-filter-any-group.md => 28020-view-filter-or-group.md} (63%) rename packages/lint/src/{filter-walk-any-group.test.ts => filter-walk-or-group.test.ts} (80%) rename packages/metadata-protocol/src/{metadata-diagnostics.view-filter-any-group.test.ts => metadata-diagnostics.view-filter-or-group.test.ts} (82%) rename packages/spec/src/ui/{view-filter-any-group.test.ts => view-filter-or-group.test.ts} (81%) diff --git a/.changeset/28020-view-filter-any-group.md b/.changeset/28020-view-filter-or-group.md similarity index 63% rename from .changeset/28020-view-filter-any-group.md rename to .changeset/28020-view-filter-or-group.md index 984cc54883e..38dd5b2983f 100644 --- a/.changeset/28020-view-filter-any-group.md +++ b/.changeset/28020-view-filter-or-group.md @@ -4,16 +4,16 @@ '@objectstack/metadata-protocol': minor --- -feat(spec): a stored view filter list can hold an any-of group, `{ any: [rule, ...] }` +feat(spec): a stored view filter list can hold an any-of group, `{ or: [rule, ...] }` Clause-②: yes (widening) Metadata-protocol: added -A view filter list (`ListView.filter`, a view tab's `filter`, `Page.interfaceConfig.filterBy`, an element data source's `filter`, the related-list and picker filters and the component `filter` doors) was an AND list of flat `{ field, operator, value }` rules, so a view whose natural filter is an any-of ("open, or completed in the last 7 days") could not be stored. The list now takes one more element kind beside the rule: `{ any: [rule, ...] }`, a non-empty array of flat rules of which ANY may match. The list still ANDs its elements. +A view filter list (`ListView.filter`, a view tab's `filter`, `Page.interfaceConfig.filterBy`, an element data source's `filter`, the related-list and picker filters and the component `filter` doors) was an AND list of flat `{ field, operator, value }` rules, so a view whose natural filter is an any-of ("open, or completed in the last 7 days") could not be stored. The list now takes one more element kind beside the rule: `{ or: [rule, ...] }`, a non-empty array of flat rules of which ANY may match. The list still ANDs its elements. ```ts filter: [ - { any: [ + { or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, ] }, @@ -21,13 +21,13 @@ filter: [ ] ``` -- New exports from `@objectstack/spec/ui`: `ViewFilterAnyGroupSchema`, `ViewFilterElementSchema` (rule or group — what every carrier holds now), their `ViewFilterAnyGroup` / `ViewFilterElement` types and `...Parsed` twins, and `isViewFilterAnyGroup(value)`, the one test that tells the two element kinds apart. +- New exports from `@objectstack/spec/ui`: `ViewFilterOrGroupSchema`, `ViewFilterElementSchema` (rule or group — what every carrier holds now), their `ViewFilterOrGroup` / `ViewFilterElement` types and `...Parsed` twins, and `isViewFilterOrGroup(value)`, the one test that tells the two element kinds apart. - A group's members are `ViewFilterRuleSchema` itself, so the operator vocabulary, the legacy-alias fold, the operator/value-shape coupling and the `icontains` comparand check apply inside a group exactly as at the top level. -- Refused at parse: an empty group (`too_small` at `filter.N.any`), a group inside a group (one level only; `unrecognized_keys` naming `any` on the inner element), a member that is not a rule, and a rule key written beside `any`. `$or`, `or`, `anyOf`, `logic` and `conditions` written on a rule are answered with the group shape. +- Refused at parse: an empty group (`too_small` at `filter.N.or`), a group inside a group (one level only; `unrecognized_keys` naming `or` on the inner element), a member that is not a rule, and a rule key written beside `or`. `$or`, `any`, `anyOf`, `logic` and `conditions` written on a rule are answered with the group shape. The key is `or` by the maintainer's ruling: it matches the and/or wording of Notion, monday.com and Dataverse filters, and a nested level added later would pair with `and`. - Every filter stored before this change parses exactly as it did: the same output for an accepted rule, and the same issues (code, path, message) for a refused one — the element union reports only the branch the element claims. - The wire door strips the console's row `id` inside a group as on a plain rule. -- `ViewFilterRule` (the type) is unchanged and still means one flat rule. A reader typed against a carrier's element now sees `ViewFilterElement`, and must step into a group (`isViewFilterAnyGroup`) rather than read `field` off every element. +- `ViewFilterRule` (the type) is unchanged and still means one flat rule. A reader typed against a carrier's element now sees `ViewFilterElement`, and must step into a group (`isViewFilterOrGroup`) rather than read `field` off every element. - `solution-blueprint` view `filters` deliberately stay flat rules: the model writes them through a strict mirror that cannot carry a union. - `[PLANNED — not yet rendered]`: no console applies the group yet. The list query build reads flat rules only, so until it reads this element a view that stores a group is refused when it loads (the server answers the passed-through group with a malformed-filter error and the list shows no rows); it fails closed, and the group's describe says so. -Readers updated: `@objectstack/lint`'s filter field-key walk (`filter-walk.ts`, used by the list-view, dataset and widget field-reference rules), the preset-comparand rule, and the page field-binding rule (`filterBy` and a related list's `filter`) step into a group, so a field inside one is checked and `any` is never reported as a field. `@objectstack/metadata-protocol`'s `computeViewReferenceDiagnostics` checks the fields inside a group in a preset tab's filter. +Readers updated: `@objectstack/lint`'s filter field-key walk (`filter-walk.ts`, used by the list-view, dataset and widget field-reference rules), the preset-comparand rule, and the page field-binding rule (`filterBy` and a related list's `filter`) step into a group, so a field inside one is checked and `or` is never reported as a field. `@objectstack/metadata-protocol`'s `computeViewReferenceDiagnostics` checks the fields inside a group in a preset tab's filter. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index d1c97a29d86..d66141a6f6e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1856,7 +1856,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1935,7 +1935,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 80e950e3cc3..ba999fbf3ee 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -375,7 +375,7 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index f2acd243832..da2b12fc792 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -392,7 +392,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterAnyGroup`, `ViewFilterElement`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterElement`, `ViewFilterOrGroup`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 93ce9829b0d..c14aaa5fda2 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -486,7 +486,7 @@ const result = ActionButtonPropsSchema.parse(data); | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar config `{ startDateField, endDateField?, titleField?, colorField?, allDayField?, initialDate? }` — the same block a list view's `calendar` declares; `startDateField` is required | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched events — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | | **staticData** | `any[]` | optional | Static inline records | @@ -517,11 +517,11 @@ View filter rule ### Nested Shape: `ObjectCalendarProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectCalendarProps.sort[number]` @@ -683,7 +683,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default). The renderer's own default is `{ mode: 'drawer' }` when the key is absent | @@ -732,11 +732,11 @@ View filter rule ### Nested Shape: `ObjectGanttProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectGanttProps.sort[number]` @@ -816,7 +816,7 @@ Sort field and direction pair | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | What the grid draws instead of an empty table: `{ title, message, icon, link }` — the list view's own empty-state shape | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -890,11 +890,11 @@ View filter rule ### Nested Shape: `ObjectGridProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectGridProps.sort[number]` @@ -1021,7 +1021,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `string[] \| { id: string; title: string; cards?: object[]; limit?: integer; … }[]` | optional | Swimlane definitions — all lane objects `{ id, title, cards?, limit?, className?, collapsed? }` (one per `groupBy` value), or all bare value strings (drawn only on a board with no `groupBy`). NOT a field projection; one spelling per list | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | @@ -1057,11 +1057,11 @@ View filter rule ### Nested Shape: `ObjectKanbanProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectKanbanProps.grouping` @@ -1099,7 +1099,7 @@ View filter rule | **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | @@ -1140,11 +1140,11 @@ View filter rule ### Nested Shape: `ObjectMapProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectMapProps.sort[number]` @@ -1251,7 +1251,7 @@ Sort field and direction pair | **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | | **aggregate** | `{ field?: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; groupBy?: string \| object }` | optional | Aggregation run against the object: `{ field?, function, groupBy? }` — `function` one of count / sum / avg / min / max / count_distinct (`field` needed for all but count), `groupBy` a field name or a `{ field, dateGranularity }` node, absent for one number over every row | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | | **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | | **prefix** | `string` | optional | Static prefix before the formatted value | @@ -1284,11 +1284,11 @@ View filter rule ### Nested Shape: `ObjectMetricProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectMetricProps.trend` @@ -1326,7 +1326,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | | **data** | `any[]` | optional | Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written | @@ -1363,11 +1363,11 @@ View filter rule ### Nested Shape: `ObjectTimelineProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectTimelineProps.sort[number]` @@ -1424,7 +1424,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default) | @@ -1462,11 +1462,11 @@ View filter rule ### Nested Shape: `ObjectTreeProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectTreeProps.tree` @@ -1888,7 +1888,7 @@ Type: `string` | **readonly** | `boolean` | optional | Render the grid read-only: cells locked, no Save button, and no adding or removing lines (renderer default: editable) | | **minRows** | `number` | optional | Minimum number of rows | | **maxRows** | `number` | optional | Maximum number of rows | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Load order for the child rows — the SortItem array form `[{ field, order }, ...]` (renderer default: storage order) | | **limit** | `integer` | optional | Row cap for the child fetch (renderer default: 500). The grid has no pagination, so this is the window of rows that are editable and saved together | @@ -1929,11 +1929,11 @@ View filter rule ### Nested Shape: `RecordLineItemsProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `RecordLineItemsProps.sort[number]` @@ -2039,7 +2039,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria for related records | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | @@ -2055,7 +2055,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -2090,11 +2090,11 @@ View filter rule ### Nested Shape: `RecordRelatedListProps.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `RecordRelatedListProps.add` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 06a70c4a145..b2b0a8d937f 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -52,7 +52,7 @@ API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -68,11 +68,11 @@ View filter rule ### Nested Shape: `ElementDataSource.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ElementDataSource.sort[number]` @@ -97,7 +97,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -150,11 +150,11 @@ View filter rule ### Nested Shape: `InterfacePageConfig.filterBy[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `InterfacePageConfig.appearance` @@ -258,7 +258,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | ### Nested Shape: `Page.regions[number]` @@ -276,7 +276,7 @@ View filter rule | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -360,7 +360,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -464,7 +464,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | ### Nested Shape: `PageRecordBinding.filter[number][option 1]` @@ -479,11 +479,11 @@ View filter rule ### Nested Shape: `PageRecordBinding.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `PageRecordBinding.sort[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f9be4c8d3d0..e1af9fe765c 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -59,8 +59,8 @@ nothing. The one item-name grammar itself lives in ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterAnyGroupSchema, ViewFilterElementSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterAnyGroup, ViewFilterElement, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterElementSchema, ViewFilterOrGroupSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterElement, ViewFilterOrGroup, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -772,7 +772,7 @@ Map view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -867,11 +867,11 @@ View filter rule ### Nested Shape: `ListView.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ListView.userFilters` @@ -1161,7 +1161,7 @@ View filter rule | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1256,11 +1256,11 @@ View filter rule ### Nested Shape: `ObjectListView.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | ### Nested Shape: `ObjectListView.selection` @@ -1703,7 +1703,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1743,7 +1743,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1822,7 +1822,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1944,29 +1944,6 @@ This schema accepts one of the following structures: --- ---- - -## ViewFilterAnyGroup - -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | - -### Nested Shape: `ViewFilterAnyGroup.any[number]` - -View filter rule - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - - --- ## ViewFilterElement @@ -2016,15 +1993,15 @@ View filter rule #### Option 2 -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | -### Nested Shape: `ViewFilterElement[option 2].any[number]` +### Nested Shape: `ViewFilterElement[option 2].or[number]` View filter rule @@ -2037,6 +2014,29 @@ View filter rule --- +--- + +## ViewFilterOrGroup + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + +### Nested Shape: `ViewFilterOrGroup.or[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + + --- ## ViewFilterRule @@ -2117,7 +2117,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2289,7 +2289,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2477,7 +2477,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { any: object[] })[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -2495,11 +2495,11 @@ View filter rule ### Nested Shape: `ViewTab.filter[number][option 2]` -[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **any** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | --- diff --git a/packages/lint/src/filter-walk-any-group.test.ts b/packages/lint/src/filter-walk-or-group.test.ts similarity index 80% rename from packages/lint/src/filter-walk-any-group.test.ts rename to packages/lint/src/filter-walk-or-group.test.ts index 27912bc017b..b19cd69b126 100644 --- a/packages/lint/src/filter-walk-any-group.test.ts +++ b/packages/lint/src/filter-walk-or-group.test.ts @@ -2,10 +2,10 @@ /** * [#28020] The filter field-key walk steps INTO an any-of group element of a - * view filter list (`{ any: [rule, ...] }`). + * view filter list (`{ or: [rule, ...] }`). * * Before the walk knew the element, a group read as a field-keyed condition - * object: its own key `any` was reported as the field it filters by, and the + * object: its own key `or` was reported as the field it filters by, and the * rules inside it — the fields the query really filters by — were never seen. * So a group cost a false finding AND hid a true one. These pins hold both * halves, on the walk itself and through the list-view rule that reads it. @@ -22,24 +22,24 @@ const keysOf = (filter: unknown) => { }; describe('walkFilterFieldKeys and the any-of group', () => { - it('reports the rules of a group, at their own paths, and never `any` itself', () => { + it('reports the rules of a group, at their own paths, and never `or` itself', () => { expect(keysOf([ - { any: [ + { or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, ] }, { field: 'owner', operator: 'equals', value: '{current_user_id}' }, ])).toEqual([ - { field: 'status', path: 'filter[0].any[0].field' }, - { field: 'completed_at', path: 'filter[0].any[1].field' }, + { field: 'status', path: 'filter[0].or[0].field' }, + { field: 'completed_at', path: 'filter[0].or[1].field' }, { field: 'owner', path: 'filter[1].field' }, ]); }); - it('CONTROL: a whole-filter condition object whose field is named `any` is still a condition', () => { + it('CONTROL: a whole-filter condition object whose field is named `or` is still a condition', () => { // Only a MEMBER of a filter list is an element; a Mongo-style record that // is the whole filter keeps its field-keyed reading. - expect(keysOf({ any: 'x' })).toEqual([{ field: 'any', path: 'filter.any' }]); + expect(keysOf({ or: 'x' })).toEqual([{ field: 'or', path: 'filter.or' }]); }); }); @@ -58,7 +58,7 @@ describe('validateListViewFieldRefs through a group', () => { }); it('a group of real fields is silent', () => { - const findings = validateListViewFieldRefs(stack([{ any: [ + const findings = validateListViewFieldRefs(stack([{ or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, ] }])); @@ -66,12 +66,12 @@ describe('validateListViewFieldRefs through a group', () => { }); it('an unknown field inside a group is an error at its own path', () => { - const findings = validateListViewFieldRefs(stack([{ any: [ + const findings = validateListViewFieldRefs(stack([{ or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'no_such_field', operator: 'equals', value: 'x' }, ] }])); expect(findings.map((f) => [f.rule, f.path])).toEqual([ - [LIST_VIEW_FIELD_UNKNOWN, 'views[0].list.filter[0].any[1].field'], + [LIST_VIEW_FIELD_UNKNOWN, 'views[0].list.filter[0].or[1].field'], ]); }); }); diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 84cd3065366..fc88bd84896 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -49,7 +49,7 @@ */ import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; -import { isViewFilterAnyGroup } from '@objectstack/spec/ui'; +import { isViewFilterOrGroup } from '@objectstack/spec/ui'; import { recordsOf } from './object-graph.js'; /** Any plain metadata record. */ @@ -282,11 +282,11 @@ export function walkFilterFieldKeys( if (typeof member === 'string') return; // the leading keyword // [#28020] An any-of group element of a view filter list. It names no // field itself, so it is stepped INTO: read as a condition object it - // would report its own key `any` as a field, and its rules would go + // would report its own key `or` as a field, and its rules would go // unchecked. const rules = filterGroupRules(member); if (rules) { - rules.forEach((rule, j) => walkFilterFieldKeys(rule, `${path}[${i}].any[${j}]`, visit, depth + 2)); + rules.forEach((rule, j) => walkFilterFieldKeys(rule, `${path}[${i}].or[${j}]`, visit, depth + 2)); return; } walkFilterFieldKeys(member, `${path}[${i}]`, visit, depth + 1); @@ -307,18 +307,18 @@ export function walkFilterFieldKeys( /** * [#28020] The rules of an any-of group element of a view filter list - * (`{ any: [rule, ...] }`, `ViewFilterAnyGroupSchema`), or `undefined` when + * (`{ or: [rule, ...] }`, `ViewFilterOrGroupSchema`), or `undefined` when * `member` is not one. * * Asked only of a MEMBER of a filter array — the position a group element * occupies — so a field-keyed condition object whose field happens to be named - * `any` (`{ any: ['a'] }` as a whole filter) is still read as one. Which value - * is a group is the spec's own predicate, `isViewFilterAnyGroup`, never a + * `or` (`{ or: ['a'] }` as a whole filter) is still read as one. Which value + * is a group is the spec's own predicate, `isViewFilterOrGroup`, never a * restated test. Exported for the other walks of a filter list in this package, * which must step into a group the same way. */ export function filterGroupRules(member: unknown): unknown[] | undefined { - if (!isViewFilterAnyGroup(member)) return undefined; - const rules = member.any; + if (!isViewFilterOrGroup(member)) return undefined; + const rules = member.or; return Array.isArray(rules) ? rules : undefined; } diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 8d6d2360bc0..312ee5bf47a 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -631,7 +631,7 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => interfaceConfig: { source: 'crm_lead', filterBy: [ - { any: [ + { or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'ghost_in_group', operator: 'equals', value: 'x' }, ] }, @@ -641,7 +641,7 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => }, ], }); - expect(findings.map((f) => f.path)).toEqual(['pages[0].interfaceConfig.filterBy[0].any[1].field']); + expect(findings.map((f) => f.path)).toEqual(['pages[0].interfaceConfig.filterBy[0].or[1].field']); }); }); diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 26e79d1e8bc..0b58c005cdd 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -175,7 +175,7 @@ export function fieldRefsFrom(value: unknown, basePath: string): FieldRef[] { * a list page's `filterBy` and a related list's `filter` carry. * * Plain rules are ordinary {@link fieldRefsFrom} work. An any-of group element - * (`{ any: [rule, ...] }`) names no field of its own, so its rules are read in + * (`{ or: [rule, ...] }`) names no field of its own, so its rules are read in * its place: {@link fieldRefsFrom} alone finds neither `field` nor `name` on the * group and would skip every field the group's rules filter by. */ @@ -184,7 +184,7 @@ function filterFieldRefs(value: unknown, basePath: string): FieldRef[] { const out: FieldRef[] = []; for (let i = 0; i < value.length; i++) { const rules = filterGroupRules(value[i]); - out.push(...(rules ? fieldRefsFrom(rules, `${basePath}[${i}].any`) : fieldRefsFrom(value[i], `${basePath}[${i}]`))); + out.push(...(rules ? fieldRefsFrom(rules, `${basePath}[${i}].or`) : fieldRefsFrom(value[i], `${basePath}[${i}]`))); } return out; } diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index dab598ba4da..c68e2e41699 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -57,20 +57,20 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => // [#28020] A rule inside an any-of group element of a view filter list is // judged exactly as a top-level rule — the group is stepped into, not read as - // a field-keyed condition whose one "field" is `any`. + // a field-keyed condition whose one "field" is `or`. it('judges the rules inside an any-of group of a view filter list', () => { const findings = validatePresetComparands({ views: [{ name: 'recent', filter: [ - { any: [ + { or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'created_at', operator: 'after', value: 'last_7_days' }, ] }, ], }], }); - expect(findings.map((f) => f.path)).toEqual(['views[0].filter[0].any[1].value']); + expect(findings.map((f) => f.path)).toEqual(['views[0].filter[0].or[1].value']); expect(findings[0]!.rule).toBe(FILTER_PRESET_COMPARAND); expect(findings[0]!.message).toContain('last_7_days'); }); diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index a57212bc8a6..dbf9e51d8f5 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -710,7 +710,7 @@ function judgeFilterValue( const rules = filterGroupRules(member); if (rules) { rules.forEach((rule, j) => - judgeFilterValue(rule, `${path}[${i}].any[${j}]`, where, out, depth + 2, isTemporal)); + judgeFilterValue(rule, `${path}[${i}].or[${j}]`, where, out, depth + 2, isTemporal)); return; } judgeFilterValue(member, `${path}[${i}]`, where, out, depth + 1, isTemporal); diff --git a/packages/metadata-protocol/src/metadata-diagnostics.ts b/packages/metadata-protocol/src/metadata-diagnostics.ts index db8a9a64308..6cf38c21da5 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.ts @@ -28,7 +28,7 @@ import type { z } from 'zod'; import { getMetadataTypeSchema, stripReadDecorations } from '@objectstack/spec/kernel'; import type { MetadataValidationResult } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; -import { isViewFilterAnyGroup } from '@objectstack/spec/ui'; +import { isViewFilterOrGroup } from '@objectstack/spec/ui'; // [#5598] The READ path's share of the #5364 expansion. `zodIssuesToMetadataIssues` // is the ONE ranking this package speaks; the save path's 422 calls the same // function, so a document's verdict cannot depend on whether it was being saved @@ -490,10 +490,10 @@ export function computeViewReferenceDiagnostics( t?.filter?.forEach((r, j) => { // [#28020] An any-of group element names no field itself; each of // its rules names one, and each is checked like a top-level rule. - if (isViewFilterAnyGroup(r)) { - const rules = Array.isArray(r.any) ? (r.any as Array<{ field?: string }>) : []; + if (isViewFilterOrGroup(r)) { + const rules = Array.isArray(r.or) ? (r.or as Array<{ field?: string }>) : []; rules.forEach((rule, k) => - requireField(rule?.field, `userFilters.tabs.${i}.filter.${j}.any.${k}.field`)); + requireField(rule?.field, `userFilters.tabs.${i}.filter.${j}.or.${k}.field`)); return; } requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`); diff --git a/packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts similarity index 82% rename from packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts rename to packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts index ffe0f88a904..7de50b0eb2f 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.view-filter-any-group.test.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts @@ -2,7 +2,7 @@ /** * [#28020] Two readers of a stored view filter list, and the any-of group - * element (`{ any: [rule, ...] }`) the list can now hold. + * element (`{ or: [rule, ...] }`) the list can now hold. * * - `computeViewReferenceDiagnostics` checks every field a list view's preset * tabs filter by. A group names no field itself, so read as a rule it was @@ -21,7 +21,7 @@ const objectDef = { fields: { status: { type: 'select' }, completed_at: { type: describe('computeViewReferenceDiagnostics and an any-of group in a preset tab', () => { const view = (rules: unknown[]) => ({ - userFilters: { tabs: [{ name: 'recent', filter: [{ any: rules }] }] }, + userFilters: { tabs: [{ name: 'recent', filter: [{ or: rules }] }] }, }); it('a group of real fields is valid', () => { @@ -38,7 +38,7 @@ describe('computeViewReferenceDiagnostics and an any-of group in a preset tab', ]), objectDef); expect(result.valid).toBe(false); expect(result.errors).toEqual([{ - path: 'userFilters.tabs.0.filter.0.any.1.field', + path: 'userFilters.tabs.0.filter.0.or.1.field', message: 'Field "ghost" does not exist on the source object', code: 'reference_not_found', }]); @@ -49,14 +49,14 @@ describe('graftNormalizedOperators reaches the rules inside a group', () => { it('folds an alias operator inside a group and changes nothing else', () => { const authored = { name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], - filter: [{ any: [{ field: 'amount', operator: 'gt', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }], + filter: [{ or: [{ field: 'amount', operator: 'gt', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }], }; const parsed = ViewMetadataSchema.safeParse(authored); expect(parsed.success).toBe(true); const out = graftNormalizedOperators(authored, parsed.data) as typeof authored; expect(out.filter).toEqual([ - { any: [{ field: 'amount', operator: 'greater_than', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }, + { or: [{ field: 'amount', operator: 'greater_than', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }, ]); - expect(authored.filter[0]!.any[0]!.operator).toBe('gt'); + expect(authored.filter[0]!.or[0]!.operator).toBe('gt'); }); }); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0dba9f40d6a..4401a101579 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -468,13 +468,13 @@ "ViewData (type)", "ViewDataParsed (type)", "ViewDataSchema (const)", - "ViewFilterAnyGroup (type)", - "ViewFilterAnyGroupParsed (type)", - "ViewFilterAnyGroupSchema (const)", "ViewFilterElement (type)", "ViewFilterElementParsed (type)", "ViewFilterElementSchema (const)", "ViewFilterOperator (type)", + "ViewFilterOrGroup (type)", + "ViewFilterOrGroupParsed (type)", + "ViewFilterOrGroupSchema (const)", "ViewFilterRule (type)", "ViewFilterRuleParsed (type)", "ViewFilterRuleSchema (const)", @@ -561,7 +561,7 @@ "isKnownComponentType (function)", "isRecordContextBlockType (function)", "isViewContainerShaped (function)", - "isViewFilterAnyGroup (function)", + "isViewFilterOrGroup (function)", "listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 6e5b26c650c..d503ecbc500 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1362,7 +1362,7 @@ "ui/View:name", "ui/View:object", "ui/View:protection", - "ui/ViewFilterAnyGroup:any", + "ui/ViewFilterOrGroup:or", "ui/ViewFilterRule:field", "ui/ViewFilterRule:operator", "ui/ViewFilterRule:value", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 785b0ada609..18436b21268 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -287,10 +287,10 @@ "View": "ui/View", "ViewData": "ui/ViewData", "ViewDataSchema": "ui/ViewData", - "ViewFilterAnyGroup": "ui/ViewFilterAnyGroup", - "ViewFilterAnyGroupSchema": "ui/ViewFilterAnyGroup", "ViewFilterElement": "ui/ViewFilterElement", "ViewFilterElementSchema": "ui/ViewFilterElement", + "ViewFilterOrGroup": "ui/ViewFilterOrGroup", + "ViewFilterOrGroupSchema": "ui/ViewFilterOrGroup", "ViewFilterRule": "ui/ViewFilterRule", "ViewFilterRuleSchema": "ui/ViewFilterRule", "ViewItem": "ui/ViewItem", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 58f60210b6c..99d392c3a96 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1878,9 +1878,9 @@ "list.timeline.groupByField" ] }, - "ui/ViewFilterAnyGroup": { + "ui/ViewFilterOrGroup": { "sites": [ - "any.element" + "or.element" ] }, "ui/ViewFilterElement": { diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index f3bd0ead607..56a1a756dbf 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -451,13 +451,13 @@ "ViewData": "src/ui/view.zod.ts#ViewData (type)", "ViewDataParsed": "src/ui/view.zod.ts#ViewDataParsed (type)", "ViewDataSchema": "src/ui/view.zod.ts#ViewDataSchema (const)", - "ViewFilterAnyGroup": "src/ui/view.zod.ts#ViewFilterAnyGroup (type)", - "ViewFilterAnyGroupParsed": "src/ui/view.zod.ts#ViewFilterAnyGroupParsed (type)", - "ViewFilterAnyGroupSchema": "src/ui/view.zod.ts#ViewFilterAnyGroupSchema (const)", "ViewFilterElement": "src/ui/view.zod.ts#ViewFilterElement (type)", "ViewFilterElementParsed": "src/ui/view.zod.ts#ViewFilterElementParsed (type)", "ViewFilterElementSchema": "src/ui/view.zod.ts#ViewFilterElementSchema (const)", "ViewFilterOperator": "src/ui/view.zod.ts#ViewFilterOperator (type)", + "ViewFilterOrGroup": "src/ui/view.zod.ts#ViewFilterOrGroup (type)", + "ViewFilterOrGroupParsed": "src/ui/view.zod.ts#ViewFilterOrGroupParsed (type)", + "ViewFilterOrGroupSchema": "src/ui/view.zod.ts#ViewFilterOrGroupSchema (const)", "ViewFilterRule": "src/ui/view.zod.ts#ViewFilterRule (type)", "ViewFilterRuleParsed": "src/ui/view.zod.ts#ViewFilterRuleParsed (type)", "ViewFilterRuleSchema": "src/ui/view.zod.ts#ViewFilterRuleSchema (const)", @@ -544,7 +544,7 @@ "isKnownComponentType": "src/ui/component-type-vocabulary.ts#isKnownComponentType (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", - "isViewFilterAnyGroup": "src/ui/view.zod.ts#isViewFilterAnyGroup (function)", + "isViewFilterOrGroup": "src/ui/view.zod.ts#isViewFilterOrGroup (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn": "src/ui/view-grouping-query.ts#listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index ac4c5fff208..400f5f41cab 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -163,8 +163,8 @@ "ui/UserFilters", "ui/View", "ui/ViewData", - "ui/ViewFilterAnyGroup", "ui/ViewFilterElement", + "ui/ViewFilterOrGroup", "ui/ViewFilterRule", "ui/ViewItem", "ui/ViewItemName", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index f5ee2261408..caceb08cfe7 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -49,7 +49,7 @@ }, "filter": { "status": "live", - "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less. One element kind rides this live verdict at PLANNED strength (#28020, 2026-10-31): the any-of group `{ any: [rule, ...] }` (ViewFilterAnyGroupSchema, its describe marked `[PLANNED — not yet rendered]`) has no console reader yet — the list query build (ui/packages/plugin-list/src/ListView.tsx, buildEffectiveFilter; ui/packages/core/src/utils/filter-converter.ts, convertFiltersToAST) and the builder save fold (ui/packages/app-shell/src/views/viewFilterFold.ts, which refuses an OR group) read flat rules only, read on this tree at 22ce3d13fe. The console half is #28370; amend this note, citing the group read, when it lands." + "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less. One element kind rides this live verdict at PLANNED strength (#28020, 2026-10-31): the any-of group `{ or: [rule, ...] }` (ViewFilterOrGroupSchema, its describe marked `[PLANNED — not yet rendered]`) has no console reader yet — the list query build (ui/packages/plugin-list/src/ListView.tsx, buildEffectiveFilter; ui/packages/core/src/utils/filter-converter.ts, convertFiltersToAST) and the builder save fold (ui/packages/app-shell/src/views/viewFilterFold.ts, which refuses an OR group) read flat rules only, read on this tree at 22ce3d13fe. The console half is #28370; amend this note, citing the group read, when it lands." }, "sort": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 8d09cb430f4..36f5e353794 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13621,7 +13621,7 @@ function dollarKeysReason(keys: readonly string[]): string { : ` (its top-level ${quoteKeys(others)} ${others.length === 1 ? 'is' : 'are'} not a field either)`; return `carries the combinator${combinators.length === 1 ? '' : 's'} ${quoteKeys(combinators)}` + `${alsoNotFields}: a rule array's elements AND, so \`$and\` is only the separate rules it ` - + 'joins, `$or` over flat rules only an any-of group element `{ any: [rule, ...] }` (declared, ' + + 'joins, `$or` over flat rules only an any-of group element `{ or: [rule, ...] }` (declared, ' + 'not yet applied by any console) that this conversion does not write, and `$not` has no rule ' + 'spelling; a combinator is never flattened — ' + 'that would change which rows the filter selects. Decide which rows it should select, and ' diff --git a/packages/spec/src/ui/filter-rule-array.ts b/packages/spec/src/ui/filter-rule-array.ts index 7991990909e..d7efe87769c 100644 --- a/packages/spec/src/ui/filter-rule-array.ts +++ b/packages/spec/src/ui/filter-rule-array.ts @@ -190,13 +190,13 @@ export function ruleArrayFilterError(options: RuleArrayFilterErrorOptions): z.co // [#28020] The rule array gained ONE combinator spelling, the any-of group // element, and this sentence names it — with its status, because no console // applies it yet: prescribing it bare would send an author to a filter that - // does not narrow the rows (`ViewFilterAnyGroupSchema`'s PLANNED note). + // does not narrow the rows (`ViewFilterOrGroupSchema`'s PLANNED note). const combinatorSentence = combinators.length === 0 ? '' : ` ${named} ${combinators.length === 1 ? 'is a combinator, not a field' : 'are combinators, not fields'}, ` + `and the rule array spells a combinator only as its own elements: they AND, so \`$and\` is ` + `the separate rules it joins, \`$or\` over flat rules is one any-of group element ` - + `\`{ any: [{ field, operator, value }, ...] }\` (declared, but no console applies it yet, so it ` + + `\`{ or: [{ field, operator, value }, ...] }\` (declared, but no console applies it yet, so it ` + `does not narrow the rows today), and \`$not\` cannot be written at all. Nothing rewrites a ` + `combinator for you — flattening one changes which rows the filter selects — so decide which ` + `rows this filter should select, and write the rules that select exactly those.`; diff --git a/packages/spec/src/ui/view-filter-any-group.test.ts b/packages/spec/src/ui/view-filter-or-group.test.ts similarity index 81% rename from packages/spec/src/ui/view-filter-any-group.test.ts rename to packages/spec/src/ui/view-filter-or-group.test.ts index 9cd4cd73ca1..61d1aef5b2c 100644 --- a/packages/spec/src/ui/view-filter-any-group.test.ts +++ b/packages/spec/src/ui/view-filter-or-group.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#28020] A view filter list can hold an any-of group: `{ any: [rule, ...] }`. + * [#28020] A view filter list can hold an any-of group: `{ or: [rule, ...] }`. * * The list stays an AND list of flat `ViewFilterRule`s; one new element kind * sits beside the rule and holds a non-empty list of flat rules of which ANY @@ -28,12 +28,12 @@ import { z } from 'zod'; import { ListViewSchema, - ViewFilterAnyGroupSchema, + ViewFilterOrGroupSchema, ViewFilterElementSchema, ViewFilterRuleSchema, ViewMetadataSchema, ViewTabSchema, - isViewFilterAnyGroup, + isViewFilterOrGroup, } from './view.zod'; import { ElementDataSourceSchema, InterfacePageConfigSchema } from './page.zod'; @@ -51,7 +51,7 @@ const listView = (filter: unknown) => ({ type: 'grid', columns: ['name'], filter /** "open, or completed in the last 7 days" — the view the card names. */ const OPEN_OR_RECENT = { - any: [ + or: [ { field: 'status', operator: 'equals', value: 'open' }, { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, ], @@ -98,7 +98,7 @@ describe('a group is accepted on every carrier of the filter list', () => { if (code.includes('z.array(ViewFilterRuleSchema')) bare.push(`${file.slice(SPEC_SRC.length + 1)}:${code.split(':')[0]}`); }); } - expect(bare.sort()).toEqual(['ai/solution-blueprint.zod.ts:filters', 'ui/view.zod.ts:any']); + expect(bare.sort()).toEqual(['ai/solution-blueprint.zod.ts:filters', 'ui/view.zod.ts:or']); }); }); @@ -147,69 +147,70 @@ describe('a filter stored before the group parses exactly as it did', () => { describe('the rule\'s refinements apply inside a group exactly as at top level', () => { it('the value shape is coupled to the operator', () => { - const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [{ field: 'stage', operator: 'in', value: 'won' }] }]))); + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [{ field: 'stage', operator: 'in', value: 'won' }] }]))); expect(issues).toHaveLength(1); expect(issues[0]!.code).toBe('custom'); - expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0, 'value']); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0, 'value']); expect(issues[0]!.message.startsWith('Operator "in" on field "stage" requires an ARRAY of values.')).toBe(true); }); it('the `icontains` comparand is judged', () => { - const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [{ field: 'name', operator: 'icontains', value: '' }] }]))); + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [{ field: 'name', operator: 'icontains', value: '' }] }]))); expect(issues).toHaveLength(1); expect(issues[0]!.code).toBe('custom'); - expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0, 'value']); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0, 'value']); }); it('legacy operator spellings fold to canonical inside a group too', () => { - const parsed = ListViewSchema.parse(listView([{ any: [{ field: 'amount', operator: 'gt', value: 1 }] }])); - expect(parsed.filter).toEqual([{ any: [{ field: 'amount', operator: 'greater_than', value: 1 }] }]); + const parsed = ListViewSchema.parse(listView([{ or: [{ field: 'amount', operator: 'gt', value: 1 }] }])); + expect(parsed.filter).toEqual([{ or: [{ field: 'amount', operator: 'greater_than', value: 1 }] }]); }); }); describe('what a group refuses', () => { it('an EMPTY group — it would match no record', () => { - const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: [] }]))); + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [] }]))); expect(issues).toHaveLength(1); expect(issues[0]!.code).toBe('too_small'); - expect(issues[0]!.path).toEqual(['filter', 0, 'any']); + expect(issues[0]!.path).toEqual(['filter', 0, 'or']); expect(issues[0]!.message.startsWith('An any-of group needs at least one rule')).toBe(true); }); it('a NESTED group — one level only, refused by name', () => { - const nested = { any: [{ any: [{ field: 'status', operator: 'equals', value: 'open' }] }] }; + const nested = { or: [{ or: [{ field: 'status', operator: 'equals', value: 'open' }] }] }; const issues = issuesOf(ListViewSchema.safeParse(listView([nested]))); const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); - expect(unknown?.path).toEqual(['filter', 0, 'any', 0]); + expect(unknown?.path).toEqual(['filter', 0, 'or', 0]); expect(unknown?.message).toContain('an any-of group cannot hold another group'); }); it('a member that is not a rule', () => { - const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: ['status = open'] }]))); + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: ['status = open'] }]))); expect(issues).toHaveLength(1); expect(issues[0]!.code).toBe('invalid_type'); - expect(issues[0]!.path).toEqual(['filter', 0, 'any', 0]); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0]); }); - it('a non-array `any`', () => { - const issues = issuesOf(ListViewSchema.safeParse(listView([{ any: { field: 'status' } }]))); + it('a non-array `or`', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: { field: 'status' } }]))); expect(issues[0]!.code).toBe('invalid_type'); - expect(issues[0]!.path).toEqual(['filter', 0, 'any']); + expect(issues[0]!.path).toEqual(['filter', 0, 'or']); }); - it('a rule key written beside `any` — it belongs to a rule', () => { + it('a rule key written beside `or` — it belongs to a rule', () => { const issues = issuesOf(ListViewSchema.safeParse(listView([{ ...OPEN_OR_RECENT, field: 'owner' }]))); expect(issues).toHaveLength(1); expect(issues[0]!.code).toBe('unrecognized_keys'); expect(issues[0]!.path).toEqual(['filter', 0]); - expect(issues[0]!.message).toContain('an any-of group holds only `any`'); + expect(issues[0]!.message).toContain('an any-of group holds only `or`'); }); - it.each(['$or', 'or', 'logic', 'conditions'])('the OR spelling `%s` on a rule is answered with the group', (key) => { + // `any` was this group's key before the maintainer's ruling renamed it to `or`; it is answered like the others. + it.each(['$or', 'any', 'anyOf', 'logic', 'conditions'])('the OR spelling `%s` on a rule is answered with the group', (key) => { const issues = issuesOf(ListViewSchema.safeParse(listView([{ [key]: [{ field: 'status', operator: 'equals', value: 'open' }] }]))); const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); expect(unknown?.path).toEqual(['filter', 0]); - expect(unknown?.message).toContain('`{ any: [rule, ...] }`'); + expect(unknown?.message).toContain('`{ or: [rule, ...] }`'); }); }); @@ -217,12 +218,12 @@ describe('the wire door and the published shape', () => { it('the console row `id` is stripped inside a group as on a plain rule', () => { const body = { name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], - filter: [{ id: 'root', any: [{ id: 'r1', field: 'status', operator: 'equals', value: 'open' }] }], + filter: [{ id: 'root', or: [{ id: 'r1', field: 'status', operator: 'equals', value: 'open' }] }], }; const parsed = ViewMetadataSchema.safeParse(body); expect(parsed.success).toBe(true); expect((parsed.data as { filter: unknown }).filter).toEqual([ - { any: [{ field: 'status', operator: 'equals', value: 'open' }] }, + { or: [{ field: 'status', operator: 'equals', value: 'open' }] }, ]); }); @@ -238,17 +239,17 @@ describe('the wire door and the published shape', () => { }; expect(json.anyOf?.map((branch) => Object.keys(branch.properties ?? {}).sort())).toEqual([ ['field', 'operator', 'value'], - ['any'], + ['or'], ]); - expect(ViewFilterAnyGroupSchema.description?.startsWith('[PLANNED — not yet rendered]')).toBe(true); + expect(ViewFilterOrGroupSchema.description?.startsWith('[PLANNED — not yet rendered]')).toBe(true); }); - it('`isViewFilterAnyGroup` tells the element kinds apart by the own `any` key', () => { - expect(isViewFilterAnyGroup(OPEN_OR_RECENT)).toBe(true); - expect(isViewFilterAnyGroup({ any: 'not yet valid' })).toBe(true); - expect(isViewFilterAnyGroup({ field: 'status', operator: 'equals', value: 'open' })).toBe(false); - expect(isViewFilterAnyGroup(Object.create({ any: [] }))).toBe(false); - expect(isViewFilterAnyGroup([OPEN_OR_RECENT])).toBe(false); - expect(isViewFilterAnyGroup(null)).toBe(false); + it('`isViewFilterOrGroup` tells the element kinds apart by the own `or` key', () => { + expect(isViewFilterOrGroup(OPEN_OR_RECENT)).toBe(true); + expect(isViewFilterOrGroup({ or: 'not yet valid' })).toBe(true); + expect(isViewFilterOrGroup({ field: 'status', operator: 'equals', value: 'open' })).toBe(false); + expect(isViewFilterOrGroup(Object.create({ or: [] }))).toBe(false); + expect(isViewFilterOrGroup([OPEN_OR_RECENT])).toBe(false); + expect(isViewFilterOrGroup(null)).toBe(false); }); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 96daefbca9c..34a03f8645e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -500,12 +500,12 @@ const VIEW_CONSOLE_ROW_ID_GUIDANCE = /** * [#28020] The one key of an any-of group element of a view filter list - * ({@link ViewFilterAnyGroupSchema}). Its value is an array of builder rows, so + * ({@link ViewFilterOrGroupSchema}). Its value is an array of builder rows, so * the wire door strips row decorations inside it too. */ -const VIEW_FILTER_ANY_GROUP_KEY = 'any' as const; +const VIEW_FILTER_OR_GROUP_KEY = 'or' as const; -/** [#28020] What a rule INSIDE an any-of group is told when it carries `any` itself. */ +/** [#28020] What a rule INSIDE an any-of group is told when it carries `or` itself. */ const VIEW_FILTER_NESTED_GROUP_GUIDANCE = 'an any-of group cannot hold another group — its members are flat rules `{ field, operator, value }`. ' + 'Move the inner group\'s rules into this group (any of them may match), or write the inner group as its ' @@ -514,15 +514,18 @@ const VIEW_FILTER_NESTED_GROUP_GUIDANCE = /** * [#28020] The spellings an author reaching for "OR" writes on a rule — the * console builder's `{ logic, conditions }`, the Query-DSL `$or`, and the - * natural near-misses — each answered with the one any-of shape this list has. + * natural near-misses `any` / `anyOf` — each answered with the one any-of + * shape this list has. `or` itself is not here: it is the group's real key + * ({@link VIEW_FILTER_OR_GROUP_KEY}), so an element carrying it is a group, and + * a rule only meets it inside a group, as a nested group. */ const VIEW_FILTER_OR_SPELLING: string = - 'a view filter writes "any of" as its own element of the filter list, `{ any: [rule, ...] }`, whose ' + 'a view filter writes "any of" as `{ or: [rule, ...] }`, its own element of the filter list, whose ' + 'flat rules `{ field, operator, value }` match when ANY of them does; the list still ANDs its elements. ' - + 'There is no `$or`, `or`, `logic` or `conditions` key on a filter rule.'; + + 'There is no `$or`, `any`, `anyOf`, `logic` or `conditions` key on a filter rule.'; const VIEW_FILTER_OR_SPELLING_GUIDANCE: Readonly> = { $or: VIEW_FILTER_OR_SPELLING, - or: VIEW_FILTER_OR_SPELLING, + any: VIEW_FILTER_OR_SPELLING, anyOf: VIEW_FILTER_OR_SPELLING, logic: VIEW_FILTER_OR_SPELLING, conditions: VIEW_FILTER_OR_SPELLING, @@ -530,8 +533,8 @@ const VIEW_FILTER_OR_SPELLING_GUIDANCE: Readonly> = { /** [#28020] What an any-of group is told when it carries a rule's own key. */ const VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE = - 'an any-of group holds only `any`, the array of its rules — a rule key beside it belongs to a rule. Put ' - + 'the rule inside `any` (any of the group\'s rules may match), or write it as its own element of the ' + 'an any-of group holds only `or`, the array of its rules — a rule key beside it belongs to a rule. Put ' + + 'the rule inside `or` (any of the group\'s rules may match), or write it as its own element of the ' + 'filter list, where it ANDs with the group.'; function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unknown { @@ -561,10 +564,10 @@ function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unk for (const [k, v] of Object.entries(next ?? dict)) { // [#28020] The rules of an any-of group are builder rows of the same - // filter, one level down: `any` carries rows only on a row (a group - // element of `filter[]`), so a key spelled `any` anywhere else in a view + // filter, one level down: `or` carries rows only on a row (a group + // element of `filter[]`), so a key spelled `or` anywhere else in a view // body is left alone. - const carriesRows = VIEW_DECORATED_ROW_CARRIERS.includes(k) || (isRow && k === VIEW_FILTER_ANY_GROUP_KEY); + const carriesRows = VIEW_DECORATED_ROW_CARRIERS.includes(k) || (isRow && k === VIEW_FILTER_OR_GROUP_KEY); const out = stripRowDecorations(v, carriesRows, depth + 1); if (out !== v) { next ??= { ...dict }; @@ -1047,10 +1050,10 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ guidance: { id: VIEW_CONSOLE_ROW_ID_GUIDANCE, // [#28020] A rule is reached two ways: as a plain element of a filter list, - // and as a member of an any-of group. An element carrying `any` is routed + // and as a member of an any-of group. An element carrying `or` is routed // to the group before it gets here, so this key only ever arrives on a // rule INSIDE a group — a nested group, which the one-level shape refuses. - [VIEW_FILTER_ANY_GROUP_KEY]: VIEW_FILTER_NESTED_GROUP_GUIDANCE, + [VIEW_FILTER_OR_GROUP_KEY]: VIEW_FILTER_NESTED_GROUP_GUIDANCE, ...VIEW_FILTER_OR_SPELLING_GUIDANCE, }, }, { @@ -1099,24 +1102,24 @@ export type ViewFilterRuleParsed = z.infer; /** * [#28020] True when `value` is an any-of group element of a view filter list - * ({@link ViewFilterAnyGroupSchema}): a plain object carrying its own - * `any` key. + * ({@link ViewFilterOrGroupSchema}): a plain object carrying its own + * `or` key. * * It answers WHICH element kind a value is, not whether it is a valid one: the * schema routes every element this predicate claims to the group shape, which - * then judges it (a non-array `any`, an empty one, a stray key). Exported for + * then judges it (a non-array `or`, an empty one, a stray key). Exported for * the readers that walk a stored filter list and must step INTO a group rather * than misread it as a rule or a field-keyed condition — the lint filter walks * and the view reference diagnostics. ⛔ Never restate the test at a reader: * this predicate is the one place the element kinds are told apart. */ -export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_ANY_GROUP_KEY]: unknown } { +export function isViewFilterOrGroup(value: unknown): value is { [VIEW_FILTER_OR_GROUP_KEY]: unknown } { return typeof value === 'object' && value !== null && !Array.isArray(value) - && Object.prototype.hasOwnProperty.call(value, VIEW_FILTER_ANY_GROUP_KEY); + && Object.prototype.hasOwnProperty.call(value, VIEW_FILTER_OR_GROUP_KEY); } /** - * [#28020] View Filter Any-Of Group Schema — one element of a view filter list + * [#28020] View Filter OR Group Schema — one element of a view filter list * that holds flat rules of which ANY may match. * * ## The stored shape, and why it is this one @@ -1143,18 +1146,20 @@ export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_AN * refused by name. The console builder edits one logic over flat rows and has * no control that creates a sub-group (`FilterBuilderRowGroup`), so nothing * it can produce needs more. - * - **`any`, not the builder's `{ logic, conditions }`.** No stored spelling of - * an any-of exists to mirror: the builder's group is its whole editing state, - * and its `logic: 'and'` arm would be a second spelling of the AND the list - * already is, leaving `logic` a key with one legal value. One key named for - * its meaning is the smaller contract. The builder's spellings, and `$or`, - * are answered with this shape when written on a rule + * - **`or`, not the builder's `{ logic, conditions }`.** The builder's group + * is its whole editing state, and its `logic: 'and'` arm would be a second + * spelling of the AND the list already is, leaving `logic` a key with one + * legal value. One key named for its meaning is the smaller contract. The + * key is `or` by the maintainer's ruling on #28020: it matches the and/or + * wording of Notion, monday.com and Dataverse filters, and a nested level + * added later would pair with `and`. The builder's spellings, `$or`, `any` + * and `anyOf` are answered with this shape when written on a rule * ({@link VIEW_FILTER_OR_SPELLING_GUIDANCE}). * - **Not empty.** An any-of over zero rules matches no record — a view that is - * permanently empty — so `{ any: [] }` is refused rather than stored. + * permanently empty — so `{ or: [] }` is refused rather than stored. * * Every filter stored before this element still parses, byte for byte: a plain - * rule never carries `any`, so it is routed to {@link ViewFilterRuleSchema} + * rule never carries `or`, so it is routed to {@link ViewFilterRuleSchema} * exactly as before. * * ## [PLANNED — not yet rendered] @@ -1172,7 +1177,7 @@ export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_AN * ```ts * // status is "open", OR completed within the last 7 days — and owned by me * filter: [ - * { any: [ + * { or: [ * { field: 'status', operator: 'equals', value: 'open' }, * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, * ] }, @@ -1180,7 +1185,7 @@ export function isViewFilterAnyGroup(value: unknown): value is { [VIEW_FILTER_AN * ] * ``` */ -export const ViewFilterAnyGroupSchema = lazySchema(() => strictObject({ +export const ViewFilterOrGroupSchema = lazySchema(() => strictObject({ surface: 'this any-of filter group', history: VIEW_HISTORY, guidance: { @@ -1196,10 +1201,10 @@ export const ViewFilterAnyGroupSchema = lazySchema(() => strictObject({ * The rules of which ANY may match — at least one, each a flat * {@link ViewFilterRuleSchema}. */ - any: z.array(ViewFilterRuleSchema) + or: z.array(ViewFilterRuleSchema) .min(1, { error: () => - 'An any-of group needs at least one rule: `{ any: [] }` is a disjunction of zero rules, which ' + 'An any-of group needs at least one rule: `{ or: [] }` is a disjunction of zero rules, which ' + 'matches no record, so the view would be permanently empty. Write the rules of which any may ' + 'match, or delete the group.', }) @@ -1208,18 +1213,18 @@ export const ViewFilterAnyGroupSchema = lazySchema(() => strictObject({ + 'each judged exactly as a rule at the top level of the filter. A group cannot hold a group.', ), }).describe( - '[PLANNED — not yet rendered] An any-of group: `{ any: [rule, ...] }` matches a record when ANY of its ' + '[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its ' + 'rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, ' - + 'so `[{ any: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", ' + + 'so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", ' + 'operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the ' + 'last 7 days. One level only: its members are flat rules. No console reads it yet: until the list ' + 'query build reads the group, a view that stores one is refused when it loads (the list reports a ' + 'malformed filter and shows no rows).', )); -export type ViewFilterAnyGroup = z.input; -/** Post-parse shape of {@link ViewFilterAnyGroup} — defaults applied, transforms run (ADR-0122). */ -export type ViewFilterAnyGroupParsed = z.infer; +export type ViewFilterOrGroup = z.input; +/** Post-parse shape of {@link ViewFilterOrGroup} — defaults applied, transforms run (ADR-0122). */ +export type ViewFilterOrGroupParsed = z.infer; /** * [#28020] Report ONE element kind's verdict, never both. @@ -1229,11 +1234,11 @@ export type ViewFilterAnyGroupParsed = z.infer; * at paths relative to the element. On this union that would bury the rule's * own refusals — a value-shape issue at `filter.0.value`, an unknown key named * with its guidance — one level down, beside a group branch's complaint about - * a missing `any` that the author never meant to write. (zod returns a branch's + * a missing `or` that the author never meant to write. (zod returns a branch's * issues unwrapped only when exactly one branch failed non-fatally, which an * unknown key or a bad operator never is.) * - * The element kind is not a guess: {@link isViewFilterAnyGroup} decides it, so + * The element kind is not a guess: {@link isViewFilterOrGroup} decides it, so * the claimed branch's issues replace the union's own issue, flat, and every * path and message an author read before the group existed is the one they * read now. The same shape {@link focusClaimedBranch} takes on @@ -1255,7 +1260,7 @@ function reportClaimedFilterElementBranch(): z.core.$ZodCheck { // Only THIS union's own issue: raised at the element itself, one error // list per branch. A nested union's issue carries a path. if ((issue.path?.length ?? 0) !== 0 || issue.errors.length !== 2) continue; - const claimed = issue.errors[isViewFilterAnyGroup(payload.value) ? 1 : 0] ?? []; + const claimed = issue.errors[isViewFilterOrGroup(payload.value) ? 1 : 0] ?? []; payload.issues.splice(index, 1, ...(claimed as unknown as typeof payload.issues)); return; } @@ -1265,7 +1270,7 @@ function reportClaimedFilterElementBranch(): z.core.$ZodCheck { /** * [#28020] One element of a view filter list: a flat - * {@link ViewFilterRuleSchema}, or an any-of {@link ViewFilterAnyGroupSchema}. + * {@link ViewFilterRuleSchema}, or an any-of {@link ViewFilterOrGroupSchema}. * * This is what every filter-list carrier holds — `ListView.filter`, a view * tab's filter, `Page.filterBy`, an element data source's filter, the @@ -1273,15 +1278,15 @@ function reportClaimedFilterElementBranch(): z.core.$ZodCheck { * group reaches all of them at once and none can drift. The list ANDs its * elements; a group ORs its rules. * - * Which branch judges an element is decided by {@link isViewFilterAnyGroup} - * (an own `any` key), and only that branch's issues are reported — see + * Which branch judges an element is decided by {@link isViewFilterOrGroup} + * (an own `or` key), and only that branch's issues are reported — see * {@link reportClaimedFilterElementBranch}. A stored filter of plain rules is * therefore judged, and refused, exactly as before. * * @example * ```ts * filter: [ - * { any: [ + * { or: [ * { field: 'status', operator: 'equals', value: 'open' }, * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, * ] }, @@ -1290,7 +1295,7 @@ function reportClaimedFilterElementBranch(): z.core.$ZodCheck { * ``` */ export const ViewFilterElementSchema = lazySchema(() => - z.union([ViewFilterRuleSchema, ViewFilterAnyGroupSchema]) + z.union([ViewFilterRuleSchema, ViewFilterOrGroupSchema]) .check(reportClaimedFilterElementBranch()) .describe('One view filter element: a flat rule, or an any-of group of flat rules'), ); diff --git a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts index d6dfce5111a..e8b7b5bbdda 100644 --- a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts +++ b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts @@ -64,7 +64,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterAnyGroup, ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -96,17 +96,17 @@ type SpecPickerFilter = NonNullable['add' */ /** * objectstack#28020: the contract's element is now a flat rule OR an any-of - * group (`{ any: [rule, ...] }`), and the group is `[PLANNED — not yet + * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) * types and lowers flat rules only. So this face carries the contract MINUS * the group until the console reads it (objectstack#28370), and the two legs * below hold exactly that gap — widen this face and the second leg's * companion together, never one alone. */ -type FlatRulesOf = Exclude[number], ViewFilterAnyGroup>[] | undefined; +type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; export type _MirrorAgreesWithTheContract = Expect>>; /** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ -export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterAnyGroup>>; +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** * …and the shape they agree on is the rule ARRAY. Stated separately from the diff --git a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts index 41e2c538bf0..fe8c74cc7f1 100644 --- a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts +++ b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts @@ -67,7 +67,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterAnyGroup, ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -96,17 +96,17 @@ type SpecFilter = z.input['filter']; /** ⭐ The ruling, in type form: the authoring face carries the protocol's declaration. */ /** * objectstack#28020: the contract's element is now a flat rule OR an any-of - * group (`{ any: [rule, ...] }`), and the group is `[PLANNED — not yet + * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) * types and lowers flat rules only. So this face carries the contract MINUS * the group until the console reads it (objectstack#28370), and the two legs * below hold exactly that gap — widen this face and the second leg's * companion together, never one alone. */ -type FlatRulesOf = Exclude[number], ViewFilterAnyGroup>[] | undefined; +type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; export type _MirrorAgreesWithTheContract = Expect>>; /** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ -export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterAnyGroup>>; +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** * …and that declaration is the rule ARRAY. Stated separately so a spec that