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/28369-identifier-issues-in-reader-locale.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 states identifier errors in the reader's language, and its summary banner wraps them

Clause-②: yes (widening)
Metadata-protocol: none

A Chinese-language administrator who gave a new permission set (or any metadata item whose name is a snake_case identifier) an all-Chinese label was told in English, in the banner above the form, that Name "must be at least 2 characters" and "must be lowercase snake_case …", and the second sentence was cut off with an ellipsis on a narrow window.

The banner and the inline field errors now pick the text by what the schema issue is (its code, and for a pattern its regex), never by reading the English: in Chinese they read 至少需要 2 个字符 and the snake_case rule in Chinese. In English they still show the sentences `@objectstack/spec` wrote, which do not change. The banner's lines now wrap instead of being truncated.

`@object-ui/i18n`'s `studio` namespace gains the zh-only `engine.validation.spec.*` rows (`stringTooShort`, `snakeCaseIdentifier`, `systemIdentifier`); English has no row on purpose and falls back to the spec's message. `SchemaFormIssue` gains an optional `detail` (the issue's code and parameters).
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#28369 — the create form's summary banner states Name's identifier
* errors in the reader's locale, and wraps them instead of cutting them.
*
* A zh-CN administrator who types an all-Chinese label into a new permission
* set leaves Name empty, and `SnakeCaseIdentifierSchema` refuses it twice
* (`.min(2)` and `.regex`). Those messages are the spec's, in English, and stay
* so; the Console picks the reader's-locale text by the issue's code and
* pattern, never by reading the English:
*
* (a) zh-CN: both banner lines are Chinese, and neither carries the spec's
* English sentence;
* (b) CONTROL en-US: the banner shows the spec's own sentences, read from
* the installed schema (no copy of them lives in this repo);
* (c) the banner lines wrap: no line is cut with an ellipsis;
* (d) both identifier grammars of `shared/identifiers.zod.ts`, parsed live,
* answer zh rows in zh and the spec's message in en.
*
* The REAL page with the live registration (`registerBuiltinAnchors`) and the
* real served permission 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 } 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 { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '@objectstack/spec/shared';
// The zh rows live in `@object-ui/i18n`'s `studio` namespace, whose `zh`
// catalogue is fetched on demand; resident before any case.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
import { translateSchemaIssue, tOptional } from './i18n';
import { validateMetadataDraft } from './clientValidation';

await loadStudioCatalogue('zh');

let localeFixture: 'en-US' | 'zh-CN' = 'zh-CN';
vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => localeFixture,
}));

/** The spec's own messages for an empty identifier, from the installed schema. */
function specIssues(schema: { safeParse: (v: unknown) => { error?: { issues: unknown[] } } }) {
return (schema.safeParse('').error?.issues ?? []) as Array<{
code: string;
message: string;
origin?: string;
minimum?: number;
inclusive?: boolean;
format?: string;
pattern?: string;
}>;
}
const SNAKE = specIssues(SnakeCaseIdentifierSchema);
const SNAKE_EN = SNAKE.map((i) => i.message);

const ZH_TOO_SHORT = (tOptional('engine.validation.spec.stringTooShort', 'zh-CN') ?? '').replace('{minimum}', '2');
const ZH_SNAKE = tOptional('engine.validation.spec.snakeCaseIdentifier', 'zh-CN') ?? '';
const ZH_SYSTEM = tOptional('engine.validation.spec.systemIdentifier', 'zh-CN') ?? '';

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(() => {
localeFixture = 'zh-CN';
window.localStorage.setItem('metadata-admin:autosave', '0');
});
afterEach(() => {
cleanup();
});

/** Opens a new permission set and types an all-Chinese label; Name stays empty. */
async function bannerLinesForChineseLabel(): Promise<HTMLElement[]> {
render(
<MemoryRouter initialEntries={['/metadata/permission/new']}>
<MetadataResourceEditPage type="permission" name="" createMode />
</MemoryRouter>,
);
const labelBox = (await screen.findByRole('textbox', { name: /^(Label|标签)/ }, { timeout: 8000 })) as HTMLInputElement;
fireEvent.change(labelBox, { target: { value: '销售运营' } });
const banner = await screen.findByTestId('metadata-validation-banner', {}, { timeout: 8000 });
await waitFor(() => expect(banner.querySelectorAll('[data-testid="metadata-diagnostics-item"]').length).toBeGreaterThanOrEqual(2));
return Array.from(banner.querySelectorAll<HTMLElement>('[data-testid="metadata-diagnostics-item"]'));
}

