Skip to content

Commit da6f0ce

Browse files
feat(studio): New similar on a read-only base's permission set, opening the copy where the clone door landed it (#28669)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3QLmcF7caVjcoG4FiNkm5U)_ Fixes #28573 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「访问」支柱里,只读基线包(如 HotCRM)的权限集多了一个「新建类似」按钮。它打开 Setup 已有的复制对话框,只提交机器名和显示名给权限集复制接口;服务端答复副本落在哪个包(通常是 `hotcrm.custom`),Studio 就打开那个包的访问支柱并选中副本。Setup 目录页的按钮文字也从「克隆后自定义」改为「新建类似」。 - **为什么改**:ADR-0146 D6 规定客户自己的权限集是基线旁的新集合,放在 `.custom` 包里。服务端已由 #28563(PR #28636)把复制目标改到 `.custom`,这张卡(N-20b)补上 Studio 侧的入口。以前 Studio 从不提供复制,只有 Setup 目录页有。 - **风险与回滚**:没有新的写入路径,只调用已有的复制接口。答复没写包名或副本名时什么都不打开,只提示。墙隔离姿态或缺 `manage_metadata` 时按钮置灰并给出原因。访问支柱现在按包重新挂载(与其他支柱一致)。回滚即 revert 本 PR。 - **先看**:`studio-design/StudioDesignSurface.tsx` 的 `AccessPillar`(`onNewSimilarSaved` 和按钮的禁用判断)、`metadata-admin/permission-set-clone.ts`(读答复里的 `package`)、`i18n/src/namespaces/studio/{en,zh}.ts`(文案)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx`,预期 9 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **New similar in Studio's Access pillar.** On a read-only package, the open permission set offers "New similar" in its header. It opens the existing `PermissionSetCloneDialog`, which posts exactly `{ name, label }` to `POST /api/v1/security/permission-sets/:name/clone`. The client sends no package and no facet. - **The copy opens where the answer says it landed.** `clonePermissionSet` now reads the answer's `package` (the shape #28636 added: `{ permissionSet, package, notice }`), as sent: a string, `null`, or absent. With a package, the pillar navigates to `/studio/<package>/access?surface=permission:<copy>`, and the server's notice rides the navigation state and shows on the copy (`access-new-similar-strip`). With no package (absent or `null`) or no copy name, the strip says so and nothing opens. The landing is never derived from the base. - **Refusals and gates.** Refusals are the dialog's own (shown verbatim, unchanged). Under a walled posture (`useWalledCatalogReadOnly`) the button is disabled with `engine.catalog.readOnly.walled`; without `manage_metadata` (`useCanAuthorMetadata`) with `engine.edit.capabilityReadOnly`. The sentence is the button's accessible description and its title. - **Access pillar keyed by package.** `<AccessPillar key={packageId}>`, as the Data, Automations and Interfaces pillars already are. Without it, the hop to the copy's package kept the base's open set, rail and `?surface=` capture, because the route keeps the surface mounted. - **One label.** `perm.clone.action` now reads "New similar" (zh 新建类似), on Setup's catalog too. The guidance strings that named the old label (`perm.readOnly.artifact.hint`, `perm.clone.hint`, `perm.clone.guidance`, `perm.save.locked`, `perm.clone.dialogTitle`) now name the new one. They also stop calling the copy "an ordinary permission set authored in this system": since #28636 the server saves a base's copy in the package that holds your changes. Setup's `../COPY` hop is unchanged. - **Strings.** Three new keys, `engine.studio.access.newSimilar.{noPackage,done,dismiss}`, all in the studio namespace (`ui/packages/i18n/src/namespaces/studio/{en,zh}.ts`, typed `Record<string, string>`). No `locales/*` pack carries this family, so the change is `Clause-②: no` (the seat amended the claim, 6557419813). ## Tests - New: `studio-design/StudioDesignSurface.accessNewSimilar-28573.test.tsx`, 9 cases. It covers the en label with no override gesture on the base, the exact `{ name, label }` body, and the copy opening in `hotcrm.custom`'s Access pillar, selected, with the done line and the notice. It also covers a package that is absent or `null` (opens nothing, says so), no copy name, a 409 verbatim, walled disabled, capability disabled, and a writable package's set offering no New similar. The real `AccessPillar`, `PermissionMatrixEditPage` and `PermissionSetCloneDialog` are routed under `/studio/:packageId/access` and keyed by package, as `StudioDesignSurface` mounts them. - Updated: `PermissionMatrixEditor.cloneToCustomize.test.tsx` and `packagedItemWriteGate-12169.test.tsx` read "New similar" / 新建类似 for Setup's button and guidance. - Ablation, run once: `if (typeof copy.package !== 'string') {` was replaced by `if (false as boolean) {` through `scripts/ablation-replace.mjs`. The anchor went 1 → 0, and the absent and `null` package cases turned red (2 failed | 7 passed). Restore showed the blob equal to HEAD and `git diff HEAD` empty. ## Acceptance notes - The keyed remount is pinned through the test's own route wrapper, which mirrors `StudioDesignSurface`'s mount. The surface itself is not rendered in that suite. - After a copy lands, the server's notice is shown on the copy's Access pillar until dismissed or until another set is selected. It rides `location.state`, read once at mount, and only when it names the pillar's own package. - Card item 3 says "all locale packs": the `perm.clone.*` family lives only in the studio namespace, which has en and zh. Other locales fall back to en, as for every studio string. ## Verification <details><summary>Builds and type-check (at d224186, after merging origin/main dd6349e)</summary> - `turbo run build --concurrency=2 --filter='@object-ui/app-shell^...' --filter=@objectstack/formula --filter=@objectstack/sdui-parser --filter=@objectstack/lint --filter=@objectstack/client`: 64/64 tasks, lock VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check`: both Done, VERDICT command-exit 0. </details> <details><summary>Tests</summary> - Touched suites at d224186: 4 files, 75 tests passed (the new suite, cloneToCustomize, packagedItemWriteGate-12169, walledCatalogReadOnly-12183). - Consumer sweep at the pre-merge head b5bb340: every app-shell test file naming `AccessPillar`, `PermissionSetCloneDialog`, `permission-set-clone`, `perm.clone` or `StudioDesignSurface`. That is 172 files in three locked batches (86 + 43 + 43): 451 + 227 + 447 = 1125 tests passed. The incoming main commits touched `PackagedAutomationPage` and `packages/spec/src/api`, neither of which these files read. CI's Test UI runs the rest of app-shell. - `pnpm exec vitest run packages/i18n/`: 94 files, 1438 passed, 13 skipped. </details> <details><summary>Derived gates (dispatch-gates --commands --repo objectstack-ai/objectstack, at d224186)</summary> All 34 derived commands exit 0, reconciled with `dispatch-gates --ran`. Among them: `node scripts/check-sdui-manifest.mjs` (exit 0, no registry change), `pnpm --dir ui exec vitest run scripts/` (169 files, 4900 tests), `pnpm check:type-check-debt` (0 errors above record), `pnpm check:query-options-erasure`, `pnpm check:nul-bytes`, and `pnpm check:cross-package-test-inputs`. The PR-body gates (`check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-closing-keyword-parity`) ran against this body through `GITHUB_EVENT_PATH`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Left to CI's Lint UI.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUn8pWsnfJNsrCm68c)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a06feb7 commit da6f0ce

