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/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"}
diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md
index e142bafabf0..ace4d76d8d1 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.
+
+```json
+{
+ "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 8409a155536..36c4fcdb51d 100644
--- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx
+++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx
@@ -312,14 +312,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.
@@ -332,6 +334,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
@@ -661,8 +675,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