Skip to content

Commit 48aaf0f

Browse files
committed
fix(app-shell): move the clone dispatch shaping out of the scanned editor surface (objectui#5987)
CI Test (shard 1/8) failed `permission-slice.authoredKeys.test.ts` on the first commit: `expected [ 'objectName', 'params' ] to deeply equal []`. The scanner reads `{ ...spread, key }` literals inside `setDraft(` regions of `PermissionMatrixEditor.tsx` as facets written into the permission-set draft. Its `setDraft(` regex also matches the prose `funnels through setDraft (matrix checkboxes, …` in a `//` comment, and the `)` that would close that region is on the next comment line, which the walker skips wholesale — so that region runs to end of file (on main too) and every leading-spread literal below it is counted. The clone dispatch literal (`{ ...localized, objectName, params }`) is the action runner's call shape, not a permission-set facet, and it landed inside that region. The construction now lives in `permission-set-clone-dispatch.ts` (`findCloneAction`, `buildCloneDispatch`), which the scan does not read. No behaviour change; the slice's carried-key list is untouched. The scanner's comment-matching is reported separately, not changed here. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
1 parent 6986574 commit 48aaf0f

2 files changed

Lines changed: 109 additions & 46 deletions

File tree

‎packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx‎

Lines changed: 17 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,18 @@ import {
7272
SheetTitle,
7373
} from '@object-ui/components';
7474
import { useAdapter, useAction, useMetadata, useActionTextLocalizer } from '@object-ui/react';
75-
import type { ActionDef } from '@object-ui/core';
7675
import { CapabilityMultiSelectField, parseCapabilityNames } from '@object-ui/fields';
7776
import { PageShell } from './PageShell.js';
7877
import { HistoryPanel } from './ResourceHistoryPage.js';
7978
import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js';
8079
import { t as translate, tFormat, useMetadataLocale } from './i18n.js';
8180
import { PermissionAdvancedFacets } from './PermissionAdvancedFacets.js';
8281
import { errorCodeIs } from '@object-ui/types';
83-
import type { ConsoleActionDispatch } from '../../consoleActionDispatch.js';
82+
import {
83+
PERMISSION_SET_OBJECT,
84+
buildCloneDispatch,
85+
findCloneAction,
86+
} from './permission-set-clone-dispatch.js';
8487
import {
8588
mergePermissionSlice,
8689
scopePermissionSet,
@@ -179,22 +182,6 @@ function isArtifactBackedLayer(
179182
);
180183
}
181184

182-
/**
183-
* "Clone to customize" (objectui#5987) — the record object a permission set is
184-
* projected onto, and the record action the server PUBLISHES on it. The
185-
* server's own `403 not_overridable` refusal names that action as the remedy
186-
* (maintainer ruling on objectstack#11513: lock the base, clone to customize),
187-
* so the editor runs THAT action — resolved by name off the object definition
188-
* the console already holds, dispatched through the console's shared action
189-
* runner exactly as a `record_header` button would be — and never hand-rolls a
190-
* copy out of create/update calls: the action's `params` list IS the payload
191-
* (which facets a clone carries is decided where the action is declared), and
192-
* a second spelling of it here would be the silent-grant-loss shape
193-
* objectstack#11703 closed.
194-
*/
195-
const PERMISSION_SET_OBJECT = 'sys_permission_set';
196-
const CLONE_PERMISSION_SET_ACTION = 'clone_permission_set';
197-
198185
/** Localized short label for an OWD value; falls back to the raw value. */
199186
function owdLabel(t: (k: string) => string, value: string): string {
200187
const key: Record<string, string> = {
@@ -304,7 +291,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
304291
// (where the `sys_permission_set` object definition, and with it the
305292
// published `clone_permission_set` action, is read from), and the
306293
// `_actions.<name>` bundle localizer every declared-action surface uses for
307-
// the dialog title. See {@link PERMISSION_SET_OBJECT}.
294+
// the dialog title. Resolution and dispatch shape live in
295+
// `permission-set-clone-dispatch.ts` — see its header for why.
308296
const { execute: executeAction } = useAction();
309297
const metadataStore = useMetadata();
310298
const localizeActionTexts = useActionTextLocalizer();
@@ -935,15 +923,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
935923
*
936924
* Every step is the same one a `record_header` "Clone" button on the
937925
* `sys_permission_set` record page takes — resolve the action off the object
938-
* definition, stash the row under `params._rowRecord` (what the runner's
939-
* param dialog seeds `defaultFromRow` params from and what the api handler
940-
* reads for `{id}` / record-id injection), surface the declared `params`
941-
* ARRAY as `actionParams`, and `execute` — so the clone's payload, dialog,
942-
* refusal toasts and success toast are the published action's, not this
943-
* editor's. What this editor adds is only where the clone OPENS: on the
944-
* routed metadata admin it navigates to the clone, which loads with no code
945-
* layer and is therefore writable; an embedded host has no route, so the
946-
* clone is announced by name instead.
926+
* definition (`findCloneAction`), shape the dispatch (`buildCloneDispatch`:
927+
* the row under `params._rowRecord`, the declared `params` ARRAY as
928+
* `actionParams`), and `execute` — so the clone's payload, dialog, refusal
929+
* toasts and success toast are the published action's, not this editor's.
930+
* What this editor adds is only where the clone OPENS: on the routed
931+
* metadata admin it navigates to the clone, which loads with no code layer
932+
* and is therefore writable; an embedded host has no route, so the clone is
933+
* announced by name instead.
947934
*
948935
* Two refusals, no fallbacks (AGENTS.md #0.1): an object that publishes no
949936
* such action, or a set with no `sys_permission_set` row, ends here with a
@@ -956,14 +943,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
956943
setError(null);
957944
setCloneNotice(null);
958945
try {
959-
const objectDefs = await metadataStore.ensureType('object');
960-
const setObject = (Array.isArray(objectDefs) ? objectDefs : []).find(
961-
(o: { name?: unknown }) => o?.name === PERMISSION_SET_OBJECT,
962-
) as { actions?: unknown } | undefined;
963-
const declaredActions = Array.isArray(setObject?.actions) ? setObject.actions : [];
964-
const cloneAction = declaredActions.find(
965-
(a: { name?: unknown }) => a?.name === CLONE_PERMISSION_SET_ACTION,
966-
) as (ActionDef & { params?: unknown }) | undefined;
946+
const cloneAction = findCloneAction(await metadataStore.ensureType('object'));
967947
if (!cloneAction) throw new Error(t('perm.clone.actionMissing'));
968948

969949
// The row the action runs against — `AssignedUsersSection` resolves the
@@ -973,16 +953,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
973953
const row = (found?.data ?? [])[0] as Record<string, unknown> | undefined;
974954
if (!row) throw new Error(tFormat('perm.clone.rowMissing', locale, { name }));
975955

976-
const { params: declaredParams, ...rest } = cloneAction;
977-
const dispatch: ConsoleActionDispatch = {
978-
...localizeActionTexts(PERMISSION_SET_OBJECT, rest as Record<string, unknown>),
979-
objectName: PERMISSION_SET_OBJECT,
980-
params: { _rowRecord: row },
981-
};
982-
if (Array.isArray(declaredParams) && declaredParams.length > 0) {
983-
dispatch.actionParams = declaredParams as ConsoleActionDispatch['actionParams'];
984-
}
985-
const result = await executeAction(dispatch);
956+
const result = await executeAction(buildCloneDispatch(cloneAction, row, localizeActionTexts));
986957
// A cancelled dialog and a refused POST both come back `success: false`;
987958
// the runner has already toasted a refusal, and a cancel needs nothing.
988959
if (!result.success) return;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* "Clone to customize" (objectui#5987) — resolving the server-published
5+
* `clone_permission_set` record action and shaping its dispatch.
6+
*
7+
* The permission matrix locks a set a code package ships (the artifact tier).
8+
* The maintainer's ruling on objectstack#11513 — lock the base, clone to
9+
* customize — makes cloning the sanctioned edit path, and the server's own
10+
* `403 not_overridable` refusal names the `clone_permission_set` action as the
11+
* remedy. The editor therefore runs THAT action, resolved by name off the
12+
* `sys_permission_set` object definition the console already holds, and never
13+
* hand-rolls a copy out of create/update calls: the action's `params` list IS
14+
* the payload (which facets a clone carries is decided where the action is
15+
* declared), and a second spelling of it here would be the silent-grant-loss
16+
* shape objectstack#11703 closed.
17+
*
18+
* ## Why this lives beside the editor rather than in it
19+
*
20+
* The dispatch is the same shape `DeclaredActionsBar` and `ObjectGrid` hand
21+
* the console action runner for a `record_header` / `list_item` action: the
22+
* declaration spread first, `objectName`, and the row under
23+
* `params._rowRecord`. That is an object literal that opens with a spread —
24+
* the very shape `permission-slice.authoredKeys.test.ts` reads inside
25+
* `PermissionMatrixEditor.tsx` as "a facet written into the permission-set
26+
* draft". These keys are the action call's arguments, not facets of a
27+
* permission set, so the construction sits in this module, which that scan
28+
* does not read, instead of being reshaped to dodge it.
29+
*/
30+
31+
import type { ActionDef } from '@object-ui/core';
32+
import type { ConsoleActionDispatch } from '../../consoleActionDispatch.js';
33+
34+
/** The record object a permission set is projected onto. */
35+
export const PERMISSION_SET_OBJECT = 'sys_permission_set';
36+
/** The record action the server publishes on it, and the 403 names as the remedy. */
37+
export const CLONE_PERMISSION_SET_ACTION = 'clone_permission_set';
38+
39+
/** The published action, as an object definition declares it. */
40+
export type CloneAction = ActionDef & { params?: unknown };
41+
42+
/**
43+
* Find `clone_permission_set` on the `sys_permission_set` object among the
44+
* object definitions the console metadata store holds. `undefined` when the
45+
* server publishes no such object or no such action — the caller refuses,
46+
* it does not fall back.
47+
*/
48+
export function findCloneAction(objectDefs: unknown): CloneAction | undefined {
49+
const defs = Array.isArray(objectDefs) ? objectDefs : [];
50+
const setObject = defs.find(
51+
(o: { name?: unknown }) => o?.name === PERMISSION_SET_OBJECT,
52+
) as { actions?: unknown } | undefined;
53+
const declared = Array.isArray(setObject?.actions) ? setObject.actions : [];
54+
return declared.find(
55+
(a: { name?: unknown }) => a?.name === CLONE_PERMISSION_SET_ACTION,
56+
) as CloneAction | undefined;
57+
}
58+
59+
/**
60+
* `localizeActionTexts` from `@object-ui/react`, by its call shape: label,
61+
* confirmText and successMessage resolved through the `_actions.NAME` bundle.
62+
*/
63+
export type ActionTextLocalize = (
64+
objectName: string,
65+
action: Record<string, unknown>,
66+
) => Record<string, unknown>;
67+
68+
/**
69+
* Shape the runner dispatch for cloning `row` through `cloneAction`.
70+
*
71+
* Same dispatch shape as `DeclaredActionsBar`: the (localized) declaration
72+
* forwarded whole, `objectName` set, the declared `params` ARRAY surfaced as
73+
* `actionParams` (the runner's param-dialog input, seeded `defaultFromRow`
74+
* from the row), and `params` reserved for the `_rowRecord` stash the api
75+
* handler reads for `{id}` interpolation and record-id injection.
76+
*/
77+
export function buildCloneDispatch(
78+
cloneAction: CloneAction,
79+
row: Record<string, unknown>,
80+
localize: ActionTextLocalize,
81+
): ConsoleActionDispatch {
82+
const { params: declaredParams, ...rest } = cloneAction;
83+
const dispatch: ConsoleActionDispatch = {
84+
...localize(PERMISSION_SET_OBJECT, rest as Record<string, unknown>),
85+
objectName: PERMISSION_SET_OBJECT,
86+
params: { _rowRecord: row },
87+
};
88+
if (Array.isArray(declaredParams) && declaredParams.length > 0) {
89+
dispatch.actionParams = declaredParams as ConsoleActionDispatch['actionParams'];
90+
}
91+
return dispatch;
92+
}

0 commit comments

Comments
 (0)