diff --git a/.changeset/28232-name-not-derived-hint.md b/.changeset/28232-name-not-derived-hint.md new file mode 100644 index 00000000000..d43bc18084b --- /dev/null +++ b/.changeset/28232-name-not-derived-hint.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(console): a metadata create form says so next to Name when the label cannot fill it + +Clause-②: no +Metadata-protocol: none + +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. + +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. + +`SchemaForm` gains an optional `fieldNotes` prop: one line shown under a top-level field, keyed by field. diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 43622480938..5d16344f6a9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -681,12 +681,18 @@ function MetadataResourceEditPageImpl({ }, [handleDraftChange], ); - const { markTouched: markCreateFieldTouched } = useCreateDerive({ + const { markTouched: markCreateFieldTouched, underivable } = useCreateDerive({ rules: config.createDerive, draft, onPatch: onCreatePatch, enabled: !!createMode, }); + // objectstack#28232 — a label the slug rule cannot turn into a name leaves + // Name empty; the form says so under it until the author types one. + const createFieldNotes = + underivable.length > 0 + ? Object.fromEntries(underivable.map((f) => [f, t('engine.create.nameNotDerived', locale)])) + : undefined; // Effective hidden-fields for create mode: collapse the form to just // the identity inputs declared by the type (or required-fields ∪ @@ -3443,6 +3449,7 @@ function MetadataResourceEditPageImpl({ revealIssues={revealIssues} onInlineIssueFieldsChange={setInlineIssueFields} onRequiredRowFieldsChange={setRequiredRowFields} + fieldNotes={createFieldNotes} /> )} @@ -3470,6 +3477,7 @@ function MetadataResourceEditPageImpl({ revealIssues={revealIssues} onInlineIssueFieldsChange={setInlineIssueFields} onRequiredRowFieldsChange={setRequiredRowFields} + fieldNotes={createFieldNotes} /> )} {/* objectui#7611 — under the designer, too: `position` renders diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.underivableName-28232.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.underivableName-28232.test.tsx new file mode 100644 index 00000000000..0c45ab9d808 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.underivableName-28232.test.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28232 — a create form whose Name the label cannot fill says so + * next to Name. + * + * The permission set's create form fills `name` from `label` through + * `slugify`, which keeps only `[a-z0-9]`. A label written all in Chinese leaves + * Name empty and required: + * + * (a) the Name row carries one line saying the label cannot be turned into a + * name, and the line goes away once a Name is typed (and comes back when + * it is cleared again); + * (b) CONTROL — a label with Latin letters fills Name and shows no line, and + * neither does a pristine form or one whose label was cleared. + * + * The REAL page with the live registration (`registerBuiltinAnchors`: Label → + * Name derive, `createFields: ['label', 'name']`) and the real served form from + * `METADATA_FORM_REGISTRY`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { z } from 'zod'; +import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { t } from './i18n'; + +const NOTE = t('engine.create.nameNotDerived', 'en-US'); + +const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, { + io: 'input', + unrepresentable: 'any', +}) as Record; + +const CLIENT = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => null), + save: vi.fn(async (_type: string, _name: string, payload: Record) => payload), + history: vi.fn(async () => []), +}; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { + type: 'permission', + label: 'Permission Set', + domain: 'security', + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, + schema: PERMISSION_JSON_SCHEMA, + form: METADATA_FORM_REGISTRY.permission, + createSeed: getMetadataCreateSeed('permission') as Record, + }, + ], + }), +})); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { registerBuiltinAnchors } from './anchors'; + +registerDefaultMetadataSchemas(); +registerBuiltinAnchors(); + +beforeEach(() => { + window.localStorage.setItem('metadata-admin:autosave', '0'); +}); +afterEach(() => { + cleanup(); +}); + +async function openNewPermissionSet() { + render( + + + , + ); + const nameBox = (await screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 })) as HTMLInputElement; + const labelBox = screen.getByRole('textbox', { name: /^Label/ }) as HTMLInputElement; + return { nameBox, labelBox }; +} + +/** The row hosting `control` — the field's own block in the form grid. */ +function rowOf(control: HTMLElement): HTMLElement { + return control.closest('[style*="grid-column"]') as HTMLElement; +} + +const noteIn = (row: HTMLElement) => within(row).queryByTestId('schema-form-field-note'); + +describe('a label that cannot fill Name says so next to Name (objectstack#28232)', () => { + it('(a) an all-Chinese label: the Name row says so until a Name is typed', async () => { + const { nameBox, labelBox } = await openNewPermissionSet(); + fireEvent.change(labelBox, { target: { value: '销售运营' } }); + await waitFor(() => expect(noteIn(rowOf(nameBox))).toHaveTextContent(NOTE)); + expect(nameBox.value).toBe(''); + expect(screen.getAllByTestId('schema-form-field-note')).toHaveLength(1); + expect(noteIn(rowOf(labelBox))).toBeNull(); + + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); + await waitFor(() => expect(screen.queryByTestId('schema-form-field-note')).toBeNull()); + + fireEvent.change(nameBox, { target: { value: '' } }); + await waitFor(() => expect(noteIn(rowOf(nameBox))).toHaveTextContent(NOTE)); + }); + + it('(b) CONTROL — a label with Latin letters fills Name and says nothing; so does a pristine or cleared form', async () => { + const { nameBox, labelBox } = await openNewPermissionSet(); + expect(screen.queryByTestId('schema-form-field-note')).toBeNull(); + + fireEvent.change(labelBox, { target: { value: 'Sales 运营' } }); + await waitFor(() => expect(nameBox.value).toBe('sales')); + expect(screen.queryByTestId('schema-form-field-note')).toBeNull(); + + fireEvent.change(labelBox, { target: { value: '' } }); + await waitFor(() => expect(nameBox.value).toBe('')); + expect(screen.queryByTestId('schema-form-field-note')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 92341066189..8480c2b14e0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -1143,6 +1143,13 @@ export interface SchemaFormProps { * reports none. */ onRequiredRowFieldsChange?: (fields: ReadonlySet) => void; + /** + * One line the host shows under a TOP-LEVEL field, keyed by field: a fact + * about that field's value the host knows and the schema cannot say, such + * as a create form's Name that could not be filled from the label + * (objectstack#28232). Shown at once, never deferred like an issue. + */ + fieldNotes?: Readonly>; } function SchemaFormBody({ @@ -1158,6 +1165,7 @@ function SchemaFormBody({ widgetContext, idPath, onRequiredRowFieldsChange, + fieldNotes, }: SchemaFormProps) { // Live app locale (follows the i18next language, not just the browser) — // hoisted above the no-schema early return so the hook order is stable. @@ -1283,6 +1291,7 @@ function SchemaFormBody({ createMode={createMode} widgetContext={widgetContext} onRequiredRowFieldsChange={onRequiredRowFieldsChange} + fieldNotes={fieldNotes} onChange={setField} /> ); @@ -1309,6 +1318,7 @@ function SchemaFormBody({ value={(v as Record)[key]} required={required.includes(key)} issues={issuesByPath[key]} + note={fieldNotes?.[key]} readOnly={readOnly} widgetContext={widgetContext} // Honor an explicit `widget` declared on a raw JSON-schema property @@ -1595,6 +1605,7 @@ function SectionedSchemaForm({ widgetContext, idPath, onRequiredRowFieldsChange, + fieldNotes, onChange, }: { /** The form's sections, already through {@link resolveFormSections}. */ @@ -1611,6 +1622,7 @@ function SectionedSchemaForm({ /** Ancestor id path, forwarded from the hosting `SchemaForm` (#5062). */ idPath?: string; onRequiredRowFieldsChange?: SchemaFormProps['onRequiredRowFieldsChange']; + fieldNotes?: SchemaFormProps['fieldNotes']; onChange: (key: string, val: unknown) => void; }) { const locale = useMetadataLocale(); @@ -1712,6 +1724,7 @@ function SectionedSchemaForm({ value={value[f.field]} required={f.required ?? required.includes(f.field)} issues={issuesByPath[f.field]} + note={fieldNotes?.[f.field]} readOnly={rowReadOnly(f)} fieldSpec={f} widgetContext={widgetContext} @@ -1854,6 +1867,7 @@ function FieldRow({ value, required, issues, + note, readOnly, fieldSpec, widgetContext, @@ -1873,6 +1887,8 @@ function FieldRow({ value: unknown; required: boolean; issues?: string[]; + /** The host's line for this field ({@link SchemaFormProps.fieldNotes}). */ + note?: string; readOnly?: boolean; fieldSpec?: FormFieldSpec; widgetContext?: WidgetContext; @@ -2015,6 +2031,11 @@ function FieldRow({ {description && (
{description}
)} + {note && ( +
+ {note} +
+ )} {visibleIssues?.map((m, i) => (
{m}
))} @@ -2071,6 +2092,11 @@ function FieldRow({ {description && (
{description}
)} + {note && ( +
+ {note} +
+ )} {visibleIssues?.map((m, i) => (
{m} diff --git a/ui/packages/app-shell/src/views/metadata-admin/createDerive.ts b/ui/packages/app-shell/src/views/metadata-admin/createDerive.ts index d4fabb3f283..9195a5d24c1 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/createDerive.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/createDerive.ts @@ -19,9 +19,9 @@ import type { CreateDeriveRule } from './registry.js'; * Strategy: NFKD-normalise, lowercase, replace runs of non-alphanum * with `_`, trim, strip leading digit, clamp to 64 chars. * - * Non-Latin scripts (CJK, Arabic, …) leave the result empty — the - * caller surface is expected to detect that and prompt the user to - * enter `name` manually rather than silently writing garbage. + * Non-Latin scripts (CJK, Arabic, …) leave the result empty rather than + * silently writing garbage; {@link underivableTargets} is how the create + * form detects that and prompts the user to enter `name` manually. */ export function slugify(input: string): string { if (typeof input !== 'string') return ''; @@ -111,6 +111,28 @@ export function applyTransform( } } +/** + * The targets a `slugify` rule cannot fill: the source holds text whose slug + * is empty (a label written all in Chinese or Japanese keeps no `[a-z0-9]`) + * and the target is still empty. The create form says so next to each one + * (objectstack#28232); a target the author has typed into drops out. + */ +export function underivableTargets( + rules: readonly CreateDeriveRule[] | undefined, + draft: Record, +): string[] { + const out: string[] = []; + for (const rule of rules ?? []) { + if (rule.transform !== 'slugify' || out.includes(rule.to)) continue; + const source = draft[rule.from]; + if (typeof source !== 'string' || source.trim() === '') continue; + const target = draft[rule.to]; + if (target != null && String(target).trim() !== '') continue; + if (slugify(source) === '') out.push(rule.to); + } + return out; +} + // ── React hook ──────────────────────────────────────────────────────── export interface UseCreateDeriveArgs> { @@ -132,6 +154,11 @@ export interface UseCreateDeriveResult { markTouched: (path: string) => void; /** Read-only set of currently touched target paths. */ touched: ReadonlySet; + /** + * The targets the author has to type: {@link underivableTargets} of the + * current draft, empty when derivation is suspended. + */ + underivable: readonly string[]; } /** @@ -178,8 +205,8 @@ export function useCreateDerive>( // Don't fight other rules in the same batch. if (rule.to in patch) continue; const derived = applyTransform(rule.transform, fromVal); - // Empty derivations (e.g. CJK → slugify) just clear the target; - // that's intentional — the UI then knows to prompt for manual entry. + // Empty derivations (e.g. CJK → slugify) just clear the target; the + // form prompts for manual entry from `underivable` below. if (draft[rule.to] !== derived) { patch[rule.to] = derived; } @@ -196,7 +223,11 @@ export function useCreateDerive>( // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft, enabled]); - return { markTouched, touched: touchedRef.current }; + return { + markTouched, + touched: touchedRef.current, + underivable: enabled ? underivableTargets(rules, draft) : [], + }; } // ── Default field selection ─────────────────────────────────────────── diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 2c25aad6419..b6321e720fb 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1716,6 +1716,8 @@ const STUDIO_EN: Record = { 'engine.form.keyPattern': 'Key must match {pattern}', 'engine.form.invalidJson': 'Invalid JSON', 'engine.form.rootJsonObject': 'Root must be a JSON object', + // objectstack#28232 — under a create form's Name the label could not fill. + 'engine.create.nameNotDerived': "This label can't be turned into a name; type one in snake_case.", // objectui#8218 — the metadata-admin form is now the Studio 「界面」 property // panel an AI-build maker edits, so every literal it still printed in English // landed inside an otherwise-Chinese surface. Swept from `widgets.tsx` + diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index aec9287fa9e..1ffa22d5ad2 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1527,6 +1527,7 @@ const STUDIO_ZH: Record = { 'engine.form.keyPattern': '键名必须匹配 {pattern}', 'engine.form.invalidJson': 'JSON 无效', 'engine.form.rootJsonObject': '根节点必须是 JSON 对象', + 'engine.create.nameNotDerived': '无法从此显示名称生成名称,请手动输入(snake_case)。', // objectui#8218 — zh half of the same sweep. Wording targets the AI-build // maker who now reads this panel, not a metadata administrator. 'engine.form.machineName': '机器名',