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
19 changes: 19 additions & 0 deletions .changeset/27504-switched-off-action-not-offered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/core': patch
'@object-ui/components': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-detail': patch
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

fix(console): a packaged action switched off for this deployment is no longer offered as a live button

Clause-②: yes (widening)
Metadata-protocol: none

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.

`@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.

What to do: nothing. A backend that does not serve the names hides nothing, as before.
44 changes: 37 additions & 7 deletions ui/packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { expandViewContainer } from '@objectstack/spec/ui';
import { ActiveOrganizationStorage, TokenStorage, useAuth } from '@object-ui/auth';
import { isMetaItemAbsentError, MetadataNotFoundError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { isActionSwitchedOff, markSwitchedOffActions, markSwitchedOffActionsByObject, normalizeSchemaReferenceKeys } from '@object-ui/core';
import { errorCodeIs } from '@object-ui/types';
import { resolveInlineMode } from '@object-ui/plugin-form';
import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCacheState, type MetadataTypeStatus } from '@object-ui/react';
Expand Down Expand Up @@ -360,6 +360,37 @@ export function extractItems(res: unknown): any[] {
return [];
}

/**
* The ingestion every OBJECT list read runs — the whole list and a package
* scope's alike, so a scoped definition is shaped exactly like the same object
* in `objects`. In place.
*
* 1. a legacy `reference_to` / `referenceTo` is folded onto `reference`;
* 2. [objectstack#27504] the envelope's `disabledActions`, keyed by object:
* each object's switched-off action entries are stamped (symbol key), so
* the action gates stop offering them.
*/
function ingestObjectList(items: any[], res: unknown): void {
for (const it of items) normalizeSchemaReferenceKeys(it);
if (res && typeof res === 'object' && !Array.isArray(res)) {
markSwitchedOffActionsByObject(items, (res as { disabledActions?: unknown }).disabledActions);
}
}

/**
* A private copy of an adapter-served object schema. `structuredClone` drops
* symbol-keyed properties, so the switched-off stamps the adapter put on the
* action entries (objectstack#27504) are carried onto the copy by name.
*/
function cloneServedObject(schema: unknown): unknown {
const copy = structuredClone(schema);
const actions = (schema as { actions?: unknown }).actions;
if (Array.isArray(actions)) {
markSwitchedOffActions(copy, actions.filter(isActionSwitchedOff).map((a: { name?: unknown }) => a.name));
}
return copy;
}

function extractItem(res: unknown): any | null {
if (res == null) return null;
if (typeof res === 'object' && 'item' in res) {
Expand Down Expand Up @@ -783,7 +814,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
const readObjectThroughAdapter = useCallback((name: string): Promise<unknown> => {
adapterObjectNamesRef.current.add(name);
return adapterRef.current.getObjectSchema(name).then(
(schema: unknown) => ({ item: schema == null ? schema : structuredClone(schema) }),
(schema: unknown) => ({ item: schema == null ? schema : cloneServedObject(schema) }),
(err: unknown) => {
throw err instanceof MetadataNotFoundError ? (err.details?.originalError ?? err) : err;
},
Expand Down Expand Up @@ -896,10 +927,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
// object field defs at ingestion (the store-side choke point,
// mirroring the adapter's getObjectSchema pass), so
// `useMetadata().objects` consumers read `reference` alone
// (objectui#11070 round 4). Idempotent, in place.
if (type === 'object') {
for (const it of items) normalizeSchemaReferenceKeys(it);
}
// (objectui#11070 round 4), and stamp the switched-off actions
// (objectstack#27504) — `ingestObjectList`. Idempotent, in place.
if (type === 'object') ingestObjectList(items, res);
entry.items = items;
entry.status = 'ready';
entry.error = null;
Expand Down Expand Up @@ -1047,7 +1077,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
.then((res: unknown) => {
if (scopesRef.current.get(packageId) !== entry) return;
const items = extractItems(res);
for (const it of items) normalizeSchemaReferenceKeys(it);
ingestObjectList(items, res);
const listed = new Set(items.map((it: any) => it?.name));
// The by-name extras stay until their own answer replaces them.
const extras = entry.items.filter((it: any) => !listed.has(it?.name) && entry.names.has(it?.name));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27504 — the LIST read's `disabledActions` reaches the objects
* the console renders from.
*
* `GET /api/v1/meta/object` serves, beside `items`, the switched-off action
* names keyed by object. The record page and the list views read their
* actions off `useMetadata().objects`, so the provider stamps each object's
* entries as it stores the list; the action gates then stop offering them.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { isActionSwitchedOff, markSwitchedOffActions } from '@object-ui/core';
import { MetadataProvider, useMetadata } from '../MetadataProvider';

type Ctx = ReturnType<typeof useMetadata>;

const TASK = {
name: 'showcase_task',
actions: [{ name: 'showcase_mark_done' }, { name: 'showcase_log_time' }],
};

function makeAdapter(listBody: Record<string, unknown>, served: Record<string, any> = {}) {
return {
clearCache: vi.fn(),
invalidateCache: vi.fn(),
// The adapter's by-name read, already stamped from its own envelope
// (`getObjectSchema.disabledActions-27504.test.ts` pins that half).
getObjectSchema: (name: string) => Promise.resolve(served[name] ?? null),
getClient: () => ({
meta: {
getItems: (type: string) =>
Promise.resolve(type === 'object' ? structuredClone(listBody) : { type, items: [] }),
getItem: () => Promise.resolve({ item: null }),
},
}),
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
}

async function mount(listBody: Record<string, unknown>, served: Record<string, any> = {}): Promise<Ctx> {
let ctx: Ctx | null = null;
function Probe() {
ctx = useMetadata();
return null;
}
render(
<MetadataProvider adapter={makeAdapter(listBody, served)}>
<Probe />
</MetadataProvider>,
);
await waitFor(() => expect(ctx).not.toBeNull());
return ctx as unknown as Ctx;
}

async function objectsServed(listBody: Record<string, unknown>): Promise<any[]> {
let ctx: Ctx | null = null;
function Probe() {
ctx = useMetadata();
void ctx.objects;
return null;
}
render(
<MetadataProvider adapter={makeAdapter(listBody)}>
<Probe />
</MetadataProvider>,
);
await waitFor(() => expect((ctx as unknown as Ctx | null)?.objects?.length).toBe(1));
return (ctx as unknown as Ctx).objects as any[];
}

describe('MetadataProvider stamps the list envelope disabledActions (objectstack#27504)', () => {
it('marks the named entry of the named object', async () => {
const [task] = await objectsServed({
type: 'object',
items: [TASK],
disabledActions: { showcase_task: ['showcase_mark_done'] },
});
const byName = (n: string) => task.actions.find((a: any) => a.name === n);
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
});

it('marks nothing when the envelope carries no `disabledActions`', async () => {
const [task] = await objectsServed({ type: 'object', items: [TASK] });
expect(task.actions.some(isActionSwitchedOff)).toBe(false);
});

it('keeps the adapter\'s stamp on the by-name read, which hands out a private copy', async () => {
// `structuredClone` drops symbol keys: without carrying the stamp across,
// the record header (`useMetadataItem` → this read) kept a live button.
const ctx = await mount(
{ type: 'object', items: [] },
{ showcase_task: markSwitchedOffActions(structuredClone(TASK), ['showcase_mark_done']) },
);
const item = await ctx.getItem('object', 'showcase_task');
const byName = (n: string) => item.actions.find((a: any) => a.name === n);
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
});
});
2 changes: 2 additions & 0 deletions ui/packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import {
ExpressionEvaluator,
hasDeclaredPredicate,
isActionSwitchedOff,
resolveEffectiveCrudAffordances,
toPredicateInput,
type UserActionsOverride,
Expand Down Expand Up @@ -79,6 +80,7 @@ export function listToolbarDrawsAction(
};
if (!actionRendersAt(action, 'list_toolbar')) return false;
if (!mayInvoke(action.requiredPermissions)) return false;
if (isActionSwitchedOff(action)) return false;
if (!hasDeclaredPredicate(action.visible)) return true;
try {
return new ExpressionEvaluator({ ...predicateScope }).evaluateCondition(
Expand Down
4 changes: 2 additions & 2 deletions ui/packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
usePredicateRecordContext,
useActionTextLocalizer,
} from '@object-ui/react';
import type { ActionDef } from '@object-ui/core';
import { isActionSwitchedOff, type ActionDef } from '@object-ui/core';
import type { ConsoleActionDispatch } from '../consoleActionDispatch.js';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, MoreHorizontal, ShieldAlert } from 'lucide-react';
Expand Down Expand Up @@ -832,7 +832,7 @@ const DeclaredActionsToolbar: React.FC<
const { t } = useObjectTranslation();
const mayInvoke = useCapabilityGate();
const permitted = useMemo(
() => actions.filter((action) => mayInvoke(action.requiredPermissions)),
() => actions.filter((action) => mayInvoke(action.requiredPermissions) && !isActionSwitchedOff(action)),
[actions, mayInvoke],
);
// objectstack#25877 — which of the permitted levers fold into the override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#27504 — a packaged action the installation switched off is not
* offered on the record header (`page:header`) or in an `action:bar`.
*
* The platform serves the switched-off names beside the object document and
* the data layer stamps those entries (`markSwitchedOffActions`); both
* surfaces ask `isActionSwitchedOff` beside their capability gate. Before this,
* the button stayed live and a click only met the door's 409.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComponentRegistry, markSwitchedOffActions } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
// The renderers register on import; this file is not in the dom-heavy set.
import '../renderers';

function Node({ schema }: { schema: any }) {
const Component = ComponentRegistry.get(schema.type);
if (!Component) throw new Error(`${schema.type} not registered`);
// eslint-disable-next-line react-hooks/static-components -- registry component is stable
return <Component schema={schema} />;
}

const served = (switchedOff: string[]) =>
markSwitchedOffActions(
{
name: 'showcase_task',
actions: [
{ name: 'showcase_mark_done', label: 'Mark Done', type: 'script', locations: ['record_header'] },
{ name: 'showcase_log_time', label: 'Log Time', type: 'script', locations: ['record_header'] },
],
},
switchedOff,
).actions;

const renderIn = (schema: any) =>
render(<ActionProvider context={{ user: { id: 'u1' } } as any}><Node schema={schema} /></ActionProvider>);

const markDone = () => screen.queryByRole('button', { name: /Mark Done/i });

describe.each([
['page:header', (actions: any[]) => ({ type: 'page:header', title: 'Task', actions })],
['action:bar', (actions: any[]) => ({ type: 'action:bar', location: 'record_header', actions })],
])('%s — switched-off packaged action (objectstack#27504)', (_type, schemaOf) => {
it('does not offer an action the platform served as switched off', () => {
renderIn(schemaOf(served(['showcase_mark_done'])));
expect(markDone()).toBeNull();
// A filter, not a blank bar: its sibling still renders.
expect(screen.getByRole('button', { name: /Log Time/i })).toBeTruthy();
});

it('offers it again once the served state no longer names it', () => {
renderIn(schemaOf(served([])));
expect(markDone()).toBeTruthy();
});
});
6 changes: 4 additions & 2 deletions ui/packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
*/

import React, { forwardRef, useMemo } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { ComponentRegistry, isActionSwitchedOff } from '@object-ui/core';
import type { UIActionSchema, ActionBarSchema, ActionComponent } from '@object-ui/types';
import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types';
import { useCondition, toPredicateInput, useCapabilityGate, useConfigBagEvaluator } from '@object-ui/react';
Expand Down Expand Up @@ -147,7 +147,9 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
// so without this a `list_toolbar` action declaring a capability nobody
// holds rendered as a live button. Same rule as the engine; unknown
// capabilities fail OPEN (see `useCapabilityGate`).
const permitted = actions.filter(a => mayInvoke((a as any)?.requiredPermissions));
// [objectstack#27504] …and an action this installation switched off is
// not offered either: the served activation state, beside the same gate.
const permitted = actions.filter(a => mayInvoke((a as any)?.requiredPermissions) && !isActionSwitchedOff(a));
// Placement is `actionRendersAt`'s call, not ours (objectui#3142): an
// action renders here only if it DECLARES this location. This bar used
// to show a locationless action at every location, which is how an
Expand Down
4 changes: 2 additions & 2 deletions ui/packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
import { useHorizontalOverflow, HOVER_SCROLLBAR_CLASSES } from '../../custom/use-horizontal-overflow';
import { ActiveListRow, listRecordIdOf } from '../../custom/record-list-face';
import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic';
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, isActionSwitchedOff, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core';
import type { DataTableSchema, TableColumn, TableSortItem, TableColumnType } from '@object-ui/types';
import type { SortDirection } from '@objectstack/spec/shared';
import { SchemaRenderer, toRenderableSchema, useCapabilityGate, useRowPredicate, usePredicateScope } from '@object-ui/react';
Expand Down Expand Up @@ -754,7 +754,7 @@ const DataTableRowActionsMenu: React.FC<{
const customActions = useMemo(
() =>
Array.isArray(schema.rowActionDefs) && schema.onRowActionDef
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions))
? schema.rowActionDefs.filter((d) => mayInvoke(d.requiredPermissions) && !isActionSwitchedOff(d))
: [],
[schema.rowActionDefs, schema.onRowActionDef, mayInvoke],
);
Expand Down
5 changes: 4 additions & 1 deletion ui/packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import React from 'react';
import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, hasDeclaredPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, hasDeclaredPredicate, getRecordDisplayName, isActionSwitchedOff, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
import type { ComponentInput } from '@object-ui/core';
import { actionRendersAt, resolveDeclaredActionIds } from '@object-ui/types';
import type { DeclaredActionsRefusal } from '@object-ui/types';
Expand Down Expand Up @@ -2045,6 +2045,9 @@ export const PageHeaderActions: React.FC<PageHeaderActionsProps> = ({
// ActionProvider user, no host predicate scope) — unknown is not denied,
// and hiding on missing data is the worse regression.
if (!mayInvoke(a?.requiredPermissions)) return false;
// [objectstack#27504] Switched off for this installation (served
// activation state): not offered, as the door would refuse it.
if (isActionSwitchedOff(a)) return false;
// Boolean / expression visibility — supports both `visible: false`,
// `visible: 'record.status == "open"'` and the structured shape
// `visible: { dialect: 'cel', source: '…' }` used by spec authors.
Expand Down
6 changes: 5 additions & 1 deletion ui/packages/core/src/actions/ActionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner.js';
import { toPredicateInput } from '../evaluator/predicateInput.js';
import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
import { isActionSwitchedOff } from '../utils/action-activation.js';

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