Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/18639-related-list-columns-listcolumn-union.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 20 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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 |
Expand All @@ -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<string, string>; 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<string, string>` | 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
Expand Down
138 changes: 136 additions & 2 deletions packages/spec/api-surface-declarations/ui.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3097,7 +3097,74 @@ declare const ComponentPropsMap: {
objectName: z.ZodString;
relationshipField: z.ZodString;
relationshipValueField: z.ZodDefault<z.ZodString>;
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodArray<z.ZodObject<{
field: z.ZodString;
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
key?: never;
defaultValue?: never;
}, Record<string, string> & {
key?: never;
defaultValue?: never;
}, z.core.$ZodTypeInternals<Record<string, string> & {
key?: never;
defaultValue?: never;
}, Record<string, string> & {
key?: never;
defaultValue?: never;
}>>]>>;
width: z.ZodOptional<z.ZodNumber>;
align: z.ZodOptional<z.ZodEnum<{
left: "left";
right: "right";
center: "center";
}>>;
hidden: z.ZodOptional<z.ZodBoolean>;
sortable: z.ZodOptional<z.ZodBoolean>;
resizable: z.ZodOptional<z.ZodBoolean>;
wrap: z.ZodOptional<z.ZodBoolean>;
type: z.ZodOptional<z.ZodString>;
pinned: z.ZodOptional<z.ZodEnum<{
left: "left";
right: "right";
}>>;
summary: z.ZodOptional<z.ZodUnion<readonly [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";
}>, 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.ZodString>;
}, z.core.$strict>]>>;
prefix: z.ZodOptional<z.ZodObject<{
field: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{
text: "text";
badge: "badge";
}>>;
}, z.core.$strict>>;
link: z.ZodOptional<z.ZodBoolean>;
action: z.ZodOptional<z.ZodString>;
}, z.core.$strict>>]>>;
sort: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodObject<{
field: z.ZodString;
order: z.ZodEnum<{
Expand Down Expand Up @@ -19236,7 +19303,74 @@ declare const RecordRelatedListProps: z.ZodObject<{
objectName: z.ZodString;
relationshipField: z.ZodString;
relationshipValueField: z.ZodDefault<z.ZodString>;
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodArray<z.ZodObject<{
field: z.ZodString;
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
key?: never;
defaultValue?: never;
}, Record<string, string> & {
key?: never;
defaultValue?: never;
}, z.core.$ZodTypeInternals<Record<string, string> & {
key?: never;
defaultValue?: never;
}, Record<string, string> & {
key?: never;
defaultValue?: never;
}>>]>>;
width: z.ZodOptional<z.ZodNumber>;
align: z.ZodOptional<z.ZodEnum<{
left: "left";
right: "right";
center: "center";
}>>;
hidden: z.ZodOptional<z.ZodBoolean>;
sortable: z.ZodOptional<z.ZodBoolean>;
resizable: z.ZodOptional<z.ZodBoolean>;
wrap: z.ZodOptional<z.ZodBoolean>;
type: z.ZodOptional<z.ZodString>;
pinned: z.ZodOptional<z.ZodEnum<{
left: "left";
right: "right";
}>>;
summary: z.ZodOptional<z.ZodUnion<readonly [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";
}>, 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.ZodString>;
}, z.core.$strict>]>>;
prefix: z.ZodOptional<z.ZodObject<{
field: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{
text: "text";
badge: "badge";
}>>;
}, z.core.$strict>>;
link: z.ZodOptional<z.ZodBoolean>;
action: z.ZodOptional<z.ZodString>;
}, z.core.$strict>>]>>;
sort: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodObject<{
field: z.ZodString;
order: z.ZodEnum<{
Expand Down
15 changes: 13 additions & 2 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 8 additions & 3 deletions packages/spec/src/data/inline-related-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading