Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/10586-inspector-strings-i18n-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the hook and action inspectors, and the shared inspector defaults, read their words in the designer's locale (objectui#10586)

`HookDefaultInspector` and `ActionDefaultInspector` rendered their section
titles, field labels, placeholders, hints, option labels and accessible names
as English literals. `InspectorComboField` and `_shared.tsx` fell back to
English defaults wherever a caller passed none: the combo's placeholder,
search box, empty state, loading text and "Use" row, and the shell's close
label, the reorder pair's names and the roster-failure notice. So a zh-CN
author read English inside an otherwise Chinese designer.

The two inspectors now read the designer's own string catalogue under
`engine.inspector.hook.*` and `engine.inspector.action.*`, each row in en and
zh, from the `locale` their host already passes. The combo's words are new
`engine.inspector.combo.*` rows. The other defaults reuse existing rows
(`engine.form.selectEllipsis`, `engine.close`, `engine.inspector.reorder.*`,
`engine.form.optionsLoadFailedTitle`), and they follow the active language
through `useMetadataLocale()`. A label the caller passes still wins. The en-US
text is unchanged. Stored values, lifecycle event names, HTTP methods, code
samples and sample values are not words and stay as they were.
325 changes: 325 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ import { expressionSource, writeExpressionSource } from './expression-envelope.j

/* ─────────────── constants ─────────────── */

/** Body languages: the stored value, and the catalogue key its label reads in
* the designer locale (objectui#10586). */
const BODY_LANG_OPTS = [
{ value: 'expression', label: 'Expression (L1)' },
{ value: 'js', label: 'Sandboxed JS (L2)' },
{ value: 'expression', labelKey: 'engine.inspector.hook.bodyLang.expression' },
{ value: 'js', labelKey: 'engine.inspector.hook.bodyLang.js' },
];