8 files changed

Lines changed: 552 additions & 33 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): "New similar" on a read-only base's permission set in the Access pillar, opening the copy where the clone door landed it
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A customer's own permission set is a new set in the package that holds their changes (ADR-0146 D6). Since the clone door lands a copy of a read-only package X's set in X's paired `X.custom`, Studio can offer that gesture where the base is designed.
12+
13+
- **New similar in Studio.** In the Access pillar of a read-only package, the open permission set offers "New similar". It asks for a machine name and a label (the same dialog as Setup's catalog) and posts exactly `{ name, label }` to `POST /security/permission-sets/:name/clone`. The client sends no package and no facet: the server decides where the copy lands.
14+
- **The copy opens where the answer says.** The door's answer names the package the copy was saved in. The pillar opens that package's Access pillar with the copy selected (`/studio/<package>/access?surface=permission:<copy>`), and shows the server's notice there as sent. An answer that names no package (absent or `null`) or no copy says so, and opens nothing.
15+
- **Refusals and gates.** A refusal (400, 403, 404, 409) is shown in the server's words in the dialog. Under a walled posture, or for a caller without `manage_metadata`, the action is drawn disabled with the console's own sentence for why. The base's sets stay read-only, and no override gesture is drawn on a permission set.
16+
- **One label.** The action reads "New similar" (zh: 新建类似) on Setup's catalog too, and the guidance strings that name it say the server saves the copy in the package that holds your changes. Setup's post-clone hop is unchanged.
17+
- **Access pillar keyed by package.** Like its sibling pillars, it remounts when the package changes, so opening the copy in another package's pillar no longer keeps the base's open set.

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

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* "Clone to customize" — the locked permission matrix's primary action
4+
* "New similar" (formerly "Clone to customize") — the locked permission matrix's primary action
55
* (objectui#5987, the UI half of objectstack#11513), calling the server's
66
* clone door (objectui#7611, PR-4).
77
*
@@ -15,7 +15,7 @@
1515
* whole definition under a new name, "objectui calling it with a name and a
1616
* label". This suite pins that the editor:
1717
*
18-
* 1. renders "Clone to customize" as the locked editor's PRIMARY action, in
18+
* 1. renders "New similar" as the locked editor's PRIMARY action, in
1919
* the slot Save would occupy, and names cloning FIRST in its guidance
2020
* (en-US and zh-CN);
2121
* 2. asks for a new name and label, and POSTs exactly `{ name, label }` to
@@ -250,8 +250,10 @@ async function renderPlain(props?: { packageId?: string; readOnly?: boolean; emb
250250
await screen.findByText('Account');
251251
}
252252

253-
const CLONE_EN = /^Clone to customize$/;
254-
const CLONE_ZH = /^克隆后自定义$/;
253+
// objectstack#28573 (ADR-0146 D6) — the action reads "New similar" on Setup's
254+
// catalog as in Studio's Access pillar.
255+
const CLONE_EN = /^New similar$/;
256+
const CLONE_ZH = /^新建类似$/;
255257
const cloneButtons = () => screen.queryAllByRole('button', { name: CLONE_EN });
256258
const saveButton = () => screen.queryByRole('button', { name: /^Save$/ });
257259
const createCopy = () => screen.getByRole('button', { name: /^Create copy$/ });
@@ -284,24 +286,24 @@ afterEach(() => {
284286

285287
/* ────────────────────────────────────────────────────────────────────────── */
286288

287-
describe('PermissionMatrixEditPage — the locked editor offers Clone to customize (objectui#5987)', () => {
288-
it('renders "Clone to customize" as the primary action where Save would be, and no Save', async () => {
289+
describe('PermissionMatrixEditPage — the locked editor offers New similar (objectui#5987)', () => {
290+
it('renders "New similar" as the primary action where Save would be, and no Save', async () => {
289291
await renderRouted();
290292

291293
expect(saveButton()).toBeNull();
292294
const [clone] = cloneButtons();
293295
expect(clone).toBeInTheDocument();
294296
expect(clone).toBeEnabled();
295297
// The visible locked-state guidance names cloning.
296-
expect(screen.getByRole('note')).toHaveTextContent(/Clone to customize/);
298+
expect(screen.getByRole('note')).toHaveTextContent(/New similar/);
297299
});
298300

299301
it('the artifact badge hint names cloning FIRST; the former remedies follow as secondary routes', async () => {
300302
await renderRouted();
301303

302304
const badge = screen.getByText(/provided by a code package/);
303305
const hint = badge.getAttribute('title') ?? '';
304-
const clone = hint.indexOf('Clone to customize');
306+
const clone = hint.indexOf('New similar');
305307
expect(clone).toBeGreaterThan(-1);
306308
for (const secondary of ['redeploy', 'new runtime set']) {
307309
const at = hint.indexOf(secondary);
@@ -325,16 +327,16 @@ describe('PermissionMatrixEditPage — the locked editor offers Clone to customi
325327

326328
expect(screen.getByRole('button', { name: CLONE_ZH })).toBeInTheDocument();
327329
expect(cloneButtons()).toHaveLength(0);
328-
expect(screen.getByRole('note')).toHaveTextContent(/克隆后自定义/);
330+
expect(screen.getByRole('note')).toHaveTextContent(/新建类似/);
329331
// Exact: the guidance note above also says 由代码包提供.
330332
const badge = screen.getByText('只读(该权限集由代码包提供)', { exact: true });
331333
const hint = badge.getAttribute('title') ?? '';
332-
expect(hint.indexOf('克隆后自定义')).toBeGreaterThan(-1);
333-
expect(hint.indexOf('克隆后自定义')).toBeLessThan(hint.indexOf('重新部署'));
334+
expect(hint.indexOf('新建类似')).toBeGreaterThan(-1);
335+
expect(hint.indexOf('新建类似')).toBeLessThan(hint.indexOf('重新部署'));
334336
});
335337
});
336338

337-
describe('PermissionMatrixEditPage — Clone to customize calls the clone door with a name and a label (objectui#7611)', () => {
339+
describe('PermissionMatrixEditPage — New similar calls the clone door with a name and a label (objectui#7611)', () => {
338340
it('POSTs exactly { name, label } to the door for THIS set, and nothing else', async () => {
339341
await renderRouted();
340342
fireEvent.click(cloneButtons()[0]);
@@ -504,7 +506,7 @@ describe('PermissionMatrixEditPage — a save the server refuses as a packaged s
504506

505507
const alert = await screen.findByRole('alert');
506508
expect(alert).toHaveTextContent(/Save refused \(403 not_overridable\)/);
507-
expect(alert).toHaveTextContent(/Clone to customize instead/);
509+
expect(alert).toHaveTextContent(/New similar instead/);
508510
expect(within(alert).getByRole('button', { name: CLONE_EN })).toBeEnabled();
509511
// The server's own sentence is kept for diagnosis, not shown alone.
510512
expect(within(alert).getByText(SERVER_TEXT)).toBeInTheDocument();

‎ui/packages/app-shell/src/views/metadata-admin/packagedItemWriteGate-12169.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ describe('a hatch-elevated type: its PACKAGED item is read-only on every gate (o
244244
expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
245245
for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled();
246246
expect(screen.getByText(ARTIFACT_CAPTION)).toBeInTheDocument();
247-
expect(screen.getAllByRole('button', { name: /Clone to customize/ }).length).toBeGreaterThan(0);
247+
expect(screen.getAllByRole('button', { name: /New similar/ }).length).toBeGreaterThan(0);
248248
});
249249

250250
it('the badge claims no more: its tooltip says the hatch never opens an item a package ships', async () => {

‎ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111
* (objectstack#22881, ADR-0126 §7.1). The server reads the source set from the
1212
* security catalog, copies its WHOLE definition under the new name, drops the
1313
* keys it declares (the provenance envelope, `packageId`, `managedBy`,
14-
* `protection`, `adminScope`, `isDefault`), and saves the copy as an ordinary
15-
* permission set authored in this environment. It answers `{ permissionSet,
16-
* notice }`: the saved copy, and a sentence naming what was not copied.
14+
* `protection`, `adminScope`, `isDefault`), and saves the copy in the package
15+
* it decides (ADR-0146 D6, "new similar"): a set a read-only package X ships
16+
* lands in X's paired `<X>.custom`, a set in a writable package in that
17+
* package, an environment-authored set in no package. It answers
18+
* `{ permissionSet, package, notice }`: the saved copy, the package it landed
19+
* in (`null` for environment-wide), and a sentence saying where it went and
20+
* what was not copied.
21+
*
22+
* ⛔ The client never names the package. The body's optional `package` key is
23+
* the server's to default, and where the copy lands is read back off the
24+
* answer, never derived here from the source's package.
1725
*
1826
* So this module sends a name and a label and nothing else (the seat's ruling
1927
* on objectui#7611, 6097763840: "objectui calling it with a name and a label").
@@ -44,13 +52,21 @@ import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
4452
export interface PermissionSetCloned {
4553
/** The copy's machine name, as the server saved it. */
4654
name: string;
55+
/**
56+
* The package the server saved the copy in: its id, `null` when the answer
57+
* says environment-wide, and `undefined` when the answer carries no
58+
* `package` at all (a server older than the "new similar" door). Read as
59+
* sent; a caller that opens the copy opens it only where this names.
60+
*/
61+
package?: string | null;
4762
/** The server's sentence on what was not copied; empty when it sent none. */
4863
notice: string;
4964
}
5065

5166
/**
5267
* Clone `source` through the door. Resolves to the saved copy's name (empty
53-
* when the answer names none) and the server's notice. A refusal rejects with
68+
* when the answer names none), the package it landed in, and the server's
69+
* notice. A refusal rejects with
5470
* the server's own message; `fallback` words one only when the answer has none.
5571
*/
5672
export async function clonePermissionSet(
@@ -70,8 +86,10 @@ export async function clonePermissionSet(
7086
if (!res.ok || envelopeRefused(json)) throw new Error(actionErrorDetail(json, fallback(res.status)));
7187
const data = envelopeData(json);
7288
const created = (data.permissionSet as { name?: unknown } | null | undefined)?.name;
89+
const landed = data.package;
7390
return {
7491
name: typeof created === 'string' ? created : '',
92+
package: typeof landed === 'string' && landed !== '' ? landed : landed === null ? null : undefined,
7593
notice: typeof data.notice === 'string' ? data.notice : '',
7694
};
7795
}

0 commit comments

Comments
 (0)