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
4 changes: 4 additions & 0 deletions .changeset/record-quick-actions-member-pins-8071.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 50 additions & 4 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2042,6 +2042,24 @@ const MULTI_KIND_MEMBER_CONTRACTS: Record<string, string> = {
// 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`
Expand Down Expand Up @@ -2271,6 +2289,14 @@ const MEMBER_PINS: Record<string, MemberPin> = {
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).',
Expand Down Expand Up @@ -2467,9 +2493,9 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {
// 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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>() };

const canSpy = vi.fn((_object: string, action: string) => stub.granted.has(action));

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
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<string, unknown>) {
return render(
<MetadataCtx.Provider value={METADATA}>
<RecordContextProvider objectName="crm_account" recordId="rec-1" data={{ id: 'rec-1' }}>
<RecordQuickActionsRenderer schema={{ actionNames: ['approve'], ...schema } as any} />
</RecordContextProvider>
</MetadataCtx.Provider>,
);
}

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');
});
});
Loading