Skip to content

Commit 0fd2d08

Browse files
fix(console): a metadata create form says so next to Name when the label cannot fill it (#28366)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoPVve4Xc2Nd78tQUar4agBb)_ Fixes #28232 Refs #28180 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:新建元数据(权限集、视图、流程、页面、动作、钩子、仪表板、文档)时,如果显示名称里没有拉丁字母或数字(例如全中文、全日文),API 名称框下会多一行提示「无法从此显示名称生成名称,请手动输入(snake_case)」;英文界面是 "This label can't be turned into a name; type one in snake_case."。用户填了名称,这行提示就消失。 - 为什么改:Console 只用显示名称里的拉丁字母和数字自动生成名称;中文或日文名称生成的是空值,名称框空着且必填,旁边却没有任何话说明原因(#28232,从 #28180 拆出的可选半边)。 - 风险与回滚:只加一行提示文字;名称怎么生成、保存怎么校验、服务端都不变。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/createDerive.ts`(`underivableTargets`:什么时候算「生成不了」);`ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx`(新可选属性 `fieldNotes`,画在字段说明下面);`ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx`(把提示交给两处 `SchemaForm`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.underivableName-28232.test.tsx`,预期 2 个用例全绿;或在中文界面的 Console 新建权限集,显示名称填「销售运营」,名称下出现这行橙色提示,填入名称后消失。 - 需要你做的:无,席位落地。 ## What changes **When the line shows** (`underivableTargets` in `createDerive.ts`): for each `createDerive` rule whose transform is `slugify`, the target is listed when the source holds non-blank text, `slugify` of that text is empty, and the target is empty. A typed Name drops it at once; clearing Name again brings it back while the label still gives nothing. `slugify` itself is unchanged (no transliteration). `useCreateDerive` returns the list as `underivable` (empty when derivation is suspended, i.e. outside create mode). **Where it shows.** `SchemaForm` gains an optional `fieldNotes` prop: one host-supplied line per top-level field, drawn under the field's help text in both the flat and the sectioned layouts (and on boolean rows), never deferred like an issue. `ResourceEditPage` maps `underivable` to the translated line and passes it to both of its `SchemaForm` call sites: the create form, and the designer's inspector fallback (the `doc` type's create page). The hunks sit in the `useCreateDerive` block and on the two `SchemaForm` prop lists, away from what #28296 moved; `main` with #28296 is merged in. **Copy.** New key `engine.create.nameNotDerived` in the `studio` namespace: en "This label can't be turned into a name; type one in snake_case.", zh "无法从此显示名称生成名称,请手动输入(snake_case)。" (the form's zh label for `label` is 显示名称). The namespace ships `en` and `zh` only; the other eight locales fall back to English there, as for every Studio string. **Rider from the #28229 review** (help length at `colSpan: 1`): measured on the live Console (backend `--fresh` + the Console dev server from this branch, Chromium, permission-set create form) in en and zh-CN at 1280 px and 820 px wide. The help wraps onto a second or third line (`white-space: normal`, `overflow: visible`, `scrollWidth == clientWidth` on the help element) and is never truncated, so nothing changes there. Screenshots: `en0.png`, `en1.png`, `zh1.png`, `en-820.png`, `zh-820.png` in the project folder `ui-seat-1/r2026-10-30/out/28232/`. ## Tests New: `ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.underivableName-28232.test.tsx` (2), the real page with the live registrations (`registerBuiltinAnchors`) and the real served permission form from `METADATA_FORM_REGISTRY`: - (a) an all-Chinese label: exactly one line, in the Name row (not the Label row), Name empty; typing a Name removes it; clearing Name brings it back; - (b) CONTROL: a label with Latin letters fills Name (`Sales 运营` → `sales`) with no line; a pristine form and a cleared label show none. **Ablation** (through `scripts/ablation-replace.mjs`, each mutation proved on disk and restored to the HEAD blob): - the page hands `SchemaForm` no notes (`underivable.length > 0` → `< 0`) → (a) red, (b) green; - `underivableTargets` ignores a typed Name (the target-holds-a-value line deleted) → (a) red at "goes away once a Name is typed", (b) green. ## Acceptance notes - The object and report designers keep their own Name input in their curated home panels (`ObjectDefaultInspector`, `ReportDefaultInspector`), so their create pages do not show this line; that is outside the order's file surface (the create form and `createDerive`). - Seen while measuring, not changed: on the same form with an all-Chinese label, the page's summary banner already lists Name's two schema errors in English ("Identifier must be at least 2 characters", "Identifier must be lowercase snake_case, …") in the zh-CN UI too, and the second one is cut with an ellipsis at 820 px. That banner text and its width are not this card's. - `Clause-②: no` follows the precedent of #28293 (#28179), which added the optional `SchemaForm` prop `onRequiredRowFieldsChange` under `Clause-②: no`; `fieldNotes` is the same shape of change. The seat may judge otherwise. ## Verification <details><summary>Local runs</summary> Head `74a406d6` (branch merged with `main` `92120357`, which carries #28296). - `OS_SKIP_DTS=1 pnpm turbo run build --filter='@object-ui/app-shell^...' --filter='!@object-ui/*'` (under the verify lock) → VERDICT command-exit 0; after the merge, `env -u OS_SKIP_DTS pnpm turbo run build --filter='@object-ui/app-shell^...'` → `Tasks: 64 successful, 64 total`. - `type-check` of `@object-ui/app-shell` and `@object-ui/i18n` (each `tsc --noEmit` and `tsc -p tsconfig.test.json`) after that dts build → VERDICT command-exit 0. - New file alone → `Test Files 1 passed (1)`, `Tests 2 passed (2)`. - Before the merge, under the verify lock: `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/ packages/i18n/` → `Test Files 781 passed (781)`, `Tests 8693 passed | 14 skipped (8707)`, VERDICT command-exit 0. After the merge (scoped to the overlap with #28296): `ResourceEditPage*`, `SchemaForm*`, `createBody*` and `packages/i18n/` → `Test Files 173 passed (173)`, `Tests 2023 passed | 13 skipped (2036)`, VERDICT command-exit 0. The whole `packages/app-shell/` suite was not run locally (CI's Test UI runs it). - `pnpm --dir ui exec eslint` on the six changed `ts` / `tsx` files → 0 errors (the warnings are on lines this diff does not touch). `pnpm --dir ui run check:new-line-citations` → 0 new citations, exit 0. - Live Console: `pnpm --filter @objectstack/example-showcase dev -- --fresh -p 38231` (the root `pnpm dev` preflight refused on unbuilt `ui/` dist entries the Console dev server does not use) plus `ui/apps/console` `vite --port 5291 --strictPort` against it; Chromium via Playwright; servers stopped by their own ports afterwards. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `74a406d6` → 34 commands, each run with stdin from /dev/null and its exit code captured before any pipe (`pnpm --dir ui exec vitest run scripts/` under the verify lock: `Test Files 169 passed | 2 skipped (171)`, `Tests 4901 passed | 2 skipped (4903)`). `--ran` reconcile: `34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. ``` 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:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: 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 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzY3yWKQ3Yxq1YdCDq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02d6507 commit 0fd2d08

7 files changed

Lines changed: 221 additions & 7 deletions

File tree

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+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(console): a metadata create form says so next to Name when the label cannot fill it
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A metadata create form that fills `name` from the label (a permission set, a view, a flow, a page, an action, a hook, a dashboard, a doc) keeps only the label's Latin letters and digits. A label written all in Chinese or Japanese therefore left Name empty and required, with nothing at the field saying why.
12+
13+
Now the Name row says "This label can't be turned into a name; type one in snake_case." (Chinese in the zh locale) while the label holds text that gives no name and Name is empty. The line goes away once a Name is typed. How a name is derived from the label does not change.
14+
15+
`SchemaForm` gains an optional `fieldNotes` prop: one line shown under a top-level field, keyed by field.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,12 +681,18 @@ function MetadataResourceEditPageImpl({
681681
},
682682
[handleDraftChange],
683683
);
684-
const { markTouched: markCreateFieldTouched } = useCreateDerive({
684+
const { markTouched: markCreateFieldTouched, underivable } = useCreateDerive({
685685
rules: config.createDerive,
686686
draft,
687687
onPatch: onCreatePatch,
688688
enabled: !!createMode,
689689
});
690+
// objectstack#28232 — a label the slug rule cannot turn into a name leaves
691+
// Name empty; the form says so under it until the author types one.
692+
const createFieldNotes =
693+
underivable.length > 0
694+
? Object.fromEntries(underivable.map((f) => [f, t('engine.create.nameNotDerived', locale)]))
695+
: undefined;
690696

691697
// Effective hidden-fields for create mode: collapse the form to just
692698
// the identity inputs declared by the type (or required-fields ∪
@@ -3443,6 +3449,7 @@ function MetadataResourceEditPageImpl({
34433449
revealIssues={revealIssues}
34443450
onInlineIssueFieldsChange={setInlineIssueFields}
34453451
onRequiredRowFieldsChange={setRequiredRowFields}
3452+
fieldNotes={createFieldNotes}
34463453
/>
34473454
)}
34483455
</div>
@@ -3470,6 +3477,7 @@ function MetadataResourceEditPageImpl({
34703477
revealIssues={revealIssues}
34713478
onInlineIssueFieldsChange={setInlineIssueFields}
34723479
onRequiredRowFieldsChange={setRequiredRowFields}
3480+
fieldNotes={createFieldNotes}
34733481
/>
34743482
)}
34753483
{/* objectui#7611 — under the designer, too: `position` renders
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28232 — a create form whose Name the label cannot fill says so
5+
* next to Name.
6+
*
7+
* The permission set's create form fills `name` from `label` through
8+
* `slugify`, which keeps only `[a-z0-9]`. A label written all in Chinese leaves
9+
* Name empty and required:
10+
*
11+
* (a) the Name row carries one line saying the label cannot be turned into a
12+
* name, and the line goes away once a Name is typed (and comes back when
13+
* it is cleared again);
14+
* (b) CONTROL — a label with Latin letters fills Name and shows no line, and
15+
* neither does a pristine form or one whose label was cleared.
16+
*
17+
* The REAL page with the live registration (`registerBuiltinAnchors`: Label →
18+
* Name derive, `createFields: ['label', 'name']`) and the real served form from
19+
* `METADATA_FORM_REGISTRY`.
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import * as React from 'react';
24+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
25+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
26+
import { MemoryRouter } from 'react-router-dom';
27+
import { z } from 'zod';
28+
import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel';
29+
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
30+
import { t } from './i18n';
31+
32+
const NOTE = t('engine.create.nameNotDerived', 'en-US');
33+
34+
const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, {
35+
io: 'input',
36+
unrepresentable: 'any',
37+
}) as Record<string, unknown>;
38+
39+
const CLIENT = {
40+
list: vi.fn(async () => []),
41+
listDrafts: vi.fn(async () => []),
42+
get: vi.fn(async () => null),
43+
getDraft: vi.fn(async () => null),
44+
references: vi.fn(async () => []),
45+
layered: vi.fn(async () => null),
46+
save: vi.fn(async (_type: string, _name: string, payload: Record<string, unknown>) => payload),
47+
history: vi.fn(async () => []),
48+
};
49+
50+
vi.mock('./useMetadata', async (importOriginal) => ({
51+
...(await importOriginal<typeof import('./useMetadata')>()),
52+
useMetadataClient: () => CLIENT,
53+
useMetadataTypes: () => ({
54+
loading: false,
55+
error: null,
56+
entries: [
57+
{
58+
type: 'permission',
59+
label: 'Permission Set',
60+
domain: 'security',
61+
allowEnvironmentOverlay: true,
62+
allowRuntimeCreate: true,
63+
schema: PERMISSION_JSON_SCHEMA,
64+
form: METADATA_FORM_REGISTRY.permission,
65+
createSeed: getMetadataCreateSeed('permission') as Record<string, unknown>,
66+
},
67+
],
68+
}),
69+
}));
70+
71+
import { MetadataResourceEditPage } from './ResourceEditPage';
72+
import { registerDefaultMetadataSchemas } from './default-schemas';
73+
import { registerBuiltinAnchors } from './anchors';
74+
75+
registerDefaultMetadataSchemas();
76+
registerBuiltinAnchors();
77+
78+
beforeEach(() => {
79+
window.localStorage.setItem('metadata-admin:autosave', '0');
80+
});
81+
afterEach(() => {
82+
cleanup();
83+
});
84+
85+
async function openNewPermissionSet() {
86+
render(
87+
<MemoryRouter initialEntries={['/metadata/permission/new']}>
88+
<MetadataResourceEditPage type="permission" name="" createMode />
89+
</MemoryRouter>,
90+
);
91+
const nameBox = (await screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 })) as HTMLInputElement;
92+
const labelBox = screen.getByRole('textbox', { name: /^Label/ }) as HTMLInputElement;
93+
return { nameBox, labelBox };
94+
}
95+
96+
/** The row hosting `control` — the field's own block in the form grid. */
97+
function rowOf(control: HTMLElement): HTMLElement {
98+
return control.closest('[style*="grid-column"]') as HTMLElement;
99+
}
100+
101+
const noteIn = (row: HTMLElement) => within(row).queryByTestId('schema-form-field-note');
102+
103+
describe('a label that cannot fill Name says so next to Name (objectstack#28232)', () => {
104+
it('(a) an all-Chinese label: the Name row says so until a Name is typed', async () => {
105+
const { nameBox, labelBox } = await openNewPermissionSet();
106+
fireEvent.change(labelBox, { target: { value: '销售运营' } });
107+
await waitFor(() => expect(noteIn(rowOf(nameBox))).toHaveTextContent(NOTE));
108+
expect(nameBox.value).toBe('');
109+
expect(screen.getAllByTestId('schema-form-field-note')).toHaveLength(1);
110+
expect(noteIn(rowOf(labelBox))).toBeNull();
111+
112+
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
113+
await waitFor(() => expect(screen.queryByTestId('schema-form-field-note')).toBeNull());
114+
115+
fireEvent.change(nameBox, { target: { value: '' } });
116+
await waitFor(() => expect(noteIn(rowOf(nameBox))).toHaveTextContent(NOTE));
117+
});
118+
119+
it('(b) CONTROL — a label with Latin letters fills Name and says nothing; so does a pristine or cleared form', async () => {
120+
const { nameBox, labelBox } = await openNewPermissionSet();
121+
expect(screen.queryByTestId('schema-form-field-note')).toBeNull();
122+
123+
fireEvent.change(labelBox, { target: { value: 'Sales 运营' } });
124+
await waitFor(() => expect(nameBox.value).toBe('sales'));
125+
expect(screen.queryByTestId('schema-form-field-note')).toBeNull();
126+
127+
fireEvent.change(labelBox, { target: { value: '' } });
128+
await waitFor(() => expect(nameBox.value).toBe(''));
129+
expect(screen.queryByTestId('schema-form-field-note')).toBeNull();
130+
});
131+
});

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,13 @@ export interface SchemaFormProps {
11431143
* reports none.
11441144
*/
11451145
onRequiredRowFieldsChange?: (fields: ReadonlySet<string>) => void;
1146+
/**
1147+
* One line the host shows under a TOP-LEVEL field, keyed by field: a fact
1148+
* about that field's value the host knows and the schema cannot say, such
1149+
* as a create form's Name that could not be filled from the label
1150+
* (objectstack#28232). Shown at once, never deferred like an issue.
1151+
*/
1152+
fieldNotes?: Readonly<Record<string, string>>;
11461153
}
11471154

11481155
function SchemaFormBody({
@@ -1158,6 +1165,7 @@ function SchemaFormBody({
11581165
widgetContext,
11591166
idPath,
11601167
onRequiredRowFieldsChange,
1168+
fieldNotes,
11611169
}: SchemaFormProps) {
11621170
// Live app locale (follows the i18next language, not just the browser) —
11631171
// hoisted above the no-schema early return so the hook order is stable.
@@ -1283,6 +1291,7 @@ function SchemaFormBody({
12831291
createMode={createMode}
12841292
widgetContext={widgetContext}
12851293
onRequiredRowFieldsChange={onRequiredRowFieldsChange}
1294+
fieldNotes={fieldNotes}
12861295
onChange={setField}
12871296
/>
12881297
);
@@ -1309,6 +1318,7 @@ function SchemaFormBody({
13091318
value={(v as Record<string, unknown>)[key]}
13101319
required={required.includes(key)}
13111320
issues={issuesByPath[key]}
1321+
note={fieldNotes?.[key]}
13121322
readOnly={readOnly}
13131323
widgetContext={widgetContext}
13141324
// Honor an explicit `widget` declared on a raw JSON-schema property
@@ -1595,6 +1605,7 @@ function SectionedSchemaForm({
15951605
widgetContext,
15961606
idPath,
15971607
onRequiredRowFieldsChange,
1608+
fieldNotes,
15981609
onChange,
15991610
}: {
16001611
/** The form's sections, already through {@link resolveFormSections}. */
@@ -1611,6 +1622,7 @@ function SectionedSchemaForm({
16111622
/** Ancestor id path, forwarded from the hosting `SchemaForm` (#5062). */
16121623
idPath?: string;
16131624
onRequiredRowFieldsChange?: SchemaFormProps['onRequiredRowFieldsChange'];
1625+
fieldNotes?: SchemaFormProps['fieldNotes'];
16141626
onChange: (key: string, val: unknown) => void;
16151627
}) {
16161628
const locale = useMetadataLocale();
@@ -1712,6 +1724,7 @@ function SectionedSchemaForm({
17121724
value={value[f.field]}
17131725
required={f.required ?? required.includes(f.field)}
17141726
issues={issuesByPath[f.field]}
1727+
note={fieldNotes?.[f.field]}
17151728
readOnly={rowReadOnly(f)}
17161729
fieldSpec={f}
17171730
widgetContext={widgetContext}
@@ -1854,6 +1867,7 @@ function FieldRow({
18541867
value,
18551868
required,
18561869
issues,
1870+
note,
18571871
readOnly,
18581872
fieldSpec,
18591873
widgetContext,
@@ -1873,6 +1887,8 @@ function FieldRow({
18731887
value: unknown;
18741888
required: boolean;
18751889
issues?: string[];
1890+
/** The host's line for this field ({@link SchemaFormProps.fieldNotes}). */
1891+
note?: string;
18761892
readOnly?: boolean;
18771893
fieldSpec?: FormFieldSpec;
18781894
widgetContext?: WidgetContext;
@@ -2015,6 +2031,11 @@ function FieldRow({
20152031
{description && (
20162032
<div className="text-xs text-muted-foreground mt-0.5">{description}</div>
20172033
)}
2034+
{note && (
2035+
<div data-testid="schema-form-field-note" className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
2036+
{note}
2037+
</div>
2038+
)}
20182039
{visibleIssues?.map((m, i) => (
20192040
<div key={i} data-testid="schema-form-issue" className="text-xs text-destructive mt-0.5">{m}</div>
20202041
))}
@@ -2071,6 +2092,11 @@ function FieldRow({
20712092
{description && (
20722093
<div className="text-xs text-muted-foreground">{description}</div>
20732094
)}
2095+
{note && (
2096+
<div data-testid="schema-form-field-note" className="text-xs text-amber-700 dark:text-amber-400">
2097+
{note}
2098+
</div>
2099+
)}
20742100
{visibleIssues?.map((m, i) => (
20752101
<div key={i} data-testid="schema-form-issue" className="text-xs text-destructive">
20762102
{m}

‎ui/packages/app-shell/src/views/metadata-admin/createDerive.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import type { CreateDeriveRule } from './registry.js';
1919
* Strategy: NFKD-normalise, lowercase, replace runs of non-alphanum
2020
* with `_`, trim, strip leading digit, clamp to 64 chars.
2121
*
22-
* Non-Latin scripts (CJK, Arabic, …) leave the result empty — the
23-
* caller surface is expected to detect that and prompt the user to
24-
* enter `name` manually rather than silently writing garbage.
22+
* Non-Latin scripts (CJK, Arabic, …) leave the result empty rather than
23+
* silently writing garbage; {@link underivableTargets} is how the create
24+
* form detects that and prompts the user to enter `name` manually.
2525
*/
2626
export function slugify(input: string): string {
2727
if (typeof input !== 'string') return '';
@@ -111,6 +111,28 @@ export function applyTransform(
111111
}
112112
}
113113

114+
/**
115+
* The targets a `slugify` rule cannot fill: the source holds text whose slug
116+
* is empty (a label written all in Chinese or Japanese keeps no `[a-z0-9]`)
117+
* and the target is still empty. The create form says so next to each one
118+
* (objectstack#28232); a target the author has typed into drops out.
119+
*/
120+
export function underivableTargets(
121+
rules: readonly CreateDeriveRule[] | undefined,
122+
draft: Record<string, unknown>,
123+
): string[] {
124+
const out: string[] = [];
125+
for (const rule of rules ?? []) {
126+
if (rule.transform !== 'slugify' || out.includes(rule.to)) continue;
127+
const source = draft[rule.from];
128+
if (typeof source !== 'string' || source.trim() === '') continue;
129+
const target = draft[rule.to];
130+
if (target != null && String(target).trim() !== '') continue;
131+
if (slugify(source) === '') out.push(rule.to);
132+
}
133+
return out;
134+
}
135+
114136
// ── React hook ────────────────────────────────────────────────────────
115137

116138
export interface UseCreateDeriveArgs<T extends Record<string, unknown>> {
@@ -132,6 +154,11 @@ export interface UseCreateDeriveResult {
132154
markTouched: (path: string) => void;
133155
/** Read-only set of currently touched target paths. */
134156
touched: ReadonlySet<string>;
157+
/**
158+
* The targets the author has to type: {@link underivableTargets} of the
159+
* current draft, empty when derivation is suspended.
160+
*/
161+
underivable: readonly string[];
135162
}
136163

137164
/**
@@ -178,8 +205,8 @@ export function useCreateDerive<T extends Record<string, unknown>>(
178205
// Don't fight other rules in the same batch.
179206
if (rule.to in patch) continue;
180207
const derived = applyTransform(rule.transform, fromVal);
181-
// Empty derivations (e.g. CJK → slugify) just clear the target;
182-
// that's intentional — the UI then knows to prompt for manual entry.
208+
// Empty derivations (e.g. CJK → slugify) just clear the target; the
209+
// form prompts for manual entry from `underivable` below.
183210
if (draft[rule.to] !== derived) {
184211
patch[rule.to] = derived;
185212
}
@@ -196,7 +223,11 @@ export function useCreateDerive<T extends Record<string, unknown>>(
196223
// eslint-disable-next-line react-hooks/exhaustive-deps
197224
}, [draft, enabled]);
198225

199-
return { markTouched, touched: touchedRef.current };
226+
return {
227+
markTouched,
228+
touched: touchedRef.current,
229+
underivable: enabled ? underivableTargets(rules, draft) : [],
230+
};
200231
}
201232

202233
// ── Default field selection ───────────────────────────────────────────

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,8 @@ const STUDIO_EN: Record<string, string> = {
17161716
'engine.form.keyPattern': 'Key must match {pattern}',
17171717
'engine.form.invalidJson': 'Invalid JSON',
17181718
'engine.form.rootJsonObject': 'Root must be a JSON object',
1719+
// objectstack#28232 — under a create form's Name the label could not fill.
1720+
'engine.create.nameNotDerived': "This label can't be turned into a name; type one in snake_case.",
17191721
// objectui#8218 — the metadata-admin form is now the Studio 「界面」 property
17201722
// panel an AI-build maker edits, so every literal it still printed in English
17211723
// landed inside an otherwise-Chinese surface. Swept from `widgets.tsx` +

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,7 @@ const STUDIO_ZH: Record<string, string> = {
15271527
'engine.form.keyPattern': '键名必须匹配 {pattern}',
15281528
'engine.form.invalidJson': 'JSON 无效',
15291529
'engine.form.rootJsonObject': '根节点必须是 JSON 对象',
1530+
'engine.create.nameNotDerived': '无法从此显示名称生成名称,请手动输入(snake_case)。',
15301531
// objectui#8218 — zh half of the same sweep. Wording targets the AI-build
15311532
// maker who now reads this panel, not a metadata administrator.
15321533
'engine.form.machineName': '机器名',

0 commit comments

Comments
 (0)