Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/28232-name-not-derived-hint.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 ∪
Expand Down Expand Up @@ -3443,6 +3449,7 @@ function MetadataResourceEditPageImpl({
revealIssues={revealIssues}
onInlineIssueFieldsChange={setInlineIssueFields}
onRequiredRowFieldsChange={setRequiredRowFields}
fieldNotes={createFieldNotes}
/>
)}
</div>
Expand Down Expand Up @@ -3470,6 +3477,7 @@ function MetadataResourceEditPageImpl({
revealIssues={revealIssues}
onInlineIssueFieldsChange={setInlineIssueFields}
onRequiredRowFieldsChange={setRequiredRowFields}
fieldNotes={createFieldNotes}
/>
)}
{/* objectui#7611 — under the designer, too: `position` renders
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<string, unknown>) => payload),
history: vi.fn(async () => []),
};

vi.mock('./useMetadata', async (importOriginal) => ({
...(await importOriginal<typeof import('./useMetadata')>()),
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<string, unknown>,
},
],
}),
}));

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(
<MemoryRouter initialEntries={['/metadata/permission/new']}>
<MetadataResourceEditPage type="permission" name="" createMode />
</MemoryRouter>,
);
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();
});
});
26 changes: 26 additions & 0 deletions ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,13 @@ export interface SchemaFormProps {
* reports none.
*/
onRequiredRowFieldsChange?: (fields: ReadonlySet<string>) => 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<Record<string, string>>;
}

function SchemaFormBody({
Expand All @@ -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.
Expand Down Expand Up @@ -1283,6 +1291,7 @@ function SchemaFormBody({
createMode={createMode}
widgetContext={widgetContext}
onRequiredRowFieldsChange={onRequiredRowFieldsChange}
fieldNotes={fieldNotes}
onChange={setField}
/>
);
Expand All @@ -1309,6 +1318,7 @@ function SchemaFormBody({
value={(v as Record<string, unknown>)[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
Expand Down Expand Up @@ -1595,6 +1605,7 @@ function SectionedSchemaForm({
widgetContext,
idPath,
onRequiredRowFieldsChange,
fieldNotes,
onChange,
}: {
/** The form's sections, already through {@link resolveFormSections}. */
Expand All @@ -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();
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1854,6 +1867,7 @@ function FieldRow({
value,
required,
issues,
note,
readOnly,
fieldSpec,
widgetContext,
Expand All @@ -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;
Expand Down Expand Up @@ -2015,6 +2031,11 @@ function FieldRow({
{description && (
<div className="text-xs text-muted-foreground mt-0.5">{description}</div>
)}
{note && (
<div data-testid="schema-form-field-note" className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{note}
</div>
)}
{visibleIssues?.map((m, i) => (
<div key={i} data-testid="schema-form-issue" className="text-xs text-destructive mt-0.5">{m}</div>
))}
Expand Down Expand Up @@ -2071,6 +2092,11 @@ function FieldRow({
{description && (
<div className="text-xs text-muted-foreground">{description}</div>
)}
{note && (
<div data-testid="schema-form-field-note" className="text-xs text-amber-700 dark:text-amber-400">
{note}
</div>
)}
{visibleIssues?.map((m, i) => (
<div key={i} data-testid="schema-form-issue" className="text-xs text-destructive">
{m}
Expand Down
43 changes: 37 additions & 6 deletions ui/packages/app-shell/src/views/metadata-admin/createDerive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down Expand Up @@ -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, unknown>,
): 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<T extends Record<string, unknown>> {
Expand All @@ -132,6 +154,11 @@ export interface UseCreateDeriveResult {
markTouched: (path: string) => void;
/** Read-only set of currently touched target paths. */
touched: ReadonlySet<string>;
/**
* The targets the author has to type: {@link underivableTargets} of the
* current draft, empty when derivation is suspended.
*/
underivable: readonly string[];
}

/**
Expand Down Expand Up @@ -178,8 +205,8 @@ export function useCreateDerive<T extends Record<string, unknown>>(
// 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;
}
Expand All @@ -196,7 +223,11 @@ export function useCreateDerive<T extends Record<string, unknown>>(
// 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 ───────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,8 @@ const STUDIO_EN: Record<string, string> = {
'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` +
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,7 @@ const STUDIO_ZH: Record<string, string> = {
'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': '机器名',
Expand Down
Loading