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/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'); + }); +}); 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