/** Lifecycle events, grouped by the operation they hang off. */
Expand Down Expand Up @@ -210,14 +212,14 @@ export function HookDefaultInspector({
hideClose
>
{/* 1 ─ Basics */}
<SectionHeader title="Basics" />
<InspectorTextField label="Label" value={localize(draft.label)} onCommit={(v) => onPatch({ label: v || undefined })} placeholder="Human-readable name" disabled={readOnly} />
<InspectorTextField label="Name" value={str('name')} onCommit={(v) => onPatch({ name: v })} placeholder="snake_case identifier" disabled={readOnly} mono testId="hook-name" />
<SectionHeader title={tr('engine.inspector.hook.basics')} />
<InspectorTextField label={tr('engine.inspector.hook.label')} value={localize(draft.label)} onCommit={(v) => onPatch({ label: v || undefined })} placeholder={tr('engine.inspector.hook.labelPlaceholder')} disabled={readOnly} />
<InspectorTextField label={tr('engine.inspector.hook.name')} value={str('name')} onCommit={(v) => onPatch({ name: v })} placeholder={tr('engine.inspector.hook.namePlaceholder')} disabled={readOnly} mono testId="hook-name" />

<div className="space-y-1.5" data-testid="hook-object-picker">
<Label className="text-xs text-muted-foreground">Object(s) this hook fires on</Label>
<Label className="text-xs text-muted-foreground">{tr('engine.inspector.hook.objects')}</Label>
<InspectorCheckboxField
label="All objects (*)"
label={tr('engine.inspector.hook.allObjects')}
value={allObjects}
onCommit={(on) => onPatch({ object: on ? ALL_OBJECTS : writeObjects(false, objectNames) })}
disabled={readOnly}
Expand All @@ -231,7 +233,7 @@ export function HookDefaultInspector({
{pickerOptions.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
{rosterAnswered
? 'No objects found — publish an object, then pick it here.'
? tr('engine.inspector.hook.noObjects')
: tr('engine.form.loadingOptions')}
</p>
) : (
Expand Down Expand Up @@ -269,42 +271,42 @@ export function HookDefaultInspector({
</p>
)}
{!allObjects && objectNames.length === 0 && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">Pick at least one object (or All objects).</p>
<p className="text-[11px] text-amber-600 dark:text-amber-400">{tr('engine.inspector.hook.pickObject')}</p>
)}
</div>

{/* 2 ─ Events */}
<div className="border-t pt-3 space-y-2">
<SectionHeader title="Events" hint="Which lifecycle events invoke this hook." />
<div className="text-[11px] font-medium text-muted-foreground/80">Write</div>
<SectionHeader title={tr('engine.inspector.hook.events')} hint={tr('engine.inspector.hook.eventsHint')} />
<div className="text-[11px] font-medium text-muted-foreground/80">{tr('engine.inspector.hook.eventsWrite')}</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{WRITE_EVENTS.map((ev) => (
<InspectorCheckboxField key={ev} label={ev} value={events.includes(ev)} onCommit={(on) => toggleEvent(ev, on)} disabled={readOnly} />
))}
</div>
<div className="pt-1 text-[11px] font-medium text-muted-foreground/80">Query</div>
<div className="pt-1 text-[11px] font-medium text-muted-foreground/80">{tr('engine.inspector.hook.eventsQuery')}</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{QUERY_EVENTS.map((ev) => (
<InspectorCheckboxField key={ev} label={ev} value={events.includes(ev)} onCommit={(on) => toggleEvent(ev, on)} disabled={readOnly} />
))}
</div>
{events.length === 0 && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">Select at least one event.</p>
<p className="text-[11px] text-amber-600 dark:text-amber-400">{tr('engine.inspector.hook.pickEvent')}</p>
)}
</div>

{/* 3 ─ Function */}
<div className="border-t pt-3 space-y-3">
<SectionHeader title="Function" hint="The handler that runs when the hook fires." />
<SectionHeader title={tr('engine.inspector.hook.function')} hint={tr('engine.inspector.hook.functionHint')} />
<InspectorSelectField
label="Language"
label={tr('engine.inspector.hook.language')}
value={language}
options={BODY_LANG_OPTS}
options={BODY_LANG_OPTS.map((o) => ({ value: o.value, label: tr(o.labelKey) }))}
onCommit={(v) => patchBody({ language: v })}
disabled={readOnly}
/>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Handler body</Label>
<Label className="text-xs text-muted-foreground">{tr('engine.inspector.hook.handlerBody')}</Label>
<Textarea
data-testid="hook-body-source"
value={typeof body.source === 'string' ? (body.source as string) : ''}
Expand All @@ -317,19 +319,19 @@ export function HookDefaultInspector({
/>
<div className="text-[11px] text-muted-foreground/70">
{language === 'expression'
? 'A single L1 expression evaluated against the record / ctx.'
: 'Runs in the sandbox as (ctx) => Promise<void>.'}
? tr('engine.inspector.hook.bodyHint.expression')
: tr('engine.inspector.hook.bodyHint.js')}
</div>
</div>
</div>

{/* 4 ─ Options */}
<div className="border-t pt-3 space-y-3">
<SectionHeader title="Options" />
<SectionHeader title={tr('engine.inspector.hook.options')} />
<div className="grid grid-cols-2 gap-3">
<InspectorNumberField label="Priority" value={typeof draft.priority === 'number' ? (draft.priority as number) : undefined} onCommit={(v) => onPatch({ priority: v })} placeholder="100" disabled={readOnly} />
<InspectorNumberField label={tr('engine.inspector.hook.priority')} value={typeof draft.priority === 'number' ? (draft.priority as number) : undefined} onCommit={(v) => onPatch({ priority: v })} placeholder="100" disabled={readOnly} />
<div className="flex items-end pb-1.5">
<InspectorCheckboxField label="Run asynchronously (after commit)" value={draft.async === true} onCommit={(v) => onPatch({ async: v })} disabled={readOnly} />
<InspectorCheckboxField label={tr('engine.inspector.hook.async')} value={draft.async === true} onCommit={(v) => onPatch({ async: v })} disabled={readOnly} />
</div>
</div>
{/* `HookSchema.condition` is `ExpressionInputSchema`: a persisted hook
Expand Down Expand Up @@ -363,7 +365,7 @@ export function HookDefaultInspector({
`record`", so a later narrowing of the root list cannot take it away
in silence. */}
<ConditionBuilder
label="Run only when (optional CEL)"
label={tr('engine.inspector.hook.condition')}
value={expressionSource(draft.condition)}
onCommit={(v) => onPatch({ condition: writeExpressionSource(draft.condition, v) })}
objectName={conditionObject}
Expand All @@ -385,7 +387,7 @@ export function HookDefaultInspector({
{/* Advanced — everything not curated above, from the live schema */}
{fallbackSchema && (
<div className="border-t pt-3 space-y-1.5">
<SectionHeader title={tr('engine.inspector.moreFields')} hint="Advanced / rarely-used properties." />
<SectionHeader title={tr('engine.inspector.moreFields')} hint={tr('engine.inspector.hook.moreFieldsHint')} />
<SchemaForm
schema={fallbackSchema}
value={draft}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@object-ui/components';
import { t, useMetadataLocale } from '../i18n.js';

export interface InspectorComboOption {
value: string;
Expand Down Expand Up @@ -84,6 +85,14 @@ export interface InspectorComboOption {
* `useId()` cannot be authored into a collision. The `id` variant re-opens that
* door by necessity — the caller must already own the id to have written the
* `for` — so reach for it only when an external label is genuinely in charge.
*
* ## Words (objectui#10586)
*
* The combo's own words — the trigger's placeholder and loading text, the
* search box, the empty state and the "Use «text»" row — read in the designer's
* active locale through `useMetadataLocale()`, as {@link InspectorSelectField}'s
* default flag does. A caller that passes its own `placeholder`,
* `searchPlaceholder` or `emptyText` keeps it.
*/
export type InspectorComboFieldNaming =
| { label: string; ariaLabel?: never; id?: never }
Expand Down Expand Up @@ -124,9 +133,9 @@ export function InspectorComboField({
value,
onCommit,
options,
placeholder = 'Select…',
searchPlaceholder = 'Search or type…',
emptyText = 'No match — keep typing to use a custom value.',
placeholder: placeholderProp,
searchPlaceholder: searchPlaceholderProp,
emptyText: emptyTextProp,
disabled,
loading,
allowCustom = true,
Expand All @@ -135,6 +144,10 @@ export function InspectorComboField({
}: InspectorComboFieldProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const locale = useMetadataLocale();
const placeholder = placeholderProp ?? t('engine.form.selectEllipsis', locale);
const searchPlaceholder = searchPlaceholderProp ?? t('engine.inspector.combo.search', locale);
const emptyText = emptyTextProp ?? t('engine.inspector.combo.noMatch', locale);

// The id goes on the trigger `Button`, never on `Popover`: Radix's
// `Popover.Root` is a context provider that renders no DOM element of its own,
Expand All @@ -151,7 +164,7 @@ export function InspectorComboField({
const triggerId = label ? autoId : id;

const selected = options.find((o) => o.value === value);
const triggerText = selected ? selected.label : value || (loading ? 'Loading…' : placeholder);
const triggerText = selected ? selected.label : value || (loading ? t('engine.inspector.combo.loading', locale) : placeholder);

const filtered = React.useMemo(() => options.filter((o) => matches(o, search)), [options, search]);
const groups = React.useMemo(() => {
Expand All @@ -171,6 +184,8 @@ export function InspectorComboField({
const trimmed = search.trim();
const showCustom =
allowCustom && !!trimmed && !options.some((o) => o.value === trimmed);
// The custom row's sentence is the locale's; the typed text is a slot in it.
const [customLead, customTail = ''] = t('engine.inspector.combo.useCustom', locale).split('{value}');

const commit = (v: string) => {
onCommit(v);
Expand Down Expand Up @@ -215,7 +230,7 @@ export function InspectorComboField({
<CommandGroup>
<CommandItem value={`__custom__${trimmed}`} onSelect={() => commit(trimmed)}>
<span className="truncate">
Use <span className="font-mono">“{trimmed}”</span>
{customLead}<span className="font-mono">“{trimmed}”</span>{customTail}
</span>
</CommandItem>
</CommandGroup>
Expand Down
48 changes: 29 additions & 19 deletions packages/app-shell/src/views/metadata-admin/inspectors/_shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
* splice + `onPatch({...})`. Locale-aware via the `useT` hook the
* caller already has in scope — the shared shell takes raw strings.
*
* One exception, and it is a DEFAULT rather than a label a caller hands in:
* the flag {@link InspectorSelectField} puts on a stored value its roster does
* not offer. Wherever a call site passes none, the default is what a zh-CN
* author reads, so it resolves through `useMetadataLocale()` — the hook the
* designer's other shared editors (`widgets.tsx`, `SchemaForm`,
* `ConditionBuilder`) already read when no `locale` prop reaches them
* (objectui#9652).
* The exceptions are DEFAULTS rather than labels a caller hands in: the flag
* {@link InspectorSelectField} puts on a stored value its roster does not
* offer (objectui#9652), and the wording a caller may omit — the shell's close
* label, the reorder pair's names and the roster-failure notice
* (objectui#10586). Wherever a call site passes none, the default is what a
* zh-CN author reads, so it resolves through `useMetadataLocale()` — the hook
* the designer's other shared editors (`widgets.tsx`, `SchemaForm`,
* `ConditionBuilder`) already read when no `locale` prop reaches them. A label
* the caller passes always wins.
*/

import * as React from 'react';
Expand All @@ -38,6 +40,7 @@ export interface InspectorShellProps {
kindLabel: string;
title: string;
onClose: () => void;
/** The close button's accessible name; omitted, `engine.close` in the designer locale. */
closeLabel?: string;
children: React.ReactNode;
footer?: React.ReactNode;
Expand All @@ -54,7 +57,9 @@ export interface InspectorShellProps {
hideClose?: boolean;
}

export function InspectorShell({ kindLabel, title, onClose, closeLabel = 'Close', children, footer, headerActions, hideClose }: InspectorShellProps) {
export function InspectorShell({ kindLabel, title, onClose, closeLabel: closeLabelProp, children, footer, headerActions, hideClose }: InspectorShellProps) {
const locale = useMetadataLocale();
const closeLabel = closeLabelProp ?? t('engine.close', locale);
return (
<div className="flex h-full flex-col">
<div className="flex items-start justify-between gap-2 border-b px-4 py-2.5">
Expand Down Expand Up @@ -86,7 +91,7 @@ export interface InspectorReorderButtonsProps {
total: number;
/** Called with the new index when the user clicks ↑ or ↓. */
onMove: (toIndex: number) => void;
/** Localized aria-labels (e.g. tr('engine.inspector.reorder.up', locale)). */
/** Localized aria-labels; omitted, `engine.inspector.reorder.up` / `.down` in the designer locale. */
upLabel?: string;
downLabel?: string;
/** Disable both buttons (read-only inspectors). */
Expand All @@ -102,11 +107,15 @@ export function InspectorReorderButtons({
index,
total,
onMove,
upLabel = 'Move up',
downLabel = 'Move down',
upLabel: upLabelProp,
downLabel: downLabelProp,
disabled,
}: InspectorReorderButtonsProps) {
// Read before the early return: a hook's call order may not depend on props.
const locale = useMetadataLocale();
if (total <= 1 || index < 0) return null;
const upLabel = upLabelProp ?? t('engine.inspector.reorder.up', locale);
const downLabel = downLabelProp ?? t('engine.inspector.reorder.down', locale);
const canUp = !disabled && index > 0;
const canDown = !disabled && index < total - 1;
return (
Expand Down Expand Up @@ -308,13 +317,13 @@ export function rosterFrom(source: {
}

/**
* Default wording for the notice {@link InspectorSelectField} renders when its
* roster failed to load. Raw English, like `placeholder`'s `'—'` default. A
* call site that passes a `roster` passes its own `rosterFailureLabel` too —
* the repo already has this exact copy localized, as the shared picker-failure
* title objectui#5170 landed for the widget family.
* Catalogue key of the default wording for the notice
* {@link InspectorSelectField} renders when its roster failed to load: the
* shared picker-failure title objectui#5170 landed for the widget family, read
* in the designer's locale (objectui#10586). A call site that passes its own
* `rosterFailureLabel` keeps it.
*/
const defaultRosterFailureLabel = 'Options could not be loaded';
const DEFAULT_ROSTER_FAILURE_LABEL_KEY = 'engine.form.optionsLoadFailedTitle';

/**
* The label of the row {@link InspectorSelectField} synthesises for a stored
Expand Down Expand Up @@ -343,7 +352,7 @@ export function InspectorSelectField({
placeholder = '—',
unknownValueLabel: unknownValueLabelProp,
roster,
rosterFailureLabel = defaultRosterFailureLabel,
rosterFailureLabel: rosterFailureLabelProp,
disabled,
}: {
label: string;
Expand Down Expand Up @@ -381,7 +390,7 @@ export function InspectorSelectField({
/**
* Wording for the notice shown when `roster` reports a failure. The CAUSE is
* rendered from the state's own message; this is the sentence in front of it.
* Defaults to raw English — pass `t('engine.form.optionsLoadFailedTitle', locale)`.
* Defaults to `engine.form.optionsLoadFailedTitle` in the designer's locale.
*/
rosterFailureLabel?: string;
disabled?: boolean;
Expand All @@ -404,6 +413,7 @@ export function InspectorSelectField({
// `VALUE (not found)` on an otherwise Chinese inspector. It resolves through
// the designer's own catalogue now, in the locale the designer is showing.
const locale = useMetadataLocale();
const rosterFailureLabel = rosterFailureLabelProp ?? t(DEFAULT_ROSTER_FAILURE_LABEL_KEY, locale);
const unknownValueLabel =
unknownValueLabelProp ??
((v: string) => flagUnknownValue(v, t('engine.form.notFound', locale), locale));
Expand Down
Loading
Loading