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
15 changes: 15 additions & 0 deletions .changeset/29729-studio-one-word-per-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

feat(i18n, app-shell): Studio and Setup give a switched-on, switched-off or customized item one word, and the permission matrix reads its columns in Chinese

Clause-②: no
Metadata-protocol: none

- Switched on and switched off read 启用 / 停用 in zh-CN wherever Studio and Setup name the state: the packaged-flow ledger switch said 开启 / 关闭, and Setup's packaged actions said 已关闭 in the column and 开启 / 关闭 in the switch-off confirm and its toasts. In en, Studio's flow run status says "Switched on" / "Switched off" (it said On / Off and Enabled / Disabled), the word the landing and the ledger switch use; the ledger switch reads "Switched on/off for this deployment".
- The Customize act reads 定制 in zh-CN (the button, its titles, the restore confirmations, the landing lines quoting the button, the form-editor notes), matching 已定制 and 可定制. 自定义 stays where it means user-defined (a custom value, key, renderer, page or code).
- A permission set no package ships reads 新增 / "Added" (it read 自定义 / "Custom"): it is an item this deployment added, not a customized one.
- The permission matrix's object columns read the action words in zh-CN (创建 … 查看全部, 修改全部) through new `perm.col.*` rows; en keeps C … MA and the legend line that spells them out, which zh no longer needs. Setup's per-object chip reads through `perm.owd.chip` (zh 记录共享模型:私有), not a hard-coded `OWD ` prefix.
- Fixed: in zh-CN, Setup's matrix legend and every column tooltip read English when the matrix rendered before the Chinese Studio strings had loaded. The columns are rebuilt once they land.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
import { en as CONSOLE_EN, zh as CONSOLE_ZH } from '@object-ui/i18n/locales';
import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost';
import type { RichMetadataTypeEntry } from './useMetadata';

Expand Down Expand Up @@ -200,8 +201,8 @@ const VOCABULARY: ReadonlyArray<{ state: string; key: string; en: string; zh: st
{ state: 'can be added', key: 'engine.studio.customize.addable', en: 'Can be added', zh: '可新增' },
{ state: 'customized', key: 'engine.customizationMark.customized', en: 'Customized ({baseName})', zh: '已定制({baseName})' },
{ state: 'added', key: 'engine.customizationMark.added', en: 'Added', zh: '新增' },
// en still reads "Off" here; its move to "Switched off" is objectstack#29729.
{ state: 'switched off', key: 'engine.studio.auto.off', en: 'Off', zh: '已停用' },
{ state: 'switched off', key: 'engine.studio.auto.off', en: 'Switched off', zh: '已停用' },
{ state: 'switched on', key: 'engine.studio.auto.on', en: 'Switched on', zh: '已启用' },
];

/** Every other key that draws one of the states, and the word it must carry. */
Expand Down Expand Up @@ -288,3 +289,108 @@ describe('Access: sharing settings in zh carry no English doublet or bare OWD (o
expect(EN['engine.studio.access.colOwd']).toBe('OWD');
});
});

