Skip to content

Commit 11c4eb5

Browse files
feat(studio): a Chinese display name derives its identifier by pinyin at every Studio create door (#29734)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbJWK4KPmYk5DcrJTSxbJRJk)_ Fixes #29690 Clause-②: no Metadata-protocol: none Claim: [6593397218](#29690 (comment)) · amendments [6593439786](#29690 (comment)), [6593495761](#29690 (comment)) ## 维护者速读 - **改了什么**:Studio 里所有“由名称生成标识”的新建入口(新建软件包、新建对象等、添加字段、通用新建表单、新建视图、对象/报表/数据集设计器的 Name、字段 API 名、选项值),中文名称现在按词转成不带声调的拼音再生成标识:`维修中心` → `weixiu_zhongxin`,`客户等级` → `kehu_dengji`。生成的标识照常显示、可改。 - **为什么改**:中文管理员用中文起名,过去每个入口都停下来要求手填英文标识,上手头十分钟要被拦好几次。 - **风险与回滚**:拼音字典 `pinyin-pro` 是按需加载的独立分包,首次遇到汉字才下载;加载失败时退回原来的“无法生成,请手填”。常驻包只多 0.9 KB(预算内)。回滚即还原本 PR,无数据迁移。 - **先看**:`romanizeHan.ts`(共享规则与加载状态);`StudioDesignSurface.tsx` 两行(添加字段预览名与实际创建名一致);`PackageFormDialog.tsx`(软件包 ID 与命名空间)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin/romanizeHan.test.ts`,预期 9 条全绿(含多音字 `银行`→`yinhang`、`行动`→`xingdong`)。 - **需要你做的**:无,席位落地。 ## What changes One shared derivation, `ui/packages/app-shell/src/views/metadata-admin/romanizeHan.ts`, sits in front of each door's existing sanitizer. Latin text is untouched; each Chinese word becomes its toneless pinyin, then the door applies the rule it always applied (`toFieldNameLoose`, `toFieldNameOrEmpty`, `slugify`, or the package id's `toIdSegment`). So the namespace prefix, the `__c` suffix of a customization pair and the taken-name hint are unchanged; they now see `weixiu_zhongxin` where they used to see an empty string. - **Doors.** New package (`com.<org>.weixiu-zhongxin`, namespace `weixiu_zhongxin`); the new-item dialog; Add field, where the popover's preview and the host's created name both go through the helper, so the name previewed is the name created; the generic create form (`createDerive.ts`); New view; the object, report and dataset designers' Name; a fresh field's API name in the field inspector; and a picklist option's value. That last one was a silent loss: a Chinese option label derived an empty value and the row was never saved. - **Words, not syllables.** Word boundaries come from `Intl.Segmenter('zh', { granularity: 'word' })`, and `pinyin-pro` reads a polyphone by its word (`银行` → `yinhang`, `行动` → `xingdong`). `ü` is written `v` (`绿色` → `lvse`). Without `Intl.Segmenter`, each character's reading is joined by `_` (`ke_hu_deng_ji`). - **Loading.** The dictionary is fetched the first time a door sees a Han character. Until it lands, the door keeps the identifier empty, keeps Create off, and holds back its "not derived" line (`romanizerPending`). When it lands, a door whose identifier still follows the name fills it in without another keystroke (`useHanRomanizer` / `useWhenRomanizerLands`). The fill runs once, on the render where the dictionary landed, so a person who clears the identifier to type their own is never refilled. If the chunk cannot be fetched, the load settles as failed, one `console.warn` says so, and every door falls back to its "not derived" line and a hand-typed identifier, as before this PR. - **Japanese.** Text containing kana is left alone, so a Japanese name still asks for the identifier. The existing pins that asserted "a Chinese name derives nothing" now assert the same invariant with a kana sample. - **Package dialog subtitle.** The subtitle says "Give it a name to start; everything else is filled in for you." That promise now holds for a Chinese name. It still does not hold for a Japanese one, which opens the advanced section with the hint, as before. The copy is unchanged. **Bundle choice.** The client derives the pinyin itself, from `pinyin-pro` loaded as a lazy chunk. It is the library the server's pinyin search plugin already uses and is already in the lockfile, and it reads polyphones by word. I rejected a server route because it would add a REST route for a create-time preview, and `plugin-pinyin-search` runs only behind `OS_SEARCH_PINYIN_ENABLED`. I rejected the collator-sort libraries because they read polyphones wrong. `pnpm --dir ui check:eager-closure`: | | eager closure (gz) | eager chunks | |:--|--:|--:| | base `1c1a5d40` | 2092.9 KB | 100 of 2397 | | this branch before the main merge | 2093.8 KB | 101 of 2399 | | final head (after merging main) | 2094.3 KB | 101 of 2399 | The last row's extra 0.5 KB is main's own change in `vendor-objectstack`, which went from 573.1 to 573.6 KB. The ceiling and baseline are untouched, and headroom is 337.3 KB. The dictionary chunk is `assets/esm-Cst8rBvH.js`: 286.6 KB raw, 141.7 KB gzipped, and lazy. The helper itself, about 0.9 KB, is in the eager closure because `PackageFormDialog` is eager. The seat accepted this on the card thread. ## Tests New: - `romanizeHan.test.ts` - Before load: text unchanged and pending. - After load: `weixiu_zhongxin` and `kehu_dengji` through every door sanitizer, plus the polyphone pair, `lvse`, mixed `维修Center 2号` → `weixiu_center_2_hao`, and kana unchanged. - With `Intl.Segmenter` absent: `ke_hu_deng_ji` and `yin_hang`. - `romanizeHan.loadFailure.test.tsx` - The chunk import rejects, the load settles, nothing is pending, and one warning is logged. - `CreateItemDialog` shows its "not derived" line, and a hand-typed id submits. - `romanizeHan.lands-29690.test.tsx` - `CreateItemDialog` stays empty and quiet with Create off while loading, then shows `weixiu_zhongxin` with Create on. - `createDerive.romanizerLands-29690.test.tsx` - The generic create form is pending, then `kehu_dengji` with nothing underivable. - `ReportDefaultInspector.romanizerLands-29690.test.tsx` - The designer Name fills on landing. - A cleared Name is not refilled. - `CreateViewDialog.pinyinName-29690.test.tsx` - `客户列表` → `kehu_liebiao`, and the name is editable. - A kana label derives nothing. Extended with a zh-CN case each: - `CreateItemDialog.notDerived-13389`: the namespace prefix gives `repairs_weixiu_zhongxin`. - `PackageFormDialog.idDerive-11792`: org `acme` gives `com.acme.weixiu-zhongxin` and namespace `weixiu_zhongxin` in the posted manifest; the id stays editable. - `DataPillar.addFieldPopover-12516`: the preview `kehu_dengji` equals the created field; a second `客户等级` gets the taken-name hint. - `DataPillar.baseObjectExtension-28575`: the preview `kehu_dengji__c` equals the extension field saved. - `ResourceEditPage.underivableName-28232` - `Report…`/`DatasetDefaultInspector.labelLeavesNameEmpty-12789` - `ObjectDefaultInspector` - `ObjectFieldInspector.apiNameSync` - `ObjectFieldInspector.optionLabelFirst-27521`: `进行中`/`已完成` → `jinxing_zhong`/`yi_wancheng` saved, and the field parses. Kana samples replace the Chinese ones in `PackageFormDialog.derivedFields-12512`, `PackageFormDialog.nameOnlyFirstStep-27833` and `ResourceEditPage.identifierIssueI18n-28369`. Ablations went through `scripts/ablation-replace.mjs`. Each one went red and was restored to HEAD: | mutation | result | |:--|:--| | `romanizeHan` returns its input | 8 of 16 red | | the Add-field host derives without pinyin | 2 of 12 red, preview ≠ created | | the landing fill runs every render | 2 of 7 red, a cleared Name is refilled | ## Acceptance notes - A fresh field's API name in the field inspector derives on the label keystroke after the dictionary has landed. It does not re-derive on landing by itself: the inspector returns early before its row exists. In practice the dictionary has already landed by then, because a field is created through Add field, which loads it. - Add field keeps parity with a Latin label. The derived name is shown as the popover's preview, and the person edits it in the field inspector after creating, as today. - No zh/en catalogue rows changed. Every door's existing "not derived" sentence still applies, and only to text the rule cannot read. ## Verification All runs were under `os-verify-lock`. <details><summary>Affected tests, type-check, lint</summary> - `turbo`-affected app-shell test files: 308 files in 6 batches. - The first run's reds were all pins asserting that a Chinese name derives nothing, plus one real bug, the designer Name refilling after it was cleared. Both are fixed above. - The new and changed files re-ran 35 of 35 green (259 tests). The field inspector files re-ran 31 of 31 green (324 tests). - `pnpm exec turbo run type-check --filter=@object-ui/app-shell`: 65 of 65 tasks pass. - `eslint` on the changed files: 0 errors. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 57 commands on this diff, and `--ran` reconciled all 57. 56 exit 0. `check:dual-build-cjs-loads` exits 3, because it needs every workspace package built; it is left to CI. The exit codes are in the os-dev report on the card. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTMu2EmYV84fwNrBXw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 545c1c7 commit 11c4eb5

33 files changed

Lines changed: 1035 additions & 104 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(studio): a Chinese display name derives its identifier by pinyin at every Studio create door
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A Chinese-speaking administrator names things in Chinese, and every Studio create door used to keep only Latin letters, so `维修中心` derived no identifier and the door waited for a hand-typed English one. Now each door that derives a machine name from a display name turns each Chinese word into its toneless pinyin first, then applies the rule it always applied: `维修中心` gives `weixiu_zhongxin` (a package id `com.<org>.weixiu-zhongxin` with the namespace `weixiu_zhongxin`), `客户等级` gives `kehu_dengji`. The derived identifier is shown and stays editable; the namespace prefix, the `__c` suffix of a customization pair and the taken-name refusal are unchanged.
11+
12+
- **Doors.** New package, the new-item dialog (objects and the other Studio items), Add field (the name the popover previews is the name the field is created under), the generic create form, New view, the object, report and dataset designers' Name, a fresh field's API name in the field inspector, and a picklist option's value (a Chinese option label used to derive an empty value, and the option was silently never saved).
13+
- **Words, not syllables.** Word boundaries come from the browser's `Intl.Segmenter`, and a polyphone reads by its word (`银行` → `yinhang`, `行动` → `xingdong`). Without `Intl.Segmenter`, each character's reading is joined by `_` (`ke_hu_deng_ji`).
14+
- **Bundle.** The dictionary (`pinyin-pro`, the library the server's pinyin search already uses) is a lazy chunk, fetched the first time a door sees a Chinese character; until it lands the door keeps Create off without a "not derived" line, and if it cannot be fetched the door falls back to that line and a hand-typed identifier, as before.
15+
- **Japanese.** Text with kana is left alone, so a Japanese name still asks for the identifier.

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ui/packages/app-shell/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@sentry/react": "^10.70.0",
9696
"jsonc-parser": "^3.3.1",
9797
"lucide-react": "^1.43.0",
98+
"pinyin-pro": "^3.29.4",
9899
"qrcode": "^1.5.4",
99100
"sonner": "^2.0.8",
100101
"zod": "^4.6.5"
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+
* objectstack#29690 — the new-view dialog fills its machine name from a
5+
* Chinese display label through the same pinyin rule as every Studio create
6+
* door (`romanizeHan`), and the name stays editable. A Japanese label still
7+
* derives nothing, so the dialog keeps asking for the key as before.
8+
*/
9+
10+
import '@testing-library/jest-dom/vitest';
11+
import { afterEach, describe, expect, it, vi } from 'vitest';
12+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
13+
import { CreateViewDialog } from './CreateViewDialog';
14+
// The pinyin dictionary, resident before any case, as it is once a Console
15+
// door has fetched it.
16+
import { loadHanRomanizer } from './metadata-admin/romanizeHan';
17+
await loadHanRomanizer();
18+
19+
afterEach(cleanup);
20+
21+
const label = () => screen.getByTestId('create-view-name-input') as HTMLInputElement;
22+
const machineName = () => screen.getByTestId('create-view-machine-name-input') as HTMLInputElement;
23+
24+
describe('CreateViewDialog — a Chinese label derives the view name by pinyin (objectstack#29690)', () => {
25+
it('fills kehu_liebiao from 客户列表, and a typed name stops following the label', () => {
26+
render(<CreateViewDialog open onOpenChange={() => {}} onCreate={vi.fn()} />);
27+
fireEvent.change(label(), { target: { value: '客户列表' } });
28+
expect(machineName().value).toBe('kehu_liebiao');
29+
fireEvent.change(machineName(), { target: { value: 'customer_list' } });
30+
fireEvent.change(label(), { target: { value: '重点客户列表' } });
31+
expect(machineName().value).toBe('customer_list');
32+
});
33+
34+
it('CONTROL — a Japanese label derives nothing and the name is asked for', () => {
35+
render(<CreateViewDialog open onOpenChange={() => {}} onCreate={vi.fn()} />);
36+
fireEvent.change(label(), { target: { value: 'カスタマー' } });
37+
expect(machineName().value).toBe('');
38+
});
39+
});

‎ui/packages/app-shell/src/views/CreateViewDialog.tsx‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
* gates it.
1313
* Step 3: The user enters a display label (required, defaults to "Grid 1" etc.)
1414
* and a machine `name` (the metadata key). The name auto-fills from the label
15-
* via `slugify`; for non-Latin (CJK/…) labels slugify yields nothing, so the
16-
* field is left empty and the user must type a key before Create enables
17-
* (#2767 P5 — no more silent random names).
15+
* via `slugify`, a Chinese label through its pinyin first (`romanizeHan`:
16+
* `客户等级` → `kehu_dengji`); for a label that still yields nothing (a
17+
* Japanese one, say) the field is left empty and the user must type a key
18+
* before Create enables (#2767 P5 — no more silent random names).
1819
*
1920
* On submit, calls `onCreate({ type, label, name, [type]: {...picked config} })`
2021
* and waits for it: the dialog closes only when the save resolves `true`, and
@@ -49,6 +50,7 @@ import {
4950
import { useObjectTranslation } from '@object-ui/i18n';
5051
import type { ListViewVisualization } from '@object-ui/core';
5152
import { slugify } from './metadata-admin/createDerive.js';
53+
import { romanizeHan, useHanRomanizer } from './metadata-admin/romanizeHan.js';
5254
import { datasetOptionLabel, useObjectDatasetBinding } from './studio-design/objectDatasets.js';
5355
import {
5456
deriveFieldOptions,
@@ -518,12 +520,15 @@ export function CreateViewDialog({
518520
}
519521
}, [selectedType, touched, types, existingSet]);
520522

521-
// Derive the machine name from the label until the user edits it. slugify
522-
// returns '' for non-Latin labels, so the field stays empty and the user is
523-
// prompted to fill it in (rather than a silent random key — #2767 P5).
523+
// Derive the machine name from the label until the user edits it. A Chinese
524+
// label derives through its pinyin, re-deriving when the dictionary lands
525+
// (`romanizerVersion`); a label that still yields '' leaves the field empty
526+
// and the user is prompted to fill it in (rather than a silent random key —
527+
// #2767 P5).
528+
const romanizerVersion = useHanRomanizer(label);
524529
useEffect(() => {
525-
if (!nameTouched) setName(slugify(label));
526-
}, [label, nameTouched]);
530+
if (!nameTouched) setName(slugify(romanizeHan(label)));
531+
}, [label, nameTouched, romanizerVersion]);
527532

528533
// Required fields for the currently selected type
529534
const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? [];

‎ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.derivedFields-12512.test.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,18 @@ describe('Create stays disabled until the id, name, namespace and version valida
152152

153153
it('a name that derives no id disables Create and the id says why', () => {
154154
const f = openCreate();
155-
fireEvent.change(f.name(), { target: { value: '工单中心' } });
155+
// A Japanese name: a Chinese one derives through its pinyin (objectstack#29690).
156+
fireEvent.change(f.name(), { target: { value: 'チケットセンター' } });
156157
expect(f.id().value).toBe('');
157158
expect(f.submit()).toBeDisabled();
158159
expect(screen.getByTestId('package-form-id-missing')).toBeInTheDocument();
159160
});
160161

161162
// objectui#13389 — the line names the cause, and the "filled in from the
162163
// display name" mark does not sit over an empty box.
163-
it('a name with no Latin letters says the id could not be made from it, with no filled-in mark', () => {
164+
it('a name that yields no id segment says the id could not be made from it, with no filled-in mark', () => {
164165
const f = openCreate();
165-
fireEvent.change(f.name(), { target: { value: '体验评审' } });
166+
fireEvent.change(f.name(), { target: { value: 'レビュー' } });
166167
expect(screen.getByTestId('package-form-id-missing')).toHaveTextContent(
167168
tFormat('engine.packages.idRule.notDerived', 'en-US', { example: 'com.acme.crm' }),
168169
);

‎ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.idDerive-11792.test.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import { t } from './i18n';
2727
// Resident before any case, so the zh rows below read synchronously.
2828
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
2929
await loadStudioCatalogue('zh');
30+
// The pinyin dictionary a Chinese name derives through, resident before any
31+
// case, as it is once a Console door has fetched it (objectstack#29690).
32+
import { loadHanRomanizer } from './romanizeHan';
33+
await loadHanRomanizer();
3034

3135
let localeFixture: 'en-US' | 'zh-CN' = 'en-US';
3236
vi.mock('./i18n', async (importOriginal) => ({
@@ -124,9 +128,11 @@ describe('New package — the id follows the display name (objectui#11792)', ()
124128
expect(f.ns().value).toBe('tickets');
125129
});
126130

131+
// objectstack#29690 — a Chinese name derives (last block); a Japanese one
132+
// is the name that still yields no segment.
127133
it('leaves the id empty when the name yields no id segment, and does not submit', () => {
128134
const f = openCreate();
129-
fireEvent.change(f.name(), { target: { value: '报修中心' } });
135+
fireEvent.change(f.name(), { target: { value: 'サポートセンター' } });
130136
expect(f.id().value).toBe('');
131137
expect(f.submit()).toBeDisabled();
132138
});
@@ -214,3 +220,34 @@ describe('Edit and view keep the spec form’s id field (objectui#11792)', () =>
214220
expect(Object.keys(JSON.parse(calls[0].init.body as string)).sort()).toEqual(['description', 'name', 'version']);
215221
});
216222
});
223+
224+
describe('New package — a Chinese name derives the id and namespace by pinyin (objectstack#29690)', () => {
225+
it('fills com.<org>.<pinyin> and the namespace from it, and Create posts them', async () => {
226+
orgFixture = { id: 'org_1', name: 'Acme Inc', slug: 'acme' };
227+
localeFixture = 'zh-CN';
228+
const f = openCreate();
229+
fireEvent.change(f.name(), { target: { value: '维修中心' } });
230+
expect(f.id().value).toBe('com.acme.weixiu-zhongxin');
231+
expect(isSpecId(f.id().value)).toBe(true);
232+
expect(f.ns().value).toBe('weixiu_zhongxin');
233+
expect(screen.queryByTestId('package-form-id-missing')).toBeNull();
234+
fireEvent.click(f.submit());
235+
await waitFor(() => expect(f.onSaved).toHaveBeenCalledTimes(1));
236+
expect(JSON.parse(calls[0].init.body as string).manifest).toMatchObject({
237+
id: 'com.acme.weixiu-zhongxin',
238+
name: '维修中心',
239+
namespace: 'weixiu_zhongxin',
240+
});
241+
});
242+
243+
it('the derived id stays editable, and an edited one stops following the name', () => {
244+
localeFixture = 'zh-CN';
245+
const f = openCreate();
246+
fireEvent.change(f.name(), { target: { value: '客户等级' } });
247+
expect(f.id().value).toBe('com.kehu-dengji');
248+
fireEvent.change(f.id(), { target: { value: 'com.example.tiers' } });
249+
fireEvent.change(f.name(), { target: { value: '客户等级管理' } });
250+
expect(f.id().value).toBe('com.example.tiers');
251+
expect(f.ns().value).toBe('tiers');
252+
});
253+
});

‎ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.nameOnlyFirstStep-27833.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ describe('the first step asks only for a name (objectstack#27833)', () => {
153153

154154
it('a name that derives no id opens Advanced by itself, at the line that says why', () => {
155155
const f = openCreate();
156-
fireEvent.change(f.name(), { target: { value: '工单中心' } });
156+
// A Japanese name: a Chinese one derives through its pinyin (objectstack#29690).
157+
fireEvent.change(f.name(), { target: { value: 'チケットセンター' } });
157158

158159
expect(f.submit()).toBeDisabled();
159160
expect(f.toggle()).toHaveAttribute('aria-expanded', 'true');

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { SchemaForm, type SchemaFormIssue } from './SchemaForm.js';
6060
import type { FormFieldSpec, FormViewSpec } from './form-spec.js';
6161
import { getPackageSchema, getPackageForm } from './package-schema.js';
6262
import { PackageDependenciesEditor, type DependencyMap } from './PackageDependenciesEditor.js';
63+
import { romanizeHan, romanizerPending, useHanRomanizer } from './romanizeHan.js';
6364
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
6465

6566
const API = '/api/v1/packages';
@@ -222,9 +223,13 @@ function toIdSegment(raw: string): string {
222223
* The candidate is checked against the spec before it is offered, so a
223224
* derivation never puts an id in the field that the dialog itself refuses.
224225
* `null` when the name yields no segment at all.
226+
*
227+
* A Chinese name derives through its pinyin (`romanizeHan`): `维修中心` gives
228+
* the segment `weixiu-zhongxin`, so `com.<org>.weixiu-zhongxin`, and the
229+
* namespace follows that id as it follows any other.
225230
*/
226231
function deriveCreatePackageId(name: string, orgSlug: string | null | undefined): string | null {
227-
const nameSegment = toIdSegment(name);
232+
const nameSegment = toIdSegment(romanizeHan(name));
228233
if (!nameSegment) return null;
229234
const orgSegment = orgSlug ? toIdSegment(orgSlug) : '';
230235
const candidate = ['com', orgSegment, nameSegment].filter(Boolean).join('.');
@@ -481,9 +486,13 @@ export function PackageFormDialog({
481486
});
482487
}, []);
483488

489+
// The pinyin dictionary a Chinese name derives through loads on first use;
490+
// the version it hands back moves when it lands, re-deriving below.
491+
const romanizerVersion = useHanRomanizer(String(draft.name ?? ''));
492+
484493
// The active organization can arrive after the author has typed the name
485-
// (the session's organization list loads on its own schedule); an id still
486-
// following the name picks the segment up then.
494+
// (the session's organization list loads on its own schedule), and so can
495+
// the pinyin dictionary; an id still following the name picks either up then.
487496
React.useEffect(() => {
488497
if (!open || !createMode) return;
489498
setForm((prev) => {
@@ -495,7 +504,7 @@ export function PackageFormDialog({
495504
const namespace = t.namespace ? was.namespace : (deriveNamespaceFromPackageId(id) ?? '');
496505
return { draft: { ...was, id, namespace }, touched: t };
497506
});
498-
}, [open, createMode, orgSlug]);
507+
}, [open, createMode, orgSlug, romanizerVersion]);
499508

500509
// The help line SchemaForm would have shown under the id: the localized row
501510
// when there is one, else the spec's own `.describe()` (see package-schema).
@@ -531,17 +540,25 @@ export function PackageFormDialog({
531540
// (a CJK-only name), an id that derived no namespace, or the author
532541
// clearing the field.
533542
const idStr = String(draft.id ?? '').trim();
534-
const idMissing = createMode && !idStr && (touched.id || nameOk);
535-
// objectui#13389 — the display name is there but holds no Latin letter to
536-
// make an id from (a Chinese name, say): say that, rather than the "filled
537-
// in from the display name" line over an empty box.
543+
// A Chinese name whose pinyin dictionary is still loading: its id is about
544+
// to be derived, so no row may say it is missing yet.
545+
const idPending = createMode && !touched.id && romanizerPending(String(draft.name ?? ''));
546+
const idMissing = createMode && !idStr && (touched.id || nameOk) && !idPending;
547+
// objectui#13389 — the display name is there but yields nothing to make an
548+
// id from (a Japanese name, say): say that, rather than the "filled in from
549+
// the display name" line over an empty box.
538550
const idNotDerived =
539-
createMode && !touched.id && !idStr && nameOk && !toIdSegment(String(draft.name ?? ''));
551+
createMode &&
552+
!touched.id &&
553+
!idStr &&
554+
nameOk &&
555+
!idPending &&
556+
!toIdSegment(romanizeHan(String(draft.name ?? '')));
540557
const nsMissing =
541558
createMode && !String(draft.namespace ?? '').trim() && (touched.namespace || !!idStr);
542559
// objectstack#27833 — once a name is in, every other create gate is a row
543560
// behind Advanced (id, namespace, version), so a failing one opens it.
544-
const advancedNeeded = createMode && nameOk && !(idOk && nsOk && versionOk);
561+
const advancedNeeded = createMode && nameOk && !idPending && !(idOk && nsOk && versionOk);
545562
const showAdvanced = advancedOpen || advancedNeeded;
546563

547564
const createForms = React.useMemo(

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.identifierIssueI18n-28369.test.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
* objectstack#28369 — the create form's summary banner states Name's identifier
55
* errors in the reader's locale, and wraps them instead of cutting them.
66
*
7-
* A zh-CN administrator who types an all-Chinese label into a new permission
8-
* set leaves Name empty, and `SnakeCaseIdentifierSchema` refuses it twice
7+
* A zh-CN administrator who types a label that derives no identifier into a
8+
* new permission set (a Japanese one: since objectstack#29690 a Chinese label
9+
* derives its name by pinyin) leaves Name empty, and `SnakeCaseIdentifierSchema` refuses it twice
910
* (`.min(2)` and `.regex`). Those messages are the spec's, in English, and stay
1011
* so; the Console picks the reader's-locale text by the issue's code and
1112
* pattern, never by reading the English:
@@ -116,15 +117,15 @@ afterEach(() => {
116117
cleanup();
117118
});
118119

119-
/** Opens a new permission set and types an all-Chinese label; Name stays empty. */
120-
async function bannerLinesForChineseLabel(): Promise<HTMLElement[]> {
120+
/** Opens a new permission set and types a Japanese label; Name stays empty. */
121+
async function bannerLinesForUnderivableLabel(): Promise<HTMLElement[]> {
121122
render(
122123
<MemoryRouter initialEntries={['/metadata/permission/new']}>
123124
<MetadataResourceEditPage type="permission" name="" createMode />
124125
</MemoryRouter>,
125126
);
126127
const labelBox = (await screen.findByRole('textbox', { name: /^(Label|标签)/ }, { timeout: 8000 })) as HTMLInputElement;
127-
fireEvent.change(labelBox, { target: { value: '销售运营' } });
128+
fireEvent.change(labelBox, { target: { value: 'セールス' } });
128129
const banner = await screen.findByTestId('metadata-validation-banner', {}, { timeout: 8000 });
129130
await waitFor(() => expect(banner.querySelectorAll('[data-testid="metadata-diagnostics-item"]').length).toBeGreaterThanOrEqual(2));
130131
return Array.from(banner.querySelectorAll<HTMLElement>('[data-testid="metadata-diagnostics-item"]'));
@@ -139,21 +140,21 @@ describe('the create banner states identifier issues in the reader’s locale (o
139140
});
140141

141142
it('(a) zh-CN: both Name lines are Chinese, and no line carries the spec’s English', async () => {
142-
const lines = (await bannerLinesForChineseLabel()).map((li) => li.textContent ?? '');
143+
const lines = (await bannerLinesForUnderivableLabel()).map((li) => li.textContent ?? '');
143144
expect(lines.some((l) => l.includes(ZH_TOO_SHORT))).toBe(true);
144145
expect(lines.some((l) => l.includes(ZH_SNAKE))).toBe(true);
145146
for (const en of SNAKE_EN) expect(lines.some((l) => l.includes(en))).toBe(false);
146147
});
147148

148149
it('(b) CONTROL en-US: the banner shows the spec’s own sentences', async () => {
149150
localeFixture = 'en-US';
150-
const lines = (await bannerLinesForChineseLabel()).map((li) => li.textContent ?? '');
151+
const lines = (await bannerLinesForUnderivableLabel()).map((li) => li.textContent ?? '');
151152
for (const en of SNAKE_EN) expect(lines.some((l) => l.includes(en))).toBe(true);
152153
expect(lines.some((l) => l.includes(ZH_SNAKE))).toBe(false);
153154
});
154155

155156
it('(c) the banner lines wrap instead of cutting the message', async () => {
156-
const lines = await bannerLinesForChineseLabel();
157+
const lines = await bannerLinesForUnderivableLabel();
157158
for (const li of lines) {
158159
expect(li.className.split(/\s+/)).not.toContain('truncate');
159160
expect(li.className.split(/\s+/)).toContain('whitespace-normal');

0 commit comments

Comments
 (0)