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
32 changes: 32 additions & 0 deletions .changeset/action-disabled-all-renderers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@object-ui/components": patch
"@object-ui/app-shell": patch
"@object-ui/plugin-detail": patch
---

fix(action): honor the spec `disabled` predicate on every action-rendering surface (#1885 follow-through)

The spec Action field is `disabled` (boolean | CEL — disabled when TRUE); the
schema has no `enabled` key. #1885 wired it in `action:button` only. Browser
dogfooding against the showcase found FIVE more surfaces where a spec-authored
`disabled` silently did nothing:

- **components** — the `action:group` leaves (inline + dropdown), `action:icon`
and `action:menu` still read the legacy non-spec `enabled`. They now consume
`disabled` as the primary control (evaluated in the same scope as `visible`),
with `enabled` kept as a deprecated fallback.
- **app-shell** — `DeclaredActionsBar` (server-declared action bar) read
neither; it gains `disabled` (no legacy fallback: declared actions are
spec-shaped and never carried `enabled`).
- **plugin-detail** — `record:quick_actions` HAD a `disabled` implementation,
but its `typeof === 'string'` split dropped the `{dialect:'cel', source}`
envelope the server compiles authored CEL into (#2661 routes envelopes to the
canonical formula engine), so the predicate never fired on real metadata. It
now feeds `toPredicateInput`'s result to `useCondition` whole, like every
other surface.

Pinned by new `DropdownActionItem` tests (disabled-when-TRUE, false-stays-
clickable, disabled-wins-over-enabled, boolean literal) and browser-verified
end-to-end against the showcase `showcase_archive_task` specimen: greyed on an
in-progress task, clickable on a done one (with `visible` hiding Mark Done on
the same screen — the hide-vs-grey contrast).
8 changes: 7 additions & 1 deletion packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ const DeclaredActionButton: React.FC<{
throwOnError: true,
label: `declared action "${action.name ?? action.label ?? 'action'}" (visible)`,
});
// Spec `disabled` (boolean | CEL — disabled when TRUE), evaluated against the
// same record context as `visible`. #1885 wired it in action-button only;
// this bar ignored it, so a spec-authored `disabled` guard on a declared
// action did nothing here. (No legacy `enabled` fallback: server-declared
// actions are spec-shaped and never carried the non-spec key.)
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), recordData);

const handleClick = useCallback(async () => {
if (loading) return;
Expand Down Expand Up @@ -219,7 +225,7 @@ const DeclaredActionButton: React.FC<{
type="button"
size="sm"
variant={variant as any}
disabled={loading}
disabled={((action as any).disabled != null ? isDisabledPred : false) || loading}
onClick={handleClick}
data-testid={`declared-action-${action.name}`}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,43 @@ describe('action:group dropdown item — visible / enabled CEL', () => {
expect(item).toHaveAttribute('data-disabled');
});
});

// Spec field is `disabled` (boolean | CEL — disabled when TRUE). #1885 wired it
// in action-button only; the group/icon/menu leaves kept reading the legacy
// non-spec `enabled`, so a spec-authored `disabled` guard silently did nothing
// there. These pin the follow-through: `disabled` is the primary control and
// wins over the deprecated `enabled` fallback.
describe('action:group dropdown item — spec `disabled` CEL', () => {
it('disables an action whose `disabled` predicate is TRUE', () => {
renderItem(
{ name: 'close', label: 'Close', disabled: 'record.status == "closed"' },
{ record: { status: 'closed' } },
);
const item = screen.getByText('Close').closest('[role="menuitem"]');
expect(item).toHaveAttribute('data-disabled');
});

it('keeps an action clickable when `disabled` is FALSE', () => {
renderItem(
{ name: 'close', label: 'Close', disabled: 'record.status == "closed"' },
{ record: { status: 'open' } },
);
const item = screen.getByText('Close').closest('[role="menuitem"]');
expect(item).not.toHaveAttribute('data-disabled');
});

it('`disabled` wins over the legacy `enabled` fallback when both are present', () => {
renderItem(
{ name: 'close', label: 'Close', disabled: 'record.locked == true', enabled: 'true' },
{ record: { locked: true } },
);
const item = screen.getByText('Close').closest('[role="menuitem"]');
expect(item).toHaveAttribute('data-disabled');
});

it('supports the boolean literal form (`disabled: true`)', () => {
renderItem({ name: 'close', label: 'Close', disabled: true });
const item = screen.getByText('Close').closest('[role="menuitem"]');
expect(item).toHaveAttribute('data-disabled');
});
});
22 changes: 20 additions & 2 deletions packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ const InlineActionButton: React.FC<{
}> = ({ action, variant, size, onExecute }) => {
const [loading, setLoading] = useState(false);
const isVisible = useCondition(toPredicateInput(action.visible));
// Spec field is `disabled` (boolean | CEL — disabled when TRUE). #1885 wired
// it in action-button only; this leaf kept reading the legacy non-spec
// `enabled`, so a spec-authored `disabled` guard did nothing here. `disabled`
// is now the primary control; `enabled` stays as a deprecated fallback.
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled));
const isEnabled = useCondition(toPredicateInput(action.enabled));

