Skip to content

Commit 0f1045b

Browse files
fix(app-shell): permission-set editor header shows the served (translated) label (#27019)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8GKuALbng1TQyrFASUX9Lyy)_ Fixes #26884 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:权限集详情页(Setup 与 Studio 访问支柱)的标题改用服务端按语言下发的标签,zh-CN 下显示「销售代表」而不是「Sales Representative」。 - **为什么改**:列表页已经显示翻译后的名称,点进去标题却是英文原文;编辑器的草稿来自未翻译的 layered 读取,而它已经读到的已发布列表是翻译过的。 - **风险与回滚**:只改显示,草稿与保存内容不变(保存仍发作者原文);标签被编辑或有不同标签的待发布草稿时显示草稿标签。回滚即还原这一个提交。 - **先看**:`PermissionMatrixEditor.tsx` 的 `headerLabel` 与加载末尾读取 served 行那段;新测试 `PermissionMatrixEditor.servedLabel-26884.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.servedLabel-26884.test.tsx`,预期 4 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - `PermissionMatrixEditPage` keeps the label of the row `GET /meta/permission` serves for this set (that read is localized at the REST boundary by the server's permission-set translator, and the editor already makes it for the published-set check). The header shows it while the draft's label equals the published authored label; otherwise it shows the draft's label, as before. - Both header sites read it: the Setup page's identity strip and the Studio Access pillar's `StudioItemHeader` title. - Display only: the served label is never written into `draft`, so a save sends the authored label unchanged. A set the published list does not hold (never published) keeps its draft label. - Scope cut, per the dispatch order: the card's second symptom (the metadata list chrome staying English under ja-JP / es-ES) is the ruled en fallback, not a defect (Ruling-ref 6431032267, verbatim: "the other eight locales fall back to en (no invented translations)"), so it is not touched here. - Changeset: `@object-ui/app-shell` patch. ## Tests New `PermissionMatrixEditor.servedLabel-26884.test.tsx` (4 cases): zh-CN served 销售代表 over layered "Sales Representative", and the autosave sends "Sales Representative" unchanged; ja-JP served label; a set the list does not hold keeps its draft label; an edit to the label shows the edit. Ablation (one-off, `scripts/ablation-replace.mjs`, restored blob equals HEAD): putting `draft.label` back in the Setup header turns 3 of the 4 cases red (the never-published case stays green, as expected); restored, 4/4 green. ## Acceptance notes - The served label comes from the same list request the editor already sends; no new request. ## Verification <details><summary>Local gates on 53382fa</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 families derived, 34 run, every one exit 0; `--ran` reconciliation: 34 run, 0 NOT-MEASURED, 0 UNRUN. `node scripts/check-sdui-manifest.mjs` is among them (exit 0). - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/`: Test Files 624 passed, Tests 6809 passed, 1 skipped (exit 0). - `pnpm --filter @object-ui/app-shell type-check`: exit 0. - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI) - [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 (the 维护者速读 block is the dispatch-ordered Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZMgz2QbrsLJv3RD41 --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZMgz2QbrsLJv3RD41)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff648d7 commit 0f1045b

3 files changed

Lines changed: 178 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the permission-set editor's header names the set by its translated label
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup → Permission Sets lists a set by the label the server serves for the viewer's locale (销售代表 under zh-CN), but opening it showed the authored label in the header ("Sales Representative"). The editor builds its draft from the layered read, which carries the authored text; the published list it already reads is the localized one. The header, on the Setup page and in the Studio Access pillar, now shows the served label while the draft's label is the published one. It is display only: a save sends the authored label unchanged, a set that was never published keeps its draft label, and an edit to the label shows the edit.
11+
12+
What to do: nothing.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26884 — the permission-set header names the set by the label
5+
* the published list serves. `GET /meta/permission` is localized at the REST
6+
* boundary (the server's permission-set translator), so under zh-CN Setup →
7+
* 权限集 lists the set as 销售代表; the layered read the editor's draft is
8+
* built from carries the authored text, and the header used to print that —
9+
* "Sales Representative" on a zh-CN page.
10+
*
11+
* The served label is display only: a save sends the authored label back
12+
* unchanged, a set the list does not hold keeps its draft label, and an edit
13+
* to the label shows the edit.
14+
*/
15+
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
18+
import { MemoryRouter } from 'react-router-dom';
19+
20+
interface Server {
21+
set: Record<string, unknown>;
22+
served: Array<Record<string, unknown>>;
23+
saved: Array<Record<string, unknown>>;
24+
}
25+
26+
function freshServer(served: Array<Record<string, unknown>>): Server {
27+
return {
28+
saved: [],
29+
served,
30+
set: {
31+
name: 'sales_rep',
32+
label: 'Sales Representative',
33+
objects: { a_account: { allowRead: true, allowCreate: true } },
34+
fields: {},
35+
},
36+
};
37+
}
38+
39+
function makeClient(server: Server) {
40+
return {
41+
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
42+
getDraft: async () => null,
43+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
44+
withPreviewDrafts() { return this; },
45+
list: async (type: string) => {
46+
if (type === 'object') return [{ item: { name: 'a_account' } }];
47+
if (type === 'permission') return server.served;
48+
return [];
49+
},
50+
get: async (type: string) => (type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null),
51+
save: async (_t: string, _n: string, payload: Record<string, unknown>) => {
52+
server.saved.push(payload);
53+
server.set = payload;
54+
return payload;
55+
},
56+
} as any;
57+
}
58+
59+
let clientImpl: any;
60+
61+
vi.mock('./useMetadata', () => ({
62+
useMetadataClient: () => clientImpl,
63+
useMetadataTypes: () => ({
64+
loading: false,
65+
error: null,
66+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
67+
}),
68+
}));
69+
70+
let localeFixture: 'zh-CN' | 'ja-JP' = 'zh-CN';
71+
vi.mock('./i18n', async (importOriginal) => ({
72+
...(await importOriginal<Record<string, unknown>>()),
73+
useMetadataLocale: () => localeFixture,
74+
}));
75+
76+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
77+
78+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
79+
80+
// The editor's `perm.*` rows live in `@object-ui/i18n`'s `studio` namespace,
81+
// whose `zh` catalogue is fetched on demand (objectstack#25922). Resident
82+
// before any case, so the zh chrome reads synchronously.
83+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
84+
await loadStudioCatalogue('zh');
85+
86+
afterEach(() => {
87+
cleanup();
88+
localeFixture = 'zh-CN';
89+
});
90+
91+
function renderSet() {
92+
return render(
93+
<MemoryRouter>
94+
<PermissionMatrixEditPage type="permission" name="sales_rep" packageId="app.a" />
95+
</MemoryRouter>,
96+
);
97+
}
98+
99+
describe('PermissionMatrixEditPage — header shows the served label (objectstack#26884)', () => {
100+
it('zh-CN: the header reads the served 销售代表, and a save keeps the authored label', async () => {
101+
const server = freshServer([{ name: 'sales_rep', label: '销售代表' }]);
102+
clientImpl = makeClient(server);
103+
renderSet();
104+
105+
expect(await screen.findByText('销售代表')).toBeInTheDocument();
106+
expect(screen.queryByText('Sales Representative')).toBeNull();
107+
108+
// Any matrix edit → the autosave sends the draft (objectui#11787).
109+
const row = screen.getByText('a_account').closest('tr')!;
110+
fireEvent.click(within(row).getByRole('button', { name: /None|清空/ }));
111+
await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
112+
expect(server.saved[0].label).toBe('Sales Representative');
113+
// The header still names the set by the served label after the save.
114+
expect(screen.getByText('销售代表')).toBeInTheDocument();
115+
});
116+
117+
it('ja-JP: the header reads the label the served row carries', async () => {
118+
localeFixture = 'ja-JP';
119+
clientImpl = makeClient(freshServer([{ name: 'sales_rep', label: '営業担当' }]));
120+
renderSet();
121+
122+
expect(await screen.findByText('営業担当')).toBeInTheDocument();
123+
expect(screen.queryByText('Sales Representative')).toBeNull();
124+
});
125+
126+
it('a set the published list does not hold keeps its draft label', async () => {
127+
clientImpl = makeClient(freshServer([{ name: 'other_set', label: '其他' }]));
128+
renderSet();
129+
130+
expect(await screen.findByText('Sales Representative')).toBeInTheDocument();
131+
});
132+
133+
it('an edit to the label shows the edit, not the served label', async () => {
134+
clientImpl = makeClient(freshServer([{ name: 'sales_rep', label: '销售代表' }]));
135+
renderSet();
136+
137+
fireEvent.click(await screen.findByRole('button', { name: /销售代表/ }));
138+
fireEvent.change(screen.getByDisplayValue('Sales Representative'), { target: { value: 'Field Sales' } });
139+
expect(screen.getByText('Field Sales')).toBeInTheDocument();
140+
expect(screen.queryByText('销售代表')).toBeNull();
141+
});
142+
});

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
780780
forget: forgetSetVersion,
781781
dialog: draftConflictDialog,
782782
} = useDraftSaveGuard(client, reloadSet);
783+
// The header's label as the published list serves it: that read is
784+
// localized at the REST boundary (the server's permission-set translator),
785+
// while the layered read the draft is built from carries the authored text.
786+
// Display only — never written into `draft`, so a save keeps the authored
787+
// label. `authored` is the published label it stands for; once the draft's
788+
// label differs (an edit, a pending draft) the header shows the draft's.
789+
const [servedLabel, setServedLabel] = React.useState<{ authored?: string; served: string } | null>(null);
783790
const [objects, setObjects] = React.useState<ObjectSummary[]>([]);
784791
const [fieldsByObject, setFieldsByObject] = React.useState<Record<string, FieldSummary[]>>({});
785792
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
@@ -833,10 +840,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
833840
// objectui#11799 — the same published read, held for the load below: a set
834841
// it does not list has never been published. The load runs after this
835842
// effect, so it reads this request rather than sending one of its own.
836-
const publishedSetsRef = React.useRef<Promise<Array<{ name?: string }>> | null>(null);
843+
const publishedSetsRef = React.useRef<Promise<Array<{ name?: string; label?: unknown }>> | null>(null);
837844
React.useEffect(() => {
838845
let cancelled = false;
839-
const published = client.list<{ name?: string }>('permission', {});
846+
const published = client.list<{ name?: string; label?: unknown }>('permission', {});
840847
publishedSetsRef.current = published;
841848
published
842849
.then((rows) => {
@@ -869,6 +876,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
869876
// that copy is the set being opened (the routed hop to it).
870877
setSaveRefusedAsLocked(null);
871878
setCloned((c) => (c && c.name === name ? c : null));
879+
setServedLabel(null);
872880
(async () => {
873881
try {
874882
// ADR-0086 P2 (D6): under the package door a set is draft/published
@@ -984,6 +992,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
984992
// live write is not pinned.
985993
if (pendingRead && packageId) recordSetRead(type, name, packageId, pendingRead.pin);
986994
else forgetSetVersion();
995+
// A draft-only set has no served row and keeps its draft label.
996+
const servedRow = publishedSets
997+
? (await publishedSets.catch(() => null))?.find((r) => r?.name === name)
998+
: undefined;
999+
if (cancelled) return;
1000+
const authoredLabel = (lay?.effective ?? lay?.code)?.label;
1001+
if (typeof servedRow?.label === 'string' && servedRow.label && servedRow.label !== authoredLabel) {
1002+
setServedLabel({ authored: authoredLabel, served: servedRow.label });
1003+
}
9871004
} catch (err: any) {
9881005
setError(err?.message ?? String(err));
9891006
} finally {
@@ -1084,6 +1101,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
10841101
).length;
10851102
const saveHeld = celErrorTotal > 0 || rlsUnnamedCount > 0;
10861103

1104+
const headerLabel =
1105+
servedLabel && draft.label === servedLabel.authored ? servedLabel.served : draft.label;
1106+
10871107
// Dirty detection — cheap JSON snapshot comparison against the last
10881108
// loaded/saved baseline (same approach as ResourceEditPage). Every mutation
10891109
// funnels through setDraft (matrix checkboxes, header inputs, capabilities,
@@ -2061,7 +2081,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
20612081
testId="access-item-header"
20622082
leading={chrome.leading}
20632083
icon={Shield}
2064-
title={draft.label || name}
2084+
title={headerLabel || name}
20652085
apiName={name}
20662086
chips={
20672087
<>
@@ -2304,7 +2324,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
23042324
)}
23052325
{/* The api-name already sits in the PageShell breadcrumb (font-mono),
23062326
so the summary carries the human label — no need to repeat it. */}
2307-
<span className="font-medium truncate">{draft.label || draft.name}</span>
2327+
<span className="font-medium truncate">{headerLabel || draft.name}</span>
23082328
{provenanceBadgesEl}
23092329
{writable && (
23102330
<span className="text-xs text-muted-foreground shrink-0">{t('perm.basics.editHint')}</span>

0 commit comments

Comments
 (0)