= [];
+
+function registerRecordingGrid() {
+ ComponentRegistry.register('object-grid', (props: any) => {
+ gridEditableCalls.push(props.editable);
+ return {String(!!props.editable)}
;
+ });
+}
+
+/** `permissions: null` renders with NO PermissionProvider at all. */
+function renderInlineEdit(
+ permissions: ObjectPermissionConfig | null,
+ schemaOverride?: Partial,
+) {
+ const view = (
+
+ );
+ return render(
+
+ {permissions
+ ? (
+
+ {view}
+
+ )
+ : view}
+ ,
+ );
+}
+
+describe('ListView – inline-edit toggle vs the principal permission gate (#4647)', () => {
+ let prevGrid: ReturnType;
+
+ beforeEach(() => {
+ mockDataSource.find.mockClear();
+ gridEditableCalls = [];
+ prevGrid = ComponentRegistry.get('object-grid');
+ registerRecordingGrid();
+ });
+
+ afterEach(() => {
+ cleanup();
+ if (prevGrid) ComponentRegistry.register('object-grid', prevGrid);
+ else ComponentRegistry.unregister('object-grid');
+ });
+
+ it('a principal WITH update keeps the inline-edit toggle', async () => {
+ renderInlineEdit(makeUpdatePermissions(true));
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).not.toBeNull();
+ });
+
+ it('a principal WITHOUT update loses it', async () => {
+ renderInlineEdit(makeUpdatePermissions(false));
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).toBeNull();
+ });
+
+ it('with NO PermissionProvider the toggle survives (fail-open preserved)', async () => {
+ renderInlineEdit(null);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).not.toBeNull();
+ });
+
+ it('a read-only principal cannot reach edit MODE through a stored inlineEdit:true view', async () => {
+ // The door that stays open if only the toggle is gated: the console
+ // persists `inlineEdit` per view, so the mode can be entered with no
+ // toggle press at all.
+ renderInlineEdit(makeUpdatePermissions(false), { inlineEdit: true } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.getByTestId('grid-stub')).toHaveTextContent('false');
+ expect(gridEditableCalls.at(-1)).toBeFalsy();
+ });
+
+ it('…while a permitted principal keeps that same stored view editable', async () => {
+ // Control group: proves the assertion above observes the PERMISSION and not
+ // a schema key that stopped being forwarded.
+ renderInlineEdit(makeUpdatePermissions(true), { inlineEdit: true } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.getByTestId('grid-stub')).toHaveTextContent('true');
+ });
+
+ it('the COMPACT toolbar entry is gated too', async () => {
+ // `showInlineEdit` on the settings popover was `currentView === 'grid'`
+ // alone — it never even required the host callback, so gating only the wide
+ // toolbar would have left the affordance reachable on mobile.
+ //
+ // Two layers have to be opened before an absence means anything, or the
+ // assertion passes for every input including a completely ungated one:
+ // the entry lives inside `PopoverContent` (closed until the trigger is
+ // clicked) and inside a `Section` that renders its children only when
+ // expanded — and that section's `defaultOpen` is `!!inlineEdit`. Hence the
+ // seeded `inlineEdit: true`, which is also the realistic shape of this
+ // regression: a stored view already in inline-edit mode, reopened by a
+ // principal who has since lost `update`.
+ renderInlineEdit(makeUpdatePermissions(false), {
+ compactToolbar: true,
+ inlineEdit: true,
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('view-settings-trigger'));
+ await screen.findByTestId('view-settings-content');
+ expect(screen.queryByTestId('view-settings-inline-edit')).toBeNull();
+ });
+
+ it('…and a permitted principal keeps that compact entry', async () => {
+ // Control group for the case above: same two layers opened the same way,
+ // so the absence there is the permission gate and not a collapsed panel.
+ renderInlineEdit(makeUpdatePermissions(true), {
+ compactToolbar: true,
+ inlineEdit: true,
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('view-settings-trigger'));
+ await screen.findByTestId('view-settings-content');
+ expect(screen.queryByTestId('view-settings-inline-edit')).not.toBeNull();
+ });
+});
+
+describe('ListView – the declared userActions.editInline switch (#4647 gap 2)', () => {
+ let prevGrid: ReturnType;
+
+ beforeEach(() => {
+ mockDataSource.find.mockClear();
+ gridEditableCalls = [];
+ prevGrid = ComponentRegistry.get('object-grid');
+ registerRecordingGrid();
+ });
+
+ afterEach(() => {
+ cleanup();
+ if (prevGrid) ComponentRegistry.register('object-grid', prevGrid);
+ else ComponentRegistry.unregister('object-grid');
+ });
+
+ it('`editInline: false` withholds the toggle from a fully-permitted principal', async () => {
+ renderInlineEdit(makeUpdatePermissions(true), {
+ userActions: { editInline: false },
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).toBeNull();
+ });
+
+ it('`editInline: false` also withholds edit MODE from a stored inlineEdit:true view', async () => {
+ renderInlineEdit(makeUpdatePermissions(true), {
+ inlineEdit: true,
+ userActions: { editInline: false },
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.getByTestId('grid-stub')).toHaveTextContent('false');
+ });
+
+ it('`editInline: true` offers the toggle', async () => {
+ renderInlineEdit(makeUpdatePermissions(true), {
+ userActions: { editInline: true },
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).not.toBeNull();
+ });
+
+ it('an ABSENT editInline defers to the host channel, keeping existing views intact', async () => {
+ // The deliberate divergence from the spec's `.default(false)`, pinned so a
+ // future change to it is a decision rather than an accident: enforcing that
+ // default would take the toggle off every stored console view at once,
+ // since nothing folds a legacy key into `editInline`. `toolbarFlags`' own
+ // rule for this block — defaults matching what the flags have always done —
+ // applied in the direction that would REMOVE an affordance.
+ renderInlineEdit(makeUpdatePermissions(true), {
+ userActions: { search: false },
+ } as Partial);
+ await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument());
+ expect(screen.queryByTestId('toolbar-inline-edit-toggle')).not.toBeNull();
+ });
+});
diff --git a/packages/react/src/context/RelatedRecordActionsContext.tsx b/packages/react/src/context/RelatedRecordActionsContext.tsx
index 5ab34a34e4..2ddc14d513 100644
--- a/packages/react/src/context/RelatedRecordActionsContext.tsx
+++ b/packages/react/src/context/RelatedRecordActionsContext.tsx
@@ -65,6 +65,21 @@ export interface RelatedRecordHandlers {
* argument is needed here (增).
*/
onCreate?: () => void;
+ /**
+ * [#4646] Offer "+ New", but GREYED — the `userActions.create.disabledWhen`
+ * verdict for the scope this toolbar sits in (the host parent record on a
+ * record page's related list).
+ *
+ * A separate channel from omitting {@link onCreate} because hidden and
+ * disabled are different answers, exactly as they are for the row Edit/Delete
+ * predicates (objectui#2614) and for the record header's (objectui#4419 /
+ * PR #4515): `visibleWhen` decides whether the affordance exists at all,
+ * `disabledWhen` decides whether the existing affordance can be pressed. The
+ * host still supplies `onCreate` in the disabled case, so the button keeps
+ * its label and tooltip and the user can see that creating is a thing this
+ * list does — just not right now.
+ */
+ createDisabled?: boolean;
/** Open an edit form for an existing child record (改). */
onEdit?: (recordId: string | number, record?: unknown) => void;
/**