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
23 changes: 23 additions & 0 deletions .changeset/11613-plugin-detail-related-list-columns-optional.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Readonly<Record<string, string>>>> = {
'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<Record<string, Readonly<Record<string, string>>>> = {};

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

Expand Down
149 changes: 149 additions & 0 deletions apps/console/src/__tests__/related-list-columns-optional-11613.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return render(
<RecordContextProvider objectName="account" recordId="ACC-1" dataSource={makeDS() as any}>
<RecordRelatedListRenderer schema={{ relationshipField: 'account_id', ...schema } as any} />
</RecordContextProvider>,
);
}

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');
});
});
13 changes: 12 additions & 1 deletion packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading