Skip to content

Commit 9324232

Browse files
fix(console): a packaged action switched off for this deployment is no longer offered as a live button (#28115)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:管理员在 Setup › Packaged automation 关掉的打包动作,控制台不再给出可点的按钮;记录页标题栏、操作栏、快捷动作、列表/关联列表工具栏、行动作和批量动作一律不显示,重新打开后按钮回来。 - 为什么改:以前按钮照样亮着,点了只会弹出写给工程师的报错(表名、ADR 编号)。服务端已由 #27842(PR #27964)在对象读的信封里下发 `disabledActions`,本 PR 是读它并隐藏的 ui 半边。 - 风险与回滚:只在服务端给出名字时才隐藏,缺键(旧后端、内联 schema)一律照旧显示;标记用 symbol 挂在动作对象上,不进 JSON,不会被存回元数据。回滚即 revert 本 PR。 - 先看:`ui/packages/core/src/utils/action-activation.ts`(标记与判定)、`ui/packages/app-shell/src/providers/MetadataProvider.tsx`(`cloneServedObject` 与 `ingestObjectList`,按名读的副本保留标记)、`ui/packages/components/src/renderers/layout/containers.tsx`(标题栏闸门)。 - 两分钟验证:showcase 里把 Mark Done 关掉,打开任一未完成的 Task,标题栏和下方操作栏都没有 Mark Done;在 Setup 里重新打开,刷新后两处都回来。 - 需要你做的:无,席位落地。 Fixes #27504 Consumer-of: #27842 Clause-②: yes (widening) — four new `@object-ui/core` exports: `ACTION_SWITCHED_OFF_KEY`, `markSwitchedOffActions`, `markSwitchedOffActionsByObject`, `isActionSwitchedOff` Metadata-protocol: none ## What changes The object reads serve, beside the document, the names of the actions this deployment switched off (`disabledActions`, #27842: a `string[]` on `GET /meta/object/NAME`, a map keyed by object name on `GET /meta/object`). The console reads it and stops offering those actions. - `@object-ui/core`: `utils/action-activation.ts` stamps a switched-off action entry under a symbol key (`markSwitchedOffActions`, `markSwitchedOffActionsByObject`) and answers `isActionSwitchedOff`. A symbol rides through a spread (localization, a promoted bulk def) and never reaches `JSON.stringify` or `Object.keys`, so an action handed back to a metadata write never carries it. `ActionEngine.getActionsForLocation` and `getBulkActions` leave such an action out. - `@object-ui/data-objectstack`: the single-object read stamps from its envelope before unwrapping `item`, beside the existing `sortability` carry. - `@object-ui/app-shell`: `MetadataProvider` stamps the whole list and a package-scoped list through one ingestion (`ingestObjectList`). Its by-name read hands out a `structuredClone` of the adapter's schema, which drops symbol keys; `cloneServedObject` carries the stamps onto the copy. Found live: without it the record header kept the button. - Every action gate that already hides an action whose `requiredPermissions` the caller lacks asks `isActionSwitchedOff` beside it: `page:header` (`containers.tsx`), `action:bar`, the data table's row action defs, `RowActionMenu`, `BulkActionBar`, the grid actions column, `RelatedList` toolbar, `DeclaredActionsBar`, and the managed-by empty state's list toolbar check. - No `packages/**` file: the server half, including the plain refusal sentence, landed with #27842. ## What to look at (preview) `examples/app-showcase`, signed in as the seeded admin, viewport 1440×900. 1. Switch **Mark Done** (`showcase_mark_done`, object `showcase_task`) off under Setup › Packaged automation › Packaged actions. 2. Open any task that is not done: the record header and the action bar under the path show **Log Time** and **Archive** but no **Mark Done**. 3. Switch it back on and reload the task: **Mark Done** is back in both places. ## Tests - New: `core/src/utils/__tests__/action-activation.test.ts` (stamp, spread, JSON, list form, engine location and bulk sets), `components/src/__tests__/page-header-switched-off-27504.test.tsx` (`page:header` and `action:bar`, hidden then offered again), `data-objectstack/src/getObjectSchema.disabledActions-27504.test.ts` (envelope carried, absent key stamps nothing), `app-shell/src/providers/__tests__/MetadataProvider.disabledActions-27504.test.tsx` (list envelope, absent key, by-name copy keeps the stamp). - 128 test files that name the touched modules, 1056 tests, all passed on c700e99. `type-check` passed for core, components, data-objectstack, plugin-detail, plugin-grid and app-shell. ## Acceptance notes - Hidden rather than disabled with a reason: it mirrors the capability gate the same surfaces already apply, and it needs no new i18n key. - Absence of `disabledActions` hides nothing, and `[]` hides nothing: only a served name hides an action. - The refusal toast a click that still reaches the door shows is the server's sentence, which #27842 made plain ("This action is turned off for this deployment. …"). ## Verification <details><summary>Repro on main 561b764 (before)</summary> With Mark Done switched off, the record header and the `record:quick_actions` bar both offered a live Mark Done, and a click toasted "Action 'showcase_mark_done' is disabled — it is switched off for this installation in the packaged-metadata activation ledger (sys_metadata_activation, ADR-0126 §8). …". `GET /meta/object/showcase_task` served `type`, `name`, `sortability`, `item` and no activation state. </details> <details><summary>Live check against #27842's branch plus this change (after)</summary> Switched off: Mark Done buttons 0, Log Time 2. Re-enabled and reloaded: Mark Done 2, Log Time 2. `GET /meta/object/showcase_task` served `disabledActions: ["showcase_mark_done"]`; the list served `showcase_task: ["showcase_mark_done"]`. </details> <details><summary>Ablations (each red, each restored to HEAD)</summary> The `page:header` gate, the `action:bar` gate, the engine's location filter, the adapter's stamp, the provider's list stamp and the by-name copy's stamp carry were each disabled through `scripts/ablation-replace.mjs`; the matching new test failed every time and the restore matched HEAD's blob. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZny6X72Dx4kxxzLm7)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CZny6X72Dx4kxxzLm7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdbf08d commit 9324232

19 files changed

Lines changed: 489 additions & 20 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/components': patch
4+
'@object-ui/plugin-grid': patch
5+
'@object-ui/plugin-detail': patch
6+
'@object-ui/data-objectstack': patch
7+
'@object-ui/app-shell': patch
8+
---
9+
10+
fix(console): a packaged action switched off for this deployment is no longer offered as a live button
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: none
14+
15+
An administrator can switch a packaged action off in Setup › Packaged automation. The action door then refuses it, but the console kept offering the button on the record header, in action bars and in list rows, so a click could only fail. The object reads now serve the names of the switched-off actions beside the object (#27842), and the console stops offering those actions everywhere it already hides an action whose capability the user lacks: the record header, action bars, record quick actions, list and related-list toolbars, row and bulk actions. Turning the action back on in Setup brings the button back.
16+
17+
`@object-ui/core` adds four exports for this: `isActionSwitchedOff(action)`, `markSwitchedOffActions(schema, served)`, `markSwitchedOffActionsByObject(objects, served)` and `ACTION_SWITCHED_OFF_KEY`. The data layer stamps a switched-off action entry under that symbol key, which survives a spread and never reaches JSON, so an action handed back to a metadata write never carries it.
18+
19+
What to do: nothing. A backend that does not serve the names hides nothing, as before.

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

Lines changed: 37 additions & 7 deletions
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 { isActionSwitchedOff, markSwitchedOffActions, 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';
@@ -360,6 +360,37 @@ export function extractItems(res: unknown): any[] {
360360
return [];
361361
}
362362

363+
/**
364+
* The ingestion every OBJECT list read runs — the whole list and a package
365+
* scope's alike, so a scoped definition is shaped exactly like the same object
366+
* in `objects`. In place.
367+
*
368+
* 1. a legacy `reference_to` / `referenceTo` is folded onto `reference`;
369+
* 2. [objectstack#27504] the envelope's `disabledActions`, keyed by object:
370+
* each object's switched-off action entries are stamped (symbol key), so
371+
* the action gates stop offering them.
372+
*/
373+
function ingestObjectList(items: any[], res: unknown): void {
374+
for (const it of items) normalizeSchemaReferenceKeys(it);
375+
if (res && typeof res === 'object' && !Array.isArray(res)) {
376+
markSwitchedOffActionsByObject(items, (res as { disabledActions?: unknown }).disabledActions);
377+
}
378+
}
379+
380+
/**
381+
* A private copy of an adapter-served object schema. `structuredClone` drops
382+
* symbol-keyed properties, so the switched-off stamps the adapter put on the
383+
* action entries (objectstack#27504) are carried onto the copy by name.
384+
*/
385+
function cloneServedObject(schema: unknown): unknown {
386+
const copy = structuredClone(schema);
387+
const actions = (schema as { actions?: unknown }).actions;
388+
if (Array.isArray(actions)) {
389+
markSwitchedOffActions(copy, actions.filter(isActionSwitchedOff).map((a: { name?: unknown }) => a.name));
390+
}
391+
return copy;
392+
}
393+
363394
function extractItem(res: unknown): any | null {
364395
if (res == null) return null;
365396
if (typeof res === 'object' && 'item' in res) {
@@ -783,7 +814,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
783814
const readObjectThroughAdapter = useCallback((name: string): Promise<unknown> => {
784815
adapterObjectNamesRef.current.add(name);
785816
return adapterRef.current.getObjectSchema(name).then(
786-
(schema: unknown) => ({ item: schema == null ? schema : structuredClone(schema) }),
817+
(schema: unknown) => ({ item: schema == null ? schema : cloneServedObject(schema) }),
787818
(err: unknown) => {
788819
throw err instanceof MetadataNotFoundError ? (err.details?.originalError ?? err) : err;
789820
},
@@ -896,10 +927,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
896927
// object field defs at ingestion (the store-side choke point,
897928
// mirroring the adapter's getObjectSchema pass), so
898929
// `useMetadata().objects` consumers read `reference` alone
899-
// (objectui#11070 round 4). Idempotent, in place.
900-
if (type === 'object') {
901-
for (const it of items) normalizeSchemaReferenceKeys(it);
902-
}
930+
// (objectui#11070 round 4), and stamp the switched-off actions
931+
// (objectstack#27504) — `ingestObjectList`. Idempotent, in place.
932+
if (type === 'object') ingestObjectList(items, res);
903933
entry.items = items;
904934
entry.status = 'ready';
905935
entry.error = null;
@@ -1047,7 +1077,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
10471077
.then((res: unknown) => {
10481078
if (scopesRef.current.get(packageId) !== entry) return;
10491079
const items = extractItems(res);
1050-
for (const it of items) normalizeSchemaReferenceKeys(it);
1080+
ingestObjectList(items, res);
10511081
const listed = new Set(items.map((it: any) => it?.name));
10521082
// The by-name extras stay until their own answer replaces them.
10531083
const extras = entry.items.filter((it: any) => !listed.has(it?.name) && entry.names.has(it?.name));
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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, markSwitchedOffActions } 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>, served: Record<string, any> = {}) {
26+
return {
27+
clearCache: vi.fn(),
28+
invalidateCache: vi.fn(),
29+
// The adapter's by-name read, already stamped from its own envelope
30+
// (`getObjectSchema.disabledActions-27504.test.ts` pins that half).
31+
getObjectSchema: (name: string) => Promise.resolve(served[name] ?? null),
32+
getClient: () => ({
33+
meta: {
34+
getItems: (type: string) =>
35+
Promise.resolve(type === 'object' ? structuredClone(listBody) : { type, items: [] }),
36+
getItem: () => Promise.resolve({ item: null }),
37+
},
38+
}),
39+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
40+
}
41+
42+
async function mount(listBody: Record<string, unknown>, served: Record<string, any> = {}): Promise<Ctx> {
43+
let ctx: Ctx | null = null;
44+
function Probe() {
45+
ctx = useMetadata();
46+
return null;
47+
}
48+
render(
49+
<MetadataProvider adapter={makeAdapter(listBody, served)}>
50+
<Probe />
51+
</MetadataProvider>,
52+
);
53+
await waitFor(() => expect(ctx).not.toBeNull());
54+
return ctx as unknown as Ctx;
55+
}
56+
57+
async function objectsServed(listBody: Record<string, unknown>): Promise<any[]> {
58+
let ctx: Ctx | null = null;
59+
function Probe() {
60+
ctx = useMetadata();
61+
void ctx.objects;
62+
return null;
63+
}
64+
render(
65+
<MetadataProvider adapter={makeAdapter(listBody)}>
66+
<Probe />
67+
</MetadataProvider>,
68+
);
69+
await waitFor(() => expect((ctx as unknown as Ctx | null)?.objects?.length).toBe(1));
70+
return (ctx as unknown as Ctx).objects as any[];
71+
}
72+
73+
describe('MetadataProvider stamps the list envelope disabledActions (objectstack#27504)', () => {
74+
it('marks the named entry of the named object', async () => {
75+
const [task] = await objectsServed({
76+
type: 'object',
77+
items: [TASK],
78+
disabledActions: { showcase_task: ['showcase_mark_done'] },
79+
});
80+
const byName = (n: string) => task.actions.find((a: any) => a.name === n);
81+
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
82+
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
83+
});
84+
85+
it('marks nothing when the envelope carries no `disabledActions`', async () => {
86+
const [task] = await objectsServed({ type: 'object', items: [TASK] });
87+
expect(task.actions.some(isActionSwitchedOff)).toBe(false);
88+
});
89+
90+
it('keeps the adapter\'s stamp on the by-name read, which hands out a private copy', async () => {
91+
// `structuredClone` drops symbol keys: without carrying the stamp across,
92+
// the record header (`useMetadataItem` → this read) kept a live button.
93+
const ctx = await mount(
94+
{ type: 'object', items: [] },
95+
{ showcase_task: markSwitchedOffActions(structuredClone(TASK), ['showcase_mark_done']) },
96+
);
97+
const item = await ctx.getItem('object', 'showcase_task');
98+
const byName = (n: string) => item.actions.find((a: any) => a.name === n);
99+
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
100+
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
101+
});
102+
});

‎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
@@ -15,7 +15,7 @@ import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
1515
import { useHorizontalOverflow, HOVER_SCROLLBAR_CLASSES } from '../../custom/use-horizontal-overflow';
1616
import { ActiveListRow, listRecordIdOf } from '../../custom/record-list-face';
1717
import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic';
18-
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
18+
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, isActionSwitchedOff, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
1919
import type { DataTableSchema, TableColumn, TableSortItem, TableColumnType } from '@object-ui/types';
2020
import type { SortDirection } from '@objectstack/spec/shared';
2121
import { SchemaRenderer, toRenderableSchema, useCapabilityGate, useRowPredicate, usePredicateScope } from '@object-ui/react';
@@ -754,7 +754,7 @@ const DataTableRowActionsMenu: React.FC<{
754754
const customActions = useMemo(
755755
() =>
756756
Array.isArray(schema.rowActionDefs) && schema.onRowActionDef
757-
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions))
757+
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions) && !isActionSwitchedOff(d))
758758
: [],
759759
[schema.rowActionDefs, schema.onRowActionDef, mayInvoke],
760760
);

‎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';
@@ -2045,6 +2045,9 @@ export const PageHeaderActions: React.FC<PageHeaderActionsProps> = ({
20452045
// ActionProvider user, no host predicate scope) — unknown is not denied,
20462046
// and hiding on missing data is the worse regression.
20472047
if (!mayInvoke(a?.requiredPermissions)) return false;
2048+
// [objectstack#27504] Switched off for this installation (served
2049+
// activation state): not offered, as the door would refuse it.
2050+
if (isActionSwitchedOff(a)) return false;
20482051
// Boolean / expression visibility — supports both `visible: false`,
20492052
// `visible: 'record.status == "open"'` and the structured shape
20502053
// `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
}

0 commit comments

Comments
 (0)