From 7e44254963ea50c4a58ba13ed763198a63fde363 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:17:59 +0000 Subject: [PATCH] test(console): pin record:quick_actions member shapes (objectui#8071 slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts both remaining `record:quick_actions` MEMBER_PIN_EXEMPTIONS entries (actionNames, requiredPermissions) into real per-block member pins and lowers MEMBER_PIN_EXEMPTION_CEILING 50 -> 48 in the same commit. `actionNames` promotes a pre-existing file (recordQuickActionsInputs.actionNamesFallback.test.tsx, written for objectui#4663 but already driving the exact read this key needs) after reading it end to end; `requiredPermissions` had no existing candidate — the only prior reference to that name in this renderer's tests is an ActionDef's own per-action field, a different mechanism from the block-level gate this key declares — so it is a new file (record-quick-actions.requiredPermissions-gate.test.tsx). Refs objectstack-ai/objectui#8071 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../record-quick-actions-member-pins-8071.md | 4 + .../registry-inputs-spec-parity.test.ts | 54 +++++- ...-actions.requiredPermissions-gate.test.tsx | 161 ++++++++++++++++++ 3 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 .changeset/record-quick-actions-member-pins-8071.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx diff --git a/.changeset/record-quick-actions-member-pins-8071.md b/.changeset/record-quick-actions-member-pins-8071.md new file mode 100644 index 0000000000..d2753b768f --- /dev/null +++ b/.changeset/record-quick-actions-member-pins-8071.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change: `record:quick_actions`'s `actionNames` and `requiredPermissions` gain per-block member pins (objectui#8071 slice 4), closing out the block entirely, and the member-pin exemption ledger in the console's registry/spec parity gate moves with them. No published behaviour changes — no runtime source, build entry, `files[]` payload or published-contract field is touched. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index f7d80d5594..2f106edd28 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2042,6 +2042,24 @@ const MULTI_KIND_MEMBER_CONTRACTS: Record = { // exercised — had no existing candidate anywhere in the repo, so it is a new // file. See `MEMBER_PIN_EXEMPTION_CEILING`'s own docblock for the accounting. // +// ⭐ FOURTH SLICE, THE SECOND WHOLE BLOCK CLOSED, AND A TWO-KEY FAMILY. +// objectui#8071's fourth slice converted both remaining keys of ONE block — +// `record:quick_actions`'s `actionNames` and `requiredPermissions` — chosen +// because the block's remaining population is small enough to close outright +// (the shape this card's own dispatch names, over an arbitrary four). Neither +// key needed `NO_READ_SITE_TO_PIN`: `actionNames` drives a real +// `useMetadataItem` lookup against the object's own `actions`, and +// `requiredPermissions` is a block-level gate (`required.every((p) => +// perms.can(objectName, p))`) that hides the whole bar before anything is +// drawn — a DIFFERENT mechanism from an `ActionDef`'s own per-action field of +// the same name, which a pre-existing fixture already exercised without +// touching this key at all. Measured over this file's own ledger, before and +// after: 90 array/object-armed inputs, 40 pinned, 50 exempt -> 90, 42 pinned, +// 48 exempt, and `MEMBER_PIN_EXEMPTION_CEILING` follows 50 -> 48 in the same +// commit. One of the two pins PROMOTES a pre-existing file +// (`recordQuickActionsInputs.actionNamesFallback.test.tsx`); the other +// (`requiredPermissions`) is new, for the reason above. +// // A list this size is the transition case, and this file already owns the // pattern for it — // `OFF_SPEC_EXEMPTIONS` / `UNPUBLISHED_EXEMPTIONS` / `OFF_SPEC_ARM_EXEMPTIONS` @@ -2271,6 +2289,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts', pins: 'Members are objects carrying `readonly` PER ENTRY — asserted to be per-entry rather than top-level, and every spec entry key must be discoverable from the `fields` description (objectui#3407).', }, + 'record:quick_actions.actionNames': { + file: 'packages/plugin-detail/src/__tests__/recordQuickActionsInputs.actionNamesFallback.test.tsx', + pins: 'Members are action-id STRINGS resolved against the object\'s own `actions` through the real renderer: with no `actionNames` (and no host `actions`) the metadata layer is NEVER queried and the bar\'s empty placeholder renders, while naming an id drives the same `useMetadataItem` lookup and the resolved action\'s button actually appears — the positive control that keeps the negative case from reading as "the wiring was never exercised". Pre-existing file, promoted to a pin here after being read end to end; it was written for objectui#4663 (the registered description promised a fallback that exists on no path) rather than for this direction, but its second case already drives the exact read this key needs pinned (objectui#8071).', + }, + 'record:quick_actions.requiredPermissions': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx', + pins: 'The BLOCK-LEVEL gate (`required.every((p) => perms.can(objectName, p))`) that hides the whole bar before any action is drawn — distinct from an `ActionDef`\'s own per-action `requiredPermissions`, which `record-quick-actions.declared-action-ids-7182.test.tsx`\'s `gated` fixture already covers. No key at all: the bar renders on the (empty) grant set, so the gate is provably driven by the key\'s PRESENCE. A single held permission gates as expected, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when read as `.every` over the WHOLE array rather than its first element — with the all-granted case as that row\'s own positive control. New file: no existing test drove `schema.requiredPermissions` itself, only the unrelated per-action field of the same name (objectui#8071).', + }, 'record:related_list.add': { file: 'packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts', pins: 'Every spec member key of `add` must be discoverable from its description, the published defaults must be the RENDERER\'s rather than the spec\'s prose, and `picker.filter` must be documented as a real restriction (objectui#3808).', @@ -2467,9 +2493,9 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // record:path 'record:path.stages': AWAITING_A_PIN, - // record:quick_actions - 'record:quick_actions.actionNames': AWAITING_A_PIN, - 'record:quick_actions.requiredPermissions': AWAITING_A_PIN, + // record:quick_actions — objectui#8071 slice 4 pinned both remaining keys + // (`actionNames`, `requiredPermissions`); the block is now fully pinned and + // this header stays only as a note for the next reader who greps for it. // record:related_list — objectui#8071 slice 2 pinned `columns`, `dataSource`, // `filter` and `sort`. `actions` is the one left, and it is left for a @@ -2583,11 +2609,31 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS = [ * `properties.sort` key before this slice — so it is the one genuinely new * file. * + * ## 50 -> 48, the fourth slice, and a two-key block closed the same way + * + * objectui#8071's fourth slice converted both remaining keys of ONE block — + * `record:quick_actions`'s `actionNames` and `requiredPermissions` — and + * deleted their two entries, so the ceiling follows to 48 in the same commit. + * Like `element:record_picker` (slice 3) and unlike `record:related_list` + * (slice 2), this block has no `NO_READ_SITE_TO_PIN` leftover: both declared + * keys have a real read site in `renderers/record-quick-actions.tsx` — the + * `actionNames` -> `useMetadataItem` lookup, and the `requiredPermissions` + * block-level gate that runs before any action is drawn — so the block drops + * out of `MEMBER_PIN_EXEMPTIONS` entirely. + * + * One of the two pins PROMOTES a pre-existing file + * (`recordQuickActionsInputs.actionNamesFallback.test.tsx`, written for + * objectui#4663 but already driving the exact `actionNames` read this key + * needs, read end to end before being credited); `requiredPermissions` had no + * candidate — every existing reference to that name in this renderer's test + * suite is an ActionDef's own PER-ACTION field, a different mechanism — so it + * is a new file. + * * ⇒ The rule for every future slice of objectui#8071: delete the entry, register * the pin, and set this constant to the new count. Not to the new count plus * room. */ -const MEMBER_PIN_EXEMPTION_CEILING = 50; +const MEMBER_PIN_EXEMPTION_CEILING = 48; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. diff --git a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx new file mode 100644 index 0000000000..68150cbf87 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx @@ -0,0 +1,161 @@ +/** + * 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. + */ + +/** + * `record:quick_actions.requiredPermissions` — the BLOCK-LEVEL permission + * gate the registration describes as "Hide the whole bar unless the user + * holds these permissions" (objectui#8071 slice 4). + * + * Not to be confused with an `ActionDef`'s OWN `requiredPermissions` — a + * per-action field the `gated` fixture in + * `record-quick-actions.declared-action-ids-7182.test.tsx` already exercises + * through `ActionEngine.getActionsForLocation`'s per-action filter. This file + * pins the renderer's OWN read of `schema.requiredPermissions`, at + * `record-quick-actions.tsx`: + * + * const required: string[] = Array.isArray(schema.requiredPermissions) + * ? schema.requiredPermissions + * : []; + * if (required.length > 0 && objectName) { + * const ok = required.every((p) => perms.can(objectName, p as any)); + * if (!ok) return <…Insufficient permissions…/>; + * } + * + * — a gate that runs BEFORE any action is drawn, over the WHOLE array via + * `.every`, against the record's own `objectName`. A declaration reading only + * "array of string" cannot tell this from a single scalar flag; the member + * shape only shows up once every element is actually checked (`.every`, not + * `[0]`), which is what the "partial grant" case below is for. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MetadataCtx, RecordContextProvider } from '@object-ui/react'; +import type { MetadataContextValue } from '@object-ui/react'; +import { RecordQuickActionsRenderer } from '../record-quick-actions'; + +/** Permissions the mocked `perms.can` currently grants — reassigned per test. */ +const stub = { granted: new Set() }; + +const canSpy = vi.fn((_object: string, action: string) => stub.granted.has(action)); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ + can: canSpy, + cannot: (object: string, action: string) => !canSpy(object, action), + }), + }; +}); + +/** Declared on the object, at the location this bar renders by default. */ +const APPROVE = { + name: 'approve', + label: 'Approve', + type: 'script', + locations: ['record_header'], +}; + +const OBJECT_META = { name: 'crm_account', actions: [APPROVE] }; + +const getItem = vi.fn(async (type: string, name: string) => + type === 'object' && name === 'crm_account' ? OBJECT_META : null, +); + +/** + * Held at MODULE level on purpose: `getItem` is an effect dependency of + * `useMetadataItem`, so a value rebuilt per render spins that hook forever + * (the same reason `recordQuickActionsInputs.actionNamesFallback.test.tsx` + * does this). + */ +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_META] as any, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: getItem as unknown as MetadataContextValue['getItem'], + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +function mount(schema: Record) { + return render( + + + + + , + ); +} + +beforeEach(() => { + stub.granted = new Set(); + canSpy.mockClear(); + getItem.mockClear(); +}); + +describe('record:quick_actions.requiredPermissions — block-level gate (objectui#8071 slice 4)', () => { + it('CONTROL: with no `requiredPermissions` declared, the bar renders regardless of the (empty) grant set', async () => { + mount({}); + expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument(); + // The gate short-circuits on `required.length > 0` — an absent key never + // asks the permission system at all. + expect(canSpy).not.toHaveBeenCalled(); + }); + + it('hides the WHOLE bar when a declared permission is not held', async () => { + mount({ requiredPermissions: ['crm.manage'] }); + expect(await screen.findByText(/insufficient permissions to view quick actions/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + expect(canSpy).toHaveBeenCalledWith('crm_account', 'crm.manage'); + }); + + it('renders once the declared permission is granted — the same key, read for real', async () => { + stub.granted = new Set(['crm.manage']); + mount({ requiredPermissions: ['crm.manage'] }); + expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument(); + expect(screen.queryByText(/insufficient permissions/i)).not.toBeInTheDocument(); + }); + + it('every declared permission must be held — a PARTIAL grant still gates the bar', async () => { + // Only one of the two declared permissions is granted. A verdict driven + // by `required[0]` alone (or by "any", not "every") would pass this — + // reading `.every` over the full array is the member shape this test + // exists to discriminate. + stub.granted = new Set(['crm.manage']); + mount({ requiredPermissions: ['crm.manage', 'crm.export'] }); + expect(await screen.findByText(/insufficient permissions to view quick actions/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + expect(canSpy).toHaveBeenCalledWith('crm_account', 'crm.manage'); + expect(canSpy).toHaveBeenCalledWith('crm_account', 'crm.export'); + }); + + it('granting ALL declared permissions renders the bar — the positive control for the partial-grant case above', async () => { + stub.granted = new Set(['crm.manage', 'crm.export']); + mount({ requiredPermissions: ['crm.manage', 'crm.export'] }); + expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument(); + }); + + it('checks the gate against THIS record\'s own `objectName`, not a fixed string', async () => { + stub.granted = new Set(['crm.manage']); + mount({ requiredPermissions: ['crm.manage'] }); + await screen.findByRole('button', { name: 'Approve' }); + expect(canSpy).toHaveBeenCalledWith('crm_account', 'crm.manage'); + expect(canSpy).not.toHaveBeenCalledWith(expect.not.stringMatching('crm_account'), 'crm.manage'); + }); +});