/**
* objectstack#29729 — the words #29705 left with a second spelling outside its
* diff. A switched-on or switched-off flow or action reads 启用 / 停用 in zh and
* "Switched on" / "Switched off" in en wherever Studio and Setup name that
* state; the Customize act reads 定制 (its state is 已定制, its offer 可定制);
* a permission set no package ships reads 新增 / "Added", not 自定义.
*/
describe('one word per state, the second pass (objectstack#29729)', () => {
const SWITCHED_ON = ['engine.studio.auto.on', 'engine.studio.auto.enabled', 'engine.studio.auto.ledger.on', 'engine.studio.auto.ledger.notRunning', 'engine.studio.auto.ledger.switchedOn'];
const SWITCHED_OFF = ['engine.studio.auto.off', 'engine.studio.auto.offTitle', 'engine.studio.auto.disabled', 'engine.studio.auto.disabledToast', 'engine.studio.auto.ledger.off', 'engine.studio.auto.ledger.switchedOff'];
const LEDGER_PROSE = ['engine.studio.auto.ledger.hint', 'engine.studio.auto.ledger.walled'];

it.each(SWITCHED_ON)('%s: switched on is 启用 / "Switched on" (or "switched on")', (key) => {
expect(ZH[key]).toContain('启用');
expect(EN[key]).toMatch(/switched on/i);
});

it.each(SWITCHED_OFF)('%s: switched off is 停用 / "Switched off" (or "switched off")', (key) => {
expect(ZH[key]).toContain('停用');
expect(EN[key]).toMatch(/switched off/i);
});

it.each([...SWITCHED_ON, ...SWITCHED_OFF, ...LEDGER_PROSE])('%s: no 开启 / 关闭 in zh, no On/Off/Enabled/Disabled in en', (key) => {
expect(ZH[key]).not.toMatch(/开启|关闭/u);
// The old state labels: a bare word, or the word before "for this deployment" / " — …".
expect(EN[key]).not.toMatch(/^(On|Off|Enabled|Disabled)( for | — |,|$)/u);
});

it("Setup's packaged actions name the same state the same way", () => {
const zh = (CONSOLE_ZH as any).packagedAutomation;
expect(zh.on).toBe('已启用');
expect(zh.off).toBe('已停用');
expect(zh.actionsSubtitle).toContain('停用后');
expect(zh.actionsSubtitle).not.toMatch(/关闭/u);
// CONTROL: en keeps its own pair for this page.
expect((CONSOLE_EN as any).packagedAutomation.off).toBe('Off');
});

// The switch-off confirm, its toasts and the cell all name one state: a
// user who switches an action off must not read 关闭 in the dialog, 已关闭
// in the toast and 已停用 in the column.
it.each(Object.keys((CONSOLE_ZH as any).packagedAutomation))(
"Setup's packaged actions zh row %s: no 开启 / 关闭",
(key) => {
expect((CONSOLE_ZH as any).packagedAutomation[key]).not.toMatch(/开启|关闭/u);
},
);

it("Setup's packaged actions: the confirm, the toasts and the cell share 启用 / 停用", () => {
const zh = (CONSOLE_ZH as any).packagedAutomation;
expect(zh.switchedOn).toBe('「{{label}}」已启用。');
expect(zh.switchedOff).toBe('「{{label}}」已在本部署中停用。');
expect(zh.actionOffTitle).toBe('停用「{{label}}」?');
expect(zh.actionOffBody).toMatch(/^在重新启用之前/u);
expect(zh.actionOffConfirm).toBe('停用');
});

const CUSTOMIZE_ACT = [
'engine.studio.customize.action',
'engine.studio.customize.actionTitle',
'engine.studio.customize.walled',
'engine.studio.customize.noVersion',
'engine.studio.customize.draftFooter',
'engine.studio.customize.restoreBody',
'engine.studio.customize.restoreFlowBody',
'engine.edit.resetConfirmBody',
'engine.studio.landing.description',
'engine.studio.landing.readonlyHint',
'engine.studio.area.formEditor.oneLocale',
'engine.studio.area.formEditor.translationWhole',
];

it.each(CUSTOMIZE_ACT)('%s: the Customize act reads 定制, never 自定义', (key) => {
expect(ZH[key]).toContain('定制');
expect(ZH[key]).not.toContain('自定义');
});

it('the Customize button and the landing lines that quote it agree', () => {
expect(ZH['engine.studio.customize.action']).toBe('定制');
expect(ZH['engine.studio.landing.description']).toContain('「定制」');
expect(ZH['engine.studio.landing.readonlyHint']).toContain('「定制」');
});

// CONTROL: 自定义 meaning "user-defined" (a custom value, key, renderer, page
// or code) is another sense and keeps its word.
it.each([
'engine.inspector.action.mode.custom',
'engine.inspector.flowNode.advancedHint',
'engine.inspector.flowEdge.branchCustom',
'engine.inspector.combo.noMatch',
'engine.form.fallbackJson',
'engine.studio.if.kindDesc.page',
'engine.flowNode.script.hint',
])('CONTROL — %s keeps 自定义 (user-defined, not the Customize act)', (key) => {
expect(ZH[key]).toContain('自定义');
});

it('a permission set no package ships reads the added word, not 自定义 / "Custom"', () => {
expect(ZH['perm.badge.custom']).toBe(ZH['engine.customizationMark.added']);
expect(EN['perm.badge.custom']).toBe(EN['engine.customizationMark.added']);
// CONTROL: the shipped answer is untouched.
expect(EN['perm.badge.package']).toBe('Package');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* The provenance badge in the permission set editor has two answers: "Package"
* (a package ships the set: the package door's `packageId`, or the layered
* envelope's code artifact) and "Custom" (authored in this environment).
* envelope's code artifact) and "Added" (authored in this environment).
*
* objectui#12753 — spec 18 retired the authored `managedBy` on a permission set
* (objectstack#23891). The badge used to read it for a third answer,
Expand Down Expand Up @@ -101,19 +101,19 @@ describe.each<Site>(['page', 'studio'])('the provenance badge (%s site)', (site)
expect(badgeText()).toBe('Package');
});

it('a set with neither reads "Custom"', async () => {
it('a set with neither reads "Added"', async () => {
await renderWith(site, {});
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
});

it('a stray managedBy "platform" on an old stored row does not read "Platform"', async () => {
await renderWith(site, { managedBy: 'platform' });
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
expect(screen.queryByText('Platform')).not.toBeInTheDocument();
});

it('a stray managedBy "package" does not make an environment-authored set read "Package"', async () => {
await renderWith(site, { managedBy: 'package' });
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#29729 — the permission matrix's object columns in zh-CN.
*
* Three things, over the real editor in both renderings (Setup's environment
* scope and the Studio Access pillar):
*
* - the column headers spell the action word in zh (创建 … 修改 全部) and keep
* the abbreviations in en (C … MA); the legend line that spells the
* abbreviations out is drawn only where the headers are abbreviations;
* - the headers, their tooltips and the legend are rebuilt once the lazily
* fetched zh `studio` catalogue lands. They were memoised on the locale
* alone, so a matrix whose first render ran before the catalogue was
* resident kept that render's `en` answer: Setup's legend line and every
* header tooltip read English in zh-CN;
* - Setup's per-object sharing-model chip reads through the catalogue
* (`perm.owd.chip`), not a hard-coded `OWD ` prefix.
*
* ⛔ No module-scope `loadStudioCatalogue('zh')` here, unlike the sibling
* suites: the defect only exists while the catalogue is NOT yet resident, so
* the zh case asserts that precondition first. The `dom` project runs each
* file isolated, so no other file can have loaded it.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider, createI18n } from '@object-ui/i18n';
import { isStudioCatalogueLoaded } from '@object-ui/i18n/namespaces/studio';

const SET = {
name: 'sales_rep',
label: 'Sales rep',
objects: { a_account: { allowRead: true } },
fields: {},
} as Record<string, unknown>;

function makeClient() {
return {
layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
withPreviewDrafts() {
return this;
},
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
} as any;
}

let clientImpl: any;

vi.mock('./useMetadata', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true, allowRuntimeCreate: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(() => {
cleanup();
});

type Rendering = 'setup' | 'studio';

function mount(language: 'zh' | 'en', rendering: Rendering) {
clientImpl = makeClient();
const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false, resources: { [language]: {} } });
render(
<I18nProvider instance={i18n}>
<MemoryRouter>
<PermissionMatrixEditPage
type="permission"
name="sales_rep"
{...(rendering === 'studio'
? { packageId: 'app.a', studio: { tab: 'objects', onTabChange: () => {} } }
: {})}
/>
</MemoryRouter>
</I18nProvider>,
);
}

const ZH_HEADERS = ['创建', '读取', '编辑', '删除', '导出', '转移', '查看 全部', '修改 全部'];
const EN_HEADERS = ['C', 'R', 'U', 'D', 'E', 'Tr', 'VA', 'MA'];

/** The eight object-action headers, in column order, with their tooltips. */
function actionHeaders(): Array<{ text: string; title: string | null }> {
const pane = screen.getByTestId('perm-matrix-pane');
return within(pane)
.getAllByRole('columnheader')
.filter((th) => th.querySelector('span.break-keep'))
.map((th) => ({ text: (th.textContent ?? '').trim(), title: th.getAttribute('title') }));
}

describe('the matrix column headers in zh, after the lazy catalogue lands (objectstack#29729)', () => {
it.each<Rendering>(['setup', 'studio'])(
'%s: a matrix mounted before the zh catalogue is resident spells the action words, with zh tooltips and no legend line',
async (rendering) => {
if (rendering === 'setup') {
expect(isStudioCatalogueLoaded('zh'), 'precondition: the zh catalogue is not resident yet').toBe(false);
}
mount('zh', rendering);
await screen.findByText('创建', { selector: 'th span' }, { timeout: 8000 });
const headers = actionHeaders();
expect(headers.map((h) => h.text)).toEqual(ZH_HEADERS);
expect(headers[0].title).toBe('创建');
expect(headers[7].title).toBe('修改所有记录(绕过共享规则)');
// The headers ARE the words: a legend line would only repeat them.
expect(screen.queryByTestId('perm-column-legend')).toBeNull();
},
);
});

describe('CONTROL — en keeps the abbreviations and their legend line (objectstack#29729)', () => {
it.each<Rendering>(['setup', 'studio'])('%s: C … MA, spelled out in the legend line', async (rendering) => {
mount('en', rendering);
await screen.findByText('Account');
const headers = actionHeaders();
expect(headers.map((h) => h.text)).toEqual(EN_HEADERS);
expect(headers[0].title).toBe('Create');
const legend = screen.getByTestId('perm-column-legend');
expect(legend).toHaveTextContent('VA');
expect(legend).toHaveTextContent(rendering === 'setup' ? 'View All Records' : 'View all');
});
});

describe("Setup's sharing-model chip reads through the catalogue (objectstack#29729)", () => {
it('zh names the sharing model in Chinese, with no bare OWD', async () => {
mount('zh', 'setup');
const chip = await screen.findByText(/^记录共享模型:/u, undefined, { timeout: 8000 });
expect(chip.textContent).toBe('记录共享模型:私有(默认)');
expect(chip.textContent).not.toMatch(/OWD/u);
});

it('CONTROL — en keeps "OWD {value}"', async () => {
mount('en', 'setup');
expect(await screen.findByText('OWD Private (default)')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

/**
* objectui#7611 (F2) — the identity strip's provenance badge reads the layered
* envelope, so a set a code package SHIPS reads "Package", not "Custom".
* envelope, so a set a code package SHIPS reads "Package", not "Added".
*
* ## The defect
*
* The badge read only `draft.managedBy` and the `packageId` prop. The registry
* serves no `managedBy` on a permission set, and the Setup catalog (the
* environment scope) passes no `packageId`, so every packaged set opened there
* read "Custom" — beside the artifact lock that says a code package provides
* read "Added" — beside the artifact lock that says a code package provides
* it. The editor already holds the fact that answers the question: the layered
* envelope's `code` layer and its ADR-0010 `provenance`, read by
* `isArtifactBackedLayer` into `codeIsArtifact` for the artifact gate. The badge
Expand All @@ -21,7 +21,7 @@
* exclusions (`PermissionMatrixEditor.artifactTier.test.tsx` pins them for the
* lock): a set with no code layer, an organization-authored set read back with
* a code-looking `_packageId`, and the `sys_metadata` save-path sentinel. Each
* must still read "Custom", so the fix cannot be "always Package".
* must still read "Added", so the fix cannot be "always Package".
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -98,7 +98,7 @@ async function renderMatrix() {
const badgeText = () => screen.getByTestId('perm-provenance-badge').textContent;

describe('the provenance badge reads the layered envelope (objectui#7611, F2)', () => {
it('a set a code package ships reads "Package" beside its lock, not "Custom"', async () => {
it('a set a code package ships reads "Package" beside its lock, not "Added"', async () => {
codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' };
provenance = 'package';
await renderMatrix();
Expand All @@ -107,21 +107,21 @@ describe('the provenance badge reads the layered envelope (objectui#7611, F2)',
expect(screen.getByText(/^Read-only \(/)).toBeInTheDocument();
});

it('a set with no code layer (authored in this environment) still reads "Custom"', async () => {
it('a set with no code layer (authored in this environment) still reads "Added"', async () => {
await renderMatrix();
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
});

it('an organization-authored set read back with a code-looking _packageId still reads "Custom"', async () => {
it('an organization-authored set read back with a code-looking _packageId still reads "Added"', async () => {
codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' };
provenance = 'org';
await renderMatrix();
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
});

it('the sys_metadata save-path sentinel still reads "Custom"', async () => {
it('the sys_metadata save-path sentinel still reads "Added"', async () => {
codeLayer = { _packageId: 'sys_metadata', name: 'showcase_contributor' };
await renderMatrix();
expect(badgeText()).toBe('Custom');
expect(badgeText()).toBe('Added');
});
});
Loading
Loading