Skip to content

Commit 0892a94

Browse files
fix(app-shell): allowed-visualization chips read the view-type select's words in zh-CN (#30433)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgUUWEuUZxPmGPaLux5Dfpsr)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Fixes #30358 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:中文界面下,视图设置「外观」里的「允许的可视化」选项(chip)改读视图类型下拉框的同一套中文词:表格 / 列表、看板、画廊、日历、时间线、甘特图、地图、图表、树形。两处共用一个取词函数,不会再说两套话。 - 为什么改:中文管理员在 Studio 编辑列表视图时,同一面板上的视图类型下拉已是中文,这排要他勾选的选项却是 Grid、Kanban、Gallery 等英文。 - 风险与回滚:只动 ui 的 metadata-admin 三个文件,不动 spec、服务端、元数据、i18n 词条。英文界面的 9 个 chip 词保持原样(仍是 Grid,不跟下拉的 Table / List),其它非视图类型的多选不变。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/widgets.tsx` 的 `enumOptionLabel` → `viewTypeLabels.ts`(从 `ViewVariantInspector.tsx` 挪出的取词函数)→ 新测试 `MultiSelectWidget.viewTypeLabels-30358.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/MultiSelectWidget.viewTypeLabels-30358.test.tsx`,预期 3/3 通过(main 源码上 zh-CN 一条失败,读到 Grid…Tree)。 - 需要你做的:无,席位落地。 ## What changes The 允许的可视化 chips (`appearance.allowedVisualizations`) are the `multiselect` widget in `widgets.tsx`. With no `fieldSpec.options`, it named each enum value with `humanizeOption(v)` (`grid` → `Grid`) in every locale. The view-type select a few rows up names the same values through `typeLabel` in `ViewVariantInspector.tsx` (the `engine.inspector.view.type.*` keys, then an English fallback map). - `typeLabel` and its fallback map move to a shared module, `metadata-admin/viewTypeLabels.ts` (`viewTypeLabel`, `isKnownViewType`). `ViewVariantInspector` imports it, so the select is unchanged. The fallback map gains `tree`, the one view type it lacked (the catalog already has `engine.inspector.view.type.tree` in en and zh). - The widget names a bare enum's values through `enumOptionLabel`: when every value is a view type and the console is not en-US, a chip reads `viewTypeLabel(v, locale)`, the select's own word. Otherwise it keeps `humanizeOption`. - **en-US keeps its chip names (the seat's ruling on the card's "en unchanged").** I kept `humanizeOption` for en-US rather than routing en through the shared helper with a `grid` override: the en catalog word for `grid` is "Table / List", so routing en through the helper would need a per-value exception to keep "Grid". Keeping en on its existing path is the smaller change and leaves all nine en names byte-identical by construction. The console's locale is `zh-CN` or `en-US` (`useMetadataLocale`), so "not en-US" is zh-CN today. - The widget is told nothing about which field it draws (no field path in `WidgetProps`), so it recognises the view-type enum by its values, not by the field's name. A mixed enum (`['grid', 'start_date']`) keeps the humanised names in zh-CN too, so a list is never half translated. - No i18n key is added. `fieldSpec.options` labels still win when a form supplies them. ## Tests - New `MultiSelectWidget.viewTypeLabels-30358.test.tsx`, over the spec's real `appearance.allowedVisualizations` JSONSchema (from `getListVariantSchema()`, not a copied enum) and the shipped `studio` catalogue: - zh-CN: the chips equal `t('engine.inspector.view.type.' + v, 'zh-CN')` for every enum value, in enum order, and none of them is Latin text. - en-US: the nine chips read `Grid, Kanban, Gallery, Calendar, Timeline, Gantt, Map, Chart, Tree`. - zh-CN, mixed enum: `['Grid', 'Start Date']`. - Red first on main's source (test file only): `Tests 1 failed | 2 passed (3)`. The zh-CN case failed with `expected [ 'Grid', 'Kanban', 'Gallery', …(6) ] to deeply equal [ '表格 / 列表', '看板', '画廊', '日历', …(5) ]`. Both en and mixed passed on main, which pins that they are unchanged. Green after the fix: `Tests 3 passed (3)`. The red run on main's source is the ablation leg; the fix is the only source change and the tests import `src`. - The changed test plus the files that render the touched code, on the merged head `6daec651`: the existing `MultiSelectWidget.test.tsx`, four `ViewVariantInspector.*.test.tsx`, `unknownValueFlag.i18n-9652.test.tsx` (the select's unknown-type label), and four `SchemaForm`/`ResourceEditPage` suites that render the multiselect. `Test Files 11 passed (11)`, `Tests 170 passed (170)`. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --concurrency=2`: `Tasks: 68 successful, 68 total`. - `eslint` on the four changed ui files: 0 errors (42 warnings, all on lines this PR does not touch in `widgets.tsx`). ## Live probe Example CRM (`examples/app-crm`, `objectstack dev --seed-admin --fresh`) with the branch's console (vite dev). Studio → `crm_activity.all` → 终端用户控制 → 外观 → 允许的可视化, read from the DOM (`[role=group] [role=checkbox]`): - zh-CN, main's widget source: `Grid, Kanban, Gallery, Calendar, Timeline, Gantt, Map, Chart, Tree`. - zh-CN, this branch: `表格 / 列表, 看板, 画廊, 日历, 时间线, 甘特图, 地图, 图表, 树形`. The view-type select on the same page reads 表格 / 列表. - en-US, this branch: `Grid, Kanban, Gallery, Calendar, Timeline, Gantt, Map, Chart, Tree`. The card's before-shot path is not in this project's folder, so the before leg was taken here, on main's widget source. ## Acceptance notes - On the same zh-CN panel, the labels and help text of the nested `appearance` / `userActions` keys still read English (`Allowed Visualizations`, `Show Description`, `Sort`, `Search`, and the quick-filter element's labels). These come from the spec's `.describe()` and the metadata-forms catalogs, which this card leaves to the catalog channel (#30164). Not changed here. ## Verification <details><summary>Derived gates (dispatch-gates --commands, final tree `6daec651`)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-sdui-manifest.mjs` :: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `node scripts/pm/dispatch-gates.mjs --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. `check:type-check-debt` ran under the verify lock (VERDICT command-exit 0). `check:sdui-manifest` (standing ui gate) is in the list. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZxcXXabvxgwiUDTa3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3461420 commit 0892a94

5 files changed

Lines changed: 162 additions & 26 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix: on a zh-CN console, the allowed-visualizations chips in a view's settings read the same Chinese words as the view-type select (#30358)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The 允许的可视化 chips (`appearance.allowedVisualizations`) were the only English row left in a list view's end-user section on a zh-CN console: they read Grid, Kanban, Gallery, Calendar, Timeline, Gantt, Map, Chart, Tree. They now read 表格 / 列表, 看板, 画廊, 日历, 时间线, 甘特图, 地图, 图表, 树形, the words the view-type select a few rows up already uses. Both controls take the name from one shared helper, so they cannot drift apart. An en-US console keeps the chip names it had. A multi-select over any other list of values keeps its existing names in every language.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30358 — on a zh-CN console the view panel's 允许的可视化 chips
5+
* (`appearance.allowedVisualizations`) read Grid / Kanban / Gallery …, while
6+
* the view-type select a few rows up already reads 表格 / 列表 / 看板 ….
7+
*
8+
* The chips are the `multiselect` widget over the spec's
9+
* `VisualizationTypeSchema` enum. Pinned here, against the spec's real enum
10+
* (not a copy of it):
11+
*
12+
* - zh-CN: every chip reads exactly the view-type select's word for that value.
13+
* - en-US: the nine chips keep the names they had (the card's "en unchanged").
14+
* - an enum that is not all view types keeps the humanised names in zh-CN too.
15+
*/
16+
17+
import { describe, it, expect, vi, afterEach } from 'vitest';
18+
import { render, screen, cleanup } from '@testing-library/react';
19+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
20+
import { WIDGETS } from './widgets';
21+
import { getListVariantSchema } from './view-schema';
22+
import { t } from './i18n';
23+
24+
// The view-type words live in `@object-ui/i18n`'s `studio` namespace, whose zh
25+
// catalogue is fetched on demand; resident before any case.
26+
await loadStudioCatalogue('zh');
27+
28+
let localeFixture: 'en-US' | 'zh-CN' = 'en-US';
29+
vi.mock('./i18n', async (importOriginal) => ({
30+
...(await importOriginal<Record<string, unknown>>()),
31+
useMetadataLocale: () => localeFixture,
32+
}));
33+
34+
afterEach(() => {
35+
cleanup();
36+
localeFixture = 'en-US';
37+
});
38+
39+
const MultiSelect = WIDGETS['multiselect'];
40+
41+
/** The `appearance.allowedVisualizations` JSONSchema exactly as the spec serialises it. */
42+
function allowedVisualizationsSchema(): Record<string, any> {
43+
const schema = getListVariantSchema()?.properties?.appearance?.properties?.allowedVisualizations;
44+
expect(schema?.items?.enum, 'spec allowedVisualizations enum').toBeTruthy();
45+
return schema as Record<string, any>;
46+
}
47+
48+
function chipNames(): string[] {
49+
return screen.getAllByRole('checkbox').map((el) => el.textContent?.replace('✓', '').trim() ?? '');
50+
}
51+
52+
describe('allowedVisualizations chips read the view-type words (objectstack#30358)', () => {
53+
it('zh-CN: every chip reads the view-type select word for that value', () => {
54+
localeFixture = 'zh-CN';
55+
const schema = allowedVisualizationsSchema();
56+
const values = schema.items.enum as string[];
57+
render(<MultiSelect value={[]} onChange={() => {}} schema={schema} />);
58+
// The select's word for each value, straight from the shipped catalogue
59+
// key the view-type select reads (`engine.inspector.view.type.*`).
60+
const selectWords = values.map((v) => t(`engine.inspector.view.type.${v}`, 'zh-CN'));
61+
expect(chipNames()).toEqual(selectWords);
62+
// And those are the catalogue's Chinese rows, not an English fallback.
63+
expect(selectWords).toContain('表格 / 列表');
64+
expect(selectWords).toContain('看板');
65+
for (const word of selectWords) expect(word).not.toMatch(/^[A-Za-z /]+$/);
66+
});
67+
68+
it('en-US: the nine chips keep their existing names', () => {
69+
localeFixture = 'en-US';
70+
const schema = allowedVisualizationsSchema();
71+
render(<MultiSelect value={[]} onChange={() => {}} schema={schema} />);
72+
expect(chipNames()).toEqual([
73+
'Grid',
74+
'Kanban',
75+
'Gallery',
76+
'Calendar',
77+
'Timeline',
78+
'Gantt',
79+
'Map',
80+
'Chart',
81+
'Tree',
82+
]);
83+
});
84+
85+
it('zh-CN: an enum that is not all view types keeps the humanised names', () => {
86+
localeFixture = 'zh-CN';
87+
const schema = { type: 'array', items: { type: 'string', enum: ['grid', 'start_date'] } };
88+
render(<MultiSelect value={[]} onChange={() => {}} schema={schema} />);
89+
expect(chipNames()).toEqual(['Grid', 'Start Date']);
90+
});
91+
});

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

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
import { isFormFamilyKey } from '../view-variant-model.js';
8989
import { mergeServerFields } from '../mergeServerFields.js';
9090
import { t, tFormat } from '../i18n.js';
91+
import { viewTypeLabel as typeLabel } from '../viewTypeLabels.js';
9192
import { useRequiredInGatedSectionIssues } from './requiredInGatedSection.js';
9293

9394
/**
@@ -139,27 +140,6 @@ export interface ViewVariantInspectorProps extends MetadataDefaultInspectorProps
139140
objectFieldsOverride?: ObjectFieldInfo[];
140141
}
141142

142-
/** English fallback labels for the spec `type` enum (used when the i18n
143-
* catalog has no entry for a view type the spec introduces later). */
144-
const TYPE_LABELS: Record<string, string> = {
145-
grid: 'Table / List',
146-
kanban: 'Kanban',
147-
calendar: 'Calendar',
148-
gallery: 'Gallery',
149-
gantt: 'Gantt',
150-
timeline: 'Timeline',
151-
map: 'Map',
152-
chart: 'Chart',
153-
};
154-
155-
/** Localized label for a view `type`, falling back to the English map (and
156-
* then the raw value) when the i18n catalog has no entry. */
157-
function typeLabel(value: string, locale?: string): string {
158-
const key = `engine.inspector.view.type.${value}`;
159-
const translated = t(key, locale);
160-
return translated === key ? (TYPE_LABELS[value] ?? value) : translated;
161-
}
162-
163143
/** Keys re-pinned from the live draft after every spec-form edit. */
164144
const PRESERVED_KEYS = ['columns', 'data', 'name'] as const;
165145

@@ -203,7 +183,7 @@ function useTypeOptions(locale?: string) {
203183
// here UNFLAGGED, so an off-spec view type read exactly like an offered
204184
// one. `InspectorSelectField` synthesises and flags it now; `typeLabel`
205185
// still gets its say through `unknownValueLabel` at the call site, since a
206-
// retired type can still have a human name in `TYPE_LABELS`.
186+
// retired type can still have a human name in `viewTypeLabels.ts`.
207187
return opts;
208188
}, [locale]);
209189
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The human name of a list view `type` (grid, kanban, …), shared by every
5+
* metadata-admin control that offers view types, so they cannot disagree:
6+
*
7+
* - the view-type select in `ViewVariantInspector`;
8+
* - the `multiselect` chips over the same enum
9+
* (`appearance.allowedVisualizations`, objectstack#30358).
10+
*/
11+
12+
import { t } from './i18n.js';
13+
14+
/** English fallback labels for the spec view-type enum (used when the i18n
15+
* catalog has no entry for a view type the spec introduces later). */
16+
const VIEW_TYPE_FALLBACK_LABELS: Record<string, string> = {
17+
grid: 'Table / List',
18+
kanban: 'Kanban',
19+
calendar: 'Calendar',
20+
gallery: 'Gallery',
21+
gantt: 'Gantt',
22+
timeline: 'Timeline',
23+
map: 'Map',
24+
chart: 'Chart',
25+
tree: 'Tree',
26+
};
27+
28+
/** Whether `value` is a view type this module has a name for. */
29+
export function isKnownViewType(value: string): boolean {
30+
return Object.prototype.hasOwnProperty.call(VIEW_TYPE_FALLBACK_LABELS, value);
31+
}
32+
33+
/** Localized label for a view `type`, falling back to the English map (and
34+
* then the raw value) when the i18n catalog has no entry. */
35+
export function viewTypeLabel(value: string, locale?: string): string {
36+
const key = `engine.inspector.view.type.${value}`;
37+
const translated = t(key, locale);
38+
return translated === key ? (VIEW_TYPE_FALLBACK_LABELS[value] ?? value) : translated;
39+
}

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { iconNames } from 'lucide-react/dynamic.mjs';
4848
import { toast } from 'sonner';
4949
import { useObjectTranslation } from '@object-ui/i18n';
5050
import { useMetadataLocale, t, tFormat } from './i18n.js';
51+
import { isKnownViewType, viewTypeLabel } from './viewTypeLabels.js';
5152
import type { FormFieldSpec, FormViewSpec, VisibilityPredicate } from './form-spec.js';
5253
import { usePredicateScope } from '@object-ui/react';
5354
import { buildPredicateCtx, visibleOptions, type PredicateCtx } from './predicate.js';
@@ -1709,6 +1710,20 @@ function humanizeOption(v: string): string {
17091710
.replace(/\b\w/g, (c) => c.toUpperCase());
17101711
}
17111712

1713+
/**
1714+
* The chip name of one value of a bare `array<enum>` (no `fieldSpec.options`).
1715+
*
1716+
* An enum made only of view types (`appearance.allowedVisualizations`) reads
1717+
* the view-type select's own words (objectstack#30358), so a zh-CN console
1718+
* shows 表格 / 列表, 看板, … in both controls. en-US keeps the humanised names
1719+
* the chips have always had (Grid, not the select's "Table / List"): the card
1720+
* asked for en to stay as it was. Any other enum is humanised in every locale.
1721+
*/
1722+
function enumOptionLabel(value: string, values: readonly string[], locale: string): string {
1723+
if (locale !== 'en-US' && values.every(isKnownViewType)) return viewTypeLabel(value, locale);
1724+
return humanizeOption(value);
1725+
}
1726+
17121727
/**
17131728
* Toggleable option set for `array<enum>` fields (e.g.
17141729
* `appearance.allowedVisualizations`). Options come from the JSON Schema's
@@ -1719,6 +1734,7 @@ function humanizeOption(v: string): string {
17191734
*/
17201735
function MultiSelectWidget({ value, onChange, readOnly, schema, fieldSpec, formData, ariaLabelledBy }: WidgetProps) {
17211736
const hostScope = usePredicateScope();
1737+
const locale = useMetadataLocale();
17221738
// Prefer explicit form options; else the JSON Schema enum on the items.
17231739
//
17241740
// ⚠️ RAW — every per-option `visibleWhen` is carried through untouched
@@ -1733,10 +1749,10 @@ function MultiSelectWidget({ value, onChange, readOnly, schema, fieldSpec, formD
17331749
}
17341750
const enumVals: unknown =
17351751
schema?.items?.enum ?? schema?.enum ?? [];
1736-
return (Array.isArray(enumVals) ? enumVals : [])
1737-
.filter((v): v is string => typeof v === 'string')
1738-
.map((v) => ({ label: humanizeOption(v), value: v }));
1739-
}, [fieldSpec, schema]);
1752+
const values = (Array.isArray(enumVals) ? enumVals : [])
1753+
.filter((v): v is string => typeof v === 'string');
1754+
return values.map((v) => ({ label: enumOptionLabel(v, values, locale), value: v }));
1755+
}, [fieldSpec, schema, locale]);
17401756

17411757
// The CONTENT half: what the author is offered right now.
17421758
const renderedOptions = React.useMemo(

0 commit comments

Comments
 (0)