diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md new file mode 100644 index 000000000..3d7e19ce4 --- /dev/null +++ b/.changeset/olive-donkeys-repeat.md @@ -0,0 +1,45 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the console record header honors `userActions` predicates + +`userActions.edit` / `.delete` reached the console record page's header in +their **boolean** form but not in their **predicate** form. +`userActions: { delete: false }` hid Delete on the list row and on the record +header alike, because the switch flows through the affordance resolver +(`resolveRecordHeaderActionGates` → `resolveEffectiveCrudAffordances`). The +per-record object form — `edit: { visibleWhen: … }` — reached the list row +only: `synthSystemActions` gated on `objectAffordances.edit ∧ +recordWriteAllowed` and never parsed, let alone evaluated, the predicate. An +author narrowing "who may edit this object" to "which records may be edited" +therefore kept the converged list and lost the record page, where the Edit +button still opened a form the server would reject on save. + +The header now folds the per-record predicates in as a fourth conjunct, +evaluated against the open record through the same helper family the row +surfaces use — `userActionPredicates` from `@object-ui/core` for the parse and +`useRowPredicate` from `@object-ui/react` for the evaluation: + +- `edit.visibleWhen` / `delete.visibleWhen` false → the synthesized header + action is not emitted (fails closed; `visibleWhen: false` counts as a + declared gate). +- `edit.disabledWhen` / `delete.disabledWhen` true → the header affordance + renders disabled rather than disappearing, matching the row kebab. On + `sys_edit` this composes with `OR` onto the `disabled` key the approval lock + already used, so an approval lock and a declared predicate stay independent + reasons for the same off. + +The record body's inline-edit session joins the same conjunction, exactly where +the approval lock already sits, so the header CTA and the body pencils cannot +disagree about whether this record may be edited. + +The existing affordance, permission and record-writability gates are unchanged +and the predicate only ever subtracts, so a predicate that holds can never +resurrect a button the bucket closed or the user may not press. The boolean +form stays the affordance resolver's channel — a bare boolean yields no +predicate. + +This completes the convergence started in the row kebab and continued in +`plugin-detail`'s `DetailView` header: one authored predicate, one evaluator, +one answer on all three surfaces. diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 2698dfb5d..15f333d78 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -14,8 +14,8 @@ import { RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema, deriveFi import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; -import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react'; -import { buildExpandFields } from '@object-ui/core'; +import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged, useRowPredicate } from '@object-ui/react'; +import { buildExpandFields, userActionPredicates } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration'; import { Database, ChevronLeft } from 'lucide-react'; @@ -991,6 +991,101 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri !!objectDef?.name && !!pureRecordId, ); + // ── Per-record `userActions` predicates — the header's FOURTH gate ──────── + // + // objectui#4213. `userActions.edit` / `.delete` reached this header in their + // BOOLEAN form and only in that form: the switch flows through + // `resolveRecordHeaderActionGates` → `resolveEffectiveCrudAffordances`, so + // `userActions: { delete: false }` hid Delete on the list row AND here. The + // per-record OBJECT form — `{ visibleWhen: … }`, objectui#2614 — was + // consumed by the list row alone (`plugin-grid`'s + // `isBuiltinRowActionVisible`), because the conjunction below is the + // affordance/permission channel and nothing on this path ever parsed a + // predicate. An author narrowing "who may edit this object" to "which + // records may be edited" therefore lost the record page while keeping the + // list — a key whose scope SHRANK when a predicate was added to it. + // + // This is the console end of the same convergence PR #4515 (objectui#4419) + // made for `plugin-detail`'s `DetailView` header. Three surfaces, one + // evaluator, one answer: row kebab, `DetailView` header, console record page. + // + // These are HOOKS, so they live here — beside the record-level verdicts they + // join — and not inside `synthSystemActions` below, which is a plain IIFE + // sitting AFTER the `isLoading` / `!objectDef` / `missing` early returns. + // Their results are threaded into that IIFE as ordinary values, the same + // shape PR #4515 used. + // + // `userActionPredicates` from `@object-ui/core` is THE parser for this form, + // shared with the row surfaces. A bare boolean yields NO predicates, which is + // what keeps the boolean form the affordance resolver's channel alone rather + // than growing a second definition of it here. + const editPredicates = useMemo( + () => userActionPredicates(objectDef?.userActions?.edit), + [objectDef], + ); + const deletePredicates = useMemo( + () => userActionPredicates(objectDef?.userActions?.delete), + [objectDef], + ); + /** + * The object's field definitions, handed to every predicate on this record + * for the same reason the row kebab passes them: a relation field must bind + * as the stored FOREIGN KEY rather than whatever `$expand` substituted for it + * on this surface (the detail fetch expands every relation it can), or + * `record.owner == os.user.id` answers a different question here than it does + * on the list. + */ + const predicateFields = objectDef?.fields; + /** + * `visibleWhen` — fails CLOSED, and counts as DECLARED by `!= null` rather + * than by truthiness, so `visibleWhen: false` hides the affordance instead of + * reading as "ungated" (the objectui#3492 invariant that + * `isBuiltinRowActionVisible` restates for the row surfaces). `?? true` + * expresses the ungated default as a boolean, which `useRowPredicate` + * short-circuits without touching the engine — so an object with no + * `userActions` takes no evaluation at all and this gate is a literal `true`. + * + * `useRowPredicate` IS the row surfaces' evaluator: `plugin-grid`'s + * `evalRowActionVisibility` documents itself as mirroring + * `useRowPredicate(pred, row, { fallback: false, warnOnError: true, label, + * fields })` exactly, boolean short-circuit included, and is hook-free only + * because a row loop evaluates a variable number of actions inside one + * `useMemo`. This header evaluates a fixed arity of one record, so the hook + * form is that same evaluator without the constraint that shaped the wrapper. + */ + const editVisible = useRowPredicate(editPredicates?.visibleWhen ?? true, pageRecord, { + fallback: false, + warnOnError: true, + label: 'builtin:edit:visibleWhen', + fields: predicateFields, + }); + const deleteVisible = useRowPredicate(deletePredicates?.visibleWhen ?? true, pageRecord, { + fallback: false, + warnOnError: true, + label: 'builtin:delete:visibleWhen', + fields: predicateFields, + }); + /** + * `disabledWhen` — fails SOFT (an unevaluable predicate must not grey a + * button forever), and the `!= null` gate lives OUTSIDE the evaluation, so + * `disabledWhen: ''` reads as "no condition" rather than as "disable". + * Verbatim the posture of `DataTableBuiltinRowActionItem` and of PR #4515. + */ + const editDisabledPred = useRowPredicate(editPredicates?.disabledWhen, pageRecord, { + fallback: false, + warnOnError: true, + label: 'builtin:edit:disabledWhen', + fields: predicateFields, + }); + const deleteDisabledPred = useRowPredicate(deletePredicates?.disabledWhen, pageRecord, { + fallback: false, + warnOnError: true, + label: 'builtin:delete:disabledWhen', + fields: predicateFields, + }); + const editDisabledByPredicate = editPredicates?.disabledWhen != null && editDisabledPred; + const deleteDisabledByPredicate = deletePredicates?.disabledWhen != null && deleteDisabledPred; + // ── Audit history fetch ──────────────────────────────────────────── // Loads recent sys_audit_log entries for this record so the record page can // render a read-only "History" tab (`record:history`). Gated on three preconditions to keep @@ -1910,10 +2005,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // just because an object has few actions. const synthSystemActions: ActionDef[] = (() => { const objectAffordances = resolveRecordHeaderActionGates(objectDef, effectiveApiOperations); - // Object-level gate AND the record-level verdict (objectstack#3821). + // Object-level gate AND the record-level verdict (objectstack#3821) AND the + // object's per-record `userActions` predicate (objectui#4213 — see the + // evaluation block beside `recordDeleteAllowed` above). + // + // The predicate is a FOURTH conjunct: the affordance and permission gates + // above are untouched, so a predicate that holds can never resurrect a + // button the bucket closed or the user may not press. It only ever + // subtracts. const affordances = { - edit: objectAffordances.edit && recordWriteAllowed, - delete: objectAffordances.delete && recordDeleteAllowed, + edit: objectAffordances.edit && recordWriteAllowed && editVisible, + delete: objectAffordances.delete && recordDeleteAllowed && deleteVisible, }; const items: ActionDef[] = []; if (affordances.edit) { @@ -1941,7 +2043,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // off, with the lock band next to it saying why. Note this is the // LOCK, not the mere presence of an approval — a `lockRecord: false` // node keeps Edit live, which is the point of that setting. - disabled: approvalLocked, + // + // objectui#4213 — `edit.disabledWhen` composes onto this SAME key + // rather than opening a second one: `visibleWhen` decides existence, + // `disabledWhen` decides pressability, and either reason for "off" is + // the same off. OR, not AND: an approval lock and a declared predicate + // are independent reasons, and neither may cancel the other. With no + // predicate declared `editDisabledByPredicate` is `false`, so this is + // byte-identical to the pre-#4213 `disabled: approvalLocked`. + disabled: approvalLocked || editDisabledByPredicate, onClick: () => onEdit({ id: pureRecordId }), } as any); } @@ -1989,6 +2099,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri variant: 'destructive', order: 120, component: 'action:menu', + // objectui#4213 — `delete.disabledWhen` greys the overflow entry, the + // kebab's rule for the same key. Spread only when it HOLDS: this action + // declared no `disabled` key before, and `page:header` reads a declared + // `disabled` by `!= null`, so emitting a bare `false` would say + // something the object never declared. With no predicate the emitted + // ActionDef is byte-identical to the pre-#4213 one. + ...(deleteDisabledByPredicate ? { disabled: true } : null), onClick: async () => { const msg = t('detail.deleteConfirmation', { defaultValue: 'Are you sure you want to delete this record?', @@ -2077,9 +2194,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri materialize an `approval_status` field (objectui#2618). `approvalPending` rides alongside it so a node that declares `lockRecord: false` still shows its band and recall button while - leaving the record editable (objectui#2902). */} + leaving the record editable (objectui#2902). + + objectui#4213 — the object's per-record `userActions.edit` predicate + joins the SAME conjunction, and joins it exactly where + `approvalLocked` already sits. This expression mirrors the header's + `sys_edit` gate on purpose (the header CTA and the body pencils are + one edit affordance in two places), so folding the predicate into + only one of them would re-create, inside a single file, precisely + the header-vs-row asymmetry #4213 is about: Edit gone from the + header while double-click still opened a draft the object says this + record may not have. `disabledWhen` suppresses the pencils for the + same reason `approvalLocked` does — a draft Save would reject. */} ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', () => ({ + useRecordPresence: () => ({ viewers: [], others: [] }), + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +// Orthogonal chrome — same posture as RecordDetailView.headerRefresh.test. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'qms_defect'; +const RECORD_ID = 'DEF-1'; + +/** The card's own predicate, verbatim from the issue body. */ +const EDIT_VISIBLE_WHEN = 'record.status == "pending" || record.status == "in_progress"'; + +/** The card's own record: `status: 'reported'` ⇒ the predicate judges FALSE. */ +const REPORTED = { id: RECORD_ID, name: 'Scratched housing', status: 'reported' }; +/** …and the control the predicate ADMITS. */ +const PENDING = { id: RECORD_ID, name: 'Scratched housing', status: 'pending' }; + +const FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, +}; + +function objectDef(userActions?: unknown) { + return { + name: OBJECT_NAME, + label: 'Defect', + // The permissive bucket — edit + delete both default open, so the ONLY + // thing moving in this file is the predicate conjunct. + managedBy: 'platform', + fields: FIELDS, + ...(userActions === undefined ? {} : { userActions }), + }; +} + +function makeDataSource(record: Record) { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => record), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +function makeMetadata(objects: any[]) { + return { + objects, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +/** + * Mount the real console record page and WAIT for the record read to land, so + * an "Edit is gone" assertion can never pass merely because the record (and + * therefore the predicate's input) had not arrived yet. + */ +async function mountRecordPage( + record: Record, + userActions?: unknown, + objectOverrides: Record = {}, +) { + const objects = [{ ...objectDef(userActions), ...objectOverrides }]; + const dataSource = makeDataSource(record); + const view = render( + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + /> + + , + ); + await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled()); + await waitFor(() => expect(headerToolbar()).toBeTruthy()); + return { ...view, dataSource }; +} + +/** The `page:header` action toolbar — the host of every synth system action. */ +function headerToolbar(): HTMLElement | null { + return document.querySelector('[role="toolbar"]'); +} + +/** Every inline CTA the header currently renders, by visible label. */ +function headerCtaLabels(): string[] { + const bar = headerToolbar(); + if (!bar) return []; + return Array.from(bar.querySelectorAll('button')) + .map((b) => (b.textContent ?? '').trim()) + .filter(Boolean); +} + +/** The header's primary Edit CTA (`sys_edit` renders inline, not in the ⋯). */ +function headerEditButton(): HTMLButtonElement | null { + const bar = headerToolbar(); + if (!bar) return null; + return ( + (Array.from(bar.querySelectorAll('button')).find( + (b) => (b.textContent ?? '').trim().toLowerCase() === 'edit', + ) as HTMLButtonElement | undefined) ?? null + ); +} + +/** Open the header's ⋯ overflow and return its rendered item labels. */ +async function openHeaderOverflow(): Promise { + const trigger = screen.getByRole('button', { name: /more actions/i }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + return screen.getAllByRole('menuitem').map((el) => (el.textContent ?? '').trim()); +} + +beforeEach(() => { + cleanup(); + // Unrelated chrome (approvals, favourites, the record-explain probe) reaches + // for the platform API; in jsdom that is a real socket. Answer locally so the + // only asynchrony here is the record read. `useRecordEditable` fails OPEN on + // this shape, which keeps `recordWriteAllowed` / `recordDeleteAllowed` true — + // the predicate conjunct is then the only variable. + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// RED — the card's repro. Pre-fix every assertion in this block fails. +// --------------------------------------------------------------------------- +describe('console record header — `userActions` predicates (objectui#4213)', () => { + it('hides the header Edit on a record its `edit.visibleWhen` excludes', async () => { + await mountRecordPage(REPORTED, { edit: { visibleWhen: EDIT_VISIBLE_WHEN } }); + + // Asserted BEFORE any overflow opens (Radix aria-hidden trap). + expect(headerEditButton()).toBeNull(); + // The companion: the toolbar itself still rendered, so "no Edit" cannot be + // "no header". + expect(await openHeaderOverflow()).toContain('Share'); + }); + + it('hides the header Delete on a record its `delete.visibleWhen` excludes', async () => { + await mountRecordPage(REPORTED, { + delete: { visibleWhen: 'record.status != "reported"' }, + }); + + expect(await openHeaderOverflow()).not.toContain('Delete'); + }); + + it('renders the header Edit DISABLED when `edit.disabledWhen` holds', async () => { + await mountRecordPage(REPORTED, { + edit: { disabledWhen: 'record.status == "reported"' }, + }); + + const edit = headerEditButton(); + expect(edit).toBeInTheDocument(); + expect(edit).toBeDisabled(); + }); + + it('greys the header Delete when `delete.disabledWhen` holds', async () => { + await mountRecordPage(REPORTED, { + delete: { disabledWhen: 'record.status == "reported"' }, + }); + + const items = screen.queryAllByRole('menuitem'); + expect(items).toHaveLength(0); // menu not open yet + await openHeaderOverflow(); + const del = screen + .getAllByRole('menuitem') + .find((el) => (el.textContent ?? '').trim() === 'Delete')!; + expect(del).toBeTruthy(); + expect(del).toHaveAttribute('data-disabled'); + }); + + /** + * `visibleWhen: false` is a DECLARED gate that excludes every record — the + * `!= null` rule, not truthiness (the objectui#3492 invariant). + */ + it('treats a literal `visibleWhen: false` as declared, not ungated', async () => { + await mountRecordPage(PENDING, { edit: { visibleWhen: false } }); + + expect(headerEditButton()).toBeNull(); + }); + + it('accepts the canonical `{ dialect, source }` envelope', async () => { + await mountRecordPage(REPORTED, { + edit: { visibleWhen: { dialect: 'cel', source: EDIT_VISIBLE_WHEN } }, + }); + + expect(headerEditButton()).toBeNull(); + }); + + /** + * A faulting predicate fails CLOSED for `visibleWhen` (never a button whose + * precondition is unknown) — the posture every other surface applies. RED: + * pre-fix nothing was evaluated, so nothing could fault. + */ + it('fails CLOSED on an unevaluable `visibleWhen`', async () => { + await mountRecordPage(PENDING, { edit: { visibleWhen: 'record.@@@ bad' } }); + + expect(headerEditButton()).toBeNull(); + }); + + /** + * Half must-not-change, half RED: `sys_share` is the non-CRUD control that + * stays put on BOTH sides, while the two CRUD entries beside it disappear + * only after the fix. + */ + it('subtracts only the CRUD entries — `sys_share` is untouched', async () => { + await mountRecordPage(REPORTED, { + edit: { visibleWhen: EDIT_VISIBLE_WHEN }, + delete: { visibleWhen: 'record.status != "reported"' }, + }); + + expect(headerEditButton()).toBeNull(); + const items = await openHeaderOverflow(); + expect(items).toContain('Share'); + expect(items).not.toContain('Delete'); + }); +}); + +// --------------------------------------------------------------------------- +// MUST-NOT-CHANGE — green on BOTH sides of the fix. +// --------------------------------------------------------------------------- +describe('console record header — the gates that must not move (objectui#4213)', () => { + it('keeps the header Edit on a record its `edit.visibleWhen` admits', async () => { + await mountRecordPage(PENDING, { edit: { visibleWhen: EDIT_VISIBLE_WHEN } }); + + const edit = headerEditButton(); + expect(edit).toBeInTheDocument(); + expect(edit).not.toBeDisabled(); + }); + + it('leaves an object with NO `userActions` completely ungated', async () => { + await mountRecordPage(REPORTED, undefined); + + expect(headerEditButton()).not.toBeDisabled(); + expect(await openHeaderOverflow()).toContain('Delete'); + }); + + /** + * The card's own 定位线索: the BOOLEAN form already reached this surface, and + * it must keep reaching it through the affordance resolver — not through a + * second definition grown here. `userActionPredicates(false)` is `undefined`, + * so the predicate conjunct is a no-op and `resolveRecordHeaderActionGates` + * is what hides Delete. + */ + it('still hides Delete for the BOOLEAN `delete: false` (the card 定位线索)', async () => { + await mountRecordPage(PENDING, { delete: false }); + + expect(await openHeaderOverflow()).not.toContain('Delete'); + }); + + it('still hides Edit for the BOOLEAN `edit: false`', async () => { + await mountRecordPage(PENDING, { edit: false }); + + expect(headerEditButton()).toBeNull(); + }); + + /** + * The predicate is a FOURTH conjunct — it can never RESURRECT a button the + * object's bucket closed. `engine-owned` shuts edit + delete regardless of + * how loudly the predicate holds. + */ + it('never resurrects a button the affordance gate closed', async () => { + await mountRecordPage( + PENDING, + { edit: { visibleWhen: 'true' }, delete: { visibleWhen: 'true' } }, + { managedBy: 'engine-owned' }, + ); + + expect(headerEditButton()).toBeNull(); + expect(await openHeaderOverflow()).not.toContain('Delete'); + }); + + /** + * …and SOFT for `disabledWhen`: an unevaluable predicate must not grey a + * button forever. + */ + it('fails SOFT on an unevaluable `disabledWhen`', async () => { + await mountRecordPage(PENDING, { edit: { disabledWhen: 'record.@@@ bad' } }); + + expect(headerEditButton()).not.toBeDisabled(); + }); + + /** + * `disabledWhen: ''` reads as "no condition", not as "disable" — the `!= null` + * gate lives OUTSIDE the evaluation. + */ + it('reads an empty `disabledWhen` as no condition', async () => { + await mountRecordPage(REPORTED, { edit: { disabledWhen: '' } }); + + expect(headerEditButton()).not.toBeDisabled(); + }); +}); + +/** + * `sys_edit` already carried a `disabled` key before #4213 — `approvalLocked` + * (framework#3794). The predicate composes ONTO that key with OR rather than + * opening a second one: an approval lock and a declared predicate are + * independent reasons for "off", and neither may cancel the other. + * + * `approval_status: 'pending'` is the record-field fallback source of + * `approvalLocked` (objectui#2618), which needs no approvals-API stub — it is + * the same `approvalLocked` boolean either way. + */ +describe('console record header — approvalLocked composition (objectui#4213)', () => { + const LOCKED = { ...REPORTED, approval_status: 'pending' }; + + it('keeps approvalLocked disabling Edit with NO predicate declared', async () => { + // The pre-#4213 behavior, byte-identical: `disabled: approvalLocked`. + await mountRecordPage(LOCKED, undefined); + + expect(headerEditButton()).toBeDisabled(); + }); + + it('keeps approvalLocked disabling Edit when the predicate does NOT disable', async () => { + // `approvalLocked || false` — the lock still wins on its own. + await mountRecordPage(LOCKED, { edit: { disabledWhen: 'record.status == "closed"' } }); + + expect(headerEditButton()).toBeDisabled(); + }); + + it('disables Edit when the predicate holds and there is no approval lock', async () => { + // `false || predicate` — the new half of the same key. + await mountRecordPage(REPORTED, { edit: { disabledWhen: 'record.status == "reported"' } }); + + expect(headerEditButton()).toBeDisabled(); + }); + + it('disables Edit when BOTH hold', async () => { + await mountRecordPage(LOCKED, { edit: { disabledWhen: 'record.status == "reported"' } }); + + expect(headerEditButton()).toBeDisabled(); + }); + + it('leaves Edit enabled when NEITHER holds', async () => { + await mountRecordPage(REPORTED, { edit: { disabledWhen: 'record.status == "closed"' } }); + + expect(headerEditButton()).not.toBeDisabled(); + }); + + /** + * `visibleWhen` still outranks the disabled channel: a record the object + * excludes has no Edit entry to grey at all, lock or no lock. + */ + it('hides rather than greys when `visibleWhen` also excludes the record', async () => { + await mountRecordPage(LOCKED, { + edit: { visibleWhen: EDIT_VISIBLE_WHEN, disabledWhen: 'record.status == "reported"' }, + }); + + expect(headerEditButton()).toBeNull(); + }); +});