From 083e06294eddf713b0e7d9369f9ef2c6f121a648 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:27:44 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(spec):=20one=20row=20bound=20per=20vie?= =?UTF-8?q?w=20=E2=80=94=20retire=20the=20unpublished=20per-kind=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the per-kind `limit` from the gallery, kanban and timeline view configs, with `rowLimitKey`, `DEFAULT_VIEW_ROW_LIMIT` and the pending changeset that would have published them. `KanbanConfigSchema` has one shape again, so `KanbanConfigParsed` goes and the schema is re-pinned isomorphic (ADR-0122 D3). The truncation obligation moves onto `pagination.pageSize`, the view's remaining row bound. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/17393-view-row-ceiling.md | 36 ---- .changeset/19228-pagesize-fetch-ceiling.md | 15 ++ .../src/type-alias-convention.pin.test.ts | 21 +- packages/spec/src/ui/component.test.ts | 44 +--- packages/spec/src/ui/component.zod.ts | 3 +- packages/spec/src/ui/view.test.ts | 105 ++------- packages/spec/src/ui/view.zod.ts | 204 +----------------- 7 files changed, 53 insertions(+), 375 deletions(-) delete mode 100644 .changeset/17393-view-row-ceiling.md create mode 100644 .changeset/19228-pagesize-fetch-ceiling.md diff --git a/.changeset/17393-view-row-ceiling.md b/.changeset/17393-view-row-ceiling.md deleted file mode 100644 index 675b2c05b0f..00000000000 --- a/.changeset/17393-view-row-ceiling.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Gallery, kanban and timeline view configs declare an author-settable row ceiling. - -`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a -`limit` member — a positive integer, default **100** — saying how many records the -view fetches. The default is APPLIED by the schema rather than only described, and -the key's own text states the other half of the contract: when the ceiling applies, -the renderer must show a visible truncation signal, because a bounded view that -looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported -so a consumer reads that number instead of re-declaring it. - -The knob belongs in the protocol because two renderers already cap by author choice -off keys the protocol never declared: objectui's kanban board fetches -`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in -`@object-ui/types` alone, its timeline does the same off a component props -interface, and its gallery caps not at all. `limit` is the name those consumers -already read, so this declaration absorbs the consumer-local keys instead of -introducing a second spelling of one concept. - -Nothing is removed, renamed or narrowed, and no document that parsed before is -refused now. Two things to know when upgrading: - -- a parsed gallery / kanban / timeline config carries `limit: 100` where the author - wrote no ceiling, so code that compares a parsed config against a literal object - sees the new member; -- `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes - for the first time; `KanbanConfig` is unchanged and remains the author state. - -The non-grid four — gantt, calendar, map and tree — are deliberately untouched: -their rows stay bounded by a platform ceiling the renderer owns, because a gantt's -range, a map's camera fit and a tree's parent chain are computed over the whole set. - -Clause-②: yes (widening) diff --git a/.changeset/19228-pagesize-fetch-ceiling.md b/.changeset/19228-pagesize-fetch-ceiling.md new file mode 100644 index 00000000000..d67a04006ca --- /dev/null +++ b/.changeset/19228-pagesize-fetch-ceiling.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +`pagination.pageSize` states what the renderer owes on a view with no pager + +On a kanban, gallery or timeline view there is no pager, so `pagination.pageSize` is the fetch +ceiling. Its description now says so, and names the renderer's two obligations there: bound the +fetch at that number, and, when the filtered set is larger than it, show a visible truncation +signal saying what is on screen is not the whole set. + +The key's accept set and its default (`25`) are unchanged, and no export or authorable key moves +relative to the last published release. + +Clause-②: no diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 5620656d047..92919bf53b4 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 790 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1612,12 +1612,11 @@ export type Iso823 = Assert, z.infer< type export type Iso826 = Assert, z.infer< typeof M167.CalendarConfigSchema > >>; export type Iso827 = Assert, z.infer< typeof M167.GanttConfigSchema > >>; export type Iso828 = Assert, z.infer< typeof M167.GanttQuickFilterSchema > >>; -// (Iso829 `KanbanConfigSchema` left this list in #17393: the author-settable -// row ceiling `limit` APPLIES its default, which is exactly the "a nested field -// gains a `.default()`" event this file exists to catch — so the schema now has -// two shapes and `KanbanConfigParsed` is declared beside the bare alias, as -// ADR-0122 prescribes. Its two page-shaped siblings needed no line moved: both -// already carried defaults and therefore both halves of the pair.) +// `KanbanConfigSchema` left this list as Iso829 when #17393 gave it a `limit` +// with an APPLIED default, and returns as Iso882 now that #19228 removed that +// member before it was published: the two shapes coincide again, so +// `KanbanConfigParsed` is deleted (ADR-0122 D3). Iso829 stays vacant. +export type Iso882 = Assert, z.infer< typeof M167.KanbanConfigSchema > >>; export type Iso851 = Assert, z.infer< typeof M167.ListMapConfigSchema > >>; export type Iso830 = Assert, z.infer< typeof M167.NavigationModeSchema > >>; export type Iso831 = Assert, z.infer< typeof M167.TreeConfigSchema > >>; @@ -1696,7 +1695,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 790 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2301,7 +2300,11 @@ describe('ADR-0122 type-alias convention', () => { // Iso877/Iso878; #17551 landed on main first, so its ids stand and this // card's pins renumbered to Iso880/Iso881 — ids are claims about pins, not // positions, so the renumbering asserts nothing new. - expect(pins).toHaveLength(789); + // 789 -> 790 is #19228's removal of that same row ceiling before any + // release carried it: `KanbanConfigSchema` loses the `limit` whose applied + // default had split its two shapes, `KanbanConfigParsed` is deleted and the + // schema is re-pinned as Iso882. +1 added. + expect(pins).toHaveLength(790); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 51de61700dd..f80a2944771 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -26,7 +26,7 @@ import { import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema, - TimelineConfigSchema, DEFAULT_VIEW_ROW_LIMIT, + TimelineConfigSchema, } from './view.zod'; import { FieldSchema } from '../data/field.zod'; import { ALL_CONVERSIONS } from '../conversions/registry'; @@ -3831,11 +3831,9 @@ describe('the three #18305 object blocks — key sets derived from the renderers }); }); -// #19228 — two authorable row bounds land on one `object-timeline` node, and -// the react tier's own precedence sentence was narrower than the guard it -// names. ⛔ This card picks NO precedence and changes no `.default()`; these -// pins only hold the two structural facts the repair rests on, measured -// first-hand at the objectui pin `87af769e9` on 2026-09-21T06:30-06:40Z. +// #19228 — the react tier's own precedence sentence was narrower than the +// guard it names. These pins hold the structural facts the repair rests on, +// measured first-hand at the objectui pin `87af769e9` on 2026-09-21T06:30-06:40Z. describe('row caps on the object-bound blocks — what #19228 recorded', () => { const timeline = ComponentPropsMap['object-timeline']; const kanban = ComponentPropsMap['object-kanban']; @@ -3853,36 +3851,10 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { } // LIT CONTROL, same instrument (a Zod applied default, observed through - // `parse`): the VIEW-face sibling DOES materialize one, so the zeros above - // are a reading rather than a parse that never ran. - const viewSide = TimelineConfigSchema.parse({ startDateField: 'start_date', titleField: 'name' }) as { limit?: number }; - expect(viewSide.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); - }); - - it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => { - // The shape the record is about: one strictObject, two authorable row - // caps, and an applied default on the nested one. ⚠️ Faces, because this - // card keeps confusing them: the NESTED key asserted below is the ELEMENT - // face, and at the pin no renderer reads it on any route. The - // route-dependent one is a VIEW document's `timeline.limit`, a different - // key on a different document, which `ObjectView.tsx:1725` flattens onto - // a generated node's FLAT `limit`. Neither is asserted here: this pin is - // about the PARSE, which is the only half a schema owns. - const result = timeline.safeParse({ - objectName: 'task', - timeline: { startDateField: 'start_date', titleField: 'name' }, - }); - expect(result.success).toBe(true); - const data = (result.success ? result.data : undefined) as - { limit?: unknown; timeline?: { limit?: unknown } } | undefined; - expect(data?.timeline?.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); - expect(Object.prototype.hasOwnProperty.call(data ?? {}, 'limit')).toBe(false); - - // CONTROL — the node is still strict, so the acceptance above is not the - // verdict of a map that has stopped refusing anything. - const control = timeline.safeParse({ objectName: 'task', zzUnlikelyBogusKey__: 1 }); - expect(control.success).toBe(false); - expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); + // `parse`): a VIEW-face block DOES materialize its `scale`, so the zeros + // above are a reading rather than a parse that never ran. + const viewSide = TimelineConfigSchema.parse({ startDateField: 'start_date', titleField: 'name' }) as { scale?: string }; + expect(viewSide.scale).toBe('week'); }); it('admits only caps the binding gate calls usable — the SUBSET that makes 「unset」 the whole rule', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 4cbb0a606d9..b19a9b36159 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -4159,8 +4159,7 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * `ListView.tsx:3084` forwards it nested instead, where nothing reads it. * The package is spelled because objectui carries a second `ObjectView.tsx` * (in `app-shell`), where that same line number is an unrelated `catch` — - * a bare spelling here names neither file. Recorded on - * `rowLimitKey` in `view.zod.ts`, which is where that key lives. + * a bare spelling here names neither file. * * ⚠️ Flagged, not fixed — a THIRD route neither the card nor the first two * reviews described: a hand-authored `object-timeline` node reaching diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 2181b8045eb..01e611520e7 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -45,7 +45,6 @@ import { ViewMetadataSchema, VIEW_METADATA_MEMBERS, TreeConfigSchema, - DEFAULT_VIEW_ROW_LIMIT, } from './view.zod'; import { @@ -4793,114 +4792,34 @@ describe('ListViewSchema — `viewType` is not a spelling of `type` (#16577)', ( // ============================================================================ -// [#17393] The author-settable row ceiling on the page-shaped view configs. -// -// A protocol-first card: objectui caps kanban and timeline by author choice off -// a key `@objectstack/spec` never declared (`$top: schema.limit ?? DEFAULT_*_LIMIT`), -// and the gallery — the third page-shaped view — caps not at all. These pins -// hold the new declaration to the three things a ceiling has to be: APPLIED -// (the default the prose states is the default the parse produces), BOUNDED -// (a value that could not cap a fetch is refused by name), and SCOPED (the -// non-grid four keep objectui#7210's platform ceiling and do not gain an -// authorable one). +// [#19228] One row bound per view: `pagination.pageSize`. No per-kind view +// config declares a `limit` of its own, so a view never carries two row bounds +// with no declared precedence between them. // ============================================================================ -describe('view row ceiling — `limit` on the page-shaped view configs (#17393)', () => { - /** - * One minimal, parse-clean block per page-shaped config, so every verdict - * below is about `limit` alone rather than about a missing sibling key. - */ - const PAGE_SHAPED = [ - ['gallery', GalleryConfigSchema as unknown as z.ZodTypeAny, {}], - ['kanban', KanbanConfigSchema as unknown as z.ZodTypeAny, { groupByField: 'status', columns: ['name'] }], - ['timeline', TimelineConfigSchema as unknown as z.ZodTypeAny, { startDateField: 'start_date', titleField: 'name' }], - ] as const; - - /** The `limit` member's own `.describe()` text, off the built shape. */ - const describeOf = (schema: z.ZodTypeAny): string => - (schema as unknown as { shape: Record }).shape.limit?.description ?? ''; - - it('applies the ceiling it declares when the author writes none', () => { - for (const [label, schema, minimal] of PAGE_SHAPED) { - const parsed = schema.parse({ ...minimal }) as { limit?: unknown }; - expect(parsed.limit, label).toBe(DEFAULT_VIEW_ROW_LIMIT); - } - }); - - it('accepts an authored ceiling as a MEMBER, with both controls firing on the same shape', () => { - for (const [label, schema, minimal] of PAGE_SHAPED) { - // CONTROL-1 — this surface CAN refuse a key, so acceptance below means something. - const control = schema.safeParse({ ...minimal, zzUnlikelyBogusKey__: 7 }); - expect(control.success, label).toBe(false); - expect(JSON.stringify((control as { error?: z.ZodError }).error?.issues), label) - .toContain('unrecognized_keys'); - - // CONTROL-2 — the refusal is about the NAME: the same block without it parses. - expect(schema.safeParse({ ...minimal }).success, label).toBe(true); - - // PROBE — the authored value SURVIVES the parse; it is not merely tolerated. - const probe = schema.safeParse({ ...minimal, limit: 25 }); - expect(probe.success, label).toBe(true); - expect(((probe as { data?: { limit?: unknown } }).data)?.limit, label).toBe(25); - } - }); - - it('refuses a value that could not bound a fetch — and refuses it BY NAME', () => { - for (const [label, schema, minimal] of PAGE_SHAPED) { - for (const bad of [0, -1, 2.5, '100', null] as const) { - const at = `${label} limit=${JSON.stringify(bad)}`; - const result = schema.safeParse({ ...minimal, limit: bad }); - expect(result.success, at).toBe(false); - const issues = (result as { error?: z.ZodError }).error?.issues ?? []; - expect(issues.some((issue) => issue.path[0] === 'limit'), `${at}: ${JSON.stringify(issues)}`) - .toBe(true); - } - } - }); - - it('states the default it ACTUALLY applies — prose and schema pinned to each other', () => { - for (const [label, schema, minimal] of PAGE_SHAPED) { - const description = describeOf(schema); - const stated = /default (\d+)/.exec(description); - expect(stated, `${label}: ${description}`).not.toBeNull(); - const applied = (schema.parse({ ...minimal }) as { limit: number }).limit; - expect(Number(stated?.[1]), `${label}: ${description}`).toBe(applied); - } - }); - - it('tells the author the renderer owes a VISIBLE truncation signal', () => { - // ⛔ The signal itself is the renderer's half and cannot be enforced from a - // schema. What the protocol can do — and what objectui#7390's ruling turns - // on — is say the cap is owed a signal, so that "bounded and silent" is - // never read as the finished job. - for (const [label, schema] of PAGE_SHAPED) { - expect(describeOf(schema), label).toContain('visible truncation signal'); - } - }); - - it('leaves the non-grid four WITHOUT an authorable ceiling (objectui#7210 keeps theirs)', () => { - // The card scopes those four out by name: their rows are capped by a - // platform constant the renderer owns, because a gantt range, a map camera - // fit and a tree parent chain are computed over the whole set. An - // authorable ceiling there would be surface no renderer reads. - const NON_GRID_FOUR = [ +describe('view row bound — no per-kind `limit` on the view configs (#19228)', () => { + it('refuses `limit` BY NAME on each per-kind view config', () => { + const PER_KIND = [ + ['gallery', GalleryConfigSchema as unknown as z.ZodTypeAny], + ['kanban', KanbanConfigSchema as unknown as z.ZodTypeAny], + ['timeline', TimelineConfigSchema as unknown as z.ZodTypeAny], ['gantt', GanttConfigSchema as unknown as z.ZodTypeAny], ['calendar', CalendarConfigSchema as unknown as z.ZodTypeAny], ['map', ListMapConfigSchema as unknown as z.ZodTypeAny], ['tree', TreeConfigSchema as unknown as z.ZodTypeAny], ] as const; - for (const [label, schema] of NON_GRID_FOUR) { + for (const [label, schema] of PER_KIND) { const result = schema.safeParse({ limit: 10 }); expect(result.success, label).toBe(false); // Asserted on the REFUSED KEY LIST rather than on a stringified issue: // when the key is accepted there is no issue to stringify, and the red // then reads as an argument-type complaint instead of as a statement - // about this view type. Measured — it is how this case first reddened. + // about this view type. const refused = ((result as { error?: z.ZodError }).error?.issues ?? []) .filter((issue) => issue.code === 'unrecognized_keys') .flatMap((issue) => (issue as unknown as { keys?: string[] }).keys ?? []); - expect(refused, `${label} accepts an authorable row ceiling it should not declare`) + expect(refused, `${label} accepts a per-kind row ceiling it should not declare`) .toContain('limit'); } }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2ecea023f2a..4498c8b09b2 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -869,7 +869,11 @@ export const PaginationConfigSchema = lazySchema(() => strictObject({ surface: 'this pagination configuration', history: VIEW_HISTORY, }, { - pageSize: z.number().int().positive().default(25).describe('Number of records per page'), + pageSize: z.number().int().positive().default(25).describe( + 'Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ' + + 'ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered ' + + 'set is larger than it, show a visible truncation signal saying what is on screen is not the whole set', + ), pageSizeOptions: z.array(z.number().int().positive()).optional().describe('Available page size options'), })); @@ -1133,199 +1137,6 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ + 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`', )); -/* - * --------------------------------------------------------------------------- - * `limit` — the author-settable row ceiling of the page-shaped views (#17393) - * --------------------------------------------------------------------------- - * - * Declared here because the PROTOCOL was the thing that was wrong: two - * renderers already cap by author choice, and the key they read was never a - * protocol key. Measured in objectui at `dda8f3815d`: - * - * - `ObjectKanban.tsx:573` fetches `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` - * (`= 100` at `:84`), and that `limit` is declared in `@object-ui/types` - * alone (`zod/objectql.zod.ts:1762`, `z.number().int().positive().optional()`); - * - `ObjectTimeline.tsx:328` fetches `$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT` - * (`= 100` at `:29`), with `limit` on that component's own props interface - * (`:129`) and on no published schema at all; - * - `ObjectGallery.tsx` sends no `$top` and reads no ceiling at all — the - * unbounded fetch objectui#7390 is ruled to close by reading this key. - * - * Consumer-local author-settable keys the protocol never declared are the - * divergence the contract-first directive forbids, so the knob enters the - * protocol first and the three spellings come under one declaration (the - * director seat's amendment of 2026-09-10T11:0xZ on objectui#7390, on the - * maintainer's principle 「我们的项目以objectstack 协议为准,文档应该以实际实现 - * 为准。协议不正确的应该先修改协议。」). - * - * ## Why on the per-view config blocks, and not as a member of the list view - * - * The alternative shape — one row ceiling on {@link ListViewShapeSchema} - * itself — is rejected on three properties of this tree: - * - * 1. The base shape ALREADY carries the row-bounding knob every view type - * reaches: `pagination.pageSize` ({@link PaginationConfigSchema}, default - * 25). A second base-level row key would leave one view with two - * base-level row bounds and no declared precedence between them — and the - * `virtualScroll` tombstone at the bottom of this same shape prescribes - * `pagination` for exactly that question. - * 2. A base member is reachable from EVERY `type`, the non-grid four - * (gantt / calendar / map / tree) included. Their ceiling is a platform - * constant the renderer owns (objectui#7210) and this card does not touch - * them, so a base member would publish an authorable ceiling on four view - * kinds no renderer reads — declared-but-unenforced on the day it lands. - * 3. The per-kind block is what actually REACHES the renderer: objectui's - * `ListView` merges `schema.` into the generated node — its kanban - * branch spreads the rest of the block flat onto `object-kanban`, so - * `kanban.limit` lands exactly where `schema.limit` is read — while a - * base-level key is forwarded into no per-kind node at all. - * - * The NAME is `limit` for the same reason: it is the name the consumer already - * reads, so this declaration absorbs the two consumer-local keys instead of - * buying a second divergence spelled differently. - * - * ⚠️ NOT the kanban LANE's `limit`. objectui's node-level - * `ObjectKanbanLaneSchema.limit` is a WIP warning threshold that never reaches - * a query; no lane object exists on this face at all - * ({@link KanbanConfigSchema}'s `columns` is a list of card FIELD names), so - * the two cannot be confused here. - */ -export const DEFAULT_VIEW_ROW_LIMIT = 100; - -/** What a page-shaped view's ceiling bounds, per view type. */ -const ROW_LIMIT_SUBJECT = { - gallery: 'cards the gallery fetches and draws', - kanban: 'records the board fetches across all its lanes', - timeline: 'rows the timeline fetches onto its rail', -} as const; - -/** The three view configs that cap by AUTHOR choice (not by platform ceiling). */ -type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; - -/** - * The `limit` declaration for one page-shaped view config. - * - * The default is APPLIED, not merely described: a `.describe()` naming a - * default the schema does not apply is a second contract that nothing - * enforces, and the two drift the first time either is edited. The agreement - * is pinned from both sides in `view.test.ts` (#17393) — the parsed default is - * compared against the number the describe text states. - * - * ⛔ The truncation signal is the renderer's half and cannot be enforced from - * here; it is stated in the describe because a bounded-and-silent view reads - * as complete, which is worse than the unbounded-and-silent one this key - * replaces — the author needs to know the cap is visible, and the renderer - * author needs to know it is owed. - * - * ⚠️ WHICH FACE THIS KEY IS ON, and why that has to be said first. There are - * TWO `limit`s a reader can confuse, on two different documents, and three - * rounds of #19228 went wrong on the boundary: - * · **VIEW FACE** — THIS key. A member of a `ListViewSchema` document's - * `kanban` / `gallery` / `timeline` block. An ADAPTER turns that document - * into a rendered node; no renderer reads this document directly. - * · **ELEMENT FACE** — a page component node's OWN `limit` - * (`ObjectKanbanPropsSchema`, `ObjectTimelinePropsSchema`), - * declared in `component.zod.ts`, with no applied default. That is the key - * every renderer and `ElementDataSourceGate` actually read. - * Every sentence below names its face before it says anything else. - * - * ⚠️ WHAT THIS VIEW-FACE KEY REACHES TODAY — recorded, not repaired (#19228). - * Measured first-hand at the pin this repo builds against (`.objectui-sha` = - * `87af769e9`), 2026-09-21T09:15Z, with TWO instruments, because one was not - * enough and the first one's answer was wrong: - * - * 1. PROPERTY-ACCESS spellings. ⛔ Published as its EXPRESSION, not as a - * number — this card exists because a confident count was wrong once, so - * a control nobody can re-derive is not a control. Run at the pin, from - * an objectui checkout, over every tracked file: - * probe: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.limit\b' - * control: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.(groupByField|scale|coverField)\b' - * Probe: **0** lines, 0 files. Control: **13** lines across **6** files — - * `app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx:41`, - * `app-shell/src/views/ObjectView.tsx:450`, - * `plugin-list/src/ListView.tsx:2538`, `:2540`, `:2547`, `:3057`, `:3114`, - * `:3116`, - * `plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`, - * `:99`, `plugin-view/src/ObjectView.tsx:1695`, and - * `types/src/__tests__/object-kanban-group-by-limit-7322.test.ts:146`, `:148`. - * ⚠️ Filtering changes that number and the filter must be stated with it. - * Of the 13: **2 are COMMENTS** (`ObjectView.galleryBinding-7547.test.tsx:41`, - * `ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`), **1 is an - * `it()` TITLE string** (same file, `:99` — ⛔ not a comment), and **2 are - * lines inside a QUOTED source-text pin** - * (`object-kanban-group-by-limit-7322.test.ts:146`, `:148`). So a reader - * counting executable reads only gets **8**. All three readings are of one - * hit set. A live instrument — and a WRONG answer. - * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is - * the hole instrument 1 cannot see by construction. ⛔ Re-take it by its - * PREDICATE, not by its count: **a spread whose target is the object - * literal an adapter RETURNS as the node** — flattening onto the node — - * as against a merge that builds a nested config (`...mergedTimeline` is - * the lit control for the instrument AND the example of what the predicate - * excludes). A grep broad enough to find these also returns the nested - * merges, so the rule, not the number, is what makes it reproducible. - * ⛔ And name what the predicate EXCLUDES, or the next reader re-finds - * it and wonders: `app-shell/src/views/ObjectView.tsx:206` and `:342` - * ARE spreads of a view block, inside `timelineViewOptions` (`:201`) and - * `galleryViewOptions` (`:334`). They build an OPTIONS BAG that feeds - * `ListView`'s nested forward, not the object literal an adapter returns - * as the node, so the predicate excludes them — deliberately, not by - * oversight. Two more the predicate excludes for their own reasons: - * `plugin-list/src/ListView.tsx:3044-3046` (`mergedGallery`) builds a - * NESTED gallery prop, the `...mergedTimeline` family; and - * `app-shell/src/views/ObjectView.tsx:1284` - * (`spec.kanban = { ...(spec.kanban || {}), columns }`) writes back into a - * VIEW document's own block — a metadata write, not a node build. - * Under that predicate, at that pin, the VIEW-face per-kind blocks give: - * `plugin-list/src/ListView.tsx:2979` `...restKanban` - * `plugin-view/src/ObjectView.tsx:1638` `...restKanban` - * `plugin-view/src/ObjectView.tsx:1697` `...(viewOptions.gallery || {})` - * `plugin-view/src/ObjectView.tsx:1725` `...(viewOptions.timeline || {})` - * Neither `restKanban` destructure strips `limit` (`ListView.tsx:2952`, - * `ObjectView.tsx:1579`), so a VIEW's per-kind `limit` — INCLUDING the 100 - * this applied default materializes — becomes the generated node's - * ELEMENT-face flat `limit`, which is the key the renderers read. - * - * ⇒ **A view's `kanban.limit`: flattened on BOTH adapter routes, and read.** - * `ObjectKanban.tsx:553` runs `describeRefusedRowLimit(schema.limit, …)` - * unconditionally. - * ⇒ **A view's `timeline.limit`: ROUTE-DEPENDENT.** `plugin-view` flattens it - * (`ObjectView.tsx:1725`) and the node it returns carries no `timeline` - * block at all, so the value arrives as the node's flat `limit` and - * `ObjectTimeline.tsx:279` reads it. `plugin-list` instead forwards the - * block NESTED (`ListView.tsx:3084`), where nothing reads it. - * ⇒ **A view's `gallery.limit`: flattened by `ObjectView.tsx:1697` and read by - * NOBODY** — `ObjectGallery.tsx` contains no `limit` at all (0 occurrences, - * case-insensitive, against a lit control `schema.imageField` / - * `schema.titleField` at `:340` / `:348`). ⛔ Do not generalise that - * asymmetry to the other two; it is gallery's alone. - * - * ⚠️ Where it IS read, the `$top` it would govern (`ObjectKanban.tsx:676`, - * `ObjectTimeline.tsx:407`) is still not issued on either adapter route today: - * both hosts hand rows down as a React `data` prop (`ListView.tsx:4702`, - * `ObjectView.tsx:2319`) and both children short-circuit their own fetch on it - * (`ObjectKanban.tsx:559`, `ObjectTimeline.tsx:420`). ⛔ That is a statement - * about the QUERY, not about the key being unread. - * - * ⚠️ A consequence of APPLIED that the open decision needs: through those - * spreads a spec-parsed view emits a node carrying an authored-LOOKING - * ELEMENT-face `limit: 100` that no author wrote. ⛔ Flagged, not acted on — - * changing it is a contract direction, not a tidy-up. - * - * ⛔ Which of the row bounds wins is NOT decided here and NOT implied by this - * declaration: #19228 opens that question and picks nothing, and neither does - * this note. What is recorded is only what each key reaches today. - */ -const rowLimitKey = (view: RowLimitView) => - z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe( - `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}; default ` - + `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. The renderer owes two things: bound its ` - + 'fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), ' - + 'show a visible truncation signal saying what is on screen is not the whole set — a bounded ' - + 'view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a ' - + 'renderer that reads this key yet; which do is recorded on the declaration.', - ); - /** * Gallery View Configuration (Airtable-style) * Configures card layout for gallery/card views. @@ -1339,7 +1150,6 @@ export const GalleryConfigSchema = lazySchema(() => strictObject({ cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'), titleField: z.string().optional().describe('Field to display as card title'), visibleFields: z.array(z.string()).optional().describe('Fields to display on card body'), - limit: rowLimitKey('gallery'), }).describe('Gallery/card view configuration')); /** @@ -1362,7 +1172,6 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({ ), colorField: z.string().optional().describe('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: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'), - limit: rowLimitKey('timeline'), }).describe('Timeline view configuration')); /** @@ -1666,7 +1475,6 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ */ titleField: z.string().optional().describe('Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain)'), columns: z.array(z.string()).describe('Fields to show on cards'), - limit: rowLimitKey('kanban'), })); /** @@ -6113,8 +5921,6 @@ export type CalendarConfig = z.input; export type GanttConfig = z.input; export type GanttQuickFilter = z.input; export type KanbanConfig = z.input; -/** Post-parse shape of {@link KanbanConfig} — defaults applied, transforms run (ADR-0122). */ -export type KanbanConfigParsed = z.infer; export type ListMapConfig = z.input; export type NavigationMode = z.input; export type TreeConfig = z.input; From c4f3dfb77286e85cdf16eb63fb6b3088b1d65120 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:30:40 +0000 Subject: [PATCH 2/6] docs(spec): delete the component-face and changeset text about the retired view limit The `object-timeline` `timeline` door describe listed `limit` as a member of the block and called it accepted and defaulted; the block no longer has it. The `object-kanban` / `object-timeline` docblocks and the pending changeset recorded where the view-face per-kind `limit` and its applied default landed. All of it is removed by deletion; the flat `limit` keys, their describes and their declared precedence are unchanged. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../19228-view-row-limit-route-record.md | 34 +------- packages/spec/src/ui/component.zod.ts | 83 ++----------------- 2 files changed, 8 insertions(+), 109 deletions(-) diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md index 4693c232b48..bd49b28b240 100644 --- a/.changeset/19228-view-row-limit-route-record.md +++ b/.changeset/19228-view-row-limit-route-record.md @@ -2,12 +2,10 @@ "@objectstack/spec": patch --- -fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228) +fix(spec): state the row-cap guard `ElementDataSourceGate` implements (#19228) Prose and pins only — zero accept-set movement, zero export movement. The same documents parse -to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedence is picked: which -of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` -should win is the open half of #19228 and is not answered here. +to the same values before and after. ⛔ No `.default()` moves. ## What the published text said, and what an author can actually reach @@ -30,31 +28,5 @@ flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but th saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with `unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document -parses with `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. No view document +parses with `pagination.pageSize: 50`. No view document declares a flat `limit` and none carries a tombstone for one. - -## Where the per-kind VIEW `limit` lands - -⚠️ Two different keys are easy to confuse here, so each statement names its face. The **view -face** is a `ListViewSchema` document's `kanban` / `gallery` / `timeline` block — that is where -this key lives. The **element face** is a page component node's own flat `limit`, declared in -`component.zod.ts`, and that is the key every renderer actually reads. An adapter turns the -first into the second. - -The adapters spread a view's per-kind block FLAT onto the node they generate — `...restKanban` -(`plugin-list/src/ListView.tsx:2979`, `plugin-view/src/ObjectView.tsx:1638`; neither destructure -strips `limit`) and `...(viewOptions.gallery || {})` / `...(viewOptions.timeline || {})` -(`ObjectView.tsx:1697` / `:1725`). So a view's `kanban.limit` — including the 100 the applied -default materializes — becomes the node's flat `limit`, which `ObjectKanban.tsx:553` reads. A -view's `timeline.limit` is route-dependent: `plugin-view` flattens it and `ObjectTimeline.tsx:279` -reads it, while `plugin-list` forwards the block nested, where nothing does. A view's -`gallery.limit` is flattened too and read by nobody — `ObjectGallery.tsx` contains no `limit` at -all. - -Where it is read, the `$top` it would govern is still not issued on either adapter route today, -because both hosts hand rows down as a React `data` prop and both children short-circuit their -own fetch; ⛔ that is a statement about the query, not about the key being unread. - -⚠️ For authors of an `object-timeline` NODE: a `limit` written inside that node's own `timeline` -block is read by no renderer on any route — the rail is capped by the flat `limit` beside it, -which is also the only one a bound `dataSource` lowers into. Write the flat one. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index b19a9b36159..d7e809e252a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3124,37 +3124,16 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * — not an authored view document. Measured on this tree: `ListViewSchema` * REFUSES a flat `limit` with `unrecognized_keys: ["limit"]`, the same * verdict a bogus key gets, while the same minimal document parses with - * `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. There is + * `pagination.pageSize: 50`. There is * no flat `limit` member on any view document and no `retiredKey()` * tombstone for one. ⛔ So naming that arm here would put a runtime-record * shape on the author face with no qualifier — the face-merge this card has * now failed on three times. * ⛔ This note reports the guard; it picks no precedence. * - * ⚠️ It takes a THIRD door, and missing it is what the at-tier review of - * #19533 caught. Stated with its faces named, because that is where this - * card keeps going wrong: `kanban.limit` is a VIEW-FACE key (a member of a - * `ListViewSchema` document's `kanban` block) and THIS `limit` is the - * ELEMENT-FACE key on the node; the third door is the one that turns the - * first into the second. The ADAPTERS spread the view's kanban block FLAT - * onto the generated node — `plugin-list/src/ListView.tsx:2979` and - * `plugin-view/src/ObjectView.tsx:1638`, both `...restKanban`, and neither - * destructure (`ListView.tsx:2952`, `ObjectView.tsx:1579`) strips `limit`. - * So a view's `kanban.limit`, INCLUDING the 100 its applied default - * materializes, lands on THIS key, and `ObjectKanban.tsx:553` reads it - * (`describeRefusedRowLimit`, unconditional). The `$top` at `:676` is not - * issued on either route today — both hosts pass rows down as a React `data` - * prop and the board short-circuits at `:559` — so it governs no query - * there, which is ⛔ NOT the same claim as 「no consumer reads it」. A - * spread carries a key without spelling it, so a property-access sweep - * cannot see this and returns a confident zero. - * - * ⭐ The arm is nonetheless REACHABLE, for a reason the spreads do not - * touch: its guard reads THIS key on the node as AUTHORED, and this + * ⭐ The arm is REACHABLE: its guard reads THIS key on the node as AUTHORED, and this * declaration is `.optional()` with no applied default, so an author's - * silence is still silence at parse time. #19228 read the VIEW-face applied - * default as killing this arm; the two are different schemas, and what the - * adapters lower is a rendered node, not the parse of this one. ⛔ Do not add + * silence is still silence at parse time. ⛔ Do not add * a `.default()` here: that — and only that — is what would make it dead. * The board * has no `pagination` read point, so declaring that spelling here would name @@ -4086,13 +4065,7 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * the shared `convertSortToQueryParams` sink, as `object-calendar`'s does), * `limit` (`:407`, the fetch's one top-level `$top`, through * `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default - * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is this FLAT - * key that is read — this node's own nested `timeline.limit` is not, on any - * route. What reaches this flat key is a VIEW document's `timeline.limit`, - * flattened onto the generated node by `plugin-view/src/ObjectView.tsx:1725` - * (`...(viewOptions.timeline || {})`, spelled with its package because objectui - * carries a second `ObjectView.tsx` in `app-shell`); see the - * `timeline` door below), `items` (`:254`, `:420`, `:487`, + * `100` at `:29` and the refused-cap diagnostic at `:279`), `items` (`:254`, `:420`, `:487`, * `:668` — the authored pass-through that short-circuits the object query), * `data` (`:255`, `:420`, `:442`, `:444` — the pre-fetched record source, * read off REACT PROPS rather than `schema`; the door's own docblock carries @@ -4124,52 +4097,6 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * VALUE posture: `timeline` takes {@link TimelineConfigSchema}, the block * `ListViewSchema.timeline` already declares — one vocabulary, taken by * reference, so this element face cannot fork from the view face. - * ⚠️ Taking it by reference also imported #19226's new `limit` onto THIS - * strictObject, beside the flat `limit` below — two authorable row caps on one - * node, and the nested one carries an APPLIED default, so every parsed node - * with a `timeline` block materializes `timeline.limit: 100` (#19228). - * - * ⛔ THE TWO ARE ON DIFFERENT FACES, and #19228 has now gone wrong on that - * boundary in both directions — once putting a view-face fact on the element - * key, once the reverse. So each statement names its face first. Measured at - * the pin `87af769e9`, 2026-09-21T09:15Z: - * - **ELEMENT FACE — THIS node's own nested `timeline.limit`: read on NO - * route.** `ObjectTimeline` binds `timelineConfig = schema.timeline` - * (`ObjectTimeline.tsx:262`) and reads exactly eight members off it — - * `startDateField`, `dateField`, `titleField`, `endDateField`, - * `groupByField`, `colorField`, `metaFields` (`:520`, through an `as any` - * cast, which is why a `timelineConfig?.x` sweep alone under-counts) and - * `scale`. `limit` is not among them, and NO spread of a node's own - * `timeline` block exists anywhere in objectui (0 hits on the NODE face — - * method: `git grep '\.\.\.(schema\.timeline'` at this pin returns four - * lines and every one is on the VIEW face, where `schema` is the view - * document: the block spread at `plugin-list/src/ListView.tsx:3066` and the - * three member-conditional ones at `:3114-3116`. ⛔ Without that scope - * written beside it the count is not 0. Lit control, same shape: the - * view-block spreads `...mergedTimeline` / - * `...(viewOptions.timeline || {})`, which do fire). The - * `ElementDataSourceGate` mapping is `limit: 'limit'` - * (`plugin-timeline/src/index.tsx:333`) — FLAT, so it never touches the - * nested key either. - * - **VIEW FACE — a `ListViewSchema` document's `timeline.limit`: that is - * the route-dependent one**, and it is a different key on a different - * document. `plugin-view/src/ObjectView.tsx:1725` flattens the view block - * onto the node it returns (which then carries no `timeline` block at all), - * so the value arrives as this node's FLAT `limit` and is read; - * `ListView.tsx:3084` forwards it nested instead, where nothing reads it. - * The package is spelled because objectui carries a second `ObjectView.tsx` - * (in `app-shell`), where that same line number is an unrelated `catch` — - * a bare spelling here names neither file. - * - * ⚠️ Flagged, not fixed — a THIRD route neither the card nor the first two - * reviews described: a hand-authored `object-timeline` node reaching - * `ObjectTimeline` through `SchemaRenderer` with no adapter in between. Its - * nested `timeline.limit` is unread there too (it is the same element-face - * key as the first bullet), and that route is the one an AUTHOR face is - * written for — so it is the route the open question most concerns. - * - * ⛔ Recorded, not repaired: which key should carry a timeline's row cap is - * the open half of #19228. * `mapping` stays `z.unknown()`: its contract * (`TimelineMappingSchema`) still lives in objectui, which is the * `object-calendar.calendar` posture this section's header prescribes for @@ -4200,7 +4127,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('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: TimelineConfigSchema.optional() - .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read'), + .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, { error: ruleArrayFilterError({ From c624b3164d4d596cf726bb3019d1a2e408ac5e21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:32:25 +0000 Subject: [PATCH 3/6] feat(spec): answer a per-kind view `limit` with the pageSize prescription The build's authorable-surface gate refuses a baseline line that leaves without a proof. The three per-kind configs now name `limit` in their strictObject guidance, so an author who writes it is refused with the pointer to `pagination.pageSize` (proof 4), and the three baseline lines go with the key. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 3 --- packages/spec/src/ui/view.zod.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f870253db81..995e9817c97 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -501,7 +501,6 @@ "ui/GalleryConfig:cardSize", "ui/GalleryConfig:coverField", "ui/GalleryConfig:coverFit", - "ui/GalleryConfig:limit", "ui/GalleryConfig:titleField", "ui/GalleryConfig:visibleFields", "ui/GanttConfig:assigneeField", @@ -611,7 +610,6 @@ "ui/JoinedReportBlock:values", "ui/KanbanConfig:columns", "ui/KanbanConfig:groupByField", - "ui/KanbanConfig:limit", "ui/KanbanConfig:summarizeField", "ui/KanbanConfig:titleField", "ui/ListChartConfig:chartType", @@ -1185,7 +1183,6 @@ "ui/TimelineConfig:colorField", "ui/TimelineConfig:endDateField", "ui/TimelineConfig:groupByField", - "ui/TimelineConfig:limit", "ui/TimelineConfig:scale", "ui/TimelineConfig:startDateField", "ui/TimelineConfig:titleField", diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4498c8b09b2..4c18373285c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1137,6 +1137,11 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ + 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`', )); +/** A view's row bound is `pagination.pageSize`; the per-kind blocks answer `limit` with this. */ +const VIEW_ROW_BOUND_GUIDANCE = + 'A view\'s row bound is `pagination.pageSize` on the view; this block declares no `limit`. ' + + 'Delete the key and set `pagination.pageSize` instead.'; + /** * Gallery View Configuration (Airtable-style) * Configures card layout for gallery/card views. @@ -1144,6 +1149,7 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ export const GalleryConfigSchema = lazySchema(() => strictObject({ surface: 'this gallery configuration', history: VIEW_HISTORY, + guidance: { limit: VIEW_ROW_BOUND_GUIDANCE }, }, { coverField: z.string().optional().describe('Attachment/image field to display as card cover'), coverFit: z.enum(['cover', 'contain']).default('cover').describe('Image fit mode for card cover'), @@ -1159,6 +1165,7 @@ export const GalleryConfigSchema = lazySchema(() => strictObject({ export const TimelineConfigSchema = lazySchema(() => strictObject({ surface: 'this timeline configuration', history: VIEW_HISTORY, + guidance: { limit: VIEW_ROW_BOUND_GUIDANCE }, }, { startDateField: z.string().describe('Field for timeline item start date'), endDateField: z.string().optional().describe('Field for timeline item end date'), @@ -1446,6 +1453,7 @@ export const AddRecordConfigSchema = lazySchema(() => strictObject({ export const KanbanConfigSchema = lazySchema(() => strictObject({ surface: 'this kanban configuration', history: VIEW_HISTORY, + guidance: { limit: VIEW_ROW_BOUND_GUIDANCE }, }, { groupByField: z.string() .superRefine(groupByFieldCheck('kanban')) From 1d91401a833ee48437f91593ec1649d7984776d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:38:43 +0000 Subject: [PATCH 4/6] chore(spec): regenerate the artifacts the row-bound retirement moves authorable-defaults, api-surface, export-origins and the reference docs, each through its own generator after a fresh build. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/component.mdx | 3 +-- content/docs/references/ui/view.mdx | 27 ++++++++--------------- packages/spec/api-surface/ui.json | 2 -- packages/spec/authorable-defaults/ui.json | 3 --- packages/spec/export-origins/ui.json | 2 -- 7 files changed, 13 insertions(+), 30 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 35e6fca36fa..52017876fe4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1674,7 +1674,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1759,7 +1759,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 13ae54574bb..12498b2485d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -374,7 +374,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7a051b848b3..02517ec848e 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -803,7 +803,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read | +| **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` | | **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 | @@ -828,7 +828,6 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **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 | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectTimelineProps.filter[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b48d558ff29..fd85d1fce07 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -545,7 +545,6 @@ Gallery/card view configuration | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -701,7 +700,6 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -803,7 +801,7 @@ Map view configuration | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -929,7 +927,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **pageSize** | `integer` | optional (default: `25`) | Number of records per page | +| **pageSize** | `integer` | optional (default: `25`) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set | | **pageSizeOptions** | `integer[]` | optional | Available page size options | ### Nested Shape: `ListView.kanban` @@ -940,7 +938,6 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.calendar` @@ -995,7 +992,6 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.timeline` @@ -1007,7 +1003,6 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **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 | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.chart` @@ -1214,7 +1209,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1331,7 +1326,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **pageSize** | `integer` | optional (default: `25`) | Number of records per page | +| **pageSize** | `integer` | optional (default: `25`) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set | | **pageSizeOptions** | `integer[]` | optional | Available page size options | ### Nested Shape: `ObjectListView.kanban` @@ -1342,7 +1337,6 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.calendar` @@ -1397,7 +1391,6 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.timeline` @@ -1409,7 +1402,6 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **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 | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.chart` @@ -1603,7 +1595,7 @@ Quick-filter field configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **pageSize** | `integer` | optional (default: `25`) | Number of records per page | +| **pageSize** | `integer` | optional (default: `25`) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set | | **pageSizeOptions** | `integer[]` | optional | Available page size options | @@ -1663,7 +1655,6 @@ Timeline view configuration | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **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 | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -1817,7 +1808,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1902,7 +1893,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2143,7 +2134,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2319,7 +2310,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index b48baedfa1a..0c312a685a2 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -107,7 +107,6 @@ "ComponentPropsMap (const)", "DATE_RANGE_DEFAULT_RANGES (const)", "DATE_RANGE_PRESETS (const)", - "DEFAULT_VIEW_ROW_LIMIT (const)", "Dashboard (const)", "Dashboard (type)", "DashboardHeader (type)", @@ -218,7 +217,6 @@ "KNOWN_COMPONENT_TYPES (const)", "KNOWN_COMPONENT_TYPE_CANDIDATES (const)", "KanbanConfig (type)", - "KanbanConfigParsed (type)", "KanbanConfigSchema (const)", "LIST_VIEW_GROUP_COUNT_ALIAS (const)", "ListChartConfig (type)", diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 006c5cd51a1..e4627338c7f 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -50,7 +50,6 @@ "ui/FormView:type = \"simple\"", "ui/GalleryConfig:cardSize = \"medium\"", "ui/GalleryConfig:coverFit = \"cover\"", - "ui/GalleryConfig:limit = 100", "ui/GlobalFilter:scope = \"dashboard\"", "ui/GroupNavItem:expanded = false", "ui/GroupingField:collapsed = false", @@ -59,7 +58,6 @@ "ui/InlineAction:refreshAfter = false", "ui/InlineAction:type = \"script\"", "ui/JoinedReportBlock:type = \"tabular\"", - "ui/KanbanConfig:limit = 100", "ui/ListChartConfig:chartType = \"bar\"", "ui/ListView:type = \"grid\"", "ui/NavigationConfig:mode = \"page\"", @@ -108,7 +106,6 @@ "ui/SelectionConfig:type = \"none\"", "ui/SharingConfig:allowAnonymous = false", "ui/SharingConfig:enabled = false", - "ui/TimelineConfig:limit = 100", "ui/TimelineConfig:scale = \"week\"", "ui/UrlNavItem:target = \"_self\"", "ui/UserActionsConfig:addRecordForm = false", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 59cc4a3b62a..04a0fd95b13 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -104,7 +104,6 @@ "ComponentPropsMap": "src/ui/component.zod.ts#ComponentPropsMap (const)", "DATE_RANGE_DEFAULT_RANGES": "src/ui/dashboard.zod.ts#DATE_RANGE_DEFAULT_RANGES (const)", "DATE_RANGE_PRESETS": "src/data/date-range-presets.ts#DATE_RANGE_PRESETS (const)", - "DEFAULT_VIEW_ROW_LIMIT": "src/ui/view.zod.ts#DEFAULT_VIEW_ROW_LIMIT (const)", "Dashboard": "src/ui/dashboard.zod.ts#Dashboard (type)", "DashboardHeader": "src/ui/dashboard.zod.ts#DashboardHeader (type)", "DashboardHeaderAction": "src/ui/dashboard.zod.ts#DashboardHeaderAction (type)", @@ -214,7 +213,6 @@ "KNOWN_COMPONENT_TYPES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPES (const)", "KNOWN_COMPONENT_TYPE_CANDIDATES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPE_CANDIDATES (const)", "KanbanConfig": "src/ui/view.zod.ts#KanbanConfig (type)", - "KanbanConfigParsed": "src/ui/view.zod.ts#KanbanConfigParsed (type)", "KanbanConfigSchema": "src/ui/view.zod.ts#KanbanConfigSchema (const)", "LIST_VIEW_GROUP_COUNT_ALIAS": "src/ui/view-grouping-query.ts#LIST_VIEW_GROUP_COUNT_ALIAS (const)", "ListChartConfig": "src/ui/view.zod.ts#ListChartConfig (type)", From ff13eece89e69e5bccaa63d47cc43598b81aefed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:00:04 +0000 Subject: [PATCH 5/6] fix(spec): word the limit prescription for both faces of the timeline block `TimelineConfigSchema` is also the nested `timeline` block of an `object-timeline` node, where the row bound is the node's flat `limit`, not `pagination.pageSize`. The prescription now names both. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/ui/view.zod.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4c18373285c..ad4dc307c92 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1137,10 +1137,10 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ + 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`', )); -/** A view's row bound is `pagination.pageSize`; the per-kind blocks answer `limit` with this. */ +/** The per-kind blocks answer `limit` with this; `timeline` is also nested on `object-timeline`. */ const VIEW_ROW_BOUND_GUIDANCE = - 'A view\'s row bound is `pagination.pageSize` on the view; this block declares no `limit`. ' - + 'Delete the key and set `pagination.pageSize` instead.'; + 'This block declares no `limit`. Delete the key: the row bound is `pagination.pageSize` on a view, ' + + 'or the flat `limit` on a page component node.'; /** * Gallery View Configuration (Airtable-style) From e4204b96162e88da8237f44e88fd0c862e5fd146 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:34:16 +0000 Subject: [PATCH 6/6] chore(spec): regenerate the artifacts the merge of main deferred The os-regen driver merged seven generated paths without a text merge and kept this branch's side of each, dropping main's changes to them. This commit takes main's side (os-regen-merge.sh step 2, against the recorded pre-merge base fae870352e) and regenerates on the merged tree: spec build (gen:schema), gen:api-surface, gen:export-origins, gen:docs. check:generated: 15 of 15 artifacts current. authorable-surface/ui.json is the one path this branch had edited by hand: its three GalleryConfig / KanbanConfig / TimelineConfig `limit` lines were deleted deliberately, since the generator refuses a bare deletion. That committed deletion is re-applied onto main's side with git apply (context verified), and the build's check (c) proof 4 accepts each of the three: the key is refused as unrecognized and the refusal carries the guidance prescription. For every one of the seven paths, this commit's delta equals main's delta since the merge base, and the branch's delta against main equals the PR's delta against the base. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 18 +++--- content/docs/references/data/object.mdx | 12 ++-- content/docs/references/ui/component.mdx | 34 +++++++---- content/docs/references/ui/view.mdx | 72 ++++++++++++------------ packages/spec/api-surface/ui.json | 3 + packages/spec/authorable-surface/ui.json | 13 +++++ packages/spec/export-origins/ui.json | 3 + 7 files changed, 91 insertions(+), 64 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 52017876fe4..e578e76d0bf 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1664,8 +1664,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1688,8 +1688,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1749,8 +1749,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1773,8 +1773,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1934,7 +1934,7 @@ Install package request | **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 12498b2485d..519783447e0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -153,7 +153,7 @@ const result = ApiMethod.parse(data); | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | | **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | -| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications | +| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | | **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). | | **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. | | **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. | @@ -319,7 +319,6 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | | **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | -| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | ### Nested Shape: `Object.access` @@ -364,8 +363,8 @@ const result = ApiMethod.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -388,8 +387,8 @@ const result = ApiMethod.parse(data); | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -748,7 +747,6 @@ Boolean-or-predicates override for a built-in CRUD affordance. | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | | **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | -| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 02517ec848e..dd412bb60f8 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -189,7 +189,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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: `ElementNumberProps.aria` @@ -231,7 +231,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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: `ElementRecordPickerProps.sort[number]` @@ -327,7 +327,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.sort[number]` @@ -463,7 +463,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.sort[number]` @@ -523,7 +523,7 @@ Sort field and direction pair | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | | **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` | -| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | +| **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | | **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; apply them by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | @@ -563,7 +563,17 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.defaultFilters[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. | ### Nested Shape: `ObjectGridProps.sort[number]` @@ -637,7 +647,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.navigation` @@ -707,7 +717,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.sort[number]` @@ -791,7 +801,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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. | --- @@ -837,7 +847,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.sort[number]` @@ -913,7 +923,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.tree` @@ -1404,7 +1414,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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.add` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index fd85d1fce07..4d978588138 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -199,7 +199,7 @@ Column footer summary configuration | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `87af769e9`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `62597c588`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -279,7 +279,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the `reference` key on the field definition. | ### Nested Shape: `FormField.keyField` @@ -317,7 +317,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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. | --- @@ -364,7 +364,7 @@ View filter rule | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `87af769e9`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `62597c588`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -790,8 +790,8 @@ Map view configuration | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -815,8 +815,8 @@ Map view configuration | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -893,8 +893,8 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | 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. | +| **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.userFilters` @@ -1124,7 +1124,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)[] }[]` | 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 | @@ -1199,8 +1199,8 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1223,8 +1223,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1302,8 +1302,8 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | 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. | +| **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.selection` @@ -1523,7 +1523,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)[] }[]` | 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 | @@ -1798,8 +1798,8 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1822,8 +1822,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1883,8 +1883,8 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1907,8 +1907,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2056,7 +2056,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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: `ViewFilterRule.operator` @@ -2123,8 +2123,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -2148,8 +2148,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2299,8 +2299,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -2324,8 +2324,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2506,7 +2506,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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. | +| **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. | --- diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0c312a685a2..d90dcff4eee 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -137,6 +137,9 @@ "DateRangePreset (type)", "DerivedMeasureOp (const)", "DerivedMeasureOpValue (type)", + "DocNavItem (type)", + "DocNavItemParsed (type)", + "DocNavItemSchema (const)", "EXPRESSION_BINDABLE_TEXT_KEYS (const)", "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 995e9817c97..bb7892953f6 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -351,6 +351,19 @@ "ui/DatasetMeasure:format", "ui/DatasetMeasure:label", "ui/DatasetMeasure:name", + "ui/DocNavItem:badge", + "ui/DocNavItem:badgeVariant", + "ui/DocNavItem:book", + "ui/DocNavItem:doc", + "ui/DocNavItem:icon", + "ui/DocNavItem:id", + "ui/DocNavItem:label", + "ui/DocNavItem:order", + "ui/DocNavItem:requiredPermissions", + "ui/DocNavItem:requiresObject", + "ui/DocNavItem:requiresService", + "ui/DocNavItem:type", + "ui/DocNavItem:visible", "ui/ElementButtonProps:action", "ui/ElementButtonProps:aria", "ui/ElementButtonProps:disabled", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 04a0fd95b13..b9e4ff35012 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -133,6 +133,9 @@ "DateRangePreset": "src/data/date-range-presets.ts#DateRangePreset (type)", "DerivedMeasureOp": "src/ui/dataset.zod.ts#DerivedMeasureOp (const)", "DerivedMeasureOpValue": "src/ui/dataset.zod.ts#DerivedMeasureOpValue (type)", + "DocNavItem": "src/ui/app.zod.ts#DocNavItem (type)", + "DocNavItemParsed": "src/ui/app.zod.ts#DocNavItemParsed (type)", + "DocNavItemSchema": "src/ui/app.zod.ts#DocNavItemSchema (const)", "EXPRESSION_BINDABLE_TEXT_KEYS": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS (const)", "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)",