diff --git a/.changeset/29729-studio-one-word-per-state.md b/.changeset/29729-studio-one-word-per-state.md new file mode 100644 index 00000000000..f90a6526c67 --- /dev/null +++ b/.changeset/29729-studio-one-word-per-state.md @@ -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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx index 9925d18b8a4..cc6c5f1bfe6 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.stateVocabulary-29705.test.tsx @@ -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'; @@ -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. */ @@ -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'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.badge.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.badge.test.tsx index 98de4589744..1daade0fbd2 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.badge.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.badge.test.tsx @@ -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, @@ -101,19 +101,19 @@ describe.each(['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'); }); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.columnHeaders-29729.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.columnHeaders-29729.test.tsx new file mode 100644 index 00000000000..9ad71e2d2e8 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.columnHeaders-29729.test.tsx @@ -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; + +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) => payload, + } as any; +} + +let clientImpl: any; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + 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( + + + {} } } + : {})} + /> + + , + ); +} + +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(['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(['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(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx index 681da0d7bf6..cabab7d3d37 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx @@ -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 @@ -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'; @@ -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(); @@ -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'); }); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index e001bbd7b7f..44d13ec9847 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -114,6 +114,7 @@ import { translateValidationMessage, useMetadataLocale, } from './i18n.js'; +import { isStudioCatalogueLoaded } from '@object-ui/i18n/namespaces/studio'; import { MatrixScrollPane } from './MatrixScrollPane.js'; import { PermissionAdvancedFacets, rlsClauseFindingCount } from './PermissionAdvancedFacets.js'; import { CelPredicateField } from './CelPredicateField.js'; @@ -465,27 +466,47 @@ function ScopePicker({ * (`@objectstack/spec` `security/high-privilege.ts`, the opt-in export axis), * so only its own checkbox grants or clears it — "None" excepted, which * empties the whole row. + * + * objectstack#29729 — `short` is the column header, per locale (`perm.col.*`): + * en abbreviates (C, R, U … VA, MA: the words do not fit the column), zh + * spells the action word, so a zh header needs no legend to be read. */ function getObjectActions( locale: string, ): Array<{ key: ObjectPermFlag; short: string; tip: string; optIn?: true }> { return [ - { key: 'allowCreate', short: 'C', tip: translate('perm.action.create', locale) }, - { key: 'allowRead', short: 'R', tip: translate('perm.action.read', locale) }, - { key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) }, - { key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) }, - { key: 'allowExport', short: 'E', tip: translate('perm.action.export', locale), optIn: true }, - { key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) }, + { key: 'allowCreate', short: translate('perm.col.create', locale), tip: translate('perm.action.create', locale) }, + { key: 'allowRead', short: translate('perm.col.read', locale), tip: translate('perm.action.read', locale) }, + { key: 'allowEdit', short: translate('perm.col.edit', locale), tip: translate('perm.action.edit', locale) }, + { key: 'allowDelete', short: translate('perm.col.delete', locale), tip: translate('perm.action.delete', locale) }, + { + key: 'allowExport', + short: translate('perm.col.export', locale), + tip: translate('perm.action.export', locale), + optIn: true, + }, + { key: 'allowTransfer', short: translate('perm.col.transfer', locale), tip: translate('perm.action.transfer', locale) }, // No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired // (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice` // for the full account and the M2 return path on objectstack#1883). They // gated ObjectQL operations that have never existed, so every tick was a // grant no runtime read. `allowTransfer` is enforced upstream and stays. - { key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) }, - { key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) }, + { key: 'viewAllRecords', short: translate('perm.col.viewAll', locale), tip: translate('perm.action.viewAll', locale) }, + { key: 'modifyAllRecords', short: translate('perm.col.modifyAll', locale), tip: translate('perm.action.modifyAll', locale) }, ]; } +/** + * objectstack#29729 — whether the column headers are abbreviations a reader + * needs the legend line for (en's C, R, U …), or the action words themselves + * (zh), which the legend would only repeat. + */ +function headersAbbreviate(actions: ReturnType, locale: string): boolean { + return actions.some( + (a) => a.short.replace(/\s+/g, '') !== translate(STUDIO_LEGEND_KEYS[a.key] ?? '', locale).replace(/\s+/g, ''), + ); +} + /** * objectui#12456 — the five tabs of the Studio Access pillar's permission-set * page (epic objectui#12446, decision C): each holds one of the sections the @@ -777,7 +798,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, const lockedByArtifactTier = artifactTierApplies && !artifactArmOpen && typeTierOpen && canAuthor && !walled; const locale = useMetadataLocale(); const t = React.useCallback((k: string) => translate(k, locale), [locale]); - const OBJECT_ACTIONS = React.useMemo(() => getObjectActions(locale), [locale]); + // objectstack#29729 — the headers, tooltips and legend read the lazily + // fetched `studio` catalogue. Keyed on the locale alone, this kept the `en` + // answer of a first render that ran before the zh catalogue was resident: + // Setup's legend line and every header tooltip read English in zh-CN. + const catalogueResident = isStudioCatalogueLoaded(locale); + const OBJECT_ACTIONS = React.useMemo( + () => getObjectActions(locale), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the residency flag is the dependency: the columns read the catalogue through `translate` + [locale, catalogueResident], + ); const [draft, setDraft] = React.useState({ name, @@ -2499,13 +2529,18 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, `title` tooltip per column, but a hover-only affordance on unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to miss. Spell them out once, up front. */} -
- {OBJECT_ACTIONS.map((a) => ( - - {a.short} {a.tip} - - ))} -
+ {headersAbbreviate(OBJECT_ACTIONS, locale) && ( +
+ {OBJECT_ACTIONS.map((a) => ( + + {a.short} {a.tip} + + ))} +
+ )} {objectInheritedLegendEl} {/* Matrix */} @@ -2637,6 +2672,7 @@ function PermissionTable({ onFieldWhenLint, onOpenOwd, }: PermissionTableProps) { + const locale = useMetadataLocale(); return ( // objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px; // a min-width makes the enclosing overflow-auto container scroll instead of @@ -2655,7 +2691,7 @@ function PermissionTable({ className="px-2 py-2 font-medium text-center w-14" title={a.tip} > - {a.short} + {a.short} ))} {SCOPE_AXES.map((s) => ( @@ -2743,7 +2779,7 @@ function PermissionTable({ } : {})} > - {`OWD ${o.owd ? owdLabel(t, o.owd) : t('perm.owd.defaultPrivate')}`} + {tFormat('perm.owd.chip', locale, { value: o.owd ? owdLabel(t, o.owd) : t('perm.owd.defaultPrivate') })} {objectActions.map((a) => ( @@ -2899,14 +2935,19 @@ function StudioObjectsToolbar({ {t('perm.filter.onlyGranted')} -
- {legend.map((a) => ( - - {a.short}{' '} - {t(STUDIO_LEGEND_KEYS[a.key] ?? '') || a.tip} - - ))} -
+ {headersAbbreviate(legend, locale) && ( +
+ {legend.map((a) => ( + + {a.short}{' '} + {t(STUDIO_LEGEND_KEYS[a.key] ?? '') || a.tip} + + ))} +
+ )} ); } @@ -2953,7 +2994,7 @@ function StudioObjectMatrix({ {t('engine.studio.access.colOwd')} {objectActions.map((a, i) => ( - {a.short} + {a.short} ))} {SCOPE_AXES.map((s, i) => ( diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx index 2f6b8215e0c..b43f014ab6b 100644 --- a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx @@ -287,7 +287,11 @@ async function expectLedgerSwitchAlone(): Promise { const ledger = await within(header()).findByTestId('auto-ledger-switch', undefined, { timeout: 8000 }); expect(ledger).toHaveAttribute('aria-checked', 'true'); expect(within(header()).getAllByRole('switch')).toEqual([ledger]); - expect(header()).not.toHaveTextContent(t('engine.studio.auto.enabled', 'en')); + // The definition switch's own mark is its enable/disable title. Its word is + // no longer one: since objectstack#29729 a switched-on flow reads "Switched + // on" in the Status pill and on the ledger switch too (one word per state). + const definitionTitle = t('engine.studio.auto.disableTitle', 'en'); + expect([...header().querySelectorAll('[title]')].some((el) => el.getAttribute('title')?.includes(definitionTitle))).toBe(false); } describe('Customize a read-only base’s flow into its pair (objectstack#28570)', () => { diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.ledgerSwitch-28148.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.ledgerSwitch-28148.test.tsx index 91594b5c828..51a6dcaf43b 100644 --- a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.ledgerSwitch-28148.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.ledgerSwitch-28148.test.tsx @@ -172,7 +172,7 @@ describe('Automations — the ledger switch on a read-only package (ADR-0146 D4) const sw = await ledgerSwitch(); // The definition reads `obsolete`; the ledger reads enabled. expect(sw).toHaveAttribute('aria-checked', 'true'); - expect(sw).toHaveTextContent('On for this deployment'); + expect(sw).toHaveTextContent('Switched on for this deployment'); expect(sw).toBeEnabled(); const statusReads = calls('/automation/_status').length; @@ -182,7 +182,7 @@ describe('Automations — the ledger switch on a read-only package (ADR-0146 D4) expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual({ enabled: false }); await waitFor(() => expect(screen.getByTestId('auto-ledger-switch')).toHaveAttribute('aria-checked', 'false')); - expect(screen.getByTestId('auto-ledger-switch')).toHaveTextContent('Off for this deployment'); + expect(screen.getByTestId('auto-ledger-switch')).toHaveTextContent('Switched off for this deployment'); // The runtime is read again: the answer carries the flag alone. await waitFor(() => expect(calls('/automation/_status').length).toBeGreaterThan(statusReads)); expect(mockClient.save).not.toHaveBeenCalled(); @@ -196,10 +196,10 @@ describe('Automations — the ledger switch on a read-only package (ADR-0146 D4) const sw = await ledgerSwitch(); expect(sw).toHaveAttribute('aria-checked', 'false'); expect(sw).toHaveAttribute('data-reading', 'off'); - expect(sw).toHaveTextContent('Off for this deployment'); - // One switch, and not the definition's: no "Enabled" word, no read-only hint. + expect(sw).toHaveTextContent('Switched off for this deployment'); + // One switch, and not the definition's: not its bare "Switched on" word, no read-only hint. expect(screen.getAllByRole('switch')).toHaveLength(1); - expect(sw).not.toHaveTextContent('Enabled'); + expect(sw).not.toHaveTextContent(/^Switched on$/); expect(document.body).not.toHaveTextContent('switch to or create a writable package'); }); @@ -210,7 +210,7 @@ describe('Automations — the ledger switch on a read-only package (ADR-0146 D4) const sw = await ledgerSwitch(); expect(sw).toHaveAttribute('aria-checked', 'true'); expect(sw).toHaveAttribute('data-reading', 'not-running'); - expect(sw).toHaveTextContent('On for this deployment, not running here'); + expect(sw).toHaveTextContent('Switched on for this deployment, not running here'); expect(screen.getByTestId('auto-ledger-not-running-reason')).toHaveTextContent(reason); expect(sw.getAttribute('aria-describedby')).toContain(screen.getByTestId('auto-ledger-not-running-reason').id); }); @@ -291,7 +291,7 @@ describe('Automations — the ledger switch on a read-only package (ADR-0146 D4) mount(false); expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval'); const sw = await screen.findByRole('switch'); - expect(sw).toHaveTextContent('Disabled'); + expect(sw).toHaveTextContent(/^Switched off$/); expect(screen.queryByTestId('auto-ledger-switch')).toBeNull(); // The definition switch never reaches the ledger door. fireEvent.click(sw); diff --git a/ui/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx b/ui/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx index 5b20449c698..6faa6781e3c 100644 --- a/ui/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx @@ -10,14 +10,14 @@ import { FlowStatusDot } from './StudioDesignSurface'; import { t } from '../metadata-admin/i18n'; describe('FlowStatusDot', () => { - it('shows a green "On" for an enabled flow', () => { + it('shows a green "Switched on" for an enabled flow', () => { render(); - expect(screen.getByText('On')).toBeTruthy(); + expect(screen.getByText('Switched on')).toBeTruthy(); }); - it('shows "Off" for a disabled flow', () => { + it('shows "Switched off" for a disabled flow', () => { render(); - expect(screen.getByText('Off')).toBeTruthy(); + expect(screen.getByText('Switched off')).toBeTruthy(); }); it('renders nothing when the flow has no runtime state (never published)', () => { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 1378c34ede1..2993d7ce450 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4838,22 +4838,22 @@ const zh = { actionsHeading: '打包动作', colActivation: '启用状态', on: '已启用', - off: '已关闭', + off: '已停用', cancel: '取消', toggleFailedHttp: '无法更改启用状态(HTTP {{status}})。', toggleFailed: '无法更改启用状态。', - actionsSubtitle: '由已安装软件包提供的动作。在本部署中关闭后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', + actionsSubtitle: '由已安装软件包提供的动作。在本部署中停用后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', colAction: '动作', colObject: '对象', actionToggleLabel: '{{object}} 上 {{label}} 的启用状态', actionsEmptyTitle: '没有打包动作', actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', actionsLoadFailed: '无法加载打包动作。', - switchedOn: '「{{label}}」已开启。', - switchedOff: '「{{label}}」已在本部署中关闭。', - actionOffTitle: '关闭「{{label}}」?', - actionOffBody: '在重新开启之前,它将在本部署中所有提供它的地方对所有用户停止运行。', - actionOffConfirm: '关闭', + switchedOn: '「{{label}}」已启用。', + switchedOff: '「{{label}}」已在本部署中停用。', + actionOffTitle: '停用「{{label}}」?', + actionOffBody: '在重新启用之前,它将在本部署中所有提供它的地方对所有用户停止运行。', + actionOffConfirm: '停用', }, connectorCredentials: { title: "连接器凭据", diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index ecac994395d..b2b7fb85389 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -31,11 +31,24 @@ const STUDIO_EN: Record = { 'perm.action.modifyAll': 'Modify All Records (bypass sharing)', 'perm.col.object': 'Object', 'perm.col.bulk': 'Bulk', + // objectstack#29729 — the object columns' headers. en abbreviates (the words + // do not fit the column) and the legend line spells them out; a locale whose + // words fit spells the action word here and the legend line is not drawn. + 'perm.col.create': 'C', + 'perm.col.read': 'R', + 'perm.col.edit': 'U', + 'perm.col.delete': 'D', + 'perm.col.export': 'E', + 'perm.col.transfer': 'Tr', + 'perm.col.viewAll': 'VA', + 'perm.col.modifyAll': 'MA', 'perm.posture.private': 'Private', 'perm.posture.private.tip': 'Secure-by-default object (access.default = private): a permission set’s “*” wildcard grant does NOT cover it. Access needs an explicit grant on this object (or View/Modify All Records). Grants you edit here apply normally.', 'perm.owd.tip': 'Org-wide default (sharingModel): the record-level visibility baseline for internal users, applied before positions and sharing rules. Object CRUD here gates the operation; the OWD decides which records it reaches (own vs org-wide).', 'perm.owd.defaultPrivate': 'Private (default)', 'perm.owd.editLink': 'Edit in the package’s Record Sharing Baseline (OWD) overview', + // objectstack#29729 — the Setup matrix's per-object chip. + 'perm.owd.chip': 'OWD {value}', 'perm.owd.private': 'Private', 'perm.owd.public_read': 'Public read', 'perm.owd.public_read_write': 'Public read/write', @@ -66,7 +79,7 @@ const STUDIO_EN: Record = { 'perm.cap.none': 'No system capabilities', 'perm.cap.add': 'Add capability', 'perm.badge.package': 'Package', - 'perm.badge.custom': 'Custom', + 'perm.badge.custom': 'Added', 'perm.badge.default': 'Default for new users', 'perm.field.loading': 'Loading fields…', 'perm.field.empty': 'No fields registered for this object.', @@ -2472,9 +2485,9 @@ const STUDIO_EN: Record = { 'engine.studio.auto.newSimilar.dismiss': 'Dismiss', // ADR-0146 D4 — a read-only package's flow: the activation-ledger switch // (this deployment only, never the definition), offered on the item. - 'engine.studio.auto.ledger.on': 'On for this deployment', - 'engine.studio.auto.ledger.off': 'Off for this deployment', - 'engine.studio.auto.ledger.notRunning': 'On for this deployment, not running here', + 'engine.studio.auto.ledger.on': 'Switched on for this deployment', + 'engine.studio.auto.ledger.off': 'Switched off for this deployment', + 'engine.studio.auto.ledger.notRunning': 'Switched on for this deployment, not running here', 'engine.studio.auto.ledger.hint': 'This flow ships in a read-only package. The switch turns it on or off for this deployment only; the package is not changed.', 'engine.studio.auto.ledger.walled': 'On this deployment, only the platform operator can switch a packaged flow on or off.', @@ -2482,9 +2495,9 @@ const STUDIO_EN: Record = { 'engine.studio.auto.ledger.switchedOff': '"{label}" is switched off for this deployment.', 'engine.studio.auto.ledger.toggleFailedHttp': 'Could not change activation (HTTP {status}).', 'engine.studio.auto.ledger.toggleFailed': 'Could not change activation.', - 'engine.studio.auto.on': 'On', - 'engine.studio.auto.off': 'Off', - 'engine.studio.auto.offTitle': 'Disabled — will not run', + 'engine.studio.auto.on': 'Switched on', + 'engine.studio.auto.off': 'Switched off', + 'engine.studio.auto.offTitle': 'Switched off — will not run', 'engine.studio.auto.onBound': 'Enabled · bound to its trigger', 'engine.studio.auto.onUnbound': 'Enabled · no trigger (run manually)', // objectui#11779 — an enabled flow whose declared trigger is not armed on this @@ -2498,12 +2511,12 @@ const STUDIO_EN: Record = { // reads "Not running here", titled by the row below. 'engine.studio.auto.refused': 'Refused by the engine', 'engine.studio.auto.unregisteredTitle': 'The automation engine on this deployment holds no registration for this flow', - 'engine.studio.auto.enabled': 'Enabled', - 'engine.studio.auto.disabled': 'Disabled', + 'engine.studio.auto.enabled': 'Switched on', + 'engine.studio.auto.disabled': 'Switched off', 'engine.studio.auto.enableTitle': 'Enable this automation (publish to apply)', 'engine.studio.auto.disableTitle': 'Disable this automation (publish to apply)', - 'engine.studio.auto.enabledToast': 'Enabled — publish to apply', - 'engine.studio.auto.disabledToast': 'Disabled — publish to apply', + 'engine.studio.auto.enabledToast': 'Switched on — publish to apply', + 'engine.studio.auto.disabledToast': 'Switched off — publish to apply', 'engine.studio.auto.newTitle': 'New automation', 'engine.studio.auto.none': 'No automations yet — click “+” above to create one', 'engine.studio.auto.emptyMain': 'No automations in this package yet.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index a818310aec2..34e27437da2 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -23,11 +23,22 @@ const STUDIO_ZH: Record = { 'perm.action.modifyAll': '修改所有记录(绕过共享规则)', 'perm.col.object': '对象', 'perm.col.bulk': '批量', + // objectstack#29729 — 对象列的表头直接写动作词,无需图例。四字词中间的空格 + // 是换行点(表头 break-keep),在 52 px 的列里折成「查看 / 全部」两行。 + 'perm.col.create': '创建', + 'perm.col.read': '读取', + 'perm.col.edit': '编辑', + 'perm.col.delete': '删除', + 'perm.col.export': '导出', + 'perm.col.transfer': '转移', + 'perm.col.viewAll': '查看 全部', + 'perm.col.modifyAll': '修改 全部', 'perm.posture.private': '私有', 'perm.posture.private.tip': '默认安全对象(access.default = private):权限集的 “*” 通配授权不覆盖它,访问需要对此对象的显式授权(或查看/修改所有记录)。在此编辑的显式授权正常生效。', 'perm.owd.tip': '组织级默认(sharingModel):内部用户的记录级可见性基线,先于岗位与共享规则生效。此处的对象 CRUD 决定能否执行操作,OWD 决定操作触达哪些记录(自己的还是全组织的)。', 'perm.owd.defaultPrivate': '私有(默认)', 'perm.owd.editLink': '在本包的“记录共享基线(OWD)”总览中编辑', + 'perm.owd.chip': '记录共享模型:{value}', 'perm.owd.private': '私有', 'perm.owd.public_read': '公共只读', 'perm.owd.public_read_write': '公共读写', @@ -58,7 +69,7 @@ const STUDIO_ZH: Record = { 'perm.cap.none': '无附加系统能力', 'perm.cap.add': '添加能力', 'perm.badge.package': '包内置', - 'perm.badge.custom': '自定义', + 'perm.badge.custom': '新增', 'perm.badge.default': '新用户默认', 'perm.field.loading': '加载字段中…', 'perm.field.empty': '该对象未注册字段。', @@ -1115,7 +1126,7 @@ const STUDIO_ZH: Record = { 'engine.edit.rollbackConfirmTitle': '将 {item} 恢复到版本 {version}?', 'engine.edit.reset': '重置覆盖', 'engine.edit.resetConfirm': '重置{typeLabel}“{item}”的覆盖层?', - 'engine.edit.resetConfirmBody': '将移除您对 {type}/{name} 的自定义,恢复使用代码中定义的版本。', + 'engine.edit.resetConfirmBody': '将移除您对 {type}/{name} 的定制,恢复使用代码中定义的版本。', 'engine.edit.delete': '删除', 'engine.edit.deleteConfirm': '删除{typeLabel}“{item}”?', 'engine.edit.deleteConfirmBody': '{type}/{name} 将被删除。此操作不可撤销。', @@ -2217,13 +2228,13 @@ const STUDIO_ZH: Record = { 'engine.studio.auto.newSimilar.noPackage': '服务器已保存副本 {name},但回答中没有说明它所在的软件包,因此未打开任何内容。', 'engine.studio.auto.newSimilar.dismiss': '关闭', // ADR-0146 D4 — 只读软件包的流程:启用账本开关(仅本部署,不改定义)。 - 'engine.studio.auto.ledger.on': '本部署中已开启', - 'engine.studio.auto.ledger.off': '本部署中已关闭', - 'engine.studio.auto.ledger.notRunning': '本部署中已开启,但未在此运行', - 'engine.studio.auto.ledger.hint': '该流程来自只读软件包。此开关只在本部署中开启或关闭它,不改动软件包本身。', - 'engine.studio.auto.ledger.walled': '在此部署中,只有平台运营方可以开启或关闭打包流程。', - 'engine.studio.auto.ledger.switchedOn': '「{label}」已在本部署中开启。', - 'engine.studio.auto.ledger.switchedOff': '「{label}」已在本部署中关闭。', + 'engine.studio.auto.ledger.on': '本部署中已启用', + 'engine.studio.auto.ledger.off': '本部署中已停用', + 'engine.studio.auto.ledger.notRunning': '本部署中已启用,但未在此运行', + 'engine.studio.auto.ledger.hint': '该流程来自只读软件包。此开关只在本部署中启用或停用它,不改动软件包本身。', + 'engine.studio.auto.ledger.walled': '在此部署中,只有平台运营方可以启用或停用打包流程。', + 'engine.studio.auto.ledger.switchedOn': '「{label}」已在本部署中启用。', + 'engine.studio.auto.ledger.switchedOff': '「{label}」已在本部署中停用。', 'engine.studio.auto.ledger.toggleFailedHttp': '无法更改启用状态(HTTP {status})。', 'engine.studio.auto.ledger.toggleFailed': '无法更改启用状态。', 'engine.studio.auto.on': '已启用', @@ -2849,17 +2860,17 @@ const STUDIO_ZH: Record = { 'engine.studio.pkg.readonly': '只读', 'engine.studio.pkg.readonlyHint': '只读软件包 — 请切换或新建可写软件包后再编辑。', 'engine.studio.pkg.readonlyLink': '只读 · 用覆盖定制', - 'engine.studio.customize.action': '自定义', - 'engine.studio.customize.actionTitle': '为本部署修改此项。第一次编辑会保存为自定义草稿,{package} 本身保持安装时的样子。', - 'engine.studio.customize.walled': '在此部署中,只有平台运营方可以自定义已安装的软件包。', - 'engine.studio.customize.noVersion': '软件包列表没有给出已安装的 {package} 版本,因此无法在此自定义它的项。', + 'engine.studio.customize.action': '定制', + 'engine.studio.customize.actionTitle': '为本部署修改此项。第一次编辑会保存为定制草稿,{package} 本身保持安装时的样子。', + 'engine.studio.customize.walled': '在此部署中,只有平台运营方可以定制已安装的软件包。', + 'engine.studio.customize.noVersion': '软件包列表没有给出已安装的 {package} 版本,因此无法在此定制它的项。', 'engine.studio.customize.overrides': '覆盖 {package}', 'engine.studio.customize.overridesTitle': '此项在本部署中替换 {package} 中的同名项,取自 {package} {basedOn}。', - 'engine.studio.customize.draftFooter': '编辑会保存为本部署自定义的草稿;发布后生效。', + 'engine.studio.customize.draftFooter': '编辑会保存为本部署定制的草稿;发布后生效。', 'engine.studio.customize.restore': '恢复软件包原版', 'engine.studio.customize.restoreConfirm': '恢复 {package} 的「{item}」?', - 'engine.studio.customize.restoreBody': '本部署对「{item}」的自定义会被删除,重新使用 {package} 的版本。', - 'engine.studio.customize.restoreFlowBody': '本部署对「{item}」的自定义会被删除,{package} 的流程重新替它运行,启用开关保持不变。', + 'engine.studio.customize.restoreBody': '本部署对「{item}」的定制会被删除,重新使用 {package} 的版本。', + 'engine.studio.customize.restoreFlowBody': '本部署对「{item}」的定制会被删除,{package} 的流程重新替它运行,启用开关保持不变。', 'engine.studio.customize.restoreDraft': '会先丢弃它未发布的草稿。', 'engine.studio.customize.restoreDone': '「{item}」已恢复为 {package} 的版本', // objectstack#28572 — 只读基础包与其定制包合为一个界面。 @@ -2874,7 +2885,7 @@ const STUDIO_ZH: Record = { 'engine.studio.interfaces.create.descriptionInPair': '存为本部署对 {baseName} 定制的草稿,并在画布上打开。{baseName} 的导航保持安装时的样子。', 'engine.studio.app.scaffoldNav': '将本包的对象添加为菜单项', 'engine.studio.landing.title': '应用构建', - 'engine.studio.landing.description': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用「自定义」或「新建类似」修改其条目,修改保存在该软件包的 .custom 定制包中。', + 'engine.studio.landing.description': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用「定制」或「新建类似」修改其条目,修改保存在该软件包的 .custom 定制包中。', 'engine.studio.landing.mineHeading': '我的软件包(可写)', 'engine.studio.landing.noneWritable': '还没有可写软件包 — 新建一个开始。', 'engine.studio.landing.dup': '复制', @@ -2886,7 +2897,7 @@ const STUDIO_ZH: Record = { 'engine.studio.landing.dupCreated': '已复制为可写软件包「{name}」', 'engine.studio.landing.installedHeading': '已安装(只读 · 可浏览)', // objectui#11808 — the routes a read-only package does have. - 'engine.studio.landing.readonlyHint': '源码与已安装的软件包为只读,不能在此编辑或复制。要修改其中一个,请打开条目并选择「自定义」或「新建类似」:修改保存在该软件包的 .custom 定制包中,升级仍会到达已安装的软件包。', + 'engine.studio.landing.readonlyHint': '源码与已安装的软件包为只读,不能在此编辑或复制。要修改其中一个,请打开条目并选择「定制」或「新建类似」:修改保存在该软件包的 .custom 定制包中,升级仍会到达已安装的软件包。', 'engine.studio.landing.marketplace': '或从应用市场安装一个模板应用', 'engine.studio.landing.customizations.customized': '已定制 {customized} 项', 'engine.studio.landing.customizations.separator': ',', @@ -2907,8 +2918,8 @@ const STUDIO_ZH: Record = { 'engine.studio.area.assignments.permission': '此权限集分配给哪些人,在设置中管理。', 'engine.studio.area.assignments.open': '在设置中打开', 'engine.studio.area.formEditor.locale': '语言 {locale}', - 'engine.studio.area.formEditor.oneLocale': '只自定义此模板的 {locale} 版本,{package} 中它的其他语言版本保持不变。', - 'engine.studio.area.formEditor.translationWhole': '自定义翻译会替换 {package} 的整个 {locale} 语言包:其中每一条文本都改由你的副本提供,包括未修改的条目。', + 'engine.studio.area.formEditor.oneLocale': '只定制此模板的 {locale} 版本,{package} 中它的其他语言版本保持不变。', + 'engine.studio.area.formEditor.translationWhole': '定制翻译会替换 {package} 的整个 {locale} 语言包:其中每一条文本都改由你的副本提供,包括未修改的条目。', 'engine.studio.publishedAllFlows': '已发布 {count} 个流程', 'engine.studio.publishedAllFlowsOne': '已发布流程「{label}」', 'engine.studio.designer.search': '搜索…', diff --git a/ui/scripts/__tests__/check-i18n-en-drift.test.ts b/ui/scripts/__tests__/check-i18n-en-drift.test.ts index 409f5afa2cd..0fa24e2cfae 100644 --- a/ui/scripts/__tests__/check-i18n-en-drift.test.ts +++ b/ui/scripts/__tests__/check-i18n-en-drift.test.ts @@ -530,10 +530,28 @@ describe('the waiver ledger', () => { ); expect(problems.map((p) => p.detail).sort()).toEqual([ 'missing a `reason`', - 'missing an `issue` of the form objectui#1234', + 'missing an `issue` of the form objectstack#1234 (or objectui#1234)', ]); }); + // objectstack#29729 — the ui tree lives in objectstack-ai/objectstack now, so + // a new waiver names an objectstack card; the objectui spelling stays valid + // for the entries that predate the move. + it('accepts an issue named objectstack#N as well as objectui#N', () => { + const enAfter = new Map([['real.key', 'text']]); + for (const issue of ['objectstack#29729', 'objectui#3650']) { + expect(checkLedger({ waivers: { 'real.key': { en: 'text', reason: 'r', issue } } }, enAfter)).toEqual([]); + } + }); + + it('CONTROL — still refuses any other issue spelling', () => { + const enAfter = new Map([['real.key', 'text']]); + for (const issue of ['objectstack 29729', 'objectstack#', 'cloud#12', '#29729', 'objectstack#29729x', 'objectui-3650']) { + const problems = checkLedger({ waivers: { 'real.key': { en: 'text', reason: 'r', issue } } }, enAfter); + expect(problems.map((p) => p.reason), issue).toEqual(['malformed-waiver']); + } + }); + it('does not treat a waiver that fired nothing as an error', () => { // Every run after the waiving PR merges sees an empty diff and this exact // shape. Failing it would hand one PR's escape hatch to every later PR as a @@ -602,11 +620,19 @@ describe('the shipped ledger', () => { expect(Array.isArray(raw.note)).toBe(true); }); - /** The designer section's own ratchet (objectui#8834). It ships EMPTY and its - * population had 0 findings on arrival, so this ceiling starts at 0 — raising - * it is a reviewed test edit in the same PR, exactly like WAIVER_CEILING. */ - it('holds at most 0 designer waiver(s) — a ratchet, and this number only goes down', () => { - expect(Object.keys(ledger.designerWaivers)).toHaveLength(0); + /** The designer section's own ratchet (objectui#8834). It shipped EMPTY and its + * population had 0 findings on arrival, so this ceiling started at 0 — raising + * it is a reviewed test edit in the same PR, exactly like WAIVER_CEILING. + * + * 0 -> 7 (objectstack#29729). Studio's flow run status moved its en words to + * the one the landing and the ledger switch use ("Switched on" / "Switched + * off"); zh already read 已启用 / 已停用 on all seven keys, so it had nothing + * to follow. */ + it('holds at most 7 designer waiver(s) — a ratchet, and this number only goes down', () => { + expect(Object.keys(ledger.designerWaivers)).toHaveLength(7); + for (const entry of Object.values(ledger.designerWaivers) as Array<{ issue: string }>) { + expect(entry.issue).toMatch(ISSUE_PATTERN); + } }); }); diff --git a/ui/scripts/check-i18n-en-drift.mjs b/ui/scripts/check-i18n-en-drift.mjs index 08a5d713301..14bd7d04195 100644 --- a/ui/scripts/check-i18n-en-drift.mjs +++ b/ui/scripts/check-i18n-en-drift.mjs @@ -181,8 +181,13 @@ export const packPath = (lang) => `packages/i18n/src/locales/${lang}.ts`; /** Where the waiver ledger lives, repo-relative. */ export const LEDGER_PATH = 'scripts/i18n-en-drift-baseline.json'; -/** A ledger entry must name the issue that justifies it, in this spelling. */ -export const ISSUE_PATTERN = /^objectui#\d+$/; +/** + * A ledger entry must name the issue that justifies it, in this spelling. The + * ui tree moved into objectstack-ai/objectstack, so a new waiver names an + * `objectstack#N` card; the `objectui#N` spelling stays for the entries that + * predate the move (objectstack#29729). + */ +export const ISSUE_PATTERN = /^(?:objectui|objectstack)#\d+$/; // -- pack parsing ------------------------------------------------------------- @@ -533,7 +538,7 @@ export function checkLedger(ledger, enAfter) { problems.push({ reason: 'malformed-waiver', key, detail: 'missing a `reason`' }); } if (typeof entry.issue !== 'string' || !ISSUE_PATTERN.test(entry.issue)) { - problems.push({ reason: 'malformed-waiver', key, detail: 'missing an `issue` of the form objectui#1234' }); + problems.push({ reason: 'malformed-waiver', key, detail: 'missing an `issue` of the form objectstack#1234 (or objectui#1234)' }); } if (!enAfter.has(key)) { problems.push({ reason: 'stale-waiver', key, detail: 'this key no longer exists in the en pack' }); @@ -868,7 +873,7 @@ if (invokedDirectly) { 'serving a retired sentence, one of them as an idiomatic translation that every other\n' + 'gate reads as perfectly healthy. Nothing downstream can find it again.\n' + `If a translation genuinely does not need to change, say so in ${LEDGER_PATH}:\n` + - ' { "": { "en": "", "reason": "…", "issue": "objectui#1234" } }\n' + + ' { "": { "en": "", "reason": "…", "issue": "objectstack#1234" } }\n' + 'A waiver covers that one sentence and expires the next time the key changes.', ); } @@ -891,7 +896,7 @@ if (invokedDirectly) { 'so fixing only the English half would have left the Chinese designer telling the old lie with\n' + 'every gate in this repo green. The key is prefixed with its table.\n' + `If the zh row genuinely does not need to change, say so in ${LEDGER_PATH}:\n` + - ' { "designerWaivers": { "": { "en": "", "reason": "…", "issue": "objectui#1234" } } }\n' + + ' { "designerWaivers": { "": { "en": "", "reason": "…", "issue": "objectstack#1234" } } }\n' + 'A waiver covers that one sentence and expires the next time the key changes.', ); } diff --git a/ui/scripts/i18n-en-drift-baseline.json b/ui/scripts/i18n-en-drift-baseline.json index e9b79f140af..6735760db92 100644 --- a/ui/scripts/i18n-en-drift-baseline.json +++ b/ui/scripts/i18n-en-drift-baseline.json @@ -3,7 +3,7 @@ "Waivers for scripts/check-i18n-en-drift.mjs (objectui#3650): an en string changed and one", "or more of the nine translation packs deliberately did not follow it.", "", - "Shape: \"\": { \"en\": \"\", \"reason\": \"…\", \"issue\": \"objectui#1234\" }", + "Shape: \"\": { \"en\": \"\", \"reason\": \"…\", \"issue\": \"objectstack#1234\" } (objectui#1234 for entries that predate the ui tree's move into this repo)", "", "This file ships EMPTY and holds no backlog, unlike scripts/i18n-call-site-key-baseline.json.", "That gate measures a STATE (keys missing from en today), so it could inventory the debt. This", @@ -34,7 +34,12 @@ "objectui#8834 (packages/app-shell/src/views/metadata-admin/i18n.ts). Same shape, same expiry rule,", "same ceiling discipline -- separate map because each section is validated against the en values of", "its own population, and a designer key is not a pack key. Keys are prefixed with their table, e.g.", - "\"ENGINE_STRINGS.engine.directory.title\". It ships EMPTY and holds no backlog either." + "\"ENGINE_STRINGS.engine.directory.title\". It ships EMPTY and holds no backlog either.", + "", + "Its first seven entries (objectstack#29729) are the one-word-per-state convergence in Studio's", + "flow run status: en moved Off/Disabled to 'Switched off' and On/Enabled to 'Switched on', the", + "word the landing, the ledger switch and the Setup page already used. zh already read 已停用 /", + "已启用 for every one of them, so there was nothing for it to follow." ], "waivers": { @@ -70,5 +75,41 @@ } }, - "designerWaivers": {} + "designerWaivers": { + "ENGINE_STRINGS.engine.studio.auto.on": { + "en": "Switched on", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已启用’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.off": { + "en": "Switched off", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已停用’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.offTitle": { + "en": "Switched off — will not run", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已停用 — 不会运行’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.enabled": { + "en": "Switched on", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已启用’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.disabled": { + "en": "Switched off", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已停用’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.enabledToast": { + "en": "Switched on — publish to apply", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已设为启用 — 发布后生效’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + }, + "ENGINE_STRINGS.engine.studio.auto.disabledToast": { + "en": "Switched off — publish to apply", + "reason": "One word per state in Studio (objectstack#29729): en now says the landing's and the ledger switch's word. zh already read ‘已设为停用 — 发布后生效’ here, so it had nothing to follow.", + "issue": "objectstack#29729" + } + } }