From 1d5efac83521f71053a916a981325781506321d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 13:28:05 +0000 Subject: [PATCH 1/4] spec(ui): declare the columns x hiddenFields x fieldOrder composition on the list view `ListViewSchema`'s three field-selection keys now state the composition they already form, in the `.describe()` text that ships to authors: `columns` projects, `hiddenFields` subtracts from that projection, and `fieldOrder` orders what survives, with a surviving column absent from `fieldOrder` sorting last. No accept set moves. No key is added, removed, narrowed or widened, and the four `@objectstack/lint` list-view validators are untouched. `packages/spec/liveness/view.json`'s `/props/list/children/fieldOrder` row is re-cited at objectui @87af769e with `verifiedAt: 2026-09-21`: its first evidence path no longer exists and its second had drifted in range, so both pointers now carry symbol anchors and the relay rung is split out into `producer`. `view-field-order-composition.pin.test.ts` holds the declaration and the accept set together across the four list-view doors. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...15184-list-view-field-order-composition.md | 19 ++ packages/spec/liveness/view.json | 7 +- .../view-field-order-composition.pin.test.ts | 269 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 67 ++++- 4 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 .changeset/15184-list-view-field-order-composition.md create mode 100644 packages/spec/src/ui/view-field-order-composition.pin.test.ts diff --git a/.changeset/15184-list-view-field-order-composition.md b/.changeset/15184-list-view-field-order-composition.md new file mode 100644 index 00000000000..ad4a6b3e642 --- /dev/null +++ b/.changeset/15184-list-view-field-order-composition.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +`ListViewSchema` declares the `columns` x `hiddenFields` x `fieldOrder` composition instead of leaving it to be inferred from a renderer (#15184) + +The three keys that decide which fields a list view shows, and in what order, now say so in their own `.describe()` text — the string that ships into `json-schema/`, into the generated `content/docs/references/ui/view.mdx`, and into the TSDoc an author hovers: + +- `columns` is the **projection**: the candidate set and the baseline order. A field omitted here is not displayed, and neither other key can add it back. +- `hiddenFields` **subtracts** from that projection, before any ordering runs. A name `columns` never projected subtracts nothing. +- `fieldOrder` **orders what survives** 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 there that did not survive orders nothing. + +**Why this is a declaration and not a precedence rule.** `fieldOrder` was proposed for retirement as a second spelling of `columns` with no contract deciding who wins. They never compete: one selects, the other sorts. Maintainer decision batch #115 (2026-09-11) kept the key and ruled the composition into the contract, which is what this change lands. + +⛔ **No accept set moves.** No key is added, removed, narrowed or widened; no parse verdict changes; the four `@objectstack/lint` list-view validators are untouched. What changes is the published description of three keys that were already there, plus one ledger row's evidence. + +**`packages/spec/liveness/view.json` — the `/props/list/children/fieldOrder` row is re-cited**, `verifiedAt: 2026-09-21`. The ledger ships in this package's `files[]`, so the pointers an upgrading reader follows are these, and both halves of the 2026-08-10 citation had rotted: its first path (`objectui packages/react/src/spec-bridge/bridges/list-view.ts`) no longer exists, and its second had drifted in range through three sets of line numbers. The row now anchors both pointers on symbols, splits the relay rung out into `producer`, and names the measurement it was taken at. + +The declaration and the accept set are held together by `packages/spec/src/ui/view-field-order-composition.pin.test.ts`: it reads the three descriptions off the live schema and parses a document carrying all three keys through the page-list, object-views, `defineView` and registered-metadata doors, asserting the arrays come back verbatim — the spec declares the composition, it does not perform it. diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 8b05bc9eed8..21c15486454 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -159,10 +159,11 @@ }, "fieldOrder": { "status": "live", - "verifiedAt": "2026-08-10", + "verifiedAt": "2026-09-21", "evidenceScope": "cross-repo", - "evidence": "objectui: packages/react/src/spec-bridge/bridges/list-view.ts:124 copies the authored key onto the list node; objectui: packages/plugin-list/src/ListView.tsx:1499-1500 builds an order map from schema.fieldOrder and reorders the effective column set by it, with :1509 listing it as a memo dependency — measured objectui @11c1e71e", - "note": "RE-CITED 2026-08-10 (#7132/#7142): the previous 'ObjectGrid.tsx (audit L15)' citation greps to zero at objectui @11c1e71e. Column ordering is applied in the same ListView effective-fields memo as hiddenFields, before the grid is handed its `columns` array." + "evidence": "objectui: packages/plugin-list/src/ListView.tsx#effectiveFields:2750-2784 is the applying reader — it takes schema.columns as the candidate set, subtracts the hidden-field set, then at :2774-2781 builds an order map from schema.fieldOrder and sorts the survivors by it (a survivor the map does not name scores Infinity, so it sorts last), with :2784 listing schema.fieldOrder as a memo dependency — measured objectui @87af769e, the pin this tree carries", + "producer": "objectui: packages/plugin-view/src/ObjectView.tsx#renderContent:2284 relays a named view's fieldOrder onto the list-view node that memo reads, so an authored value reaches the consumer instead of arriving undefined — view-sourced only, no host fallback, per objectui#8980 — measured objectui @87af769e", + "note": "RE-CITED 2026-09-21 (objectstack#15184 ruling B, maintainer decision batch #115 of 2026-09-11): the key STAYS, and the composition it belongs to is now declared in the contract — `columns` projects, `hiddenFields` subtracts, `fieldOrder` orders what survives, and a survivor absent from `fieldOrder` sorts last. Both halves of the 2026-08-10 citation had rotted. Its first path, objectui packages/react/src/spec-bridge/bridges/list-view.ts, is GONE: `git ls-tree -r` at objectui @87af769e returns 0 files under packages/react/src/spec-bridge/ while packages/react/ itself returns 160, so the zero belongs to the directory and not to the instrument. Its second rotted IN RANGE — the ListView memo was at :1499-1500 when measured at @11c1e71e, ~:2424-2434 when ruling B was written on 2026-09-11, and :2774-2781 here — which is why both pointers now carry symbol anchors. The spec-side declaration is the three `.describe()` strings on `columns` / `hiddenFields` / `fieldOrder` in packages/spec/src/ui/view.zod.ts, held together with the accept set by packages/spec/src/ui/view-field-order-composition.pin.test.ts. One measured gap, objectui's to close and NOT a defect in this key: the OTHER relay, objectui packages/app-shell/src/views/ObjectView.tsx, carries `hiddenFields` at :2480 but still records `fieldOrder` as a declared absence (objectui#7516) in its relay-rung census; the plugin-view rung cited under `producer` is the live one." }, "rowActions": { "status": "live", diff --git a/packages/spec/src/ui/view-field-order-composition.pin.test.ts b/packages/spec/src/ui/view-field-order-composition.pin.test.ts new file mode 100644 index 00000000000..00f2dce0ce7 --- /dev/null +++ b/packages/spec/src/ui/view-field-order-composition.pin.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15184] The `columns` x `hiddenFields` x `fieldOrder` composition is a + * DECLARED part of the list-view contract, and every authoring door accepts + * all three keys together. + * + * ## Why this pin exists + * + * `fieldOrder` was proposed for retirement (ruling of 2026-09-04) on the + * premise that it is a second spelling of `columns` with no contract deciding + * who wins. The premise was measured false: the two keys never compete, + * because one projects and the other sorts, and objectui applies all three in + * one memo. Ruling B (2026-09-11, decision batch #115) therefore KEPT the key + * and ruled the composition into the contract instead: + * + * `columns` is the projection, `hiddenFields` subtracts from it, + * `fieldOrder` orders what survives (entries absent from `fieldOrder` + * sort last). + * + * A composition stated only in prose rots the way the retired citation rotted + * — so this file holds the two halves that make it a contract rather than a + * comment: + * + * - **The declaration half** reads the three `.describe()` strings off the + * LIVE schema, not off the source text. Those strings are the published + * authoring surface: they ship into `json-schema/`, into the generated + * `content/docs/references/ui/view.mdx`, and into the TSDoc an author (often + * an AI author, ADR-0033) hovers. Dropping the composition from any one of + * them reds here, naming the key that lost it. + * - **The door half** parses documents carrying all three keys through the + * four doors a list view really arrives by, and asserts the values survive + * VERBATIM. The spec declares the composition; it does not perform it. A + * door that started reordering, deduplicating or cross-validating these + * arrays would be silently doing the renderer's job at parse time, and the + * renderer would then compose over an input it did not receive. + * + * ⛔ Scope: the RELATION, not the wording. Rewording any of the three + * descriptions is fine — what they may not do is stop naming the other two + * keys, stop stating their own role in the composition, or start claiming that + * `fieldOrder` selects fields or that `hiddenFields` orders them. + * + * ⛔ This pin deliberately asserts nothing about HOW a renderer sorts. That + * behaviour is objectui's, measured in the liveness ledger row + * (`packages/spec/liveness/view.json`, `/props/list/children/fieldOrder`). + * Restating it as a spec test would be a claim this package cannot falsify. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +import { + ListViewSchema, + ObjectListViewSchema, + defineView, +} from './view.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VIEW_SOURCE = join(HERE, 'view.zod.ts'); + +/** The three composing keys, in the order they are applied. */ +const COMPOSITION = ['columns', 'hiddenFields', 'fieldOrder'] as const; + +type Shape = Record; + +const listShape = (): Shape => + (ListViewSchema as unknown as { shape: Shape }).shape; + +/** The `.describe()` text the published surface carries for `key`, lowercased. */ +function description(key: string): string { + const entry = listShape()[key]; + expect(entry, `\`${key}\` is no longer a member of ListViewSchema — re-anchor this pin`).toBeTruthy(); + const text = entry?.description; + expect(text, `\`${key}\` carries no .describe() text`).toBeTruthy(); + return String(text).toLowerCase(); +} + +/** + * The JSDoc block attached to the `hiddenFields` declaration — the one that + * states the composition once for the whole block. + */ +function compositionDocblock(): string { + const source = readFileSync(VIEW_SOURCE, 'utf8'); + const key = source.indexOf('hiddenFields: z.array(z.string()).optional().describe('); + expect(key, 'the `hiddenFields` declaration moved — re-anchor this pin').toBeGreaterThan(-1); + const open = source.lastIndexOf('/**', key); + const close = source.indexOf('*/', open); + expect(open, 'no JSDoc block precedes `hiddenFields`').toBeGreaterThan(-1); + expect(close, 'unterminated JSDoc block').toBeLessThan(key); + return source + .slice(open, close + 2) + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .join(' ') + .toLowerCase(); +} + +/** A list view exercising all three keys at once. */ +const COMPOSED_LIST = { + type: 'grid' as const, + columns: ['name', 'stage', 'amount', 'owner'], + hiddenFields: ['owner'], + // Deliberately partial and deliberately out of `columns` order: `amount` + // moves to the front, `name` and `stage` are unlisted survivors, and + // `owner` is a subtracted name this list still mentions. + fieldOrder: ['amount', 'owner'], +}; + +describe('[#15184] the list-view field composition is declared, not implied', () => { + describe('declaration — the published `.describe()` of each composing key', () => { + it.each(COMPOSITION)('`%s` names all three keys of the composition', (key) => { + const text = description(key); + for (const sibling of COMPOSITION) { + expect(text, `\`${key}\` no longer names \`${sibling}\``).toContain(sibling.toLowerCase()); + } + }); + + it('`columns` declares itself the PROJECTION, and that nothing re-adds what it omits', () => { + const text = description('columns'); + expect(text).toContain('projection'); + // The half that makes it a contract rather than a label: the other two + // keys cannot widen the set. + expect(text).toMatch(/cannot add|not add|never add/); + }); + + it('`hiddenFields` declares itself the SUBTRACTION, applied before the ordering', () => { + const text = description('hiddenFields'); + expect(text).toMatch(/subtract|removed from/); + expect(text).toContain('before'); + expect(text).toContain('fieldorder'); + }); + + it('`fieldOrder` declares itself the ORDERING, additive of nothing', () => { + const text = description('fieldOrder'); + expect(text).toMatch(/orders|ordering/); + expect(text).toMatch(/never adds|does not add|adds no/); + }); + + it('`fieldOrder` states where an unlisted survivor lands — LAST', () => { + // The one rule an author cannot guess and the renderer really applies + // (`?? Infinity` in objectui's effective-fields memo). Without it the + // description would describe a partial `fieldOrder` as undefined + // behaviour, which is exactly what it is not. + const text = description('fieldOrder'); + expect(text).toContain('last'); + }); + + it('the block docblock states the composition once, in application order', () => { + const doc = compositionDocblock(); + expect(doc).toContain('projection'); + expect(doc).toContain('subtract'); + expect(doc).toContain('orders what survives'); + // Application order, read positionally: projection, then subtraction, + // then ordering. + const iColumns = doc.indexOf('`columns` is the **projection**'); + const iHidden = doc.indexOf('`hiddenfields` **subtracts**'); + const iOrder = doc.indexOf('`fieldorder` **orders what survives**'); + expect(iColumns, 'the docblock no longer states the projection step').toBeGreaterThan(-1); + expect(iHidden, 'the docblock no longer states the subtraction step').toBeGreaterThan(-1); + expect(iOrder, 'the docblock no longer states the ordering step').toBeGreaterThan(-1); + expect(iColumns).toBeLessThan(iHidden); + expect(iHidden).toBeLessThan(iOrder); + }); + }); + + describe('doors — all three keys are accepted together, and survive verbatim', () => { + it('ListViewSchema (the page list door)', () => { + const parsed = ListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('ObjectListViewSchema (the ADR-0047 "views" mode door)', () => { + const parsed = ObjectListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('defineView (the authoring door)', () => { + const view = defineView({ name: 'crm_lead', list: { ...COMPOSED_LIST } } as never); + const list = (view as { list?: typeof COMPOSED_LIST }).list; + expect(list?.columns).toEqual(COMPOSED_LIST.columns); + expect(list?.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(list?.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('the registered `view` metadata door (Studio, the API, an agent)', () => { + const schema = getMetadataTypeSchema('view'); + expect(schema, 'the `view` metadata type is no longer registered').toBeTruthy(); + const parsed = schema!.parse({ + name: 'crm_lead', + list: { ...COMPOSED_LIST }, + } as never) as { list?: typeof COMPOSED_LIST }; + expect(parsed.list?.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.list?.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.list?.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('accepts all three keys with NO unrecognized-key report — they are real authorable slots', () => { + // The strict door's own verdict, not an inference from `success`: a key + // the shape did not declare would surface here by name. + const r = ListViewSchema.safeParse({ ...COMPOSED_LIST } as never); + expect(r.success).toBe(true); + expect(JSON.stringify(r.error?.issues ?? [])).not.toContain('unrecognized_keys'); + }); + }); + + describe('doors — the spec DECLARES the composition, it does not perform it', () => { + it('does not cross-validate `fieldOrder` against `columns`', () => { + // `fieldOrder` may name a field `columns` never projected. The renderer + // orders nothing by it; reference integrity is `@objectstack/lint`'s + // job (`validate-list-view-field-refs.ts`), against the object's real + // field map — a door that guessed here would reject documents the + // platform itself writes. + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + fieldOrder: ['not_projected', 'name'], + } as never); + expect(r.success).toBe(true); + }); + + it('does not cross-validate `hiddenFields` against `columns`', () => { + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + hiddenFields: ['never_projected'], + } as never); + expect(r.success).toBe(true); + }); + + it('does not apply the composition at parse time — `columns` comes back whole', () => { + // The failure this forbids is a door that "helpfully" subtracts or + // reorders: the renderer would then compose over an already-composed + // input, and `hiddenFields` would subtract twice from nothing. + const parsed = ListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toHaveLength(COMPOSED_LIST.columns.length); + expect(parsed.columns).toContain('owner'); + expect(parsed.columns[0]).toBe('name'); + }); + }); + + describe('shape — the three keys are siblings on one schema', () => { + it('all three are declared on ListViewSchema itself', () => { + const shape = listShape(); + for (const key of COMPOSITION) { + expect(Object.keys(shape), `\`${key}\` left ListViewSchema`).toContain(key); + } + }); + + it('`columns` is required and the other two are optional', () => { + // The projection is the only one an author must write: a list view with + // no candidate set has nothing to subtract from or order. + const r = ListViewSchema.safeParse({ type: 'grid', hiddenFields: ['a'], fieldOrder: ['a'] } as never); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues ?? [])).toContain('columns'); + + const ok = ListViewSchema.safeParse({ type: 'grid', columns: ['a'] } as never); + expect(ok.success).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 860d3ac1e6d..2123deab881 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2432,11 +2432,24 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Data Source Configuration */ data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), - /** Shared Query Config */ + /** + * Shared Query Config + * + * `columns` is the PROJECTION member of the per-view field composition + * declared on `hiddenFields` / `fieldOrder` below: it is the candidate set + * and the baseline order, `hiddenFields` subtracts from it, and `fieldOrder` + * orders what survives. Nothing downstream may re-add a field this list + * omits. + */ columns: z.union([ z.array(z.string()), // Legacy: simple field names z.array(ListColumnSchema), // Enhanced: detailed column config - ]).describe('Fields to display as columns'), + ]).describe( + '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. ' + + 'A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back.', + ), filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), /** * Sort order — the structured `{ field, order }[]` array, and only that. @@ -2564,9 +2577,53 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Row Color (Airtable-style) */ rowColor: RowColorConfigSchema.optional().describe('Color rows based on field value'), - /** Field Visibility & Ordering per View (Airtable-style) */ - hiddenFields: z.array(z.string()).optional().describe('Fields to hide in this specific view'), - fieldOrder: z.array(z.string()).optional().describe('Explicit field display order for this view'), + /** + * Field Visibility & Ordering per View (Airtable-style) + * + * ## The composition, declared (#15184 ruling B, 2026-09-11) + * + * Three keys on this schema decide which fields a list view shows and in + * what order, and they COMPOSE — they are not three ways to say the same + * thing, and none of them is a fallback for another: + * + * 1. `columns` is the **projection**: the candidate set and the baseline + * order. + * 2. `hiddenFields` **subtracts** from that projection: every name it lists + * is removed. A name it lists that `columns` never projected subtracts + * nothing. + * 3. `fieldOrder` **orders what survives**: it sorts the set left after the + * subtraction and never adds to it. A surviving column absent from + * `fieldOrder` sorts LAST, after every listed one, keeping its + * `columns`-relative order among its fellow unlisted columns; a name + * `fieldOrder` lists that did not survive orders nothing. + * + * ⛔ This is a DECLARATION of the order of application, not a precedence + * rule between rival spellings: `columns` and `fieldOrder` never contradict + * each other, because one selects and the other sorts. An author who wants a + * field gone edits `columns` or `hiddenFields`; an author who wants it moved + * edits `fieldOrder`. + * + * The composition was ruled into the contract rather than retired to one key + * because it is the shape the renderer already applies — objectui + * `packages/plugin-list/src/ListView.tsx`, the `effectiveFields` memo, runs + * exactly these three steps in this order. The ledger row + * (`packages/spec/liveness/view.json`, `/props/list/children/fieldOrder`) + * carries the measured citation; `view-field-order-composition.pin.test.ts` + * holds this declaration and the accept set together. + */ + hiddenFields: z.array(z.string()).optional().describe( + '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: z.array(z.string()).optional().describe( + '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.', + ), /** Row & Bulk Actions */ rowActions: z.array(z.string()).optional().describe('Actions available for individual row items'), From cfc103a20a34a6eb35670986a130fa7b3dc266ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 13:36:07 +0000 Subject: [PATCH 2/4] spec(ui): regenerate the docs references and the react-blocks contract for the composition text Mechanical output of `pnpm --filter @objectstack/spec gen:docs` and `gen:react-blocks` after the three `.describe()` strings moved. No hand edits: every changed row is one of the three keys' descriptions, rendered where the generators already render them. `skills/objectstack-ui/references/react-blocks.md` is the `gen:react-blocks` output row in the generated-surface register, recomputable byte-exact from `packages/spec/scripts/build-react-blocks-contract.ts`, which this diff does not touch. 115 lines before and after; the package's SKILL.md total is unchanged at 309. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 12 +++---- content/docs/references/data/object.mdx | 6 ++-- content/docs/references/ui/view.mdx | 36 +++++++++---------- .../objectstack-ui/references/react-blocks.md | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 88d647fc075..f2d6a59af18 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1664,7 +1664,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -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,7 +1749,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -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}`. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a72891c4f58..ac6cc257f7a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -364,7 +364,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -388,8 +388,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}`. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ea800bd3ae5..66193bc13a7 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -792,7 +792,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -817,8 +817,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}`. | @@ -1204,7 +1204,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -1228,8 +1228,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}`. | @@ -1807,7 +1807,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -1831,8 +1831,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}`. | @@ -1892,7 +1892,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -1916,8 +1916,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}`. | @@ -2132,7 +2132,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -2157,8 +2157,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}`. | @@ -2308,7 +2308,7 @@ 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 | +| **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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | | **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 | @@ -2333,8 +2333,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}`. | diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 8a668e8c7aa..573f51b2704 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -65,7 +65,7 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `filters` | `FilterArray e.g. ['status','=','active']` | controlled | | ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: ["and", […], […]]). | | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'new_window') => void` | callback | | Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch. | -| `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | +| `columns` | `string[] \| object[]` | data | ✓ | 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 t… | | `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | | `sort` | `object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search | From 47bc43df13ebc14f8caf3b1e3af0d94728367908 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:19:20 +0000 Subject: [PATCH 3/4] merge origin/main: regenerate the docs references on the merged tree The os-regen driver deferred content/docs/references/{api/protocol,data/object,ui/view}.mdx; os-regen-merge.sh took main's side and its step-3 commit was refused as stale. This commit is that side regenerated from the merged tree (gen:schema, gen:docs, gen:react-blocks): the only delta over main's side is the three list-view describe rows this branch already carried. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 6 +++--- content/docs/references/data/object.mdx | 18 ++++++++---------- content/docs/references/ui/view.mdx | 12 ++++++------ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f2d6a59af18..24ef24a2043 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1615,9 +1615,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | --- @@ -1910,7 +1910,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it | +| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: `true` enables, `false` disables, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | ### Nested Shape: `InstallPackageRequest.manifest` @@ -2274,7 +2274,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | -| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `NO_DRAFT`. Absent = environment-wide. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ac6cc257f7a..985f29117e3 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. | @@ -218,7 +218,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | optional | Human readable label | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | -| **format** | `string` | optional | Format string (e.g. email, phone) | +| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | @@ -230,7 +230,7 @@ const result = ApiMethod.parse(data); | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -311,7 +311,7 @@ const result = ApiMethod.parse(data); | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | | **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. | -| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum. | +| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum. | ### Nested Shape: `Object.tenancy` @@ -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` @@ -461,7 +460,7 @@ const result = ApiMethod.parse(data); | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | -| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. | +| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. | | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | @@ -551,7 +550,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | optional | Human readable label | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | -| **format** | `string` | optional | Format string (e.g. email, phone) | +| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | @@ -563,7 +562,7 @@ const result = ApiMethod.parse(data); | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -667,7 +666,7 @@ External datasource binding (ADR-0015) | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | | **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. | -| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum. | +| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum. | --- @@ -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/view.mdx b/content/docs/references/ui/view.mdx index 66193bc13a7..b8acbae548f 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -331,8 +331,8 @@ View filter rule | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -463,8 +463,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -479,8 +479,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | From e76041c2b472a401dd1fec67cb124de2da2fc9cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:25:27 +0000 Subject: [PATCH 4/4] spec(ui): state the empty-columns boundary and drop the hover claim from the composition text Fix round for the contract review of cfc103a20a. - columns: an empty list declares no projection, so neither hiddenFields nor fieldOrder applies; objectui's ListView grid then derives the object's default columns. Replaces "a field omitted here is not displayed" and "nothing downstream may re-add a field this list omits". - composition docblock: the same boundary on step 1; the effectiveFields memo runs the three steps with a field-level-security read filter between steps 1 and 2 (was "exactly these three steps"); the opening sentence and the "author who wants a field gone" advice narrowed or dropped. - pin header and changeset: the describe text ships into json-schema/ and the generated docs; the TSDoc-hover leg is dropped. - pins: the columns boundary, the docblock boundary, and every door admitting columns: [] verbatim (18 -> 21 cases). - regenerated content/docs/references/{api/protocol,data/object,ui/view}.mdx. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...15184-list-view-field-order-composition.md | 4 +- content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 12 ++-- .../view-field-order-composition.pin.test.ts | 59 ++++++++++++++++--- packages/spec/src/ui/view.zod.ts | 30 ++++++---- 6 files changed, 81 insertions(+), 30 deletions(-) diff --git a/.changeset/15184-list-view-field-order-composition.md b/.changeset/15184-list-view-field-order-composition.md index ad4a6b3e642..6074260b69c 100644 --- a/.changeset/15184-list-view-field-order-composition.md +++ b/.changeset/15184-list-view-field-order-composition.md @@ -4,9 +4,9 @@ `ListViewSchema` declares the `columns` x `hiddenFields` x `fieldOrder` composition instead of leaving it to be inferred from a renderer (#15184) -The three keys that decide which fields a list view shows, and in what order, now say so in their own `.describe()` text — the string that ships into `json-schema/`, into the generated `content/docs/references/ui/view.mdx`, and into the TSDoc an author hovers: +The three keys that together build a list view's field list now say how they compose, in their own `.describe()` text — the string that ships into `json-schema/` and into the generated `content/docs/references/ui/view.mdx`: -- `columns` is the **projection**: the candidate set and the baseline order. A field omitted here is not displayed, and neither other key can add it back. +- `columns` is the **projection**: the candidate set and the baseline order, and neither other key can add a field it omits. An **empty** `columns` declares no projection, so neither other key applies: which columns show is left to the renderer (objectui's `ListView` grid derives the object's default columns). - `hiddenFields` **subtracts** from that projection, before any ordering runs. A name `columns` never projected subtracts nothing. - `fieldOrder` **orders what survives** 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 there that did not survive orders nothing. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 24ef24a2043..9695b71653a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1664,7 +1664,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -1749,7 +1749,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 985f29117e3..4b61d98733e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -363,7 +363,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b8acbae548f..b3a8853d434 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -792,7 +792,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -1204,7 +1204,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -1807,7 +1807,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -1892,7 +1892,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -2132,7 +2132,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | @@ -2308,7 +2308,7 @@ 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 — 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. A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back. | +| **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 | diff --git a/packages/spec/src/ui/view-field-order-composition.pin.test.ts b/packages/spec/src/ui/view-field-order-composition.pin.test.ts index 00f2dce0ce7..58b21ffda8f 100644 --- a/packages/spec/src/ui/view-field-order-composition.pin.test.ts +++ b/packages/spec/src/ui/view-field-order-composition.pin.test.ts @@ -24,10 +24,11 @@ * * - **The declaration half** reads the three `.describe()` strings off the * LIVE schema, not off the source text. Those strings are the published - * authoring surface: they ship into `json-schema/`, into the generated - * `content/docs/references/ui/view.mdx`, and into the TSDoc an author (often - * an AI author, ADR-0033) hovers. Dropping the composition from any one of - * them reds here, naming the key that lost it. + * authoring surface: they ship into `json-schema/` and into the generated + * `content/docs/references/ui/view.mdx`. Dropping the composition from any + * one of them reds here, naming the key that lost it. So does dropping the + * EMPTY-`columns` boundary, from the `columns` description or from the + * docblock's projection step. * - **The door half** parses documents carrying all three keys through the * four doors a list view really arrives by, and asserts the values survive * VERBATIM. The spec declares the composition; it does not perform it. A @@ -37,8 +38,9 @@ * * ⛔ Scope: the RELATION, not the wording. Rewording any of the three * descriptions is fine — what they may not do is stop naming the other two - * keys, stop stating their own role in the composition, or start claiming that - * `fieldOrder` selects fields or that `hiddenFields` orders them. + * keys, stop stating their own role in the composition, drop the boundary an + * empty `columns` sets, or start claiming that `fieldOrder` selects fields or + * that `hiddenFields` orders them. * * ⛔ This pin deliberately asserts nothing about HOW a renderer sorts. That * behaviour is objectui's, measured in the liveness ledger row @@ -120,7 +122,7 @@ describe('[#15184] the list-view field composition is declared, not implied', () } }); - it('`columns` declares itself the PROJECTION, and that nothing re-adds what it omits', () => { + it('`columns` declares itself the PROJECTION, and that neither other key re-adds what it omits', () => { const text = description('columns'); expect(text).toContain('projection'); // The half that makes it a contract rather than a label: the other two @@ -128,6 +130,19 @@ describe('[#15184] the list-view field composition is declared, not implied', () expect(text).toMatch(/cannot add|not add|never add/); }); + it('`columns` states the EMPTY-list boundary: no projection, and neither other key applies', () => { + // Every door admits `columns: []` (pinned in the door half below), and + // the reference renderer reads it as UNAUTHORED: objectui's ListView + // hands its grid no columns and the grid derives the object's defaults, + // which neither `hiddenFields` nor `fieldOrder` reaches. A projection + // clause silent on that boundary promises a subtraction and an ordering + // that never happen. + const text = description('columns'); + expect(text).toContain('empty list declares no projection'); + expect(text).toContain('neither of them applies'); + expect(text).toContain('default columns'); + }); + it('`hiddenFields` declares itself the SUBTRACTION, applied before the ordering', () => { const text = description('hiddenFields'); expect(text).toMatch(/subtract|removed from/); @@ -166,6 +181,17 @@ describe('[#15184] the list-view field composition is declared, not implied', () expect(iColumns).toBeLessThan(iHidden); expect(iHidden).toBeLessThan(iOrder); }); + + it('the block docblock states the EMPTY-`columns` boundary on the projection step', () => { + const doc = compositionDocblock(); + const iColumns = doc.indexOf('`columns` is the **projection**'); + const iHidden = doc.indexOf('`hiddenfields` **subtracts**'); + expect(iColumns, 'the docblock no longer states the projection step').toBeGreaterThan(-1); + expect(iHidden, 'the docblock no longer states the subtraction step').toBeGreaterThan(iColumns); + const projectionStep = doc.slice(iColumns, iHidden); + expect(projectionStep).toContain('empty `columns` declares no projection'); + expect(projectionStep).toContain('steps 2 and 3 do not apply'); + }); }); describe('doors — all three keys are accepted together, and survive verbatim', () => { @@ -210,6 +236,25 @@ describe('[#15184] the list-view field composition is declared, not implied', () expect(r.success).toBe(true); expect(JSON.stringify(r.error?.issues ?? [])).not.toContain('unrecognized_keys'); }); + + it('every door ADMITS `columns: []` verbatim — the boundary the declaration states is reachable', () => { + // This is why the `columns` description and the docblock state what an + // empty list means. If a later ruling refuses `[]` at the door, this case + // and that boundary sentence retire together. + const EMPTY = { type: 'grid' as const, columns: [] as string[], hiddenFields: ['owner'], fieldOrder: ['name'] }; + const viaList = ListViewSchema.parse({ ...EMPTY } as never) as typeof EMPTY; + const viaViews = ObjectListViewSchema.parse({ ...EMPTY } as never) as typeof EMPTY; + const viaDefine = (defineView({ name: 'crm_lead', list: { ...EMPTY } } as never) as { list?: typeof EMPTY }).list; + const viaRegistered = (getMetadataTypeSchema('view')!.parse({ + name: 'crm_lead', + list: { ...EMPTY }, + } as never) as { list?: typeof EMPTY }).list; + for (const list of [viaList, viaViews, viaDefine, viaRegistered]) { + expect(list?.columns).toEqual([]); + expect(list?.hiddenFields).toEqual(EMPTY.hiddenFields); + expect(list?.fieldOrder).toEqual(EMPTY.fieldOrder); + } + }); }); describe('doors — the spec DECLARES the composition, it does not perform it', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4fe1dee14a3..5a0b5cf9d29 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2438,8 +2438,9 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ * `columns` is the PROJECTION member of the per-view field composition * declared on `hiddenFields` / `fieldOrder` below: it is the candidate set * and the baseline order, `hiddenFields` subtracts from it, and `fieldOrder` - * orders what survives. Nothing downstream may re-add a field this list - * omits. + * orders what survives. Neither of those two keys can add a field this list + * omits. An EMPTY list declares no projection, so neither of them applies + * (step 1 of the composition docblock below states the boundary). */ columns: z.union([ z.array(z.string()), // Legacy: simple field names @@ -2448,7 +2449,9 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ '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. ' - + 'A field omitted here is not displayed: `hiddenFields` and `fieldOrder` cannot add it back.', + + '`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: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), /** @@ -2582,12 +2585,15 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ * * ## The composition, declared (#15184 ruling B, 2026-09-11) * - * Three keys on this schema decide which fields a list view shows and in - * what order, and they COMPOSE — they are not three ways to say the same - * thing, and none of them is a fallback for another: + * Three keys on this schema together build one field list, and they + * COMPOSE — they are not three ways to say the same thing, and none of them + * is a fallback for another: * * 1. `columns` is the **projection**: the candidate set and the baseline - * order. + * order. An EMPTY `columns` declares no projection, so steps 2 and 3 do + * not apply: which columns show is left to the renderer (objectui's + * `ListView` hands its grid no columns, and the grid derives the + * object's default ones). * 2. `hiddenFields` **subtracts** from that projection: every name it lists * is removed. A name it lists that `columns` never projected subtracts * nothing. @@ -2599,14 +2605,14 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ * * ⛔ This is a DECLARATION of the order of application, not a precedence * rule between rival spellings: `columns` and `fieldOrder` never contradict - * each other, because one selects and the other sorts. An author who wants a - * field gone edits `columns` or `hiddenFields`; an author who wants it moved - * edits `fieldOrder`. + * each other, because one selects and the other sorts. * * The composition was ruled into the contract rather than retired to one key - * because it is the shape the renderer already applies — objectui + * because it is the shape objectui's list-view renderer already applies — * `packages/plugin-list/src/ListView.tsx`, the `effectiveFields` memo, runs - * exactly these three steps in this order. The ledger row + * these three steps in this order, with one more between steps 1 and 2: it + * drops the columns field-level security denies the current user read on. + * The ledger row * (`packages/spec/liveness/view.json`, `/props/list/children/fieldOrder`) * carries the measured citation; `view-field-order-composition.pin.test.ts` * holds this declaration and the accept set together.