From 16f675b7f71b42355b98b74a25f3c6b4d802d27b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:39:18 +0000 Subject: [PATCH 1/4] fix(plugin-detail): related list with actions: [] no longer draws the built-in New An authored `actions: []` on `record:related_list` is the author's choice of no actions on that list, the generic create form included. It holds per list, independent of the child object's userActions.create. Absent or non-empty `actions` keep New as before; row affordances unchanged. Claude-Session: https://claude.ai/code/session_01V5ckU1cUgQX9SC8V9TG3dh Co-authored-by: Claude --- .../26182-related-list-empty-actions-new.md | 13 +++++ ui/packages/plugin-detail/README.md | 16 ++++++ ...istRenderer.authoredActions-11163.test.tsx | 53 ++++++++++++++++--- .../src/renderers/record-related-list.tsx | 25 +++++++-- 4 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 .changeset/26182-related-list-empty-actions-new.md diff --git a/.changeset/26182-related-list-empty-actions-new.md b/.changeset/26182-related-list-empty-actions-new.md new file mode 100644 index 00000000000..fb2c2d706a3 --- /dev/null +++ b/.changeset/26182-related-list-empty-actions-new.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-detail': minor +--- + +fix(plugin-detail): `record:related_list` with `actions: []` no longer draws the built-in New + +Clause-②: no +Metadata-protocol: semantic + +- **Before.** `actions: []` on a `record:related_list` hid the list's authored and default actions, but the built-in **New** stayed whenever the host offered create. The only way to drop it was the child object's `userActions.create`, which removes New from every list of that object. A page could not say that one list's children must be created through a process (a renewal through its flow, a version through upload), and the generic create form, with the relationship prefilled, bypassed that process. +- **Now.** An authored `actions: []` is the author's choice of no actions on that list, New included. It holds per list and does not depend on the child object's `userActions.create`. Every list that does not author `[]` keeps the host's verdict. +- **Unchanged.** A list with no `actions`, or with a non-empty `actions` list, draws New exactly as before. The row affordances (View, Edit, Delete) and the `add` (link existing) button are not governed by this key. No key is added and none is removed. +- **What to do.** Nothing for a list that should keep New. A page that authored `actions: []` and still wants New on that list deletes the key. diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index e142bafabf0..40ba67d6dae 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -737,6 +737,22 @@ the whole section renders nothing — no header, no empty grid, no "New" button that would be rejected server-side. With no `PermissionProvider` mounted (Studio designer, standalone embeds) the gate stays open. +A page author withdraws **New** from one related list by authoring +`actions: []` on its `record:related_list` node — the author's choice of no +actions on that list, the generic create form included. Use it where the +children must be created through a process (a renewal through its flow, a +version through upload). It holds per list, whatever the child object's +`userActions.create` says; every list that does not author `[]` keeps the +host's verdict. A non-empty `actions` list leaves New as it was, and the row +affordances (View, Edit, Delete) are not governed by this key. + +```ts +{ + type: 'record:related_list', + properties: { objectName: 'contract', relationshipField: 'renewed_from', actions: [] }, +} +``` + ### InlineEditSaveBar & InlineFieldInput The record-level inline-edit session (#2407): double-clicking a field (or its diff --git a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx index 2cacbabca97..7db7fcd84cc 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx @@ -27,9 +27,11 @@ * its own `locations`, with the bridge's rule: `list_toolbar` → header * button, `list_item` → row menu. Running it stays the host's (the * bridge's `onToolbarAction` / `onRowAction`). - * - `actions: []` → the author's choice of NO actions. The built-in - * New / Edit / Delete / View affordances are not action ids (the runtime - * ships no built-in action names) and are untouched by this key. + * - `actions: []` → the author's choice of NO actions, the built-in New + * included (#26182): the list offers no generic create form, whatever the + * child object's `userActions.create` says. A NON-empty list leaves New + * the host's, and Edit / Delete / View are row affordances this key never + * touches (the runtime ships no built-in action names). * - An id the registry cannot resolve, or one whose action declares no * location this list renders, is REFUSED where the lookup answers: an * inline `role="status"` notice on the list names it and says why. Never a @@ -251,18 +253,55 @@ describe('record:related_list.actions — AUTHORED: the authored list is what re expect(onToolbarAction.mock.calls[0][0]).toMatchObject({ name: 'invite', label: 'Invite', type: 'api' }); }); - it('`actions: []` is the author’s choice of NO actions — while New stays, because it is not an action id', async () => { + it('`actions: []` is the author’s choice of NO actions — New included, though the host offers it (#26182)', async () => { mount([]); await waitForRow(); - // Positive first, so the empty reads below are taken on a list that - // really drew its header: the host's create affordance is still there. - expect(await screen.findByTestId('related-list-new')).toBeInTheDocument(); + expect(screen.queryByTestId('related-list-new')).toBeNull(); expect(toolbarNames()).toEqual([]); expect(rowTriggers()).toHaveLength(0); expect(refusal()).toBeNull(); }); }); +describe('record:related_list.actions — the built-in New: only `[]` withholds it (#26182)', () => { + // Each case reads the SAME host, which offers create for `contact`: what + // differs is only the authored key, so the verdict is this list's own. + it('CONTROL: with no `actions` the host’s New is drawn and runs the host’s create', async () => { + mount(undefined); + await waitForRow(); + const button = await screen.findByTestId('related-list-new'); + fireEvent.click(button); + expect(onCreate).toHaveBeenCalledTimes(1); + }); + + it('a NON-empty authored list leaves New the host’s: naming actions is not withholding create', async () => { + mount(['invite']); + await waitForRow(); + await waitFor(() => expect(toolbarNames()).toEqual(['invite'])); + expect(screen.getByTestId('related-list-new')).toBeInTheDocument(); + }); + + it('`actions: []` withholds New on THIS list only — a sibling list of the same object keeps it', async () => { + render( + + + +
+ +
+
+ +
+
+
+
, + ); + await waitFor(() => expect(screen.getAllByText('Alice')).toHaveLength(2)); + expect(await within(screen.getByTestId('list-default')).findByTestId('related-list-new')).toBeInTheDocument(); + expect(within(screen.getByTestId('list-empty')).queryByTestId('related-list-new')).toBeNull(); + }); +}); + describe('record:related_list.actions — an id that cannot be placed is REFUSED where it resolves (objectui#11163)', () => { it('an id the registry cannot resolve is named in a `role="status"` notice; the resolvable ones still render', async () => { mount(['invite', 'no_such_action']); diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index ad2cd164c6a..100d1a73e4d 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -304,14 +304,16 @@ const RecordRelatedListBody: React.FC = ({ * - AUTHORED → the authored list is what renders, in authored order: * each id resolves against the RELATED object's registered `actions` * and is placed by its own `locations` (see `relatedListActions.ts`). - * `[]` is the author's choice of no actions. + * - AUTHORED `[]` → the author's choice of NO actions on this list, the + * built-in New included (#26182): see `createWithheldByAuthor` below. * * The bridge offers no per-list channel of its own — `resolve` is keyed on * the child OBJECT (`objectName`, `relationshipField`, `parentId`), so two * lists of one object get one set — which is why this key is the per-list * one rather than a duplicate of it. Built-in New / Edit / Delete / View are - * NOT action ids (the runtime ships no built-in action names) and stay the - * host's either way. Running an authored action stays the host's too: it is + * NOT action ids (the runtime ships no built-in action names): a non-empty + * list leaves all four the host's, and `[]` withdraws New alone. Running an + * authored action stays the host's too: it is * handed to the bridge's `onToolbarAction` / `onRowAction`, the executor * that already runs the child object's actions against the clicked row, so * with no host the list stays read-only exactly as it always has. @@ -324,6 +326,18 @@ const RecordRelatedListBody: React.FC = ({ */ const authoredActions: unknown = schema.actions; const actionsAuthored = authoredActions !== undefined; + /** + * `actions: []` withholds the built-in New on THIS list (#26182). A related + * list often shows children that may only be created through a process (a + * renewal through its flow, a version through upload), and the empty list is + * how the page author says the list offers nothing — so the generic create + * form, which would bypass that process, is not offered either. Per list, + * and independent of the child object's `userActions.create`: the host's + * object-wide verdict still decides every list that does not say `[]`. + * Edit / Delete / View are row affordances on records that already exist and + * stay the host's. Absent, or a non-empty list, leaves New exactly as before. + */ + const createWithheldByAuthor = Array.isArray(authoredActions) && authoredActions.length === 0; const needsActionLookup = !!objectName && relatedListActionsNeedLookup(authoredActions); /** * The FALLBACK remove (below, `onRowDelete`): offered with an `add` config @@ -653,8 +667,9 @@ const RecordRelatedListBody: React.FC = ({ toolbarActions={toolbarActions} onToolbarAction={handlers?.onToolbarAction} // Create a new child, pre-linked to this parent (增). Host omits when - // create is denied by lifecycle/permissions, hiding the "New" button. - onNew={handlers?.onCreate} + // create is denied by lifecycle/permissions, hiding the "New" button; + // an authored `actions: []` omits it on this list alone (#26182). + onNew={createWithheldByAuthor ? undefined : handlers?.onCreate} // [#4646] …and greys it — without hiding it — when the child object's // `userActions.create.disabledWhen` holds for THIS parent record. The // host owns both verdicts (it is the side that has the parent record From e7398539f898ef202bbcf87e6df3d9257d042ee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:58:52 +0000 Subject: [PATCH 2/4] chore(sdui): re-record plugin-detail source digest Claude-Session: https://claude.ai/code/session_01V5ckU1cUgQX9SC8V9TG3dh Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-detail.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 94e9aa537aa..b1077b6c79c 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"0dc57d949e415a180604fe00baee0a445dab4595a49517c259dfd5f156bdf102","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 1fd0208c9b1d1d5f6ddfb6702b94d15d7afeeb8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:38:34 +0000 Subject: [PATCH 3/4] chore(sdui): re-record plugin-detail source digest after merging main Claude-Session: https://claude.ai/code/session_01V5ckU1cUgQX9SC8V9TG3dh Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-detail.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 7d009b67eb2..8a4ae13d597 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"3a6fffd41940e71d2402028d44c078cf9cdb7e9428120b180af072491ecfa2a9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"f0ccf59f3607d2208796fa3fbbd3d176b7ad09850cbd1e6feca0cc46eb108895","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 85bada25b724b9408f15cfd84905372170c8f374 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:17:42 +0000 Subject: [PATCH 4/4] docs(plugin-detail): related-list actions: [] example as a JSON node, not a bare TS literal Claude-Session: https://claude.ai/code/session_01V5ckU1cUgQX9SC8V9TG3dh Co-authored-by: Claude --- ui/packages/plugin-detail/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index 40ba67d6dae..ace4d76d8d1 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -746,10 +746,10 @@ version through upload). It holds per list, whatever the child object's host's verdict. A non-empty `actions` list leaves New as it was, and the row affordances (View, Edit, Delete) are not governed by this key. -```ts +```json { - type: 'record:related_list', - properties: { objectName: 'contract', relationshipField: 'renewed_from', actions: [] }, + "type": "record:related_list", + "properties": { "objectName": "contract", "relationshipField": "renewed_from", "actions": [] } } ```