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
43 changes: 43 additions & 0 deletions .changeset/8218-studio-property-panel-de-developerize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/app-shell': patch
---

De-developerize the metadata-admin property form now that Studio's 「界面」 panel
grafts it in front of an AI-build maker (objectui#8218).

`SchemaForm` + `widgets.tsx` were written for an administrator editing metadata.
Studio's interface panel renders the very same form for someone who has never
seen a JSON Schema, so four developer habits landed inside an otherwise fully
Chinese surface. Same cause, one pass:

- **Machine-name tooltip.** The `title="Machine name"` on the identifier chip
went through the engine string table. Note the chip's own predicate degrades
under localization: it shows when the visible label does not spell the machine
name, which a translated label never does — so in a Chinese panel it renders
for *every* field. Reported, not changed here; whether a maker should see the
identifier at all is a product call, not a rendering one.

- **Master-detail column headers.** A column whose item schema carries no
`title` printed the raw JSON Schema key (`actionUrl`, `actionType`) as if it
were a column name — in *every* locale, English included. It now humanises the
key, the same convention this tree already applies wherever a title is absent
(`json-schema-to-fields.ts`'s `prop.title || humanizeKey`, and `SchemaForm`'s
own grid repeater: `s.label || prettify(s.field)`). `title` is an OPTIONAL
annotation, so its absence is not off-spec metadata and the renderer has to
render something; this is not a lenient contract fallback. The LOCALIZED
column name needs an upstream channel that does not exist yet — filed there.

- **Numeric fields.** Both numeric renderers (`SchemaForm`'s field control and
the master-detail cell) now gray the schema's `default` in as a placeholder,
so an empty box reads "using the default" instead of "unknown", and forward
`minimum` / `maximum` / `multipleOf` onto the control. Previously only
`fieldSpec.min` / `.max` were read — which a spec-derived authoring form never
declares — so a panel accepted a negative column count its own contract had
already ruled out. The default stays a placeholder, never a written value, so
"left on the default" remains distinguishable from "pinned to today's default"
in the saved metadata.

- **Untranslated strings.** A full sweep of both files: 45 user-visible literals
(aria-labels, placeholders, empty-state prose, segmented-control labels,
secret-field copy, the code editor's chrome) now resolve through the engine
table in `en` and `zh`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#8218 — the metadata-admin form stops reading as a developer tool.
*
* `SchemaForm` + `widgets.tsx` were built for an administrator editing metadata.
* Studio's 「界面」 property panel now grafts the very same form in front of an
* AI-build MAKER, so four developer-facing habits landed in a fully Chinese
* surface. These pin the three that are mechanically checkable from the
* rendered output:
*
* 1. the machine-name chip's tooltip is translated, in both locales;
* 2. a master-detail column whose item schema carries no `title` gets a
* humanised header instead of the raw JSON Schema key;
* 3. a numeric field grays its schema `default` in as a PLACEHOLDER and
* forwards `minimum` / `maximum` / `multipleOf` to the control.
*
* Plus a parity pin over the keys the sweep introduced, because this console's
* `engine.*` table is the ONE string table the repo's i18n gates cannot see
* (see `./i18n.ts`'s header and `packages/i18n/README.md`) — nothing else would
* notice an entry added to `en` and forgotten in `zh`.
*
* ## What is deliberately NOT pinned here
*
* The LOCALIZED column name. `dashboard.header.actions[]` shows `Label` /
* `Action Url` after this change, not 「标签」/「链接」, and that is the correct
* outcome for this repo: the header text can only come from the JSON Schema's
* `items.properties.<k>.title`, which no localization overlay reaches, and the
* spec emits none. Both halves are upstream — see the PR body.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, screen } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { SchemaForm } from './SchemaForm';
import { t } from './i18n';

afterEach(cleanup);

function renderIn(language: 'en' | 'zh', ui: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{ui}
</I18nProvider>,
);
}

/* ── 1. machine-name chip tooltip ─────────────────────────────────────────── */

// The chip renders only when the visible label does NOT already spell the
// machine name — which, under a localized panel, is EVERY field: `prettify
// ('columns')` can never equal 「列数」. That is exactly the shape #8218 hit.
const COLUMNS_SCHEMA = {
type: 'object',
properties: { columns: { type: 'integer', minimum: 1, maximum: 24 } },
} as never;

const COLUMNS_FORM = {
type: 'simple',
sections: [{ label: 'Layout', fields: [{ field: 'columns', type: 'number', label: '列数' }] }],
} as never;

describe('#8218 · machine-name chip tooltip is translated', () => {
it('reads Chinese under a Chinese provider', () => {
renderIn('zh', <SchemaForm schema={COLUMNS_SCHEMA} form={COLUMNS_FORM} value={{}} onChange={() => {}} />);
const chip = screen.getByText('columns');
expect(chip.tagName).toBe('CODE');
expect(chip).toHaveAttribute('title', '机器名');
});

it('reads English under an English provider', () => {
renderIn('en', <SchemaForm schema={COLUMNS_SCHEMA} form={COLUMNS_FORM} value={{}} onChange={() => {}} />);
expect(screen.getByText('columns')).toHaveAttribute('title', 'Machine name');
});

it('never emits the untranslated literal in the Chinese panel', () => {
renderIn('zh', <SchemaForm schema={COLUMNS_SCHEMA} form={COLUMNS_FORM} value={{}} onChange={() => {}} />);
expect(document.body.innerHTML).not.toContain('Machine name');
});
});

/* ── 2. master-detail headers ─────────────────────────────────────────────── */

// Shaped after `DashboardSchema.header.actions` as the spec actually emits it:
// every item property carries `description` and NONE carries `title`.
const ACTIONS_SCHEMA = {
type: 'object',
properties: {
actions: {
type: 'array',
title: 'Header actions',
items: {
type: 'object',
required: ['label', 'actionUrl'],
properties: {
label: { type: 'string', description: 'Action button label' },
actionUrl: { type: 'string', description: 'URL or target for the action' },
actionType: { type: 'string', description: 'Type of action' },
icon: { type: 'string', description: 'Icon identifier for the action button' },
},
},
},
},
} as never;

const ACTIONS_FORM = {
type: 'simple',
sections: [{ label: 'Header', fields: [{ field: 'actions' }] }],
} as never;

describe('#8218 · a title-less master-detail column is not a raw schema key', () => {
it('humanises every header the item schema leaves untitled', () => {
renderIn('zh', <SchemaForm schema={ACTIONS_SCHEMA} form={ACTIONS_FORM} value={{ actions: [{}] }} onChange={() => {}} />);
const headers = Array.from(document.querySelectorAll('th')).map((th) => th.textContent?.replace('*', '').trim());
expect(headers).toContain('Label');
expect(headers).toContain('Action Url');
expect(headers).toContain('Action Type');
expect(headers).toContain('Icon');
// The regression itself: the raw camelCase keys must be gone.
expect(headers).not.toContain('actionUrl');
expect(headers).not.toContain('actionType');
});

it("still prefers the item schema's own `title` when it has one", () => {
const titled = {
type: 'object',
properties: {
actions: {
type: 'array',
items: { type: 'object', properties: { actionUrl: { type: 'string', title: 'Destination' } } },
},
},
} as never;
renderIn('en', <SchemaForm schema={titled} form={ACTIONS_FORM} value={{ actions: [{}] }} onChange={() => {}} />);
const headers = Array.from(document.querySelectorAll('th')).map((th) => th.textContent?.trim());
expect(headers).toContain('Destination');
expect(headers).not.toContain('Action Url');
});
});

/* ── 3. numeric fields: default as placeholder, schema bounds honoured ────── */

function numberInput(): HTMLInputElement {
const el = document.querySelector('input[type="number"]');
expect(el, 'no numeric input rendered').not.toBeNull();
return el as HTMLInputElement;
}

describe('#8218 · numeric fields surface their default and their bounds', () => {
it('grays the schema `default` in as a placeholder, and does not write it', () => {
const schema = {
type: 'object',
properties: { columns: { type: 'integer', default: 12, minimum: 1, maximum: 24 } },
} as never;
renderIn('en', <SchemaForm schema={schema} form={COLUMNS_FORM} value={{}} onChange={() => {}} />);
const input = numberInput();
expect(input).toHaveAttribute('placeholder', '12');
// Placeholder, not value: an untouched field still saves as absent, so
// "leave it on the default" stays distinct from "pin it to today's 12".
expect(input.value).toBe('');
});

it('forwards minimum / maximum / multipleOf onto the control', () => {
const schema = {
type: 'object',
properties: { columns: { type: 'integer', minimum: 1, maximum: 24, multipleOf: 1 } },
} as never;
renderIn('en', <SchemaForm schema={schema} form={COLUMNS_FORM} value={{ columns: 4 }} onChange={() => {}} />);
const input = numberInput();
expect(input).toHaveAttribute('min', '1');
expect(input).toHaveAttribute('max', '24');
expect(input).toHaveAttribute('step', '1');
});

it('lets an explicit form-spec bound win over the schema one', () => {
const schema = { type: 'object', properties: { columns: { type: 'integer', minimum: 1, maximum: 24 } } } as never;
const form = {
type: 'simple',
sections: [{ label: 'Layout', fields: [{ field: 'columns', type: 'number', label: '列数', min: 2, max: 8 }] }],
} as never;
renderIn('en', <SchemaForm schema={schema} form={form} value={{}} onChange={() => {}} />);
const input = numberInput();
expect(input).toHaveAttribute('min', '2');
expect(input).toHaveAttribute('max', '8');
});

it('emits no placeholder or bounds when the schema declares none', () => {
const schema = { type: 'object', properties: { columns: { type: 'number' } } } as never;
renderIn('en', <SchemaForm schema={schema} form={COLUMNS_FORM} value={{}} onChange={() => {}} />);
const input = numberInput();
expect(input).not.toHaveAttribute('placeholder');
expect(input).not.toHaveAttribute('min');
expect(input).not.toHaveAttribute('max');
expect(input).not.toHaveAttribute('step');
});

// The same two files render numbers twice; a bound honoured in one and
// dropped in the other is the drift this pins shut. This one goes through
// `widgets.tsx`'s master-detail CELL renderer.
it('applies the same treatment inside a master-detail cell', () => {
const schema = {
type: 'object',
properties: {
rows: {
type: 'array',
items: { type: 'object', properties: { span: { type: 'integer', default: 3, minimum: 1, maximum: 12 } } },
},
},
} as never;
const form = { type: 'simple', sections: [{ label: 'S', fields: [{ field: 'rows' }] }] } as never;
renderIn('en', <SchemaForm schema={schema} form={form} value={{ rows: [{}] }} onChange={() => {}} />);
const input = document.querySelector('td input[type="number"]') as HTMLInputElement | null;
expect(input, 'no numeric cell rendered').not.toBeNull();
expect(input).toHaveAttribute('placeholder', '3');
expect(input).toHaveAttribute('min', '1');
expect(input).toHaveAttribute('max', '12');
});
});

/* ── 4. the sweep's keys exist in BOTH locale tables ──────────────────────── */

// Every key the #8218 sweep introduced. This console's `engine.*` table is
// invisible to the repo's i18n parity gates, so a key added to one table and
// forgotten in the other would ship as an English string inside the Chinese
// panel — the exact defect the sweep was for.
const SWEPT_KEYS = [
'engine.form.machineName',
'engine.form.sectionN',
'engine.form.notConfigured',
'engine.form.configure',
'engine.form.removeNamed',
'engine.form.moveUp',
'engine.form.moveDown',
'engine.form.noneShort',
'engine.form.filterModeNone',
'engine.form.filterModeTabs',
'engine.form.filterModeDropdown',
'engine.form.filterElement',
'engine.form.removeField',
'engine.form.addFilterField',
'engine.form.bindObjectForFilterFields',
'engine.form.tabLabel',
'engine.form.moveTabUp',
'engine.form.moveTabDown',
'engine.form.removeTab',
'engine.form.bindObjectForTabRules',
'engine.form.bindObjectForConditions',
'engine.form.addActionButtonPlain',
'engine.form.addActionButton',
'engine.form.allActionsAdded',
'engine.form.bindObjectForActions',
'engine.form.color',
'engine.form.colorHexPlaceholder',
'engine.form.secretSetTypeToReplace',
'engine.form.secretSetLeaveBlank',
'engine.form.enterAValue',
'engine.form.secretValue',
'engine.form.hideValue',
'engine.form.revealValue',
'engine.form.clear',
'engine.form.anOption',
'engine.form.selectDepToConfigure',
'engine.form.noConfigNeeded',
'engine.form.readOnly',
'engine.form.loadingEditor',
] as const;

describe('#8218 · the swept strings resolve in both locales', () => {
it('every key is present in the en and zh tables', () => {
for (const key of SWEPT_KEYS) {
// `t()` echoes the key back when the table has no entry.
expect(t(key, 'en-US'), `EN missing ${key}`).not.toBe(key);
expect(t(key, 'zh-CN'), `ZH missing ${key}`).not.toBe(key);
}
});

it('the prose keys actually say something different in Chinese', () => {
// `colorHexPlaceholder` is a notation and is intentionally identical, so it
// is excluded rather than silently weakening the assertion for the rest.
const NOTATION_ONLY = new Set(['engine.form.colorHexPlaceholder']);
for (const key of SWEPT_KEYS) {
if (NOTATION_ONLY.has(key)) continue;
expect(t(key, 'zh-CN'), `ZH is a copy of EN for ${key}`).not.toBe(t(key, 'en-US'));
}
// And the excluded one really is the notation, not an untranslated string
// that slipped into the exemption.
expect(t('engine.form.colorHexPlaceholder', 'zh-CN')).toBe('#RRGGBB');
});

it('the placeholder tokens the callers pass are the ones the templates use', () => {
for (const locale of ['en-US', 'zh-CN'] as const) {
expect(t('engine.form.sectionN', locale)).toContain('{n}');
expect(t('engine.form.removeNamed', locale)).toContain('{name}');
expect(t('engine.form.selectDepToConfigure', locale)).toContain('{dep}');
expect(t('engine.form.noConfigNeeded', locale)).toContain('{value}');
}
});
});
Loading
Loading