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
12 changes: 12 additions & 0 deletions .changeset/26884-permission-set-header-served-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the permission-set editor's header names the set by its translated label

Clause-②: no
Metadata-protocol: none

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.

What to do: nothing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26884 — the permission-set header names the set by the label
* the published list serves. `GET /meta/permission` is localized at the REST
* boundary (the server's permission-set translator), so under zh-CN Setup →
* 权限集 lists the set as 销售代表; the layered read the editor's draft is
* built from carries the authored text, and the header used to print that —
* "Sales Representative" on a zh-CN page.
*
* The served label is display only: a save sends the authored label back
* unchanged, a set the list does not hold keeps its draft label, and an edit
* to the label shows the edit.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

interface Server {
set: Record<string, unknown>;
served: Array<Record<string, unknown>>;
saved: Array<Record<string, unknown>>;
}

function freshServer(served: Array<Record<string, unknown>>): Server {
return {
saved: [],
served,
set: {
name: 'sales_rep',
label: 'Sales Representative',
objects: { a_account: { allowRead: true, allowCreate: true } },
fields: {},
},
};
}

function makeClient(server: Server) {
return {
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => {
if (type === 'object') return [{ item: { name: 'a_account' } }];
if (type === 'permission') return server.served;
return [];
},
get: async (type: string) => (type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => {
server.saved.push(payload);
server.set = payload;
return payload;
},
} as any;
}

let clientImpl: any;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
}),
}));

let localeFixture: 'zh-CN' | 'ja-JP' = 'zh-CN';
vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => localeFixture,
}));

vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

// The editor's `perm.*` rows live in `@object-ui/i18n`'s `studio` namespace,
// whose `zh` catalogue is fetched on demand (objectstack#25922). Resident
// before any case, so the zh chrome reads synchronously.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

afterEach(() => {
cleanup();
localeFixture = 'zh-CN';
});

function renderSet() {
return render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_rep" packageId="app.a" />
</MemoryRouter>,
);
}

describe('PermissionMatrixEditPage — header shows the served label (objectstack#26884)', () => {
it('zh-CN: the header reads the served 销售代表, and a save keeps the authored label', async () => {
const server = freshServer([{ name: 'sales_rep', label: '销售代表' }]);
clientImpl = makeClient(server);
renderSet();

expect(await screen.findByText('销售代表')).toBeInTheDocument();
expect(screen.queryByText('Sales Representative')).toBeNull();

// Any matrix edit → the autosave sends the draft (objectui#11787).
const row = screen.getByText('a_account').closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: /None|清空/ }));
await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
expect(server.saved[0].label).toBe('Sales Representative');
// The header still names the set by the served label after the save.
expect(screen.getByText('销售代表')).toBeInTheDocument();
});

it('ja-JP: the header reads the label the served row carries', async () => {
localeFixture = 'ja-JP';
clientImpl = makeClient(freshServer([{ name: 'sales_rep', label: '営業担当' }]));
renderSet();

expect(await screen.findByText('営業担当')).toBeInTheDocument();
expect(screen.queryByText('Sales Representative')).toBeNull();
});

it('a set the published list does not hold keeps its draft label', async () => {
clientImpl = makeClient(freshServer([{ name: 'other_set', label: '其他' }]));
renderSet();

expect(await screen.findByText('Sales Representative')).toBeInTheDocument();
});

it('an edit to the label shows the edit, not the served label', async () => {
clientImpl = makeClient(freshServer([{ name: 'sales_rep', label: '销售代表' }]));
renderSet();

fireEvent.click(await screen.findByRole('button', { name: /销售代表/ }));
fireEvent.change(screen.getByDisplayValue('Sales Representative'), { target: { value: 'Field Sales' } });
expect(screen.getByText('Field Sales')).toBeInTheDocument();
expect(screen.queryByText('销售代表')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
forget: forgetSetVersion,
dialog: draftConflictDialog,
} = useDraftSaveGuard(client, reloadSet);
// The header's label as the published list serves it: that read is
// localized at the REST boundary (the server's permission-set translator),
// while the layered read the draft is built from carries the authored text.
// Display only — never written into `draft`, so a save keeps the authored
// label. `authored` is the published label it stands for; once the draft's
// label differs (an edit, a pending draft) the header shows the draft's.
const [servedLabel, setServedLabel] = React.useState<{ authored?: string; served: string } | null>(null);
const [objects, setObjects] = React.useState<ObjectSummary[]>([]);
const [fieldsByObject, setFieldsByObject] = React.useState<Record<string, FieldSummary[]>>({});
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
Expand Down Expand Up @@ -833,10 +840,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// objectui#11799 — the same published read, held for the load below: a set
// it does not list has never been published. The load runs after this
// effect, so it reads this request rather than sending one of its own.
const publishedSetsRef = React.useRef<Promise<Array<{ name?: string }>> | null>(null);
const publishedSetsRef = React.useRef<Promise<Array<{ name?: string; label?: unknown }>> | null>(null);
React.useEffect(() => {
let cancelled = false;
const published = client.list<{ name?: string }>('permission', {});
const published = client.list<{ name?: string; label?: unknown }>('permission', {});
publishedSetsRef.current = published;
published
.then((rows) => {
Expand Down Expand Up @@ -869,6 +876,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// that copy is the set being opened (the routed hop to it).
setSaveRefusedAsLocked(null);
setCloned((c) => (c && c.name === name ? c : null));
setServedLabel(null);
(async () => {
try {
// ADR-0086 P2 (D6): under the package door a set is draft/published
Expand Down Expand Up @@ -984,6 +992,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// live write is not pinned.
if (pendingRead && packageId) recordSetRead(type, name, packageId, pendingRead.pin);
else forgetSetVersion();
// A draft-only set has no served row and keeps its draft label.
const servedRow = publishedSets
? (await publishedSets.catch(() => null))?.find((r) => r?.name === name)
: undefined;
if (cancelled) return;
const authoredLabel = (lay?.effective ?? lay?.code)?.label;
if (typeof servedRow?.label === 'string' && servedRow.label && servedRow.label !== authoredLabel) {
setServedLabel({ authored: authoredLabel, served: servedRow.label });
}
} catch (err: any) {
setError(err?.message ?? String(err));
} finally {
Expand Down Expand Up @@ -1084,6 +1101,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
).length;
const saveHeld = celErrorTotal > 0 || rlsUnnamedCount > 0;

const headerLabel =
servedLabel && draft.label === servedLabel.authored ? servedLabel.served : draft.label;

// Dirty detection — cheap JSON snapshot comparison against the last
// loaded/saved baseline (same approach as ResourceEditPage). Every mutation
// funnels through setDraft (matrix checkboxes, header inputs, capabilities,
Expand Down Expand Up @@ -2061,7 +2081,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
testId="access-item-header"
leading={chrome.leading}
icon={Shield}
title={draft.label || name}
title={headerLabel || name}
apiName={name}
chips={
<>
Expand Down Expand Up @@ -2304,7 +2324,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
)}
{/* The api-name already sits in the PageShell breadcrumb (font-mono),
so the summary carries the human label — no need to repeat it. */}
<span className="font-medium truncate">{draft.label || draft.name}</span>
<span className="font-medium truncate">{headerLabel || draft.name}</span>
{provenanceBadgesEl}
{writable && (
<span className="text-xs text-muted-foreground shrink-0">{t('perm.basics.editHint')}</span>
Expand Down
Loading