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 4c71915193..d40ed33acb 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1366,7 +1366,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 | @@ -1378,6 +1378,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 ec6a2389bb..2cfe91bb48 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 { * 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. 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({