diff --git a/.changeset/25877-approval-override-menu.md b/.changeset/25877-approval-override-menu.md new file mode 100644 index 00000000000..a8333bb4b6e --- /dev/null +++ b/.changeset/25877-approval-override-menu.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/react': patch +'@object-ui/plugin-detail': patch +--- + +fix(app-shell,react,plugin-detail): on a record under approval, an administrator who is not the approver sees one status line and one override menu instead of five buttons above the title, and Recall is drawn once + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@object-ui/app-shell`:** for a viewer who holds no approver slot but may override, the record page's approval bar is now one line: the request's pending status and who the step waits on, the levers that have no override arm (Reply) as buttons, and one marked menu holding every lever the override flag admits (Override Approve, Override Reject, Override Reassign, and Recall). The approver's own bar is unchanged. Who may override, the override notice each lever shows, and the request each lever sends are unchanged. `DeclaredActionsBar` takes a new optional `foldOverrides` prop for this; a host that does not pass it renders as before. +- **`@object-ui/react`:** `InlineEditProvider` and the `InlineEditContext` value gain an optional `approvalRecallDrawnByHost`. It is optional so every existing host keeps its behaviour: absent means `false`, and then the approval band draws its own Recall exactly as before. A host that draws the pending request's declared Recall itself sets it to `true`. +- **`@object-ui/plugin-detail`:** the DetailView approval band leaves out its Recall button when the host says it draws one, and keeps its lock badge and tally. The record page sets the signal only while its approval bar actually draws the request's declared Recall, so Recall appears once on the page; before the bar can draw it (definition still loading, or a backend that sends no viewer block) the band keeps its own. + +What to do: nothing. diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 986c530b31b..94e9aa537aa 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"c13b11a725295ab5e510c572d774271e3ec9a7120442907249bace960974f9bd","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 1af268c0a57..3a0b892f9ae 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"1d1036b80f21e46d0b90bb61ba8cb285336f396ac85ce66daa42073e8682c3e2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx index f27b4ab0eff..bd27b2b24a7 100644 --- a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -32,7 +32,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { + Badge, Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Separator, cn, hasDeclaredVisibilityGate, @@ -54,7 +59,7 @@ import { import type { ActionDef } from '@object-ui/core'; import type { ConsoleActionDispatch } from '../consoleActionDispatch.js'; import { useObjectTranslation } from '@object-ui/i18n'; -import { Loader2, ShieldAlert } from 'lucide-react'; +import { Loader2, MoreHorizontal, ShieldAlert } from 'lucide-react'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; import { useAdapter } from '../providers/AdapterProvider.js'; // Straight from `@object-ui/react`, NOT through `../providers/MetadataProvider` @@ -74,7 +79,12 @@ import { readDecisionOutputRegistry, } from '../utils/decisionOutputParams.js'; import { getIcon } from '../utils/getIcon.js'; -import { isOverrideDecision, bypassedApproverNames } from '../utils/approvalOverride.js'; +import { + actionAdmittedByOverride, + bypassedApproverNames, + isOverrideDecision, + isOverrideOnlyViewer, +} from '../utils/approvalOverride.js'; import { approverCopyFrom, approverDisplay, @@ -117,6 +127,37 @@ export interface DeclaredActionsBarProps { * returns null when empty), so the host never gets an orphan divider. */ label?: string; + /** + * Fold the admin-override levers into one menu for a viewer who holds no + * approver slot but may override (objectstack#25877). + * + * For that viewer every lever the override flag admits — approve, reject, + * reassign, and recall, whose gate ORs the flag in too — is the exception + * path, and a row of them drawn as buttons is the most prominent thing on + * the page. With this set, such a viewer sees one status line instead + * (pending, and who the step waits on), the levers the override flag does + * NOT admit (reply) as buttons, and the folded ones behind a single marked + * menu. The set is read off each action's own `visible` gate + * (`actionAdmittedByOverride`), not off a name list, so a lever shipped as + * metadata alone folds the same way. + * + * Placement only: every folded lever keeps its own `visible` / `disabled` / + * capability gates, its override framing and its dialog, and the request it + * sends is unchanged. A viewer who CAN act (the approver) and any viewer the + * server sent no `viewer` block for render exactly as without it. + */ + foldOverrides?: boolean; + /** + * Told which declared actions the bar DRAWS for this viewer, by name, each + * time that set changes, and an empty list when the bar goes away + * (objectstack#25877). "Drawn" is after the capability gate and the action's + * own `visible` verdict — a button on the bar or an item in the folded + * menu — so a host can yield a lever it would otherwise draw itself exactly + * when the bar really draws it, and not merely when the bar is mounted (the + * request's definition may not have loaded, or a backend too old to send a + * `viewer` block fails every gate closed). Pass a stable function. + */ + onDrawnActionsChange?: (names: string[]) => void; } /** @@ -137,6 +178,60 @@ function approvalRouteVerb(action: ActionDef): ApprovalRouteVerb | undefined { return match ? (match[1] as ApprovalRouteVerb) : undefined; } +/** + * Is this declared action SHOWN to the viewer? The one evaluation of its + * `visible` gate, shared by the button and by the visibility probe the folded + * override menu reports through (objectstack#25877), so the two cannot reach + * different verdicts on one action. + * + * `visible` fails CLOSED on a throwing predicate — mirrors action:button and + * ActionEngine.getActionsForLocation: a guard that can't be evaluated hides + * the action rather than exposing one whose precondition is broken. + * + * Does the action DECLARE a `visible` gate? `hasDeclaredVisibilityGate` + * (`!= null && !== ''`) is the one definition on the question, imported rather + * than re-spelled. This gate used to ask truthiness, which classified + * `visible: false` — the most explicit "never show this" an author can write — + * as "no gate declared", skipped the verdict, and rendered the action for + * everyone (objectui#3835, the fifth member of the objectui#3492 family). + * + * The stakes here are the highest of the family: the actions are + * SERVER-declared (`objectDef.actions[]`), so "the spec's `visible` has no + * boolean member, `objectstack build` cannot emit one" does not apply, and this + * bar is mounted as plain JSX by its hosts — `packages/react`'s + * `SchemaRenderer`, which hides a `visible`-carrying node before its component + * mounts, is not on this path. This is the only gate on it, in front of the + * approvals inbox's Approve / Reject buttons. + * + * The verdict stays with the evaluation entry: `toPredicateInput` passes a + * boolean through untouched and `useCondition` short-circuits it instead of + * calling the expression engine, so a declared `false` is `false`. + */ +function useDeclaredActionShown(action: ActionDef, predicateRecord: ReturnType): boolean { + const isVisible = useCondition(toPredicateInput(action.visible), predicateRecord, { + throwOnError: true, + label: `declared action "${action.name ?? action.label ?? 'action'}" (visible)`, + }); + return !(hasDeclaredVisibilityGate(action.visible) && !isVisible); +} + +/** + * Report whether a declared action is drawn, and withdraw the report when the + * reporter unmounts (objectstack#25877). `onDrawn` is the toolbar's stable + * setter, so the effect re-runs only when the verdict or the name moves. + */ +function useReportDrawn( + name: string | undefined, + drawn: boolean, + onDrawn: ((name: string, drawn: boolean) => void) | undefined, +): void { + React.useEffect(() => { + if (!onDrawn || !name) return undefined; + onDrawn(name, drawn); + return () => onDrawn(name, false); + }, [name, drawn, onDrawn]); +} + /** * One declared-action button. Extracted so the `visible` CEL predicate can be * evaluated with a hook (rules-of-hooks) and so the dispatch can carry the @@ -148,7 +243,15 @@ const DeclaredActionButton: React.FC<{ action: ActionDef; objectName: string; record: any; -}> = ({ action, objectName, record }) => { + /** + * Draw the lever as an item of the override menu instead of a button + * (objectstack#25877). Every gate, label and dispatch above the render is + * shared, so the two forms cannot disagree about what the lever does. + */ + asMenuItem?: boolean; + /** The toolbar's drawn-set reporter (objectstack#25877). */ + onDrawn?: (name: string, drawn: boolean) => void; +}> = ({ action, objectName, record, asMenuItem = false, onDrawn }) => { const { execute } = useAction(); const [loading, setLoading] = useState(false); // Localize the SERVER-DECLARED strings through the `_actions..*` @@ -206,13 +309,12 @@ const DeclaredActionButton: React.FC<{ * `record is not defined` and `throwOnError` turns that into "hidden". */ const predicateRecord = usePredicateRecordContext(record); - // `visible` fails CLOSED on a throwing predicate — mirrors action:button and - // ActionEngine.getActionsForLocation: a guard that can't be evaluated hides - // the action rather than exposing one whose precondition is broken. - const isVisible = useCondition(toPredicateInput(action.visible), predicateRecord, { - throwOnError: true, - label: `declared action "${action.name ?? action.label ?? 'action'}" (visible)`, - }); + // The `visible` verdict — one evaluation, shared with the folded menu's + // visibility probe (`useDeclaredActionShown` states the rule). + const shown = useDeclaredActionShown(action, predicateRecord); + // A menu item does not report: it is mounted only while its menu is open, + // so its unmount would read as "not drawn". The probe reports for it. + useReportDrawn(action.name, shown, asMenuItem ? undefined : onDrawn); // Spec `disabled` — the same three arms as `visible` (`boolean | CEL string | // { dialect, source }`, disabled when TRUE), evaluated against the same record // context. objectstack-ai/objectstack#1885 wired it in action-button only; this bar ignored it, so a @@ -227,6 +329,7 @@ const DeclaredActionButton: React.FC<{ const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord); // Called up here with the other hooks: the `visible` gate below returns early. const reasonId = React.useId(); + const labelId = React.useId(); /** * Is the button this viewer is looking at an ADMIN OVERRIDE (objectui#5178)? @@ -438,25 +541,8 @@ const DeclaredActionButton: React.FC<{ } }, [action, execute, loading, objectName, record, localizeActionTexts, t, overrideNotice, overrideLabel]); - // Does the action DECLARE a `visible` gate? `hasDeclaredVisibilityGate` - // (`!= null && !== ''`) is the one definition on the question, imported rather - // than re-spelled. This gate used to ask truthiness, which classified - // `visible: false` — the most explicit "never show this" an author can write — - // as "no gate declared", skipped the verdict, and rendered the action for - // everyone (objectui#3835, the fifth member of the objectui#3492 family). - // - // The stakes here are the highest of the family: the actions are - // SERVER-declared (`objectDef.actions[]`), so "the spec's `visible` has no - // boolean member, `objectstack build` cannot emit one" does not apply, and this - // bar is mounted as plain JSX by its hosts — `packages/react`'s - // `SchemaRenderer`, which hides a `visible`-carrying node before its component - // mounts, is not on this path. This is the only gate on it, in front of the - // approvals inbox's Approve / Reject buttons. - // - // The verdict stays with the evaluation entry above: `toPredicateInput` passes - // a boolean through untouched and `useCondition` short-circuits it instead of - // calling the expression engine, so a declared `false` is `false`. - if (hasDeclaredVisibilityGate(action.visible) && !isVisible) return null; + // The declared `visible` gate (`useDeclaredActionShown` above). + if (!shown) return null; const iconName = typeof (action as any).icon === 'string' ? (action as any).icon as string : undefined; // Map the spec's action `variant` enum (primary|secondary|danger|ghost|link) @@ -513,6 +599,48 @@ const DeclaredActionButton: React.FC<{ ? String(t('actions.notAvailableForRecord', { defaultValue: 'Not available for this record' })) : undefined; + if (asMenuItem) { + // The menu form of the same lever (objectstack#25877). Shaped like the + // page header's overflow items (`PageHeaderActions`): `preventDefault` on + // select keeps the menu mounted while the action's own dialog takes over, + // and a disabled item states its reason as a visible second line, because + // a tooltip inside a menu is unreachable from the keyboard (a disabled + // item is skipped by arrow-key focus). + return ( + { + event.preventDefault(); + void handleClick(); + }} + className={cn( + 'gap-2', + isOverride && 'text-amber-700 focus:text-amber-800 dark:text-amber-400 dark:focus:text-amber-300', + )} + data-testid={`declared-action-${action.name}`} + data-override-decision={isOverride ? 'true' : undefined} + > + {loading + ? + : isOverride + ? + : iconName + ? React.createElement(getIcon(iconName), { className: 'h-4 w-4' }) + : null} + {disabledReason ? ( + + {label} + {disabledReason} + + ) : ( + {label} + )} + + ); + } + const button = ( + + + {folded.map((action) => ( + + ))} + + + )} + + + + ); + } + return (
{label && ( @@ -660,6 +959,7 @@ const DeclaredActionsToolbar: React.FC< action={action} objectName={objectName} record={record} + onDrawn={reportDrawn} /> ))}
@@ -676,6 +976,8 @@ export function DeclaredActionsBar({ exclude, className, label, + foldOverrides, + onDrawnActionsChange, }: DeclaredActionsBarProps) { const dataSource = useAdapter(); // Fetch the object def (and its declared actions) unless the host passed @@ -730,6 +1032,8 @@ export function DeclaredActionsBar({ record={record} className={className} label={label} + foldOverrides={foldOverrides} + onDrawnActionsChange={onDrawnActionsChange} /> {runtime.dialogs} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx index 9519ae70f74..8025f28ab7c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx @@ -471,9 +471,15 @@ describe('record page decision actions — gating is the SERVER\'s (objectui#305 stubApprovalsApi(pendingRequest({ can_act: false, is_submitter: false, can_override: true })); renderRecordPage(); - await waitFor(() => expect(decisionButton('approval_approve')).toBeTruthy()); - expect(decisionButton('approval_reject')).toBeTruthy(); - expect(decisionButton('approval_reassign')).toBeTruthy(); + // objectstack#25877 — for an admin who holds no slot the record page folds + // the rescue levers into one marked menu, so they are read from there. + const trigger = await screen.findByTestId('declared-actions-override-menu'); + expect(decisionButton('approval_approve'), 'folded, not a button').toBeNull(); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await screen.findByRole('menu'); + expect(decisionButton('approval_approve')?.getAttribute('role')).toBe('menuitem'); + expect(decisionButton('approval_reject')?.getAttribute('role')).toBe('menuitem'); + expect(decisionButton('approval_reassign')?.getAttribute('role')).toBe('menuitem'); expect(decisionButton('approval_send_back')).toBeNull(); expect(decisionButton('approval_request_info')).toBeNull(); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx index 00dabe39552..1bb4b111ce0 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx @@ -91,6 +91,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { data-testid="inline-probe" data-approval-is-submitter={String(inline?.approvalIsSubmitter)} data-approval-pending={String(inline?.approvalPending)} + data-approval-recall-drawn-by-host={String(inline?.approvalRecallDrawnByHost)} /> ); }; @@ -158,15 +159,37 @@ function stubApprovalsApi(row: Record | null, listStatus = 200) vi.stubGlobal('fetch', approvalsFetch); } +/** + * The request object's declared recall, so the page's decision bar can draw it + * (objectstack#25877). The gate is the shipped one in the reduced spelling the + * sibling approval suites use: the submitter arm OR the override arm. + */ +const SYS_APPROVAL_REQUEST_DEF = { + name: 'sys_approval_request', + label: 'Approval Request', + fields: { id: { type: 'text', label: 'Id' }, status: { type: 'text', label: 'Status' } }, + actions: [ + { + name: 'approval_recall', label: 'Recall', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/recall', + params: [{ name: 'comment', label: 'Comment', type: 'textarea', required: false }], + visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter' + + ' || record.viewer.can_override', + locations: ['record_section'], + }, + ], +}; + const METADATA = { - objects: OBJECTS, + objects: [...OBJECTS, SYS_APPROVAL_REQUEST_DEF], pages: [], loading: false, error: null, refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], - getItem: async () => null, + getItem: async (type: string, name: string) => + (type === 'object' && name === SYS_APPROVAL_REQUEST_DEF.name ? SYS_APPROVAL_REQUEST_DEF : null), getItemsByType: () => [], } as any; @@ -286,3 +309,44 @@ describe('record page → band: who may see recall (objectui#6464)', () => { expect(probe.getAttribute('data-approval-is-submitter')).toBe('undefined'); }); }); + +/** + * Recall is drawn ONCE per page (objectstack#25877). The page's declared + * decision bar carries `approval_recall`, so whenever the page mounts that bar + * it must tell the band below to keep its lock badge only, and whenever it + * does not, the band stays the recall lever. `DetailView.approvalRecallHostDrawn` + * measures the band; this measures the seam. + */ +describe('record page → band: who draws recall (objectstack#25877)', () => { + it('tells the band the page draws recall while the decision bar draws it', async () => { + stubApprovalsApi(pendingRequest({ viewer: { can_act: false, is_submitter: true, can_override: false } })); + renderRecordPage(); + + const probe = await settledProbe(true); + await waitFor(() => + expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('true'), + ); + expect(screen.getByTestId('declared-action-approval_recall')).toBeInTheDocument(); + }); + + it('leaves the band its recall when the bar is up but draws none', async () => { + // A server too old to send a `viewer` block: every declared gate fails + // closed, so the bar draws no Recall, and the band, which falls back to + // comparing `submitter_id`, must stay the submitter's lever. + stubApprovalsApi(pendingRequest({ submitter_id: SIGNED_IN_USER })); + renderRecordPage(); + + const probe = await settledProbe(true); + await waitFor(() => expect(probe.getAttribute('data-approval-is-submitter')).toBe('true')); + expect(screen.queryByTestId('declared-action-approval_recall')).toBeNull(); + expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false'); + }); + + it('leaves the band as the recall lever when there is no approvals API', async () => { + stubApprovalsApi(null, 404); + renderRecordPage(); + + const probe = await settledProbe(true); + expect(probe.getAttribute('data-approval-recall-drawn-by-host')).toBe('false'); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index a5875975f88..d375ca2b3b7 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -383,6 +383,12 @@ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; /** The object the record feed's comments and replies are rows of. */ const COMMENT_OBJECT = 'sys_comment'; +/** + * `sys_approval_request`'s declared recall. While the decision bar draws it, + * the DetailView approval band yields its own Recall (objectstack#25877). + */ +const APPROVAL_RECALL_ACTION = 'approval_recall'; + /** * Which writes the record feed may offer: its comment and reply composers, the * author's Edit and Delete on their own comment (objectui#12268), and its @@ -1683,6 +1689,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // This gates the AFFORDANCE only. `canEdit` / `approvalLocked` below are // untouched by it, and the recall endpoint authorizes the recall itself. const approvalIsSubmitter = isSubmitterOf(approvals.pendingRequest, user?.id); + // Does this page mount the pending request's declared decision bar + // (objectui#3055)? + const approvalBarMounted = approvals.available && !!approvals.pendingRequest; + // Recall is drawn ONCE per page (objectstack#25877). The bar reports which + // declared actions it actually draws, after their capability and `visible` + // gates; while it draws the request's own `approval_recall`, the DetailView + // approval band keeps its lock badge only. Read off what the bar DRAWS, not + // off whether it is mounted: before the request's definition loads, or on a + // backend too old to send a `viewer` block (every declared gate fails + // closed), the bar draws no recall and the band stays the lever. + const [approvalBarDrawn, setApprovalBarDrawn] = useState([]); + const approvalRecallDrawnByHost = + approvalBarMounted && approvalBarDrawn.includes(APPROVAL_RECALL_ACTION); // A decision landed through the declared-action bar (objectui#3055). The // action itself already POSTed and the runtime already toasted; what the HOST @@ -3676,6 +3695,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri approvalReadAnswered={approvals.answered} approvalProgress={approvalProgress} approvalIsSubmitter={approvalIsSubmitter} + approvalRecallDrawnByHost={approvalRecallDrawnByHost} lockedReason={t('detail.lockedTooltip', { defaultValue: 'This record has a pending approval request; editing is locked', })} @@ -3808,8 +3828,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri renders a richer remind (throttle-aware copy, timeline refresh), and `exclude` is how a host keeps one of an object's declared actions in its own UI without the bar - double-rendering it. */} - {approvals.available && approvals.pendingRequest && ( + double-rendering it. + + objectstack#25877 — for an administrator who is NOT an + approver, the override levers (approve / reject / reassign + in their name, and recall) are the exception path, so + `foldOverrides` collapses them into one status line and one + marked menu; the approver's own bar is unchanged. While the + bar draws the request's Recall, it is the page's one Recall: + `onDrawnActionsChange` is how the page learns that and tells + the DetailView band below to keep its lock badge only. */} + {approvalBarMounted && ( )} { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useAction: () => ({ execute: executeSpy }), + }; +}); + +vi.mock('../../hooks/useConsoleActionRuntime', () => ({ + useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }), +})); +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadataItem: () => ({ item: null, loading: false, error: null }), +})); +vi.mock('../../utils/getIcon', () => ({ + getIcon: (name: string) => () => , +})); + +// Interpolating `t` over `defaultValue`, so the status line and the override +// labels read as composed English. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectLabel: () => ({ + actionLabel: (_o: unknown, _n: unknown, fallback: string) => fallback, + actionConfirm: (_o: unknown, _n: unknown, fallback?: string) => fallback, + actionSuccess: (_o: unknown, _n: unknown, fallback?: string) => fallback, + }), + useObjectTranslation: () => ({ + t: (key: string, options?: any) => + String(options?.defaultValue ?? key).replace( + /\{\{(\w+)\}\}/g, + (_m: string, name: string) => String(options?.[name] ?? ''), + ), + language: 'en', + }), + pickLocalized: (value: unknown) => (typeof value === 'string' ? value : ''), +})); + +import { AdapterCtx } from '@object-ui/react'; +import { DeclaredActionsBar } from '../DeclaredActionsBar'; +import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory'; + +// The reduced gate spellings the sibling override suite uses, for the reason +// it gives: `actionAdmittedByOverride` reads only whether `can_override` is +// ORed in, and is pinned against the verbatim shipped predicates in +// `utils/approvalOverride.test.ts`. +const DECISION_VISIBLE = 'record.viewer.can_act || record.viewer.can_override'; +const RECALL_VISIBLE = + '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter' + + ' || record.viewer.can_override'; +// Reply has no override arm (the route has none). +const REPLY_VISIBLE = 'record.viewer.can_act || record.status == "pending" && record.viewer.is_submitter'; + +const ACTIONS = [ + { + name: 'approval_approve', + label: 'Approve', + variant: 'primary', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', + params: [{ name: 'comment', label: 'Comment', type: 'textarea' }], + visible: DECISION_VISIBLE, + locations: ['record_section'], + }, + { + name: 'approval_reject', + label: 'Reject', + variant: 'danger', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/reject', + params: [{ name: 'comment', label: 'Comment', type: 'textarea' }], + visible: DECISION_VISIBLE, + locations: ['record_section'], + }, + { + name: 'approval_reassign', + label: 'Reassign', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/reassign', + params: [{ name: 'to', label: 'To', type: 'text' }], + visible: DECISION_VISIBLE, + locations: ['record_section'], + }, + { + name: 'approval_recall', + label: 'Recall', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/recall', + params: [{ name: 'comment', label: 'Comment', type: 'textarea' }], + visible: RECALL_VISIBLE, + locations: ['record_section'], + }, + { + name: 'approval_comment', + label: 'Reply', + type: 'api', + method: 'POST', + target: '/api/v1/approvals/requests/{id}/comment', + params: [{ name: 'comment', label: 'Comment', type: 'textarea' }], + visible: REPLY_VISIBLE, + locations: ['record_section'], + }, +] as any[]; + +const FOLDED = ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_recall']; + +const REQUEST = { + id: 'req_1', + status: 'pending', + record_id: 'opp_1', + pending_approvers: ['u_sales_mgr'], + pending_approver_names: { u_sales_mgr: 'Sales Manager' }, +}; + +/** The reported viewer: an admin who submitted the record and holds no slot. */ +const ADMIN_SUBMITTER = { can_act: false, is_submitter: true, can_override: true }; +const ADMIN_ONLY = { can_act: false, is_submitter: false, can_override: true }; +const APPROVER = { can_act: true, is_submitter: false, can_override: false }; + +function renderBar( + viewer: Record, + foldOverrides = true, + onDrawnActionsChange?: (names: string[]) => void, + actions: any[] = ACTIONS, +) { + return render( + + + , + ); +} + +/** The last set the bar reported. */ +function lastReport(spy: ReturnType): string[] { + return spy.mock.calls.at(-1)?.[0] ?? []; +} + +function openOverrideMenu() { + const trigger = screen.getByTestId('declared-actions-override-menu'); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + return screen.findByRole('menu'); +} + +beforeEach(() => { + executeSpy.mockClear(); + resetApproverDirectoryCache(); +}); + +describe('DeclaredActionsBar — foldOverrides (objectstack#25877)', () => { + it('folds the override levers and recall into one marked menu for an admin who is not the approver', async () => { + renderBar(ADMIN_SUBMITTER); + + // The row above the title is now a status line, reply, and one menu. + const status = screen.getByTestId('declared-actions-approval-status'); + expect(status.textContent).toContain('Pending'); + expect(status.textContent).toContain('Waiting on'); + expect(status.textContent).toContain('Sales Manager'); + + for (const name of FOLDED) { + expect(screen.queryByTestId(`declared-action-${name}`), `${name} must not be a button`).toBeNull(); + } + // Reply has no override arm, so it stays a visible button. + expect(screen.getByTestId('declared-action-approval_comment').tagName).toBe('BUTTON'); + const trigger = screen.getByTestId('declared-actions-override-menu'); + expect(trigger).toHaveAccessibleName('Admin override'); + + const menu = await openOverrideMenu(); + const items = within(menu).getAllByRole('menuitem'); + expect(items.map((item) => item.getAttribute('data-testid'))).toEqual( + FOLDED.map((name) => `declared-action-${name}`), + ); + // The levers keep their override framing inside the menu… + expect(within(menu).getByTestId('declared-action-approval_approve').textContent).toContain('Override Approve'); + expect(within(menu).getByTestId('declared-action-approval_approve').getAttribute('data-override-decision')).toBe('true'); + // …and the submitter's own recall stays the plain Recall (objectui#13225). + const recall = within(menu).getByTestId('declared-action-approval_recall'); + expect(recall.textContent).toBe('Recall'); + expect(recall.getAttribute('data-override-decision')).toBeNull(); + }); + + it('dispatches a folded lever exactly as the button form does, override notice included', async () => { + renderBar(ADMIN_ONLY); + const menu = await openOverrideMenu(); + fireEvent.click(within(menu).getByTestId('declared-action-approval_approve')); + await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1)); + + const dispatch = executeSpy.mock.calls[0][0]; + expect(dispatch.name).toBe('approval_approve'); + expect(dispatch.target).toBe('/api/v1/approvals/requests/{id}/approve'); + expect(dispatch.params._rowRecord.id).toBe('req_1'); + expect(dispatch.label).toBe('Override Approve'); + expect(String(dispatch.overrideNotice)).toContain('Sales Manager'); + }); + + it('folds the override-arm recall too for an admin who did not submit', async () => { + renderBar(ADMIN_ONLY); + // Reply is not theirs (no slot, not the submitter), so the row is the + // status line and the menu alone. + expect(screen.queryByTestId('declared-action-approval_comment')).toBeNull(); + const menu = await openOverrideMenu(); + const recall = within(menu).getByTestId('declared-action-approval_recall'); + expect(recall.textContent).toBe('Override Recall'); + expect(recall.getAttribute('data-override-decision')).toBe('true'); + }); + + it("leaves the approver's own bar unchanged", () => { + renderBar(APPROVER); + expect(screen.queryByTestId('declared-actions-override-menu')).toBeNull(); + expect(screen.queryByTestId('declared-actions-approval-status')).toBeNull(); + for (const name of ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_comment']) { + expect(screen.getByTestId(`declared-action-${name}`).tagName).toBe('BUTTON'); + } + }); + + it('leaves a host that does not ask for the fold unchanged', () => { + renderBar(ADMIN_SUBMITTER, false); + expect(screen.queryByTestId('declared-actions-override-menu')).toBeNull(); + for (const name of [...FOLDED, 'approval_comment']) { + expect(screen.getByTestId(`declared-action-${name}`).tagName).toBe('BUTTON'); + } + }); + + it('reports what it draws: the folded levers while closed, never a hidden one', () => { + const spy = vi.fn(); + renderBar(ADMIN_SUBMITTER, true, spy); + expect([...lastReport(spy)].sort()).toEqual([...FOLDED, 'approval_comment'].sort()); + + cleanup(); + spy.mockClear(); + renderBar(APPROVER, true, spy); + // Recall's gate admits neither arm for the approver, so it is not drawn. + expect(lastReport(spy)).not.toContain('approval_recall'); + expect(lastReport(spy)).toContain('approval_approve'); + }); + + it('reports an empty set when the bar goes away', () => { + const spy = vi.fn(); + const { unmount } = renderBar(ADMIN_SUBMITTER, true, spy); + expect(lastReport(spy)).toContain('approval_recall'); + unmount(); + expect(lastReport(spy)).toEqual([]); + }); + + it('draws no menu when every folded lever is hidden by its own gate', () => { + // Folded (its gate names the override flag) yet never shown: the trigger + // must not open onto an empty menu. + const hiddenOverride = [ + { ...ACTIONS[0], visible: 'record.viewer.can_override && record.status == "returned"' }, + ACTIONS[4], + ]; + renderBar(ADMIN_SUBMITTER, true, undefined, hiddenOverride); + expect(screen.getByTestId('declared-actions-approval-status')).toBeInTheDocument(); + expect(screen.queryByTestId('declared-actions-override-menu')).toBeNull(); + expect(screen.getByTestId('declared-action-approval_comment')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-detail/src/DetailView.tsx b/ui/packages/plugin-detail/src/DetailView.tsx index d6bd3fe7aa3..7f2ff303aaf 100644 --- a/ui/packages/plugin-detail/src/DetailView.tsx +++ b/ui/packages/plugin-detail/src/DetailView.tsx @@ -1787,8 +1787,16 @@ export const DetailView: React.FC = ({ `undefined` means the host resolves no approval identity, and then this renders exactly as it did before the signal existed — hiding on absent information would take the submitter's own - unlock lever away (see InlineEditContextValue). */} - {dataSource?.cancelPendingApproval && inline?.approvalIsSubmitter !== false && ( + unlock lever away (see InlineEditContextValue). + + And it is drawn ONCE per page (objectstack#25877). A host that + already draws the request's declared recall lever — the record + page's approval bar — says so with `approvalRecallDrawnByHost`, + and the band then keeps its status badge only. Absent, the band + stays the recall lever exactly as above. */} + {dataSource?.cancelPendingApproval + && !inline?.approvalRecallDrawnByHost + && inline?.approvalIsSubmitter !== false && (