describe('the create banner states identifier issues in the reader’s locale (objectstack#28369)', () => {
it('the fixtures are real: the spec refuses an empty identifier twice, and the zh rows exist', () => {
expect(SNAKE.map((i) => i.code)).toEqual(['too_small', 'invalid_format']);
expect(ZH_TOO_SHORT).toBe('至少需要 2 个字符');
expect(ZH_SNAKE).toMatch(/snake_case/);
expect(ZH_SYSTEM).not.toBe('');
});

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

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

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

describe('the spec’s identifier grammars are rendered by code and pattern (objectstack#28369)', () => {
it('(d) the client door carries each issue’s code and pattern beside the spec’s message', async () => {
const { issues } = await validateMetadataDraft('permission', { name: '', label: '销售运营' });
const nameIssues = issues.filter((i) => i.path === 'name');
expect(nameIssues.map((i) => i.message)).toEqual(SNAKE_EN);
expect(nameIssues.map((i) => translateSchemaIssue(i, 'zh-CN'))).toEqual([ZH_TOO_SHORT, ZH_SNAKE]);
expect(nameIssues.map((i) => translateSchemaIssue(i, 'en-US'))).toEqual(SNAKE_EN);
});

it.each([
['SnakeCaseIdentifierSchema', SNAKE, ZH_SNAKE],
['SystemIdentifierSchema', specIssues(SystemIdentifierSchema), ZH_SYSTEM],
] as const)('(d) %s, parsed live: zh rows in zh, the spec’s message in en', (_name, parsed, zhFormat) => {
const asIssue = ({ message, ...detail }: (typeof parsed)[number]) => ({ message, detail });
expect(parsed.map((i) => translateSchemaIssue(asIssue(i), 'zh-CN'))).toEqual([ZH_TOO_SHORT, zhFormat]);
expect(parsed.map((i) => translateSchemaIssue(asIssue(i), 'en-US'))).toEqual(parsed.map((i) => i.message));
});

it('an issue without a code keeps the existing message path', () => {
expect(translateSchemaIssue({ message: SNAKE_EN[1] }, 'zh-CN')).toBe(SNAKE_EN[1]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ import { readFields } from './previews/object-fields-io.js';
import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../assistant/assistantBus.js';
import { useRegisteredMetadataInspector } from './inspector-registry.js';
import { useRegisteredMetadataDefaultInspector } from './default-inspector-registry.js';
import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidationMessage } from './i18n.js';
import { useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js';
import { JsonSourceEditor } from '../item-tools/JsonSourceEditor.js';
import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js';
import {
Expand Down Expand Up @@ -867,7 +867,7 @@ function MetadataResourceEditPageImpl({
const errs = (hasClientValidator(type, draftMode)
? withServerOnlyErrors(displayIssues, diag?.errors)
: (diag?.errors ?? [])
).map((i) => ({ path: i.path, message: translateValidationMessage(i.message, locale) }));
).map((i) => ({ path: i.path, message: translateSchemaIssue(i, locale) }));
const warns = (diag?.warnings ?? []).map((i) => ({
path: i.path,
message: translateValidationMessage(i.message, locale),
Expand Down Expand Up @@ -3044,7 +3044,7 @@ function MetadataResourceEditPageImpl({
: (diag?.errors ?? [])
).map((i) => ({
path: i.path,
message: translateValidationMessage(i.message, locale),
message: translateSchemaIssue(i, locale),
}));
const liveValid = hasClientValidator(type, draftMode)
? liveErrors.length === 0
Expand Down Expand Up @@ -3102,7 +3102,9 @@ function MetadataResourceEditPageImpl({
</div>
<ul className="mt-1 space-y-0.5 font-mono text-[11px]">
{head.map((e, i) => (
<li key={i} className="truncate">
// Wraps: a message is the fix the author needs, so
// it is never cut to the banner's width.
<li key={i} data-testid="metadata-diagnostics-item" className="whitespace-normal break-words">
<span className="opacity-70">{e.path ? labelForIssuePath(e.path) : '(root)'}</span>: {e.message}
</li>
))}
Expand Down
11 changes: 9 additions & 2 deletions ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ import {
type WidgetLabelling,
type WidgetRenderer,
} from './widgets.js';
import { useMetadataLocale, t, tFormat, translateValidationMessage, translateEnumOption, translateSchemaFieldLabel, translateSchemaFieldHelp } from './i18n.js';
import { useMetadataLocale, t, tFormat, translateSchemaIssue, translateEnumOption, translateSchemaFieldLabel, translateSchemaFieldHelp } from './i18n.js';
import type { SchemaIssueDetail } from './i18n.js';
import { prettifyFieldName as prettify, untranslatedFieldLabel } from './field-source-label.js';

/**
Expand Down Expand Up @@ -1078,6 +1079,12 @@ function readVisibility(
export interface SchemaFormIssue {
path: string;
message: string;
/**
* The Zod issue's code and parameters when the issue came from a client
* schema parse; what `translateSchemaIssue` picks the reader's-locale text
* by. Absent on server diagnostics and lint findings.
*/
detail?: SchemaIssueDetail;
}

export interface SchemaFormProps {
Expand Down Expand Up @@ -1189,7 +1196,7 @@ function SchemaFormBody({
const issuesByPath = React.useMemo(() => {
const map: Record<string, string[]> = {};
for (const i of issues) {
(map[i.path] ??= []).push(translateValidationMessage(i.message, locale));
(map[i.path] ??= []).push(translateSchemaIssue(i, locale));
}
return map;
}, [issues, locale]);
Expand Down
45 changes: 41 additions & 4 deletions ui/packages/app-shell/src/views/metadata-admin/clientValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/

import type { SchemaFormIssue } from './SchemaForm.js';
import type { SchemaIssueDetail } from './i18n.js';
import { lintCelPredicate } from './celAuthoring.js';
import { readFields } from './previews/object-fields-io.js';

Expand Down Expand Up @@ -48,6 +49,12 @@ type ZodLikeIssue = {
* can list several retired aliases in a single `keys` array.
*/
keys?: string[];
/** `too_small` / `invalid_format` parameters, carried to the render as `detail`. */
origin?: string;
minimum?: number | bigint;
inclusive?: boolean;
format?: string;
pattern?: string;
};

type ZodLikeSchema = {
Expand Down Expand Up @@ -1027,6 +1034,32 @@ function annotateRetiredFormulaKeyIssues(issues: ZodLikeIssue[], draft: unknown)
});
}

/**
* The issue codes `translateSchemaIssue` picks a reader's-locale text by. Only
* these carry a `detail`: every other issue keeps the plain `{ path, message }`
* it had, so a code that no rendering reads adds nothing to the door's answer.
*/
const LOCALIZED_ISSUE_CODES: ReadonlySet<string> = new Set(['too_small', 'invalid_format']);

/**
* The issue's code and parameters, so the page renders it in the reader's
* locale by WHAT it is (`translateSchemaIssue`) instead of by its English.
* PRESENTATION ONLY, like the two passes above: the message stays the spec's,
* and the verdict is already decided.
*/
function issueDetail(issue: ZodLikeIssue): SchemaIssueDetail | undefined {
if (typeof issue.code !== 'string' || !LOCALIZED_ISSUE_CODES.has(issue.code)) return undefined;
const minimum = typeof issue.minimum === 'bigint' ? Number(issue.minimum) : issue.minimum;
return {
code: issue.code,
...(typeof issue.origin === 'string' ? { origin: issue.origin } : {}),
...(typeof minimum === 'number' ? { minimum } : {}),
...(typeof issue.inclusive === 'boolean' ? { inclusive: issue.inclusive } : {}),
...(typeof issue.format === 'string' ? { format: issue.format } : {}),
...(typeof issue.pattern === 'string' ? { pattern: issue.pattern } : {}),
};
}

// Keyed by mode AND type — `view` resolves to a different schema per mode, so
// caching by type alone would hand the create gate whichever mode asked first.
const SCHEMA_CACHE = new Map<string, ZodLikeSchema | null>();
Expand Down Expand Up @@ -1167,10 +1200,14 @@ export async function validateMetadataDraft(
? annotateRetiredFormulaKeyIssues(rawIssues, draft)
: rawIssues;
const issues: SchemaFormIssue[] = [
...renderable.map((issue) => ({
path: (issue.path ?? []).map((seg) => String(seg)).join('.'),
message: issue.message,
})),
...renderable.map((issue) => {
const detail = issueDetail(issue);
return {
path: (issue.path ?? []).map((seg) => String(seg)).join('.'),
message: issue.message,
...(detail ? { detail } : {}),
};
}),
...celIssues,
];
return { ok: false, issues };
Expand Down
Loading
Loading