Skip to content

Commit 2c18cc9

Browse files
committed
fix(console): a switched-off packaged action is not offered as a live button
The object reads will serve the names of the actions the installation switched off beside the document (`disabledActions`). The data layer stamps those action entries under a symbol key (survives a spread, never reaches JSON), and every action gate that hides a capability-gated action asks `isActionSwitchedOff` beside it: the record header, action bars, the engine's location and bulk sets, grid row and bulk actions, related-list toolbars and the declared-actions bar. No client copy of the ledger. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CZny6X72Dx4kxxzLm7
1 parent 561b764 commit 2c18cc9

18 files changed

Lines changed: 410 additions & 14 deletions

File tree

‎ui/packages/app-shell/src/providers/MetadataProvider.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { expandViewContainer } from '@objectstack/spec/ui';
1010
import { ActiveOrganizationStorage, TokenStorage, useAuth } from '@object-ui/auth';
1111
import { isMetaItemAbsentError, MetadataNotFoundError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
12-
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
12+
import { markSwitchedOffActionsByObject, normalizeSchemaReferenceKeys } from '@object-ui/core';
1313
import { errorCodeIs } from '@object-ui/types';
1414
import { resolveInlineMode } from '@object-ui/plugin-form';
1515
import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCacheState, type MetadataTypeStatus } from '@object-ui/react';
@@ -899,6 +899,12 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
899899
// (objectui#11070 round 4). Idempotent, in place.
900900
if (type === 'object') {
901901
for (const it of items) normalizeSchemaReferenceKeys(it);
902+
// [objectstack#27504] The list envelope's `disabledActions`, keyed
903+
// by object: each object's switched-off action entries are stamped
904+
// (symbol key) so the action gates stop offering them.
905+
if (res && typeof res === 'object' && !Array.isArray(res)) {
906+
markSwitchedOffActionsByObject(items, (res as { disabledActions?: unknown }).disabledActions);
907+
}
902908
}
903909
entry.items = items;
904910
entry.status = 'ready';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27504 — the LIST read's `disabledActions` reaches the objects
5+
* the console renders from.
6+
*
7+
* `GET /api/v1/meta/object` serves, beside `items`, the switched-off action
8+
* names keyed by object. The record page and the list views read their
9+
* actions off `useMetadata().objects`, so the provider stamps each object's
10+
* entries as it stores the list; the action gates then stop offering them.
11+
*/
12+
13+
import { describe, it, expect, vi } from 'vitest';
14+
import { render, waitFor } from '@testing-library/react';
15+
import { isActionSwitchedOff } from '@object-ui/core';
16+
import { MetadataProvider, useMetadata } from '../MetadataProvider';
17+
18+
type Ctx = ReturnType<typeof useMetadata>;
19+
20+
const TASK = {
21+
name: 'showcase_task',
22+
actions: [{ name: 'showcase_mark_done' }, { name: 'showcase_log_time' }],
23+
};
24+
25+
function makeAdapter(listBody: Record<string, unknown>) {
26+
return {
27+
clearCache: vi.fn(),
28+
invalidateCache: vi.fn(),
29+
getObjectSchema: () => Promise.resolve(null),
30+
getClient: () => ({
31+
meta: {
32+
getItems: (type: string) =>
33+
Promise.resolve(type === 'object' ? structuredClone(listBody) : { type, items: [] }),
34+
getItem: () => Promise.resolve({ item: null }),
35+
},
36+
}),
37+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
38+
}
39+
40+
async function objectsServed(listBody: Record<string, unknown>): Promise<any[]> {
41+
let ctx: Ctx | null = null;
42+
function Probe() {
43+
ctx = useMetadata();
44+
void ctx.objects;
45+
return null;
46+
}
47+
render(
48+
<MetadataProvider adapter={makeAdapter(listBody)}>
49+
<Probe />
50+
</MetadataProvider>,
51+
);
52+
await waitFor(() => expect((ctx as unknown as Ctx | null)?.objects?.length).toBe(1));
53+
return (ctx as unknown as Ctx).objects as any[];
54+
}
55+
56+
describe('MetadataProvider stamps the list envelope disabledActions (objectstack#27504)', () => {
57+
it('marks the named entry of the named object', async () => {
58+
const [task] = await objectsServed({
59+
type: 'object',
60+
items: [TASK],
61+
disabledActions: { showcase_task: ['showcase_mark_done'] },
62+
});
63+
const byName = (n: string) => task.actions.find((a: any) => a.name === n);
64+
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
65+
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
66+
});
67+
68+
it('marks nothing when the envelope carries no `disabledActions`', async () => {
69+
const [task] = await objectsServed({ type: 'object', items: [TASK] });
70+
expect(task.actions.some(isActionSwitchedOff)).toBe(false);
71+
});
72+
});

‎ui/packages/app-shell/src/utils/managedByEmptyState.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import {
2525
ExpressionEvaluator,
2626
hasDeclaredPredicate,
27+
isActionSwitchedOff,
2728
resolveEffectiveCrudAffordances,
2829
toPredicateInput,
2930
type UserActionsOverride,
@@ -79,6 +80,7 @@ export function listToolbarDrawsAction(
7980
};
8081
if (!actionRendersAt(action, 'list_toolbar')) return false;
8182
if (!mayInvoke(action.requiredPermissions)) return false;
83+
if (isActionSwitchedOff(action)) return false;
8284
if (!hasDeclaredPredicate(action.visible)) return true;
8385
try {
8486
return new ExpressionEvaluator({ ...predicateScope }).evaluateCondition(

‎ui/packages/app-shell/src/views/DeclaredActionsBar.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
usePredicateRecordContext,
5757
useActionTextLocalizer,
5858
} from '@object-ui/react';
59-
import type { ActionDef } from '@object-ui/core';
59+
import { isActionSwitchedOff, type ActionDef } from '@object-ui/core';
6060
import type { ConsoleActionDispatch } from '../consoleActionDispatch.js';
6161
import { useObjectTranslation } from '@object-ui/i18n';
6262
import { Loader2, MoreHorizontal, ShieldAlert } from 'lucide-react';
@@ -832,7 +832,7 @@ const DeclaredActionsToolbar: React.FC<
832832
const { t } = useObjectTranslation();
833833
const mayInvoke = useCapabilityGate();
834834
const permitted = useMemo(
835-
() => actions.filter((action) => mayInvoke(action.requiredPermissions)),
835+
() => actions.filter((action) => mayInvoke(action.requiredPermissions) && !isActionSwitchedOff(action)),
836836
[actions, mayInvoke],
837837
);
838838
// objectstack#25877 — which of the permitted levers fold into the override
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27504 — a packaged action the installation switched off is not
11+
* offered on the record header (`page:header`) or in an `action:bar`.
12+
*
13+
* The platform serves the switched-off names beside the object document and
14+
* the data layer stamps those entries (`markSwitchedOffActions`); both
15+
* surfaces ask `isActionSwitchedOff` beside their capability gate. Before this,
16+
* the button stayed live and a click only met the door's 409.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { render, screen } from '@testing-library/react';
21+
import { ComponentRegistry, markSwitchedOffActions } from '@object-ui/core';
22+
import { ActionProvider } from '@object-ui/react';
23+
// The renderers register on import; this file is not in the dom-heavy set.
24+
import '../renderers';
25+
26+
function Node({ schema }: { schema: any }) {
27+
const Component = ComponentRegistry.get(schema.type);
28+
if (!Component) throw new Error(`${schema.type} not registered`);
29+
// eslint-disable-next-line react-hooks/static-components -- registry component is stable
30+
return <Component schema={schema} />;
31+
}
32+
33+
const served = (switchedOff: string[]) =>
34+
markSwitchedOffActions(
35+
{
36+
name: 'showcase_task',
37+
actions: [
38+
{ name: 'showcase_mark_done', label: 'Mark Done', type: 'script', locations: ['record_header'] },
39+
{ name: 'showcase_log_time', label: 'Log Time', type: 'script', locations: ['record_header'] },
40+
],
41+
},
42+
switchedOff,
43+
).actions;
44+
45+
const renderIn = (schema: any) =>
46+
render(<ActionProvider context={{ user: { id: 'u1' } } as any}><Node schema={schema} /></ActionProvider>);
47+
48+
const markDone = () => screen.queryByRole('button', { name: /Mark Done/i });
49+
50+
describe.each([
51+
['page:header', (actions: any[]) => ({ type: 'page:header', title: 'Task', actions })],
52+
['action:bar', (actions: any[]) => ({ type: 'action:bar', location: 'record_header', actions })],
53+
])('%s — switched-off packaged action (objectstack#27504)', (_type, schemaOf) => {
54+
it('does not offer an action the platform served as switched off', () => {
55+
renderIn(schemaOf(served(['showcase_mark_done'])));
56+
expect(markDone()).toBeNull();
57+
// A filter, not a blank bar: its sibling still renders.
58+
expect(screen.getByRole('button', { name: /Log Time/i })).toBeTruthy();
59+
});
60+
61+
it('offers it again once the served state no longer names it', () => {
62+
renderIn(schemaOf(served([])));
63+
expect(markDone()).toBeTruthy();
64+
});
65+
});

‎ui/packages/components/src/renderers/action/action-bar.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
*/
4949

5050
import React, { forwardRef, useMemo } from 'react';
51-
import { ComponentRegistry } from '@object-ui/core';
51+
import { ComponentRegistry, isActionSwitchedOff } from '@object-ui/core';
5252
import type { UIActionSchema, ActionBarSchema, ActionComponent } from '@object-ui/types';
5353
import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types';
5454
import { useCondition, toPredicateInput, useCapabilityGate, useConfigBagEvaluator } from '@object-ui/react';
@@ -147,7 +147,9 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
147147
// so without this a `list_toolbar` action declaring a capability nobody
148148
// holds rendered as a live button. Same rule as the engine; unknown
149149
// capabilities fail OPEN (see `useCapabilityGate`).
150-
const permitted = actions.filter(a => mayInvoke((a as any)?.requiredPermissions));
150+
// [objectstack#27504] …and an action this installation switched off is
151+
// not offered either: the served activation state, beside the same gate.
152+
const permitted = actions.filter(a => mayInvoke((a as any)?.requiredPermissions) && !isActionSwitchedOff(a));
151153
// Placement is `actionRendersAt`'s call, not ours (objectui#3142): an
152154
// action renders here only if it DECLARES this location. This bar used
153155
// to show a locationless action at every location, which is how an

‎ui/packages/components/src/renderers/complex/data-table.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { hasDeclaredVisibilityGate } from '../action/visibility-gate';
1414
import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
1515
import { useHorizontalOverflow, HOVER_SCROLLBAR_CLASSES } from '../../custom/use-horizontal-overflow';
1616
import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic';
17-
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
17+
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, isActionSwitchedOff, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
1818
import type { DataTableSchema, TableColumn, TableSortItem, TableColumnType } from '@object-ui/types';
1919
import type { SortDirection } from '@objectstack/spec/shared';
2020
import { SchemaRenderer, toRenderableSchema, useCapabilityGate, useRowPredicate, usePredicateScope } from '@object-ui/react';
@@ -752,7 +752,7 @@ const DataTableRowActionsMenu: React.FC<{
752752
const customActions = useMemo(
753753
() =>
754754
Array.isArray(schema.rowActionDefs) && schema.onRowActionDef
755-
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions))
755+
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions) && !isActionSwitchedOff(d))
756756
: [],
757757
[schema.rowActionDefs, schema.onRowActionDef, mayInvoke],
758758
);

