From f67c707783236e3da9328b0e55b68282dbc790b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:56:13 +0000 Subject: [PATCH 1/2] test(console,plugin-detail): pin a record:related_list with no columns at the compile and at render; drop the columns ledger row (objectui#11613) The compile pins (`related-list-columns-optional-11613.test.ts`) are red on this commit: the registration still declares `columns` required. The render pins measure what a columns-less node draws through the real renderer, gate and table. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../objectname-binding-required-11605.test.ts | 20 +-- ...elated-list-columns-optional-11613.test.ts | 149 ++++++++++++++++++ ...istRenderer.columnsOptional-11613.test.tsx | 147 +++++++++++++++++ 3 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 apps/console/src/__tests__/related-list-columns-optional-11613.test.ts create mode 100644 packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnsOptional-11613.test.tsx diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts index 2de0987b3a..28fcd28e1b 100644 --- a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -160,19 +160,15 @@ const specRowRequires = (tag: string, key: string): boolean => { * Required inputs that the binding supplies and the spec row does not require, * each with the reason it stays required. Entries are debt, not acceptance: * row 2 fails on one that no longer describes the manifest. + * + * Empty. Its last row, `record:related_list.columns`, was moved rather than + * kept (objectui#11613): the registration stopped requiring `columns`, as the + * spec row does not, so row 1 now covers that member like the others. A + * columns-less node draws the named view's columns or, with no view, columns + * derived from the related object; the console's + * `related-list-columns-optional-11613.test.ts` pins the compile. */ -const LEDGER: Readonly>>> = { - 'record:related_list': { - // Outside this card's `objectName` family, recorded rather than moved: the - // binding supplies `columns` only through a NAMED VIEW (the gate maps the - // view's field list onto them), never through its own `object`, so a node - // bound by `dataSource.object` alone still needs them. The spec row leaves - // `columns` optional; whether the registration should follow it is a - // separate question, reported on objectui#11605's dev report. - columns: - '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)', - }, -}; +const LEDGER: Readonly>>> = {}; /* ── 1–3: the enumeration pin ──────────────────────────────────────────────── */ diff --git a/apps/console/src/__tests__/related-list-columns-optional-11613.test.ts b/apps/console/src/__tests__/related-list-columns-optional-11613.test.ts new file mode 100644 index 0000000000..cd48d1c3bb --- /dev/null +++ b/apps/console/src/__tests__/related-list-columns-optional-11613.test.ts @@ -0,0 +1,149 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11613 — the page compile accepts a `record:related_list` node that + * authors no `columns`. + * + * `@objectstack/spec`'s `ComponentPropsMap['record:related_list'].columns` is + * optional, and its describe says what an omitted list means ("columns derive + * from the related object's highlightFields / default list columns"). The + * registration in `@object-ui/plugin-detail` still declared + * `{ name: 'columns', required: true }`, and the page compile (`compile()` in + * `@object-ui/sdui-parser`, whose `ok` is the save gate) reads the + * registration, so it refused two nodes the row and the renderer accept: + * + * - a node whose `dataSource` binding names a VIEW, which supplies the view's + * columns (the block's binding map carries `columns: true`); + * - a node with neither, which `RelatedList` answers by deriving its columns + * from the related object. + * + * Triage ruling on objectui#11613 (after objectui#11605's ruling (a)): the + * registration may not be stricter than the row it publishes, so it stops + * requiring the key. What each node DRAWS is pinned beside the renderer, in + * `RecordRelatedListRenderer.columnsOptional-11613.test.tsx` in + * `@object-ui/plugin-detail`; this file pins the gate. + * + * Judged against the manifest the console SHIPS: `emitSduiManifest` over the + * registry `dev/manifest-registry.ts` loads, read back from the written + * `sdui.manifest.json`, the file a host registers as the page-save gate's + * manifest (objectui#11403). + * + * Rows: + * 1. The published entry declares `columns`, not required, with a description + * that names both other sources (the named view, the derivation); + * `objectName` and `relationshipField` stay required, as the row requires. + * 2. A view-bound node with no `columns` compiles `ok` with no diagnostic, and + * the binding is recorded. + * 3. The neither node compiles `ok` with no diagnostic: unbound, and bound by + * object alone (no view). + * 4. Control: authored `columns` beside a view still compile `ok`. + * 5. Controls on what stays refused: a node without `relationshipField`, and a + * node without `objectName`. Both author `columns`, so they read the same + * before and after the change: they are readings of the gate, not pins. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { compile, type Manifest } from '@object-ui/sdui-parser'; +// Module scope, not a hook: the whole registration graph loads at import time. +import '../../dev/manifest-registry'; +import { emitSduiManifest } from '../../scripts/emit-sdui-manifest'; + +const scratchDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11613-')); +afterAll(() => { + rmSync(scratchDir, { recursive: true, force: true }); +}); + +/** The shipped `sdui.manifest.json`, read back as the host reads it. */ +const shipped = JSON.parse(readFileSync(emitSduiManifest(scratchDir), 'utf8')) as Manifest; + +const TAG = 'record:related_list'; + +const relatedList = (props: string) => `<${TAG} ${props} />`; + +const inputNamed = (name: string) => shipped.components[TAG]?.inputs.find((input) => input.name === name); + +const diagnosticsOf = (source: string) => + compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]); + +describe('objectui#11613 — record:related_list does not require columns at the page compile', () => { + it('the published entry declares columns as not required, and names where columns come from without it', () => { + const columns = inputNamed('columns'); + expect(columns, `${TAG} publishes no columns input`).toBeDefined(); + expect(columns?.required).not.toBe(true); + const description = columns?.description ?? ''; + expect(description).toContain('`dataSource`'); + expect(description).toContain('highlightFields'); + // The two members the spec row requires stay required. + expect(inputNamed('objectName')?.required).toBe(true); + expect(inputNamed('relationshipField')?.required).toBe(true); + }); + + it('a view-bound node with no columns compiles ok, and the binding is recorded', () => { + const result = compile( + relatedList('objectName="task" relationshipField="account" dataSource={{ object: "task", view: "open_tasks" }}'), + shipped, + ); + expect(result.diagnostics.map((d) => [d.severity, d.code, d.message])).toEqual([]); + expect(result.ok).toBe(true); + expect(result.bindings).toEqual([ + { tag: TAG, input: 'dataSource', kind: 'object', value: { object: 'task', view: 'open_tasks' } }, + ]); + }); + + it('the neither node (no columns, no view) compiles ok', () => { + const result = compile(relatedList('objectName="task" relationshipField="account"'), shipped); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + }); + + it('the neither node bound by object alone (no view) compiles ok', () => { + const result = compile( + relatedList('objectName="task" relationshipField="account" dataSource={{ object: "task" }}'), + shipped, + ); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + }); + + it('control: authored columns beside a named view still compile ok', () => { + const result = compile( + relatedList( + 'objectName="task" relationshipField="account" columns={["priority"]} dataSource={{ object: "task", view: "open_tasks" }}', + ), + shipped, + ); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + }); + + it('control: a node without relationshipField is still refused', () => { + const source = relatedList('objectName="task" columns={["subject"]}'); + expect(diagnosticsOf(source)).toEqual([ + ['error', 'missing-required-prop', `<${TAG}> is missing required prop "relationshipField"`], + ]); + expect(compile(source, shipped).ok).toBe(false); + }); + + it('control: a node without objectName is still refused, bound or not', () => { + // The row requires `objectName`, so a binding's object does not waive it + // on this tag (objectui#11605's control, read here beside the change). + for (const source of [ + relatedList('relationshipField="account" columns={["subject"]}'), + relatedList('relationshipField="account" columns={["subject"]} dataSource={{ object: "task" }}'), + ]) { + expect(diagnosticsOf(source)).toEqual([ + ['error', 'missing-required-prop', `<${TAG}> is missing required prop "objectName"`], + ]); + expect(compile(source, shipped).ok).toBe(false); + } + }); +}); diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnsOptional-11613.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnsOptional-11613.test.tsx new file mode 100644 index 0000000000..f117c9e7ee --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnsOptional-11613.test.tsx @@ -0,0 +1,147 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#11613 — `record:related_list` draws columns whether or not the node + * authors `columns`, so the registration need not require the key. + * + * The spec row (`ComponentPropsMap['record:related_list'].columns`) is + * optional, and its describe says what an omitted list means: "columns derive + * from the related object's highlightFields / default list columns". The + * registration declared `columns` required anyway, so the page compile refused + * a node the row accepts. Dropping `required` (the console's + * `related-list-columns-optional-11613.test.ts` pins the compile) is only + * honest if the node with no authored `columns` still draws a list. This file + * measures that through the REAL renderer, the real `ElementDataSourceGate`, + * the real `RelatedList` and the real table, reading rendered header and body + * cells: + * + * 1. The neither node (no `columns`, no named view) draws columns derived from + * the related object, with or without an object-only `dataSource` binding. + * No hint, no blank, no throw: `RelatedList` reads the unauthored list as + * "nothing authored" and derives. + * 2. The view-bound node (no `columns`, a `dataSource` naming a view) draws the + * VIEW's columns, not the derived set. + * 3. Authored `columns` still win, over the view and over the derivation. + * + * Every read waits on a positive body cell first: the derived set cannot exist + * before the object schema lands, so a table with a cell is a table whose + * columns are settled. + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordRelatedListRenderer } from '../renderers/record-related-list'; + +/** + * Desktop, pinned rather than inherited (the objectui#8399 reason): under the + * 768 breakpoint a `type="table"` related list renders a card gallery with no + * header cells, and every assertion here reads them. + */ +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +afterEach(() => cleanup()); + +/** The related object: three listable fields plus the foreign key back to the parent. */ +const FIELDS = { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'text', label: 'Status' }, + priority: { type: 'text', label: 'Priority' }, + account_id: { type: 'lookup', label: 'Account', reference: 'account' }, +}; + +/** A saved list view of the related object that lists one column. */ +const LIST_VIEWS = { + open_tasks: { name: 'open_tasks', label: 'Open tasks', columns: ['subject'] }, +}; + +const ROWS = [ + { id: 't1', subject: 'Fix the pump', status: 'open', priority: 'high', account_id: 'ACC-1' }, +]; + +const makeDS = () => ({ + find: vi.fn(async () => ROWS), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: FIELDS, listViews: LIST_VIEWS })), +}); + +/** Every rendered header cell's text, in DOM order. */ +const headers = () => + Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); + +/** Every rendered body cell's text, in DOM order. */ +const cellTexts = () => screen.getAllByRole('cell').map((c) => (c.textContent || '').trim()); + +const waitForCell = (text: string) => waitFor(() => expect(cellTexts()).toContain(text)); + +/** Render the BLOCK end to end, under a record context for the parent `account`. */ +function renderBlock(schema: Record) { + return render( + + + , + ); +} + +describe('objectui#11613 — a record:related_list with no authored columns still draws columns', () => { + it('the neither node (no columns, no view) draws columns derived from the related object', async () => { + const { container } = renderBlock({ objectName: 'task' }); + await waitForCell('Fix the pump'); + + // Derived from the object's fields: the listable ones, not the foreign key + // back to this parent, which the walk drops. + expect(headers()).toEqual(expect.arrayContaining(['Subject', 'Status', 'Priority'])); + expect(headers()).not.toContain('Account'); + expect(cellTexts()).toEqual(expect.arrayContaining(['Fix the pump', 'open', 'high'])); + // Not a hint and not the gate's error panel: the list itself is drawn. + expect(container.textContent).not.toContain('missing objectName'); + expect( + container.querySelector('[data-testid="record-related-list-datasource-error"]'), + ).toBeNull(); + }); + + it('the neither node bound by object alone (no view) draws the same derived columns', async () => { + // An object-only binding supplies no columns: the gate maps a view's + // field list onto `columns`, and there is no view here. + renderBlock({ objectName: 'task', dataSource: { object: 'task' } }); + await waitForCell('Fix the pump'); + + expect(headers()).toEqual(expect.arrayContaining(['Subject', 'Status', 'Priority'])); + expect(headers()).not.toContain('Account'); + }); + + it('the view-bound node with no columns draws the view’s columns, not the derived set', async () => { + renderBlock({ objectName: 'task', dataSource: { object: 'task', view: 'open_tasks' } }); + await waitForCell('Fix the pump'); + + expect(headers()).toEqual(['Subject']); + expect(cellTexts()).not.toContain('high'); + }); + + it('authored columns win over a named view', async () => { + renderBlock({ + objectName: 'task', + columns: ['priority'], + dataSource: { object: 'task', view: 'open_tasks' }, + }); + await waitForCell('high'); + + expect(headers()).toEqual(['Priority']); + expect(cellTexts()).not.toContain('Fix the pump'); + }); + + it('authored columns win over the derivation', async () => { + renderBlock({ objectName: 'task', columns: ['status'] }); + await waitForCell('open'); + + expect(headers()).toEqual(['Status']); + expect(cellTexts()).not.toContain('Fix the pump'); + }); +}); From 06241180dca4a7ba07f3bd78059d1654b1fc8ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:56:13 +0000 Subject: [PATCH 2/2] fix(plugin-detail): record:related_list stops requiring `columns`, so the page compile accepts a node that lists none (objectui#11613) The spec row leaves `columns` optional. A view-bound node draws the view's columns, and a node with neither draws columns derived from the related object, so the registration may not be stricter than the row it publishes. The published description now says where the columns come from without it. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- ...in-detail-related-list-columns-optional.md | 23 +++++++++++++++++++ packages/plugin-detail/src/index.tsx | 13 ++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/11613-plugin-detail-related-list-columns-optional.md diff --git a/.changeset/11613-plugin-detail-related-list-columns-optional.md b/.changeset/11613-plugin-detail-related-list-columns-optional.md new file mode 100644 index 0000000000..cd4865ee0a --- /dev/null +++ b/.changeset/11613-plugin-detail-related-list-columns-optional.md @@ -0,0 +1,23 @@ +--- +'@object-ui/plugin-detail': minor +--- + +The `record:related_list` registration no longer declares `columns` required, so +the page compile accepts a related list that lists no columns of its own +(objectui#11613). + +`@objectstack/spec`'s `record:related_list` row leaves `columns` optional, and the +renderer agrees: a `dataSource` binding that names a view lands that view's +columns on the node, and with neither the list derives its columns from the +related object (its `highlightFields`, otherwise its listable fields). The +registration still declared `required: true`, and the page compile reads the +registration, so a node with no `columns` was refused with +`missing-required-prop` and the save failed. + +**Clause-②: yes (widening)** — a `record:related_list` node that sets no `columns` +now compiles and saves, whether a `dataSource` binding names a view (the list +draws the view's columns) or not (the list draws columns derived from the related +object, as it already did when such a node reached it). Authored `columns` still +win over both. `objectName` and `relationshipField` are still required, as the +spec row requires them. The published `columns` input now carries a description +that says where the columns come from when it is absent. diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 2f4fd90089..62fba905fb 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -623,7 +623,18 @@ ComponentRegistry.register('related_list', RecordRelatedListRenderer, { { name: 'objectName', type: 'string', required: true, description: 'Related object name (e.g. "task")' }, { name: 'relationshipField', type: 'string', required: true, description: 'Field on the related object pointing back to this record' }, { 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.' }, - { name: 'columns', type: 'array', of: 'string', required: true, description: 'Fields to display in the related list' }, + // `columns` is NOT required (objectui#11613). The spec row leaves it + // optional, and the registration may not be stricter than the row it + // publishes: the page compile reads `required` here, and with it set a node + // the row and the renderer accept was refused at the save gate. Both + // columns-less nodes draw a list: a `dataSource` binding that names a view + // lands the view's columns (`RECORD_RELATED_LIST_DATA_SOURCE` maps + // `columns: true`), and with neither `RelatedList` reads the unauthored list + // as "nothing authored" and derives the columns from the related object. + // Pinned by `RecordRelatedListRenderer.columnsOptional-11613.test.tsx` + // (what draws) and the console's `related-list-columns-optional-11613.test.ts` + // (what compiles). + { 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.' }, { name: 'sort', type: 'array' }, { name: 'limit', type: 'number', description: 'Records to display initially' }, // `type: 'array'` matches the spec (`RecordRelatedListProps.filter` is