Skip to content

Commit 4c127cd

Browse files
fix(plugin-detail): record:related_list stops requiring columns, so the page compile accepts a node that lists none (objectui#11613) (#11621)
Fixes #11613 Clause-②: yes ## What changes The `record:related_list` registration in `@object-ui/plugin-detail` (`ComponentRegistry.register('related_list', …)`) no longer declares `required: true` on its `columns` input. The input's description now says where the columns come from when the key is absent. The ledger row `record:related_list.columns` in objectui#11605's enumeration pin is deleted, so that pin now covers this member like the others. This is triage's first grade on the card (comment `5980741977`), under objectui#11605's ruling (a): the spec row `ComponentPropsMap['record:related_list'].columns` is optional, and the registration may not be stricter than the row it publishes. The spec row's own describe says what an omitted list means: "Optional: when omitted, columns derive from the related object's highlightFields / default list columns". No new manifest vocabulary, no new compile rule, no runtime change. `packages/types`, `sdui-parser` and the renderer are not edited. Landing site as dispatched: the registration in `packages/plugin-detail/src/index.tsx`, the ledger row, and tests beside them. ## Accept-set changes (for the contract review) Measured against the manifest the console ships: `emitSduiManifest` over `dev/manifest-registry.ts`, read back from the written `sdui.manifest.json`. "Before" is the registration with `required: true` (base `2abec3a`, re-measured on this branch by ablation 1 below); "after" is this head. Nodes are written as JSON keys here; the pins write the same nodes as JSX. | `type: "record:related_list"` node with | Before | After | |:--|:--|:--| | `objectName`, `relationshipField`, `dataSource: { object, view }`, no `columns` | `ok: false`, one `missing-required-prop` error naming `columns` | `ok: true`, no diagnostics, the binding recorded | | `objectName`, `relationshipField`, no `columns`, no binding | `ok: false`, the same error | `ok: true`, no diagnostics | | `objectName`, `relationshipField`, `dataSource: { object }` (no view), no `columns` | `ok: false`, the same error | `ok: true`, no diagnostics | | `objectName`, `relationshipField`, `columns`, `dataSource: { object, view }` (control) | `ok: true` | the same | | `objectName`, `columns`, no `relationshipField` (control) | refused, one error naming `relationshipField` | the same | | `relationshipField`, `columns`, no `objectName`, bound by `dataSource: { object }` or not (control) | refused, one error naming `objectName` | the same; the spec row requires it | **What the accept set gains:** exactly the nodes that omit `columns` and carry everything else the gate already required (`objectName` and `relationshipField`), with or without a `dataSource` binding. **What stays refused:** a node without `objectName`, bound or not, and a node without `relationshipField`. **Unchanged, and not refusals before or after:** a `columns` value of the wrong kind (a number or a string) draws a `type-mismatch` warning with `ok: true`; a spec-legal column object draws a `member-type-mismatch` warning with `ok: true` (Acceptance notes). Neither check reads `required`. ## What a columns-less node draws (measured, not assumed) Triage's premise, that the neither node auto-derives its columns, is now a measurement. `RecordRelatedListRenderer.columnsOptional-11613.test.tsx` renders through the real renderer, the real `ElementDataSourceGate`, the real `RelatedList` and the real table, and reads header and body cells: - The neither node draws columns derived from the related object (`Subject`, `Status`, `Priority`, and not the foreign key back to the parent). This holds unbound and bound by object alone. No hint, no blank, no throw. - The view-bound node draws the view's columns only (`Subject`). - Authored `columns` win over the view and over the derivation. ## Who reads the registration's `required` for this input Enumerated by grepping non-test sources for reads of `required` off registration or manifest `inputs`: - `@object-ui/sdui-parser` `validateTree`, the `missing-required-prop` check (`input.required && !(input.name in node)`). It is the page compile and its `ok`, the save gate. It now accepts the nodes above. - `@object-ui/components`' page renderer, the `kind: 'html'` page compile (`manifestFromConfigs` over the live registry). The same validator at render time, so the same widening. Source reading, not separately measured. - `@object-ui/sdui-parser` codegen. `generateDts` now writes `columns?: string[]` on `RecordRelatedListProps`, and `generateBlockList`'s required column for the tag reads `objectName, relationshipField`. Measured once on this branch by a throwaway probe, not committed. - Downstream: the objectstack tools that load the shipped `sdui.manifest.json` (the CLI's `compile`, `lint` and `validate`, and `metadata-protocol`'s runtime authoring gate) inherit the widening when the next console manifest ships. Not measured here. - Studio's designer and the AI prompt keys: no reader of the registration's `required` found. `check:prompt-keys` reads the key bullets in `.github/prompts/**`, not `required`, and no prompt teaches `columns` as required. ## Generated artifacts `sdui.manifest.json`, `sdui-intrinsics.d.ts` and `sdui-blocks.md` are not tracked: no tracked file has those names, and the positive control `apps/console/scripts/emit-sdui-manifest.ts` is tracked. They regenerate from the registration in the console build, as for objectui#11605. This diff has no regeneration step. ## Pins - New `apps/console/src/__tests__/related-list-columns-optional-11613.test.ts`, 7 rows, judged against the shipped manifest: - The published `columns` is not required, and its description names `dataSource` and `highlightFields`. `objectName` and `relationshipField` stay required. - The view-bound node, the neither node, and the neither node bound by object alone each compile `ok` with no diagnostic. - Three controls: authored columns beside a view; no `relationshipField`; no `objectName`, bound or not. - New `packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnsOptional-11613.test.tsx`, 5 rows, as described above. - `apps/console/src/__tests__/objectname-binding-required-11605.test.ts`: the `LEDGER` is now empty and says why. Its stale-row check stays, so a future row is still judged. **Ablations** on the committed fix (`0624118`), through objectstack's `scripts/ablation-replace.mjs`. The console project aliases `@object-ui/plugin-detail` and `@object-ui/sdui-parser` to `src`, so a mutation reaches the tests without a build. 1. `required: true` put back on `columns` (`packages/plugin-detail/src/index.tsx`, blob `62fba905fb9f` became `23df5500cb78`), with the two console files run. Exactly 5 rows went red and 50 passed. Red: the enumeration row for `record:related_list` (its message names `"columns"`), the published-input row, and the three columns-less compile rows, each with a `missing-required-prop` naming `columns`. Every control stayed green. 2. `RelatedList`'s derivation switched off (the `if (!objectSchema?.fields) return [];` guard made unconditional in `RelatedList.tsx`, blob `3c5bbfc1b87d` became `e5a0270e381b`), with the render file run. Exactly the 2 neither-node rows went red and 3 passed. Each restore is proven by the tool: blob equals HEAD and `git diff HEAD` is empty. The render rows need no "before" run: the runtime is not changed, and they read triage's premise. ## Gates Final head `305c738`, which merges `main` at `6e9090c`. Every run below is on that head, from the repo root. - `pnpm exec vitest run packages/plugin-detail/`, run as two path-scoped halves to stay under the foreground time cap. `src/__tests__/`: 149 files passed, 1 skipped; 1335 tests passed, 8 skipped. `src/renderers/`, `src/synth/` and the 7 top-level test files: 86 files, 991 tests passed. The 236 files are all of the package's tracked test files. - `pnpm exec vitest run apps/console/`, as two halves. `src/__tests__/`: 51 files, 880 tests passed. The rest: 97 files, 888 tests passed. The 148 files are all of the console's tracked test files. - `pnpm --filter @object-ui/plugin-detail type-check` (echoed `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0, after `turbo run build --filter='@object-ui/console^...'` rebuilt the dependency closure on this head. `--listFiles`, run once before the merge, shows the new render test is in the test program. - `pnpm --filter @object-ui/console type-check` (echoed `tsc --noEmit && tsc -b tsconfig.node.json --force`): exit 0. Its tsconfig includes `src`, so the new console test is in the program. - Lint, narrowed to the 4 touched code files with `eslint --format json`: 4 files, 0 errors, 34 warnings. `index.tsx` carries 32, the same as at base `2abec3a` read through `--stdin`. The new render test carries 2 `no-explicit-any`, the pattern its sibling `redactedDerivation-9053` test uses. The narrowing is a measurement because: each file is linted by the root `eslint.config.js` (`--print-config` exit 0 for each); the count comes from the JSON output; and `eslint.config.js` enables no type-aware linting (no `parserOptions.project`, no `projectService`), and no rule under `eslint-rules/` reads the disk, so this diff cannot move any untouched file's verdict. - Root checks, each exit 0: `check-changeset-presence.mjs`, `check-changeset-no-major.mjs`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations` (0 new citations), `check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:element-data-source-declaration`, `check:prompt-keys`, `check:component-surface-parity` (report-only), `check:unreferenced-sources`. - The changeset scripts' own suites (`scripts/__tests__/` no-major, presence, claims, fixed, overwrite, polarity census): 6 files, 277 tests passed. CI runs the full farm. ## Changeset `.changeset/11613-plugin-detail-related-list-columns-optional.md`: `@object-ui/plugin-detail` `minor`, with the widening paragraph opening `**Clause-②: yes (widening)**`. ## Acceptance notes - **The member kind on `columns`.** Measured on this head: a spec-legal column object (`{ field, label }`) compiles `ok: true` with a `member-type-mismatch` warning, because the registration declares `of: 'string'` while the spec row admits string or `ListColumn` members. This is already recorded in-tree as a gap, in `registry-inputs-spec-parity.test.ts`'s `record:related_list.columns` entry. This PR does not change it. - **A real producer of the neither node.** The platform's own default-record-page synthesizer (`buildDefaultPageSchema` in this package, fed by app-shell's `deriveRelatedLists`) emits `record:related_list` nodes with no `columns` whenever the foreign key declares no `relatedListColumns`. Whether such a node ever reached the save gate was not measured. - **The dispatch brief's churn note.** It said PR objectui#11612 last touched both files. Measured: that PR touched the enumeration pin only. `packages/plugin-detail/src/index.tsx` was last changed by objectui#11531 (`6158e4c`). - **Docs.** The binding doc's paragraph on the `objectName` exception stays true. No doc or README says `columns` is required (grep over `content/docs` and the package README), so no doc edit is owed. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c096f03 commit 4c127cd

5 files changed

Lines changed: 339 additions & 13 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/plugin-detail': minor
3+
---
4+
5+
The `record:related_list` registration no longer declares `columns` required, so
6+
the page compile accepts a related list that lists no columns of its own
7+
(objectui#11613).
8+
9+
`@objectstack/spec`'s `record:related_list` row leaves `columns` optional, and the
10+
renderer agrees: a `dataSource` binding that names a view lands that view's
11+
columns on the node, and with neither the list derives its columns from the
12+
related object (its `highlightFields`, otherwise its listable fields). The
13+
registration still declared `required: true`, and the page compile reads the
14+
registration, so a node with no `columns` was refused with
15+
`missing-required-prop` and the save failed.
16+
17+
**Clause-②: yes (widening)** — a `record:related_list` node that sets no `columns`
18+
now compiles and saves, whether a `dataSource` binding names a view (the list
19+
draws the view's columns) or not (the list draws columns derived from the related
20+
object, as it already did when such a node reached it). Authored `columns` still
21+
win over both. `objectName` and `relationshipField` are still required, as the
22+
spec row requires them. The published `columns` input now carries a description
23+
that says where the columns come from when it is absent.

‎apps/console/src/__tests__/objectname-binding-required-11605.test.ts‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,15 @@ const specRowRequires = (tag: string, key: string): boolean => {
160160
* Required inputs that the binding supplies and the spec row does not require,
161161
* each with the reason it stays required. Entries are debt, not acceptance:
162162
* row 2 fails on one that no longer describes the manifest.
163+
*
164+
* Empty. Its last row, `record:related_list.columns`, was moved rather than
165+
* kept (objectui#11613): the registration stopped requiring `columns`, as the
166+
* spec row does not, so row 1 now covers that member like the others. A
167+
* columns-less node draws the named view's columns or, with no view, columns
168+
* derived from the related object; the console's
169+
* `related-list-columns-optional-11613.test.ts` pins the compile.
163170
*/
164-
const LEDGER: Readonly<Record<string, Readonly<Record<string, string>>>> = {
165-
'record:related_list': {
166-
// Outside this card's `objectName` family, recorded rather than moved: the
167-
// binding supplies `columns` only through a NAMED VIEW (the gate maps the
168-
// view's field list onto them), never through its own `object`, so a node
169-
// bound by `dataSource.object` alone still needs them. The spec row leaves
170-
// `columns` optional; whether the registration should follow it is a
171-
// separate question, reported on objectui#11605's dev report.
172-
columns:
173-
'supplied only by a named view, not by `dataSource.object`; a node bound by object alone still needs its own columns (objectui#11605 dev report)',
174-
},
175-
};
171+
const LEDGER: Readonly<Record<string, Readonly<Record<string, string>>>> = {};
176172

177173
/* ── 1–3: the enumeration pin ──────────────────────────────────────────────── */
178174

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11613 — the page compile accepts a `record:related_list` node that
11+
* authors no `columns`.
12+
*
13+
* `@objectstack/spec`'s `ComponentPropsMap['record:related_list'].columns` is
14+
* optional, and its describe says what an omitted list means ("columns derive
15+
* from the related object's highlightFields / default list columns"). The
16+
* registration in `@object-ui/plugin-detail` still declared
17+
* `{ name: 'columns', required: true }`, and the page compile (`compile()` in
18+
* `@object-ui/sdui-parser`, whose `ok` is the save gate) reads the
19+
* registration, so it refused two nodes the row and the renderer accept:
20+
*
21+
* - a node whose `dataSource` binding names a VIEW, which supplies the view's
22+
* columns (the block's binding map carries `columns: true`);
23+
* - a node with neither, which `RelatedList` answers by deriving its columns
24+
* from the related object.
25+
*
26+
* Triage ruling on objectui#11613 (after objectui#11605's ruling (a)): the
27+
* registration may not be stricter than the row it publishes, so it stops
28+
* requiring the key. What each node DRAWS is pinned beside the renderer, in
29+
* `RecordRelatedListRenderer.columnsOptional-11613.test.tsx` in
30+
* `@object-ui/plugin-detail`; this file pins the gate.
31+
*
32+
* Judged against the manifest the console SHIPS: `emitSduiManifest` over the
33+
* registry `dev/manifest-registry.ts` loads, read back from the written
34+
* `sdui.manifest.json`, the file a host registers as the page-save gate's
35+
* manifest (objectui#11403).
36+
*
37+
* Rows:
38+
* 1. The published entry declares `columns`, not required, with a description
39+
* that names both other sources (the named view, the derivation);
40+
* `objectName` and `relationshipField` stay required, as the row requires.
41+
* 2. A view-bound node with no `columns` compiles `ok` with no diagnostic, and
42+
* the binding is recorded.
43+
* 3. The neither node compiles `ok` with no diagnostic: unbound, and bound by
44+
* object alone (no view).
45+
* 4. Control: authored `columns` beside a view still compile `ok`.
46+
* 5. Controls on what stays refused: a node without `relationshipField`, and a
47+
* node without `objectName`. Both author `columns`, so they read the same
48+
* before and after the change: they are readings of the gate, not pins.
49+
*/
50+
51+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
52+
import { tmpdir } from 'node:os';
53+
import { join } from 'node:path';
54+
import { afterAll, describe, expect, it } from 'vitest';
55+
import { compile, type Manifest } from '@object-ui/sdui-parser';
56+
// Module scope, not a hook: the whole registration graph loads at import time.
57+
import '../../dev/manifest-registry';
58+
import { emitSduiManifest } from '../../scripts/emit-sdui-manifest';
59+
60+
const scratchDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11613-'));
61+
afterAll(() => {
62+
rmSync(scratchDir, { recursive: true, force: true });
63+
});
64+
65+
/** The shipped `sdui.manifest.json`, read back as the host reads it. */
66+
const shipped = JSON.parse(readFileSync(emitSduiManifest(scratchDir), 'utf8')) as Manifest;
67+
68+
const TAG = 'record:related_list';
69+
70+
const relatedList = (props: string) => `<${TAG} ${props} />`;
71+
72+
const inputNamed = (name: string) => shipped.components[TAG]?.inputs.find((input) => input.name === name);
73+
74+
const diagnosticsOf = (source: string) =>
75+
compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]);
76+
77+
describe('objectui#11613 — record:related_list does not require columns at the page compile', () => {
78+
it('the published entry declares columns as not required, and names where columns come from without it', () => {
79+
const columns = inputNamed('columns');
80+
expect(columns, `${TAG} publishes no columns input`).toBeDefined();
81+
expect(columns?.required).not.toBe(true);
82+
const description = columns?.description ?? '';
83+
expect(description).toContain('`dataSource`');
84+
expect(description).toContain('highlightFields');
85+
// The two members the spec row requires stay required.
86+
expect(inputNamed('objectName')?.required).toBe(true);
87+
expect(inputNamed('relationshipField')?.required).toBe(true);
88+
});
89+
90+
it('a view-bound node with no columns compiles ok, and the binding is recorded', () => {
91+
const result = compile(
92+
relatedList('objectName="task" relationshipField="account" dataSource={{ object: "task", view: "open_tasks" }}'),
93+
shipped,
94+
);
95+
expect(result.diagnostics.map((d) => [d.severity, d.code, d.message])).toEqual([]);
96+
expect(result.ok).toBe(true);
97+
expect(result.bindings).toEqual([
98+
{ tag: TAG, input: 'dataSource', kind: 'object', value: { object: 'task', view: 'open_tasks' } },
99+
]);
100+
});
101+
102+
it('the neither node (no columns, no view) compiles ok', () => {
103+
const result = compile(relatedList('objectName="task" relationshipField="account"'), shipped);
104+
expect(result.diagnostics).toEqual([]);
105+
expect(result.ok).toBe(true);
106+
});
107+
108+
it('the neither node bound by object alone (no view) compiles ok', () => {
109+
const result = compile(
110+
relatedList('objectName="task" relationshipField="account" dataSource={{ object: "task" }}'),
111+
shipped,
112+
);
113+
expect(result.diagnostics).toEqual([]);
114+
expect(result.ok).toBe(true);
115+
});
116+
117+
it('control: authored columns beside a named view still compile ok', () => {
118+
const result = compile(
119+
relatedList(
120+
'objectName="task" relationshipField="account" columns={["priority"]} dataSource={{ object: "task", view: "open_tasks" }}',
121+
),
122+
shipped,
123+
);
124+
expect(result.diagnostics).toEqual([]);
125+
expect(result.ok).toBe(true);
126+
});
127+
128+
it('control: a node without relationshipField is still refused', () => {
129+
const source = relatedList('objectName="task" columns={["subject"]}');
130+
expect(diagnosticsOf(source)).toEqual([
131+
['error', 'missing-required-prop', `<${TAG}> is missing required prop "relationshipField"`],
132+
]);
133+
expect(compile(source, shipped).ok).toBe(false);
134+
});
135+
136+
it('control: a node without objectName is still refused, bound or not', () => {
137+
// The row requires `objectName`, so a binding's object does not waive it
138+
// on this tag (objectui#11605's control, read here beside the change).
139+
for (const source of [
140+
relatedList('relationshipField="account" columns={["subject"]}'),
141+
relatedList('relationshipField="account" columns={["subject"]} dataSource={{ object: "task" }}'),
142+
]) {
143+
expect(diagnosticsOf(source)).toEqual([
144+
['error', 'missing-required-prop', `<${TAG}> is missing required prop "objectName"`],
145+
]);
146+
expect(compile(source, shipped).ok).toBe(false);
147+
}
148+
});
149+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* objectui#11613 — `record:related_list` draws columns whether or not the node
9+
* authors `columns`, so the registration need not require the key.
10+
*
11+
* The spec row (`ComponentPropsMap['record:related_list'].columns`) is
12+
* optional, and its describe says what an omitted list means: "columns derive
13+
* from the related object's highlightFields / default list columns". The
14+
* registration declared `columns` required anyway, so the page compile refused
15+
* a node the row accepts. Dropping `required` (the console's
16+
* `related-list-columns-optional-11613.test.ts` pins the compile) is only
17+
* honest if the node with no authored `columns` still draws a list. This file
18+
* measures that through the REAL renderer, the real `ElementDataSourceGate`,
19+
* the real `RelatedList` and the real table, reading rendered header and body
20+
* cells:
21+
*
22+
* 1. The neither node (no `columns`, no named view) draws columns derived from
23+
* the related object, with or without an object-only `dataSource` binding.
24+
* No hint, no blank, no throw: `RelatedList` reads the unauthored list as
25+
* "nothing authored" and derives.
26+
* 2. The view-bound node (no `columns`, a `dataSource` naming a view) draws the
27+
* VIEW's columns, not the derived set.
28+
* 3. Authored `columns` still win, over the view and over the derivation.
29+
*
30+
* Every read waits on a positive body cell first: the derived set cannot exist
31+
* before the object schema lands, so a table with a cell is a table whose
32+
* columns are settled.
33+
*/
34+
35+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
36+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
37+
import '@testing-library/jest-dom';
38+
import * as React from 'react';
39+
import { RecordContextProvider } from '@object-ui/react';
40+
import { RecordRelatedListRenderer } from '../renderers/record-related-list';
41+
42+
/**
43+
* Desktop, pinned rather than inherited (the objectui#8399 reason): under the
44+
* 768 breakpoint a `type="table"` related list renders a card gallery with no
45+
* header cells, and every assertion here reads them.
46+
*/
47+
beforeAll(() => {
48+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
49+
});
50+
51+
afterEach(() => cleanup());
52+
53+
/** The related object: three listable fields plus the foreign key back to the parent. */
54+
const FIELDS = {
55+
subject: { type: 'text', label: 'Subject' },
56+
status: { type: 'text', label: 'Status' },
57+
priority: { type: 'text', label: 'Priority' },
58+
account_id: { type: 'lookup', label: 'Account', reference: 'account' },
59+
};
60+
61+
/** A saved list view of the related object that lists one column. */
62+
const LIST_VIEWS = {
63+
open_tasks: { name: 'open_tasks', label: 'Open tasks', columns: ['subject'] },
64+
};
65+
66+
const ROWS = [
67+
{ id: 't1', subject: 'Fix the pump', status: 'open', priority: 'high', account_id: 'ACC-1' },
68+
];
69+
70+
const makeDS = () => ({
71+
find: vi.fn(async () => ROWS),
72+
getObjectSchema: vi.fn(async (name: string) => ({ name, fields: FIELDS, listViews: LIST_VIEWS })),
73+
});
74+
75+
/** Every rendered header cell's text, in DOM order. */
76+
const headers = () =>
77+
Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());
78+
79+
/** Every rendered body cell's text, in DOM order. */
80+
const cellTexts = () => screen.getAllByRole('cell').map((c) => (c.textContent || '').trim());
81+
82+
const waitForCell = (text: string) => waitFor(() => expect(cellTexts()).toContain(text));
83+
84+
/** Render the BLOCK end to end, under a record context for the parent `account`. */
85+
function renderBlock(schema: Record<string, unknown>) {
86+
return render(
87+
<RecordContextProvider objectName="account" recordId="ACC-1" dataSource={makeDS() as any}>
88+
<RecordRelatedListRenderer schema={{ relationshipField: 'account_id', ...schema } as any} />
89+
</RecordContextProvider>,
90+
);
91+
}
92+
93+
describe('objectui#11613 — a record:related_list with no authored columns still draws columns', () => {
94+
it('the neither node (no columns, no view) draws columns derived from the related object', async () => {
95+
const { container } = renderBlock({ objectName: 'task' });
96+
await waitForCell('Fix the pump');
97+
98+
// Derived from the object's fields: the listable ones, not the foreign key
99+
// back to this parent, which the walk drops.
100+
expect(headers()).toEqual(expect.arrayContaining(['Subject', 'Status', 'Priority']));
101+
expect(headers()).not.toContain('Account');
102+
expect(cellTexts()).toEqual(expect.arrayContaining(['Fix the pump', 'open', 'high']));
103+
// Not a hint and not the gate's error panel: the list itself is drawn.
104+
expect(container.textContent).not.toContain('missing objectName');
105+
expect(
106+
container.querySelector('[data-testid="record-related-list-datasource-error"]'),
107+
).toBeNull();
108+
});
109+
110+
it('the neither node bound by object alone (no view) draws the same derived columns', async () => {
111+
// An object-only binding supplies no columns: the gate maps a view's
112+
// field list onto `columns`, and there is no view here.
113+
renderBlock({ objectName: 'task', dataSource: { object: 'task' } });
114+
await waitForCell('Fix the pump');
115+
116+
expect(headers()).toEqual(expect.arrayContaining(['Subject', 'Status', 'Priority']));
117+
expect(headers()).not.toContain('Account');
118+
});
119+
120+
it('the view-bound node with no columns draws the view’s columns, not the derived set', async () => {
121+
renderBlock({ objectName: 'task', dataSource: { object: 'task', view: 'open_tasks' } });
122+
await waitForCell('Fix the pump');
123+
124+
expect(headers()).toEqual(['Subject']);
125+
expect(cellTexts()).not.toContain('high');
126+
});
127+
128+
it('authored columns win over a named view', async () => {
129+
renderBlock({
130+
objectName: 'task',
131+
columns: ['priority'],
132+
dataSource: { object: 'task', view: 'open_tasks' },
133+
});
134+
await waitForCell('high');
135+
136+
expect(headers()).toEqual(['Priority']);
137+
expect(cellTexts()).not.toContain('Fix the pump');
138+
});
139+
140+
it('authored columns win over the derivation', async () => {
141+
renderBlock({ objectName: 'task', columns: ['status'] });
142+
await waitForCell('open');
143+
144+
expect(headers()).toEqual(['Status']);
145+
expect(cellTexts()).not.toContain('Fix the pump');
146+
});
147+
});

‎packages/plugin-detail/src/index.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,18 @@ ComponentRegistry.register('related_list', RecordRelatedListRenderer, {
623623
{ name: 'objectName', type: 'string', required: true, description: 'Related object name (e.g. "task")' },
624624
{ name: 'relationshipField', type: 'string', required: true, description: 'Field on the related object pointing back to this record' },
625625
{ name: 'relationshipValueField', type: 'string', description: 'Which field OF THIS PARENT record `relationshipField` stores. Defaults to "id"; set it to the field a name-keyed junction points at (e.g. "name" when sys_user_position.position holds sys_position.name). The resolved value drives three things at once — the list filter, the Add-picker link value, and the pre-filled create form — so they cannot drift apart. While the parent record is still loading, a non-"id" field resolves to null and the list holds its fetch rather than querying on an empty value.' },
626-
{ name: 'columns', type: 'array', of: 'string', required: true, description: 'Fields to display in the related list' },
626+
// `columns` is NOT required (objectui#11613). The spec row leaves it
627+
// optional, and the registration may not be stricter than the row it
628+
// publishes: the page compile reads `required` here, and with it set a node
629+
// the row and the renderer accept was refused at the save gate. Both
630+
// columns-less nodes draw a list: a `dataSource` binding that names a view
631+
// lands the view's columns (`RECORD_RELATED_LIST_DATA_SOURCE` maps
632+
// `columns: true`), and with neither `RelatedList` reads the unauthored list
633+
// as "nothing authored" and derives the columns from the related object.
634+
// Pinned by `RecordRelatedListRenderer.columnsOptional-11613.test.tsx`
635+
// (what draws) and the console's `related-list-columns-optional-11613.test.ts`
636+
// (what compiles).
637+
{ name: 'columns', type: 'array', of: 'string', description: 'Fields to display in the related list. Optional: without it, a `dataSource` binding that names a view supplies that view\'s columns, and with neither the list derives its columns from the related object (its `highlightFields`, otherwise its listable fields). Authored columns win over both.' },
627638
{ name: 'sort', type: 'array' },
628639
{ name: 'limit', type: 'number', description: 'Records to display initially' },
629640
// `type: 'array'` matches the spec (`RecordRelatedListProps.filter` is

0 commit comments

Comments
 (0)