const Icon = resolveIcon(action.icon);
Expand All @@ -93,7 +98,13 @@ const InlineActionButton: React.FC<{
variant={btnVariant as any}
size={btnSize as any}
className={action.className}
disabled={(action.enabled ? !isEnabled : false) || loading}
disabled={(
(action as any).disabled != null
? isDisabledPred
: action.enabled != null
? !isEnabled
: false
) || loading}
onClick={handleClick}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Expand Down Expand Up @@ -122,10 +133,17 @@ export const DropdownActionItem: React.FC<{
onSelect: (action: ActionSchema) => void | Promise<void>;
}> = ({ action, index, onSelect }) => {
const isVisible = useCondition(toPredicateInput(action.visible));
// Spec `disabled` primary, legacy non-spec `enabled` fallback (see
// InlineActionButton above — #1885 follow-through).
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled));
const isEnabled = useCondition(toPredicateInput(action.enabled));
if (action.visible && !isVisible) return null;
const Icon = resolveIcon(action.icon);
const isDisabled = action.enabled ? !isEnabled : false;
const isDisabled = (action as any).disabled != null
? isDisabledPred
: action.enabled != null
? !isEnabled
: false;
const showSeparator = action.tags?.includes('separator-before') && index > 0;
return (
<>
Expand Down
12 changes: 11 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const ActionIconRenderer = forwardRef<HTMLButtonElement, ActionIconProps>(
const [loading, setLoading] = useState(false);

const isVisible = useCondition(toPredicateInput(schema.visible));
// Spec `disabled` (boolean | CEL — disabled when TRUE) primary, legacy
// non-spec `enabled` fallback (#1885 follow-through — only action-button
// was wired; this renderer ignored a spec-authored `disabled`).
const isDisabledPred = useCondition(toPredicateInput((schema as any).disabled));
const isEnabled = useCondition(toPredicateInput(schema.enabled));

const Icon = resolveIcon(schema.icon);
Expand Down Expand Up @@ -85,7 +89,13 @@ const ActionIconRenderer = forwardRef<HTMLButtonElement, ActionIconProps>(
variant={variant as any}
size={size}
className={cn('h-8 w-8', schema.className, className)}
disabled={(schema.enabled ? !isEnabled : false) || loading}
disabled={(
(schema as any).disabled != null
? isDisabledPred
: schema.enabled != null
? !isEnabled
: false
) || loading}
onClick={handleClick}
aria-label={schema.label || schema.name}
{...rest}
Expand Down
10 changes: 9 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ const ActionMenuItem: React.FC<{
throwOnError: true,
label: `action "${action.name ?? action.label ?? 'action:menu item'}" (visible)`,
});
// Spec `disabled` (boolean | CEL — disabled when TRUE) primary, legacy
// non-spec `enabled` fallback (#1885 follow-through — only action-button
// was wired; this renderer ignored a spec-authored `disabled`).
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled));
const isEnabled = useCondition(toPredicateInput(action.enabled));

const iconElement = useMemo(() => {
Expand All @@ -81,7 +85,11 @@ const ActionMenuItem: React.FC<{

return (
<DropdownMenuItem
disabled={action.enabled ? !isEnabled : false}
disabled={(action as any).disabled != null
? isDisabledPred
: action.enabled != null
? !isEnabled
: false}
onSelect={(e) => {
e.preventDefault();
onExecute(action);
Expand Down
14 changes: 8 additions & 6 deletions packages/plugin-detail/src/renderers/record-quick-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,14 @@ function QuickActionButton({
}) {
const [running, setRunning] = React.useState(false);
const recordCtx = React.useMemo(() => ({ record: record || {} }), [record]);
// `disabled` may be a boolean or a CEL predicate. Evaluate the predicate form
// against the record; useCondition must be called unconditionally (hook).
const disabledPred = toPredicateInput((action as any).disabled);
const condDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, recordCtx);
const isDisabled =
(typeof disabledPred === 'string' ? condDisabled : disabledPred === true) || running;
// `disabled` may be a boolean or a CEL predicate (disabled when TRUE). Feed
// toPredicateInput's result to useCondition WHOLE — since #2661 a CEL-dialect
// `{dialect, source}` envelope (the shape the server compiles authored CEL
// into) must reach useCondition intact to route to the canonical formula
// engine. The previous `typeof === 'string'` split dropped the envelope, so
// a spec-authored `disabled` never disabled anything on this surface.
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), recordCtx);
const isDisabled = ((action as any).disabled != null ? isDisabledPred : false) || running;
return (
<Button
variant={variant}
Expand Down
Loading