From 0f09146262ba4e4f2766a28733ce8d1103ebb92a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 02:08:42 +0000 Subject: [PATCH 1/4] feat(spec): widen `record:related_list.columns` to the saved-view `ListColumn` union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RecordRelatedListProps.columns` declared `z.array(z.string())` while the saved-view key the same spec declares — `listViews[].columns` — declared `z.union([z.array(z.string()), z.array(ListColumnSchema)])`, and objectui composes a saved view's columns onto this block verbatim (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). Two published declarations disagreed about one key. The block key now takes the SAME union by reference — `ListColumnSchema` is imported from the view face rather than re-spelled — so a decorated saved view arrives here in the spelling it was authored in, and the two declarations cannot drift apart. The `describe()` names the override chain and the `ListColumn` spelling, and says the arms are exclusive because the schema enforces that. Scope fences from the ruling are unchanged and pinned: `field.relatedListColumns` stays child field-name strings only, and the `field-column-lists-canonicalized` conversion still folds its object entries to the identity string. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/src/ui/component.test.ts | 166 ++++++++++++++++++++++++- packages/spec/src/ui/component.zod.ts | 28 ++++- 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 20d4d8195f..13cc425e65 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -24,7 +24,9 @@ import { ObjectKanbanPropsSchema, } from './component.zod'; import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; -import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema } from './view.zod'; +import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema } from './view.zod'; +import { FieldSchema } from '../data/field.zod'; +import { ALL_CONVERSIONS } from '../conversions/registry'; import { strictObjectDeclarations } from '../shared/strict-object'; describe('PageHeaderProps', () => { @@ -858,6 +860,168 @@ describe('RecordRelatedListProps', () => { }); }); +// =========================================================================== +// #18639 — `record:related_list.columns` IS the saved-view `ListColumn` union +// =========================================================================== +/** + * Ruling A on objectui#9593 (decision batch #144 item 3, maintainer verbatim + * 「9593 A,其他同意」): `RecordRelatedListProps.columns` declares the SAME + * union as the saved-view key `listViews[].columns`, because objectui composes + * a saved view's columns onto this block VERBATIM (`dataSource.view` → + * `composeElementDataSource` → `savedViewColumns`). Before it, two published + * declarations disagreed about one key. + * + * What is pinned here: + * + * 1. both spellings parse, and the decoration SURVIVES the parse — a + * `describe()` promising decoration a parse strips would be the defect + * class this card exists to close, one layer over; + * 2. it is the same DECLARATION, not a lookalike: the object arm is the + * `ListColumnSchema` binding itself (reference identity, and the refusal + * text carries that schema's own surface word), and block and view give + * every fixture the same verdict; + * 3. what it still REFUSES — an unknown member on a column object, a MIXED + * array (the arms are exclusive), a non-array, an identity-less object; + * 4. the prose↔schema agreement, so the two cannot drift apart silently; + * 5. the ruling's two scope fences, held by measurement rather than intent. + */ +describe('RecordRelatedListProps.columns — the saved-view ListColumn union (#18639)', () => { + const base = { objectName: 'contact', relationshipField: 'account_id' }; + const parse = (columns: unknown) => RecordRelatedListProps.safeParse({ ...base, columns }); + /** Refuse `columns` and hand back the issues as a searchable string. */ + const refusalOf = (columns: unknown): string => { + const r = parse(columns); + expect(r.success, `expected REJECTION of ${JSON.stringify(columns)}`).toBe(false); + return JSON.stringify(r.error?.issues ?? []); + }; + + it('still accepts the legacy field-name string array', () => { + const r = parse(['name', 'email']); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(r.data?.columns).toEqual(['name', 'email']); + }); + + it("accepts a saved view's decorated columns, and the decoration survives the parse", () => { + const decorated = [ + { field: 'name', label: 'Name', link: true }, + { field: 'amount', width: 120, align: 'right', summary: 'sum' }, + { field: 'internal_note', hidden: true, sortable: false }, + ]; + const r = parse(decorated); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + // Not just "it parsed": the keys the ruling wants on the screen are still + // there afterwards. A strip would satisfy `success` and lose the point. + expect(r.data?.columns).toMatchObject(decorated); + }); + + it('is the SAME union the saved-view key declares — the object arm IS ListColumnSchema', () => { + /** The element schema of a `columns` union's object arm, both carriers. */ + const objectArmElement = (schema: unknown): unknown => { + const def = (schema as { _zod: { def: Record } })._zod.def; + const union = (def.type === 'optional' + ? (def.innerType as { _zod: { def: Record } })._zod.def + : def) as { type: string; options: Array<{ _zod: { def: { element: unknown } } }> }; + expect(union.type).toBe('union'); + expect(union.options).toHaveLength(2); + return union.options[1]._zod.def.element; + }; + const blockArm = objectArmElement((RecordRelatedListProps as unknown as { + shape: Record; + }).shape.columns); + const viewArm = objectArmElement((ListViewSchema as unknown as { + shape: Record; + }).shape.columns); + // Reference identity, not structural resemblance: one def, two carriers. + expect(blockArm).toBe(ListColumnSchema); + expect(viewArm).toBe(ListColumnSchema); + expect(blockArm).toBe(viewArm); + }); + + it('agrees with `listViews[].columns` on every fixture — one union, two carriers', () => { + const fixtures: unknown[] = [ + ['name', 'email'], + [{ field: 'amount', label: 'Amount', width: 120 }], + [{ field: 'amount', summary: { type: 'avg', field: 'total' } }], + [{ field: 'name', prefix: { field: 'status', type: 'badge' } }], + [{ field: 'amount', bogus: 1 }], + ['name', { field: 'amount' }], + [{ field: 'name', prefix: { field: 'status', type: 'chip' } }], + [{}], + 'name', + { field: 'name' }, + ]; + for (const columns of fixtures) { + const view = (ListViewSchema as unknown as { + safeParse: (v: unknown) => { success: boolean }; + }).safeParse({ columns }); + expect( + parse(columns).success, + `block and saved view disagree about ${JSON.stringify(columns)}`, + ).toBe(view.success); + } + }); + + it("refuses an unknown member on a column object — through ListColumnSchema's own strictness", () => { + const text = refusalOf([{ field: 'amount', bogus: 1 }]); + expect(text).toContain('"code":"unrecognized_keys"'); + expect(text).toContain('bogus'); + // The named-surface refusal is ListColumnSchema's own text. A re-spelled + // lookalike would refuse too, and would not say this. + expect(text).toContain('this list column'); + }); + + it('refuses a MIXED array — the two arms are exclusive, exactly as the describe says', () => { + const text = refusalOf(['name', { field: 'amount' }]); + expect(text).toContain('"code":"invalid_union"'); + }); + + it('refuses a non-array, and an object entry with no resolvable field', () => { + refusalOf('name'); + refusalOf({ field: 'name' }); + refusalOf([{}]); + refusalOf([{ label: 'Amount' }]); + }); + + it('the describe() says what the schema does — override chain and the ListColumn spelling', () => { + const text = String((RecordRelatedListProps as unknown as { + shape: Record; + }).shape.columns.description); + // The override chain the ruling asked the describe to name … + expect(text).toContain('Override chain: child highlightFields → field-level relatedListColumns'); + // … that a view-supplied list may arrive in the ListColumn spelling … + expect(text).toContain('`ListColumn`'); + expect(text).toContain('listViews[].columns'); + expect(text).toContain('verbatim'); + // … and the exclusivity the schema really enforces (pinned above). + expect(text).toContain('the two arms are exclusive'); + }); +}); + +describe('#18639 scope fences — held by measurement, not by intent', () => { + it('`field.relatedListColumns` is still strings-only — a ListColumn entry is refused at the field door', () => { + const lookup = { name: 'project', label: 'Project', type: 'lookup', reference: 'showcase_project' }; + const field = (relatedListColumns: unknown) => + FieldSchema.safeParse({ ...lookup, relatedListColumns }); + expect(field(['status', 'amount']).success).toBe(true); + const refused = field([{ field: 'amount', label: 'Amount', width: 120 }]); + expect(refused.success, 'the sibling key stays strings-only by ruling').toBe(false); + expect(JSON.stringify(refused.error?.issues)).toContain('FIELD-NAME strings'); + }); + + it('the `field-column-lists-canonicalized` conversion is unchanged — object entries still fold to the identity string', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'field-column-lists-canonicalized'); + expect(entry, 'the ruling keeps this conversion exactly as it is').toBeDefined(); + expect(entry?.surface).toBe('field.inlineColumns[].field / field.relatedListColumns[] object entries'); + const after = entry?.fixture.after as { + objects: Array<{ fields: Record }>; + }; + // `{ field: 'status', label: 'Status' }` → `'status'`: the decoration is + // still DROPPED on this key, which is precisely what widening the block + // sibling does NOT do. + expect(after.objects[0].fields.project.relatedListColumns).toEqual(['status', 'amount', 'issued_on']); + }); +}); + describe('RecordHighlightsProps', () => { it('should accept valid highlights', () => { const props = { fields: ['name', 'status', 'amount'] }; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d663b3c630..4d341f733c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -12,6 +12,11 @@ import { // standalone element cannot fork the vocabulary a view already declares. NavigationConfigSchema, TimelineConfigSchema, + // [#18639] `record:related_list.columns` is the SAME union the saved-view key + // declares, taken by reference for the same reason: objectui composes a saved + // view's `columns` onto this block verbatim, so a second spelling of the + // member schema would be a second thing to drift. + ListColumnSchema, } from './view.zod'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -1150,7 +1155,28 @@ export const RecordRelatedListProps = strictObject({ * parent-side value written by the Add picker. */ relationshipValueField: z.string().default('id').describe("Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions)."), - columns: z.array(z.string()).optional().describe('Fields to display in the related list. Optional: when omitted, columns derive from the related object\'s highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list.'), + /** + * [#18639] The SAME union `listViews[].columns` declares (`view.zod.ts`) — + * not a lookalike: `ListColumnSchema` is imported from the view face, so two + * published declarations of one key cannot drift apart. The composition that + * makes them one key is objectui's: `dataSource.view` → + * `composeElementDataSource` → `savedViewColumns`, copied onto this block + * VERBATIM, so a decorated saved view arrives here already in the + * `ListColumn` spelling. + * + * ⛔ The two arms are EXCLUSIVE, and the `describe()` below says so because + * the schema enforces it: `['name', { field: 'amount' }]` matches neither + * `z.array(z.string())` nor `z.array(ListColumnSchema)` and is refused. + * + * ⛔ The sibling `field.relatedListColumns` (`field.zod.ts`) is NOT widened + * with it — that key is child field-name STRINGS only (#9227), and the + * `field-column-lists-canonicalized` conversion that folds its object entries + * back to strings stays as ruled. + */ + columns: z.union([ + z.array(z.string()), // field names + z.array(ListColumnSchema), // the saved view's own per-column decoration + ]).optional().describe('Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view\'s `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object\'s highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list.'), sort: z.union([ z.string(), z.array(strictObject({ From 007310f74dc8364516456c1e922c2367da03deaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 02:16:17 +0000 Subject: [PATCH 2/4] chore(spec): changeset + regenerated artifacts for the widened related-list columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:generated` proved exactly two artifacts stale and `--fix` regenerated those two, never the whole set: `api-surface-declarations/ui.txt` (the declaration text of `RecordRelatedListProps` and `ComponentPropsMap`) and `content/docs/references/ui/component.mdx`, which now renders the nested `RecordRelatedListProps.columns[number]` shape the union admits. Changeset: `@objectstack/spec` minor, `Clause-②: yes (widening)`. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...9-related-list-columns-listcolumn-union.md | 15 ++ content/docs/references/ui/component.mdx | 21 ++- packages/spec/api-surface-declarations/ui.txt | 138 +++++++++++++++++- 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 .changeset/18639-related-list-columns-listcolumn-union.md diff --git a/.changeset/18639-related-list-columns-listcolumn-union.md b/.changeset/18639-related-list-columns-listcolumn-union.md new file mode 100644 index 0000000000..da5b62ce4d --- /dev/null +++ b/.changeset/18639-related-list-columns-listcolumn-union.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +`record:related_list.columns` now declares the SAME union the saved-view key declares — `z.union([z.array(z.string()), z.array(ListColumnSchema)])` — so a saved view's per-column decoration reaches the related list instead of being refused at the block door (#18639, the upstream half of objectui#9593). + +**Clause-②: yes (widening)** — one published accept set grows: the key admitted `string[]` and now also admits `ListColumn[]`. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write the new arm. Contract-review tier. + +Two published declarations disagreed about one key. `RecordRelatedListProps.columns` (`ui/component.zod.ts`) was `z.array(z.string())`, while `listViews[].columns` (`ui/view.zod.ts`) was already the union — and objectui composes a saved view's `columns` onto this block **verbatim** (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). A view whose columns carried `label` / `width` / `hidden` / `summary` therefore arrived at a block that declared it could not carry them. + +- **The same union, by reference — not a lookalike.** `ListColumnSchema` is imported from the view face rather than re-spelled, so the object arm is one def with two carriers. The pin asserts reference identity on both sides and then asserts block and saved view return the same verdict for every fixture: two spellings of one key is the defect this closes, so a second spelling would not have fixed it. +- **The arms are exclusive, and the description says so because the schema enforces it.** `['name', { field: 'amount' }]` matches neither arm and is refused. The decoration also survives the parse — a description promising keys a parse strips would be the same defect one layer up, so the pin asserts the parsed value, not merely `success`. +- **Unchanged, by ruling and by measurement.** `field.relatedListColumns` stays child field-name STRINGS only and still refuses a column object with its derivation prescription, and the `field-column-lists-canonicalized` conversion still folds an object entry on that key to its identity string. Both are pinned next to the widening so the fences cannot erode quietly. + +No migration: authors writing `string[]` are unaffected, and the new arm is opt-in. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4c6811e7a7..f9270fb19f 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1365,7 +1365,7 @@ Type: `string` | **objectName** | `string` | ✅ | Related object name (e.g., "task", "opportunity") | | **relationshipField** | `string` | ✅ | Field on related object that points to this record (e.g., "account_id") | | **relationshipValueField** | `string` | optional (default: `"id"`) | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | -| **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | @@ -1377,6 +1377,25 @@ Type: `string` | **redactFields** | `string[]` | optional | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +### Nested Shape: `RecordRelatedListProps.columns[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name (snake_case) | +| **label** | `string \| Record` | optional | Display label override | +| **width** | `number` | optional | Column width in pixels | +| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment | +| **hidden** | `boolean` | optional | Hide column by default | +| **sortable** | `boolean` | optional | Allow sorting by this column | +| **resizable** | `boolean` | optional | Allow resizing this column | +| **wrap** | `boolean` | optional | Allow text wrapping | +| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") | +| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side | +| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field | +| **prefix** | `{ field: string; type: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | +| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | +| **action** | `string` | optional | Registered Action ID to execute when clicked | + ### Nested Shape: `RecordRelatedListProps.filter[number]` View filter rule diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 44957c6290..2b350eb0b8 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -3097,7 +3097,74 @@ declare const ComponentPropsMap: { objectName: z.ZodString; relationshipField: z.ZodString; relationshipValueField: z.ZodDefault; - columns: z.ZodOptional>; + columns: z.ZodOptional, z.ZodArray & { + key?: never; + defaultValue?: never; + }, Record & { + key?: never; + defaultValue?: never; + }, z.core.$ZodTypeInternals & { + key?: never; + defaultValue?: never; + }, Record & { + key?: never; + defaultValue?: never; + }>>]>>; + width: z.ZodOptional; + align: z.ZodOptional>; + hidden: z.ZodOptional; + sortable: z.ZodOptional; + resizable: z.ZodOptional; + wrap: z.ZodOptional; + type: z.ZodOptional; + pinned: z.ZodOptional>; + summary: z.ZodOptional, z.ZodObject<{ + type: z.ZodEnum<{ + count: "count"; + none: "none"; + min: "min"; + max: "max"; + sum: "sum"; + avg: "avg"; + count_empty: "count_empty"; + count_filled: "count_filled"; + count_unique: "count_unique"; + percent_empty: "percent_empty"; + percent_filled: "percent_filled"; + }>; + field: z.ZodOptional; + }, z.core.$strict>]>>; + prefix: z.ZodOptional>; + }, z.core.$strict>>; + link: z.ZodOptional; + action: z.ZodOptional; + }, z.core.$strict>>]>>; sort: z.ZodOptional; - columns: z.ZodOptional>; + columns: z.ZodOptional, z.ZodArray & { + key?: never; + defaultValue?: never; + }, Record & { + key?: never; + defaultValue?: never; + }, z.core.$ZodTypeInternals & { + key?: never; + defaultValue?: never; + }, Record & { + key?: never; + defaultValue?: never; + }>>]>>; + width: z.ZodOptional; + align: z.ZodOptional>; + hidden: z.ZodOptional; + sortable: z.ZodOptional; + resizable: z.ZodOptional; + wrap: z.ZodOptional; + type: z.ZodOptional; + pinned: z.ZodOptional>; + summary: z.ZodOptional, z.ZodObject<{ + type: z.ZodEnum<{ + count: "count"; + none: "none"; + min: "min"; + max: "max"; + sum: "sum"; + avg: "avg"; + count_empty: "count_empty"; + count_filled: "count_filled"; + count_unique: "count_unique"; + percent_empty: "percent_empty"; + percent_filled: "percent_filled"; + }>; + field: z.ZodOptional; + }, z.core.$strict>]>>; + prefix: z.ZodOptional>; + }, z.core.$strict>>; + link: z.ZodOptional; + action: z.ZodOptional; + }, z.core.$strict>>]>>; sort: z.ZodOptional Date: Sun, 20 Sep 2026 02:58:54 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(spec):=20the=20related-list=20column?= =?UTF-8?q?=20keys=20diverge=20on=20purpose=20=E2=80=94=20say=20so=20in=20?= =?UTF-8?q?both=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening `record:related_list.columns` to the saved-view `ListColumn` union falsified one clause in two in-repo comments, both of which claimed the page-block sibling was "the same strings-only shape" as `field.relatedListColumns`. Both now state what is true after the change AND that the divergence is deliberate: the block key takes the saved-view union because objectui composes a saved view's columns onto it verbatim, while `field.relatedListColumns` stays child field-name strings only by ruling and its `field-column-lists-canonicalized` conversion still folds object entries to the identity string. Comment text only — no schema, no refusal behaviour, no conversion and no assertion is touched, and the diff carries no non-comment line. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/src/data/field.zod.ts | 15 +++++++++++++-- .../spec/src/data/inline-related-columns.test.ts | 11 ++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index e16ed2b9ef..8bf5f10619 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1454,8 +1454,19 @@ export const FieldSchema = lazySchema(() => { * Deliberately narrower than `inlineColumns`: the related list is not an * editable grid, and per-column display overrides are not part of its * measured renderer contract (objectui RelatedList hydrates string entries - * fully; the page-block sibling `record:related_list.columns` is the same - * strings-only shape). Column OBJECTS are refused with a prescription. + * fully). Column OBJECTS are refused with a prescription. + * + * ⚠️ AND deliberately narrower than the PAGE-BLOCK sibling, which is no + * longer the same shape (#18639). `record:related_list.columns` + * (`ui/component.zod.ts`) now declares the SAME union as the saved-view key + * `listViews[].columns` — field-name strings OR `ListColumnSchema` entries — + * because objectui composes a saved view's `columns` onto that block + * VERBATIM, so a decorated list arrives there already in the `ListColumn` + * spelling. THIS key stays strings-only BY RULING: objectui#9593 ruling A + * widened that one and fenced this one in the same breath, and the + * `field-column-lists-canonicalized` conversion still folds an object entry + * here down to its identity string. The two keys diverge ON PURPOSE — + * ⛔ do not "align" them. */ relatedListColumns: z.array(z.string({ error: (issue) => issue.code === 'invalid_type' diff --git a/packages/spec/src/data/inline-related-columns.test.ts b/packages/spec/src/data/inline-related-columns.test.ts index ec21115946..b437ca5d89 100644 --- a/packages/spec/src/data/inline-related-columns.test.ts +++ b/packages/spec/src/data/inline-related-columns.test.ts @@ -25,9 +25,14 @@ * parse clean and render every computed cell '—'. The refusal is the * producer-side guard for that renderer fact. * 4. `relatedListColumns`: child field-name STRINGS only, matching every - * in-repo usage and the strings-only page-block sibling - * (`record:related_list.columns`, ui/component.zod.ts). A column OBJECT - * is refused with the derivation prescription. + * in-repo usage. A column OBJECT is refused with the derivation + * prescription. ⚠️ Since #18639 this is NO LONGER the same shape as the + * page-block sibling: `record:related_list.columns` + * (ui/component.zod.ts) declares the saved-view `ListColumnSchema` union, + * because a saved view's columns are composed onto that block verbatim. + * THIS key stays strings-only by ruling — objectui#9593 ruling A widened + * that one and fenced this one — so the divergence is DELIBERATE, and + * every pin below is unchanged by it. * 5. The showcase invoice fixture form — identity-only `{ name }` entries — * parses, so the one authored in-repo usage stays green in the spelling * the renderer actually reads. From 91b9137c498b0202023dbbc58f86122750e2067a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 04:30:08 +0000 Subject: [PATCH 4/4] chore(spec): regenerate ui.txt and component.mdx from the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main` moved under this branch: #19226 landed the author-settable row ceiling and touched the same two generated artifacts this branch owns. Both are routed to the `os-regen` merge driver, which merges them with exit 0 while silently keeping one side, so `scripts/pm/os-regen-merge.sh` was run: it merged `origin/main`, took main's side of the two artifacts in the worktree, and committed the merge first. This is its step 4 — regenerate on the committed merge, never a text merge and never a hand edit. Regenerated with the repo's own tooling on a REAL build (no `OS_SKIP_DTS`): `build` -> `gen:schema` -> `gen:api-surface-declarations` -> `gen:docs`. `check:api-surface-declarations` reads "declaration text unchanged (17 entry points, 5364 declarations)" and `check:generated` is green on all 16 artefacts. Both sides asserted present afterwards, against the STAGED index blobs as well as the worktree, with a dark control reading 0: #19226's `ui/GalleryConfig:limit` / `ui/KanbanConfig:limit` / `ui/TimelineConfig:limit`, `DEFAULT_VIEW_ROW_LIMIT` and `KanbanConfigParsed`; and this branch's own `RecordRelatedListProps.columns` union with its nested `columns[number]` docs table. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 1 + packages/spec/api-surface-declarations/ui.txt | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index f9270fb19f..d40ed33acb 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -828,6 +828,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | ### Nested Shape: `ObjectTimelineProps.filter[number]` diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 2b350eb0b8..2cfe91bb48 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -12,8 +12,8 @@ # excluded: documentation drift is `check:docs`'s axis, not this one. # # entry: ./ui -# exported names: 474 -# declarations: 488 +# exported names: 476 +# declarations: 490 # # GENERATED — ⛔ never hand-edited. Regenerate after a real build: # pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations @@ -5402,6 +5402,7 @@ declare const ComponentPropsMap: { week: "week"; quarter: "quarter"; }>>; + limit: z.ZodDefault; }, z.core.$strict>>; filter: z.ZodOptional) => Dashboard; @@ -8811,6 +8815,7 @@ declare const GalleryConfigSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>; // ── GanttConfig (type) ── @@ -9754,12 +9759,16 @@ declare const KNOWN_COMPONENT_TYPE_CANDIDATES: readonly string[]; // ── KanbanConfig (type) ── type KanbanConfig = z.input; +// ── KanbanConfigParsed (type) ── +type KanbanConfigParsed = z.infer; + // ── KanbanConfigSchema (const) ── declare const KanbanConfigSchema: z.ZodObject<{ groupByField: z.ZodString; summarizeField: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>; // ── LIST_VIEW_GROUP_COUNT_ALIAS (const) ── @@ -10220,6 +10229,7 @@ declare const ListViewSchema: z.ZodObject<{ summarizeField: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; timeline: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; filter: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>; // ── TreeConfig (type) ── @@ -20993,6 +21010,7 @@ declare const VIEW_METADATA_MEMBERS: { }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; timeline: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional