Skip to content

Commit f02e644

Browse files
fix(plugin-detail): related list with actions: [] no longer draws the built-in New (#26277)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi97pCdCGqJdn5YdkDr3iy7cB)_ Fixes #26182 Clause-②: no Metadata-protocol: semantic ## What `record:related_list` with an authored `actions: []` no longer draws the built-in **New**. The empty list is the author's choice of no actions on that list, and the generic create form is now part of "no actions". It holds per list, whatever the child object's `userActions.create` says. - `ui/packages/plugin-detail/src/renderers/record-related-list.tsx`: a new `createWithheldByAuthor` (an authored array of length 0) withholds `onNew` from `RelatedList`. Absent `actions`, or a non-empty list, passes the host's `onCreate` exactly as before. - The composition-rule comment in the same file now states the `[]` arm. The old text said the built-ins "stay the host's either way". - No host change: the app-shell bridge still decides create object-wide; the renderer drops it for one list. - README (`RelatedList` section) documents the authoring move. ## Why the meaning changes (Metadata-protocol: semantic) Before, `actions: []` hid the list's authored and default actions while New stayed. A pinned case in `RecordRelatedListRenderer.authoredActions-11163.test.tsx` said so ("while New stays, because it is not an action id"). That pin was the implementer's reading, not the ruling: the ENFORCE ruling on #20665 (comment 5902374623) says "the authored list is what renders when it is present" and names the Salesforce shape, where New is one of the list's own buttons. The card's triage grade reads `[]` the same way: "An explicit empty list means no actions; absent keeps the defaults." The value `[]` was already accepted, so no key is added or removed and the spec is untouched. What changes is what the value means, so this declares `semantic`, and the changeset grades `@object-ui/plugin-detail` `minor`. The claim on the card carried `Metadata-protocol: none`; the seat agreed to the `semantic` declaration and will amend the claim. Producers measured at `3d50a43b`: no in-repo page, example, platform page or synthesiser (`buildDefaultPageSchema`) authors `actions: []` on a `record:related_list`, so no shipped page loses New. ## Tests At head `58a07ee6` (the code commit is `16f675b7`; `e7398539` re-records the sdui source digest; `58a07ee6` merges `origin/main`): - `RecordRelatedListRenderer.authoredActions-11163.test.tsx`: 15/15 passed. The old `[]` case is inverted (New absent though the host offers create). A new block pins the rule: CONTROL with no `actions` draws New and runs the host's create; a non-empty list keeps New; `[]` on one list leaves a sibling list of the same object with New. - Ablation (`scripts/ablation-replace.mjs`, `onNew={createWithheldByAuthor ? undefined : handlers?.onCreate}` back to `onNew={handlers?.onCreate}`): the two `[]` cases went red, 2 failed / 13 passed. Restore verified: blob equals HEAD, `git diff HEAD` empty. The test imports the package source, so no build leg was needed. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-detail/`: 304 files passed, 1 skipped; 2828 tests passed, 18 skipped. Exit 0. - `pnpm --filter @object-ui/plugin-detail type-check`: exit 0. - `node scripts/gen-sdui-manifest-node.mjs`: 1 of 27 package records re-recorded (plugin-detail); `sdui.manifest.json` unchanged. `node scripts/check-sdui-manifest.mjs` exit 0 after the main merge. - `node scripts/pm/dispatch-gates.mjs --commands`: 47 derived families, all run, each exit 0; `--ran` reconciled 47/47 with exit codes recorded, 0 NOT-MEASURED. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. - Console build after the main merge, `pnpm exec turbo run build --filter=@object-ui/console --concurrency=2`: 71/71 tasks, exit 0. ## Acceptance notes - Only `[]` withholds New. A list that authors `['renew']` still draws New beside its authored action. That keeps every existing page that names actions unchanged, but a page cannot yet say "these actions and no New". This is noted, not filed. - Row affordances (View, Edit, Delete) and the `add` (link existing) button are not governed by `actions`. The card asks only about New. - The spec `describe()` on `RecordRelatedListProps.actions` ("Action IDs available for related records") is not changed here. The dispatch keeps this card ui-only, and the text is not wrong, only silent on New. - sdui: plugin-detail is a registry package. This PR lands after #26060 and #26055 on that package and merges `origin/main` and re-records before landing. --- _Generated by [Claude Code](https://claude.ai/code/session_01V5ckU1cUgQX9SC8V9TG3dh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6390fe5 commit f02e644

5 files changed

Lines changed: 96 additions & 13 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-detail': minor
3+
---
4+
5+
fix(plugin-detail): `record:related_list` with `actions: []` no longer draws the built-in New
6+
7+
Clause-②: no
8+
Metadata-protocol: semantic
9+
10+
- **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.
11+
- **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.
12+
- **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.
13+
- **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.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"3a6fffd41940e71d2402028d44c078cf9cdb7e9428120b180af072491ecfa2a9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"f0ccf59f3607d2208796fa3fbbd3d176b7ad09850cbd1e6feca0cc46eb108895","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/plugin-detail/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,22 @@ the whole section renders nothing — no header, no empty grid, no "New"
737737
button that would be rejected server-side. With no `PermissionProvider`
738738
mounted (Studio designer, standalone embeds) the gate stays open.
739739
740+
A page author withdraws **New** from one related list by authoring
741+
`actions: []` on its `record:related_list` node — the author's choice of no
742+
actions on that list, the generic create form included. Use it where the
743+
children must be created through a process (a renewal through its flow, a
744+
version through upload). It holds per list, whatever the child object's
745+
`userActions.create` says; every list that does not author `[]` keeps the
746+
host's verdict. A non-empty `actions` list leaves New as it was, and the row
747+
affordances (View, Edit, Delete) are not governed by this key.
748+
749+
```json
750+
{
751+
"type": "record:related_list",
752+
"properties": { "objectName": "contract", "relationshipField": "renewed_from", "actions": [] }
753+
}
754+
```
755+
740756
### InlineEditSaveBar & InlineFieldInput
741757
742758
The record-level inline-edit session (#2407): double-clicking a field (or its

‎ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
* its own `locations`, with the bridge's rule: `list_toolbar` → header
2828
* button, `list_item` → row menu. Running it stays the host's (the
2929
* bridge's `onToolbarAction` / `onRowAction`).
30-
* - `actions: []` → the author's choice of NO actions. The built-in
31-
* New / Edit / Delete / View affordances are not action ids (the runtime
32-
* ships no built-in action names) and are untouched by this key.
30+
* - `actions: []` → the author's choice of NO actions, the built-in New
31+
* included (#26182): the list offers no generic create form, whatever the
32+
* child object's `userActions.create` says. A NON-empty list leaves New
33+
* the host's, and Edit / Delete / View are row affordances this key never
34+
* touches (the runtime ships no built-in action names).
3335
* - An id the registry cannot resolve, or one whose action declares no
3436
* location this list renders, is REFUSED where the lookup answers: an
3537
* 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
251253
expect(onToolbarAction.mock.calls[0][0]).toMatchObject({ name: 'invite', label: 'Invite', type: 'api' });
252254
});
253255

254-
it('`actions: []` is the author’s choice of NO actions — while New stays, because it is not an action id', async () => {
256+
it('`actions: []` is the author’s choice of NO actions — New included, though the host offers it (#26182)', async () => {
255257
mount([]);
256258
await waitForRow();
257-
// Positive first, so the empty reads below are taken on a list that
258-
// really drew its header: the host's create affordance is still there.
259-
expect(await screen.findByTestId('related-list-new')).toBeInTheDocument();
259+
expect(screen.queryByTestId('related-list-new')).toBeNull();
260260
expect(toolbarNames()).toEqual([]);
261261
expect(rowTriggers()).toHaveLength(0);
262262
expect(refusal()).toBeNull();
263263
});
264264
});
265265

266+
describe('record:related_list.actions — the built-in New: only `[]` withholds it (#26182)', () => {
267+
// Each case reads the SAME host, which offers create for `contact`: what
268+
// differs is only the authored key, so the verdict is this list's own.
269+
it('CONTROL: with no `actions` the host’s New is drawn and runs the host’s create', async () => {
270+
mount(undefined);
271+
await waitForRow();
272+
const button = await screen.findByTestId('related-list-new');
273+
fireEvent.click(button);
274+
expect(onCreate).toHaveBeenCalledTimes(1);
275+
});
276+
277+
it('a NON-empty authored list leaves New the host’s: naming actions is not withholding create', async () => {
278+
mount(['invite']);
279+
await waitForRow();
280+
await waitFor(() => expect(toolbarNames()).toEqual(['invite']));
281+
expect(screen.getByTestId('related-list-new')).toBeInTheDocument();
282+
});
283+
284+
it('`actions: []` withholds New on THIS list only — a sibling list of the same object keeps it', async () => {
285+
render(
286+
<MetadataCtx.Provider value={METADATA}>
287+
<RecordContextProvider objectName="account" recordId="ACC-1" dataSource={makeDataSource() as never}>
288+
<RelatedRecordActionsProvider value={HOST}>
289+
<div data-testid="list-empty">
290+
<SchemaRenderer schema={doc([]) as never} />
291+
</div>
292+
<div data-testid="list-default">
293+
<SchemaRenderer schema={doc(undefined) as never} />
294+
</div>
295+
</RelatedRecordActionsProvider>
296+
</RecordContextProvider>
297+
</MetadataCtx.Provider>,
298+
);
299+
await waitFor(() => expect(screen.getAllByText('Alice')).toHaveLength(2));
300+
expect(await within(screen.getByTestId('list-default')).findByTestId('related-list-new')).toBeInTheDocument();
301+
expect(within(screen.getByTestId('list-empty')).queryByTestId('related-list-new')).toBeNull();
302+
});
303+
});
304+
266305
describe('record:related_list.actions — an id that cannot be placed is REFUSED where it resolves (objectui#11163)', () => {
267306
it('an id the registry cannot resolve is named in a `role="status"` notice; the resolvable ones still render', async () => {
268307
mount(['invite', 'no_such_action']);

‎ui/packages/plugin-detail/src/renderers/record-related-list.tsx‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,14 +312,16 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
312312
* - AUTHORED → the authored list is what renders, in authored order:
313313
* each id resolves against the RELATED object's registered `actions`
314314
* and is placed by its own `locations` (see `relatedListActions.ts`).
315-
* `[]` is the author's choice of no actions.
315+
* - AUTHORED `[]` → the author's choice of NO actions on this list, the
316+
* built-in New included (#26182): see `createWithheldByAuthor` below.
316317
*
317318
* The bridge offers no per-list channel of its own — `resolve` is keyed on
318319
* the child OBJECT (`objectName`, `relationshipField`, `parentId`), so two
319320
* lists of one object get one set — which is why this key is the per-list
320321
* one rather than a duplicate of it. Built-in New / Edit / Delete / View are
321-
* NOT action ids (the runtime ships no built-in action names) and stay the
322-
* host's either way. Running an authored action stays the host's too: it is
322+
* NOT action ids (the runtime ships no built-in action names): a non-empty
323+
* list leaves all four the host's, and `[]` withdraws New alone. Running an
324+
* authored action stays the host's too: it is
323325
* handed to the bridge's `onToolbarAction` / `onRowAction`, the executor
324326
* that already runs the child object's actions against the clicked row, so
325327
* with no host the list stays read-only exactly as it always has.
@@ -332,6 +334,18 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
332334
*/
333335
const authoredActions: unknown = schema.actions;
334336
const actionsAuthored = authoredActions !== undefined;
337+
/**
338+
* `actions: []` withholds the built-in New on THIS list (#26182). A related
339+
* list often shows children that may only be created through a process (a
340+
* renewal through its flow, a version through upload), and the empty list is
341+
* how the page author says the list offers nothing — so the generic create
342+
* form, which would bypass that process, is not offered either. Per list,
343+
* and independent of the child object's `userActions.create`: the host's
344+
* object-wide verdict still decides every list that does not say `[]`.
345+
* Edit / Delete / View are row affordances on records that already exist and
346+
* stay the host's. Absent, or a non-empty list, leaves New exactly as before.
347+
*/
348+
const createWithheldByAuthor = Array.isArray(authoredActions) && authoredActions.length === 0;
335349
const needsActionLookup = !!objectName && relatedListActionsNeedLookup(authoredActions);
336350
/**
337351
* The FALLBACK remove (below, `onRowDelete`): offered with an `add` config
@@ -661,8 +675,9 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
661675
toolbarActions={toolbarActions}
662676
onToolbarAction={handlers?.onToolbarAction}
663677
// Create a new child, pre-linked to this parent (增). Host omits when
664-
// create is denied by lifecycle/permissions, hiding the "New" button.
665-
onNew={handlers?.onCreate}
678+
// create is denied by lifecycle/permissions, hiding the "New" button;
679+
// an authored `actions: []` omits it on this list alone (#26182).
680+
onNew={createWithheldByAuthor ? undefined : handlers?.onCreate}
666681
// [#4646] …and greys it — without hiding it — when the child object's
667682
// `userActions.create.disabledWhen` holds for THIS parent record. The
668683
// host owns both verdicts (it is the side that has the parent record

0 commit comments

Comments
 (0)