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
17 changes: 17 additions & 0 deletions .changeset/28573-new-similar-permission-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

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

Clause-②: no
Metadata-protocol: none

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.

- **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.
- **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.
- **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.
- **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.
- **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.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

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

/* ────────────────────────────────────────────────────────────────────────── */

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

expect(saveButton()).toBeNull();
const [clone] = cloneButtons();
expect(clone).toBeInTheDocument();
expect(clone).toBeEnabled();
// The visible locked-state guidance names cloning.
expect(screen.getByRole('note')).toHaveTextContent(/Clone to customize/);
expect(screen.getByRole('note')).toHaveTextContent(/New similar/);
});

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

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

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

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

const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/Save refused \(403 not_overridable\)/);
expect(alert).toHaveTextContent(/Clone to customize instead/);
expect(alert).toHaveTextContent(/New similar instead/);
expect(within(alert).getByRole('button', { name: CLONE_EN })).toBeEnabled();
// The server's own sentence is kept for diagnosis, not shown alone.
expect(within(alert).getByText(SERVER_TEXT)).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ describe('a hatch-elevated type: its PACKAGED item is read-only on every gate (o
expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled();
expect(screen.getByText(ARTIFACT_CAPTION)).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /Clone to customize/ }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: /New similar/ }).length).toBeGreaterThan(0);
});

it('the badge claims no more: its tooltip says the hatch never opens an item a package ships', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@
* (objectstack#22881, ADR-0126 §7.1). The server reads the source set from the
* security catalog, copies its WHOLE definition under the new name, drops the
* keys it declares (the provenance envelope, `packageId`, `managedBy`,
* `protection`, `adminScope`, `isDefault`), and saves the copy as an ordinary
* permission set authored in this environment. It answers `{ permissionSet,
* notice }`: the saved copy, and a sentence naming what was not copied.
* `protection`, `adminScope`, `isDefault`), and saves the copy in the package
* it decides (ADR-0146 D6, "new similar"): a set a read-only package X ships
* lands in X's paired `<X>.custom`, a set in a writable package in that
* package, an environment-authored set in no package. It answers
* `{ permissionSet, package, notice }`: the saved copy, the package it landed
* in (`null` for environment-wide), and a sentence saying where it went and
* what was not copied.
*
* ⛔ The client never names the package. The body's optional `package` key is
* the server's to default, and where the copy lands is read back off the
* answer, never derived here from the source's package.
*
* So this module sends a name and a label and nothing else (the seat's ruling
* on objectui#7611, 6097763840: "objectui calling it with a name and a label").
Expand Down Expand Up @@ -44,13 +52,21 @@ import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
export interface PermissionSetCloned {
/** The copy's machine name, as the server saved it. */
name: string;
/**
* The package the server saved the copy in: its id, `null` when the answer
* says environment-wide, and `undefined` when the answer carries no
* `package` at all (a server older than the "new similar" door). Read as
* sent; a caller that opens the copy opens it only where this names.
*/
package?: string | null;
/** The server's sentence on what was not copied; empty when it sent none. */
notice: string;
}

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