‎ui/packages/components/src/renderers/layout/containers.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import React from 'react';
22-
import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, hasDeclaredPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
22+
import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, hasDeclaredPredicate, getRecordDisplayName, isActionSwitchedOff, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
2323
import type { ComponentInput } from '@object-ui/core';
2424
import { actionRendersAt, resolveDeclaredActionIds } from '@object-ui/types';
2525
import type { DeclaredActionsRefusal } from '@object-ui/types';
@@ -2026,6 +2026,9 @@ export const PageHeaderActions: React.FC<PageHeaderActionsProps> = ({
20262026
// ActionProvider user, no host predicate scope) — unknown is not denied,
20272027
// and hiding on missing data is the worse regression.
20282028
if (!mayInvoke(a?.requiredPermissions)) return false;
2029+
// [objectstack#27504] Switched off for this installation (served
2030+
// activation state): not offered, as the door would refuse it.
2031+
if (isActionSwitchedOff(a)) return false;
20292032
// Boolean / expression visibility — supports both `visible: false`,
20302033
// `visible: 'record.status == "open"'` and the structured shape
20312034
// `visible: { dialect: 'cel', source: '…' }` used by spec authors.

‎ui/packages/core/src/actions/ActionEngine.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner.js';
2020
import { toPredicateInput } from '../evaluator/predicateInput.js';
2121
import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
22+
import { isActionSwitchedOff } from '../utils/action-activation.js';
2223

2324
/**
2425
* Action location types — re-exported from `@objectstack/spec/ui` so the
@@ -228,6 +229,9 @@ export class ActionEngine {
228229
const evaluator = this.runner.getEvaluator();
229230
return Array.from(this.actions.values())
230231
.filter(ra => ra.locations.includes(location))
232+
// [objectstack#27504] An action this installation switched off is not
233+
// offered (the served activation state — see `utils/action-activation`).
234+
.filter(ra => !isActionSwitchedOff(ra.action))
231235
.filter(ra => {
232236
// [ADR-0066 D4] Capability gate (UI half — the server enforces the
233237
// source of truth). Hide an action whose `requiredPermissions` are not
@@ -304,7 +308,7 @@ export class ActionEngine {
304308
/** Get actions that support bulk operations */
305309
getBulkActions(): ActionDef[] {
306310
return Array.from(this.actions.values())
307-
.filter(ra => ra.bulkEnabled)
311+
.filter(ra => ra.bulkEnabled && !isActionSwitchedOff(ra.action))
308312
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
309313
.map(ra => ra.action);
310314
}

‎ui/packages/core/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export * from './utils/unmaterialized-fields.js';
5656
// because a consumer that reaches for one must be able to see the other and
5757
// know which one the platform actually served.
5858
export * from './utils/column-sortability.js';
59+
// [objectstack#27504] The served packaged-action activation: which declared
60+
// actions the installation switched off, stamped by the data layer and asked
61+
// by every action gate beside the capability check.
62+
export * from './utils/action-activation.js';
5963
// Named, not `export *`: the module's `reset*` helper is test-only and stays off
6064
// the public barrel (objectui#13453); in-repo tests import it from the module.
6165
export {

0 commit comments

Comments
 (0)