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
13 changes: 13 additions & 0 deletions .changeset/26182-related-list-empty-actions-new.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-detail.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"3a6fffd41940e71d2402028d44c078cf9cdb7e9428120b180af072491ecfa2a9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"f0ccf59f3607d2208796fa3fbbd3d176b7ad09850cbd1e6feca0cc46eb108895","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
16 changes: 16 additions & 0 deletions ui/packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
<MetadataCtx.Provider value={METADATA}>
<RecordContextProvider objectName="account" recordId="ACC-1" dataSource={makeDataSource() as never}>
<RelatedRecordActionsProvider value={HOST}>
<div data-testid="list-empty">
<SchemaRenderer schema={doc([]) as never} />
</div>
<div data-testid="list-default">
<SchemaRenderer schema={doc(undefined) as never} />
</div>
</RelatedRecordActionsProvider>
</RecordContextProvider>
</MetadataCtx.Provider>,
);
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']);
Expand Down
25 changes: 20 additions & 5 deletions ui/packages/plugin-detail/src/renderers/record-related-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,16 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
* - 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.
Expand All @@ -332,6 +334,18 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
*/
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
Expand Down Expand Up @@ -661,8 +675,9 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
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
Expand Down
Loading