From d21dcab6dacb0c330b400244fed82714903f0403 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:37:06 +0000 Subject: [PATCH 1/4] feat(studio): one word per state for switched on/off and Customize, Chinese matrix headers Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jR2PhzeUkFuXHiwpdp --- .../metadata-admin/PermissionMatrixEditor.tsx | 95 +++++++++++++------ ui/packages/i18n/src/locales/zh.ts | 4 +- ui/packages/i18n/src/namespaces/studio/en.ts | 35 ++++--- ui/packages/i18n/src/namespaces/studio/zh.ts | 51 ++++++---- .../__tests__/check-i18n-en-drift.test.ts | 38 ++++++-- ui/scripts/check-i18n-en-drift.mjs | 15 ++- ui/scripts/i18n-en-drift-baseline.json | 47 ++++++++- 7 files changed, 211 insertions(+), 74 deletions(-) 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/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 36fae1fc1e1..76f81661f44 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4830,11 +4830,11 @@ const zh = { actionsHeading: '打包动作', colActivation: '启用状态', on: '已启用', - off: '已关闭', + off: '已停用', cancel: '取消', toggleFailedHttp: '无法更改启用状态(HTTP {{status}})。', toggleFailed: '无法更改启用状态。', - actionsSubtitle: '由已安装软件包提供的动作。在本部署中关闭后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', + actionsSubtitle: '由已安装软件包提供的动作。在本部署中停用后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', colAction: '动作', colObject: '对象', actionToggleLabel: '{{object}} 上 {{label}} 的启用状态', diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index b2951029110..42810c42484 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.', @@ -2470,9 +2483,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.', @@ -2480,9 +2493,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 @@ -2496,12 +2509,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 61ca19c0f53..3935fa9cb6f 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} 将被删除。此操作不可撤销。', @@ -2215,13 +2226,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': '已启用', @@ -2847,17 +2858,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 — 只读基础包与其定制包合为一个界面。 @@ -2872,7 +2883,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': '复制', @@ -2884,7 +2895,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': ',', @@ -2905,8 +2916,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" + } + } } From cae18522cbedc79256f0966dc774cf78ad3c14a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:58:21 +0000 Subject: [PATCH 2/4] test(studio): pin one word per state, the matrix headers and the drift gate's issue spelling Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jR2PhzeUkFuXHiwpdp --- ...rectoryPage.stateVocabulary-29705.test.tsx | 91 ++++++++++- .../PermissionMatrixEditor.badge.test.tsx | 10 +- ...nMatrixEditor.columnHeaders-29729.test.tsx | 149 ++++++++++++++++++ ...MatrixEditor.provenanceBadge-7611.test.tsx | 20 +-- ...mationsPillar.customizeFlow-28570.test.tsx | 6 +- ...omationsPillar.ledgerSwitch-28148.test.tsx | 14 +- .../studio-design/FlowStatusDot.test.tsx | 8 +- 7 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.columnHeaders-29729.test.tsx 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..84fc944b749 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,89 @@ 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'); + }); + + 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/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)', () => { From 9019c719d3282d55d67c11e8ef07816d8cb86592 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:03:03 +0000 Subject: [PATCH 3/4] chore(changeset): Studio one word per state Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jR2PhzeUkFuXHiwpdp --- .changeset/29729-studio-one-word-per-state.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/29729-studio-one-word-per-state.md 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..6ac4b202a4c --- /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 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. From 3368fa7542385420bc39bd53c3caababfb70c531 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:09:15 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(i18n):=20Setup's=20packaged-action=20co?= =?UTF-8?q?nfirm=20and=20toasts=20say=20=E5=90=AF=E7=94=A8=20/=20=E5=81=9C?= =?UTF-8?q?=E7=94=A8=20in=20zh-CN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch-off confirm (title, body, button) and the switched-on/off toasts still read 开启 / 关闭 on the page whose column reads 已停用. Five zh rows move to 启用 / 停用, pinned by a census over every zh packagedAutomation row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jR2PhzeUkFuXHiwpdp --- .changeset/29729-studio-one-word-per-state.md | 2 +- ...rectoryPage.stateVocabulary-29705.test.tsx | 19 +++++++++++++++++++ ui/packages/i18n/src/locales/zh.ts | 10 +++++----- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.changeset/29729-studio-one-word-per-state.md b/.changeset/29729-studio-one-word-per-state.md index 6ac4b202a4c..f90a6526c67 100644 --- a/.changeset/29729-studio-one-word-per-state.md +++ b/.changeset/29729-studio-one-word-per-state.md @@ -8,7 +8,7 @@ feat(i18n, app-shell): Studio and Setup give a switched-on, switched-off or cust 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 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". +- 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. 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 84fc944b749..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 @@ -328,6 +328,25 @@ describe('one word per state, the second pass (objectstack#29729)', () => { 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', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 3a8c5e5f30a..2993d7ce450 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4849,11 +4849,11 @@ const zh = { actionsEmptyTitle: '没有打包动作', actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', actionsLoadFailed: '无法加载打包动作。', - switchedOn: '「{{label}}」已开启。', - switchedOff: '「{{label}}」已在本部署中关闭。', - actionOffTitle: '关闭「{{label}}」?', - actionOffBody: '在重新开启之前,它将在本部署中所有提供它的地方对所有用户停止运行。', - actionOffConfirm: '关闭', + switchedOn: '「{{label}}」已启用。', + switchedOff: '「{{label}}」已在本部署中停用。', + actionOffTitle: '停用「{{label}}」?', + actionOffBody: '在重新启用之前,它将在本部署中所有提供它的地方对所有用户停止运行。', + actionOffConfirm: '停用', }, connectorCredentials: { title: "连接器凭据",