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
11 changes: 11 additions & 0 deletions .changeset/11782-label-translation-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

Studio's field inspector says when a translation overrides the label being edited (objectui#11782).

The inspector's Label input edits a field's source label. The Data pillar's grid headers and form canvas show the label the app's translation bundle gives the field for the active language, so a translated field read one way on the canvas and another in the input, with nothing explaining why: the showcase's `showcase_account.tax_id` showed "Tax ID" on the canvas and "Tax ID (EIN)" in the input.

A muted line under the Label input now reads, for example, `Shown as “Tax ID” in en: the app’s translation bundle overrides this label.` The label comes from the same resolver the grid and the canvas use (`useSafeFieldLabel().fieldLabel` from `@object-ui/i18n`), and the language named is the one that resolver reads, so a `ja` session is told `ja` even though the designer's own strings are English. The line shows only when the translated label differs from the input's current value: a field with no translation, or one whose translation equals the label being edited, shows nothing. The input still edits and saves the source label exactly as before, and how translations resolve is unchanged.

Nothing is added to the package entry: no export, prop, type member or language-pack key. The new copy lives in the metadata-admin designer's own string tables (en and zh).
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2536,6 +2536,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'designer.field.section.options': '{type} options',
'designer.field.apiName': 'API name',
'designer.field.label': 'Label',
// objectui#11782 — under the Label input: the label a translation shows instead.
'designer.field.labelTranslated': 'Shown as “{label}” in {language}: the app’s translation bundle overrides this label.',
'designer.field.type': 'Type',
'designer.field.required': 'Required',
'designer.field.unique': 'Unique',
Expand Down Expand Up @@ -5533,6 +5535,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'designer.field.section.options': '{type} 选项',
'designer.field.apiName': 'API 名称',
'designer.field.label': '显示名',
// objectui#11782.
'designer.field.labelTranslated': '在 {language} 中显示为“{label}”:应用的翻译包覆盖了此显示名。',
'designer.field.type': '类型',
'designer.field.required': '必填',
'designer.field.unique': '唯一',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { I18nProvider, createI18n } from '@object-ui/i18n';

// The inspector loads the object list and the picklist roster on mount; stub
// both, as the other inspector suites do.
vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({
list: vi.fn().mockResolvedValue([]),
listDrafts: vi.fn().mockResolvedValue([]),
}),
}));

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { ObjectFieldInspector } from './ObjectFieldInspector';
import { tFormat, type SupportedLocale } from '../i18n';

afterEach(cleanup);

const HINT = 'field-label-translation-hint';

/**
* The runtime bundle shape `transformSpecTranslations` produces from an app's
* authored `objects.<obj>.fields.<field>.label` entries: field labels are
* flattened to `<ns>.fields.<obj>.<field>`. This is what the console's
* `loadLanguage` installs, and what the Data pillar's grid and form canvas
* resolve a field's label from (`useSafeFieldLabel().fieldLabel`).
*/
function appBundle(fields: Record<string, string>) {
return { showcase: { fields: { showcase_account: fields } } };
}

function renderInspector({
language,
locale = 'en-US',
bundle,
label = 'Tax ID (EIN)',
}: {
language: string;
locale?: SupportedLocale;
bundle: Record<string, string>;
label?: string;
}) {
const onPatch = vi.fn();
const instance = createI18n({
defaultLanguage: language,
detectBrowserLanguage: false,
resources: { [language]: appBundle(bundle) },
});
const ui = (fieldLabel: string) => (
<I18nProvider instance={instance}>
<ObjectFieldInspector
type="object"
name="showcase_account"
draft={{
name: 'showcase_account',
fields: {
tax_id: { type: 'text', label: fieldLabel },
website: { type: 'url', label: 'Website' },
},
}}
selection={{ kind: 'field', id: 'tax_id' }}
onPatch={onPatch}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={locale}
/>
</I18nProvider>
);
const utils = render(ui(label));
return { onPatch, rerenderWithLabel: (next: string) => utils.rerender(ui(next)), ...utils };
}

const labelInput = () => screen.getByTestId('field-label-input') as HTMLInputElement;

describe('ObjectFieldInspector — the Label input says when a translation overrides it (objectui#11782)', () => {
it('names the translated label and the language it is shown in', () => {
renderInspector({ language: 'en', bundle: { tax_id: 'Tax ID' } });

const hint = screen.getByTestId(HINT);
expect(hint.textContent).toBe(
tFormat('designer.field.labelTranslated', 'en-US', { label: 'Tax ID', language: 'en' }),
);
// The input still edits the SOURCE label; the hint only reports.
expect(labelInput().value).toBe('Tax ID (EIN)');
});

it('names the language the resolver read, not the designer locale it collapsed to', () => {
// A ja session reads the designer's en-US table (`useMetadataLocale`
// collapses every non-zh language), but the canvas shows the ja bundle.
renderInspector({ language: 'ja', locale: 'en-US', bundle: { tax_id: '納税者番号' } });

expect(screen.getByTestId(HINT).textContent).toBe(
tFormat('designer.field.labelTranslated', 'en-US', { label: '納税者番号', language: 'ja' }),
);
});

it('reads the zh table in a zh session', () => {
renderInspector({ language: 'zh-CN', locale: 'zh-CN', bundle: { tax_id: '税号' } });

expect(screen.getByTestId(HINT).textContent).toBe(
tFormat('designer.field.labelTranslated', 'zh-CN', { label: '税号', language: 'zh-CN' }),
);
});

it('shows nothing for a field the bundle does not translate', () => {
// Positive control in the same bundle: `website` IS translated, so the
// namespace is discovered and the absence is about `tax_id` alone.
renderInspector({ language: 'en', bundle: { website: 'Web site' } });

expect(screen.queryByTestId(HINT)).toBeNull();
});

it('shows nothing when the translation equals the label being edited', () => {
renderInspector({ language: 'en', bundle: { tax_id: 'Tax ID (EIN)' } });

expect(screen.queryByTestId(HINT)).toBeNull();
});

it("compares against the input's current value, not the label the field loaded with", () => {
const { rerenderWithLabel } = renderInspector({ language: 'en', bundle: { tax_id: 'Tax ID' } });
expect(screen.getByTestId(HINT)).toBeTruthy();

rerenderWithLabel('Tax ID');
expect(screen.queryByTestId(HINT)).toBeNull();

rerenderWithLabel('Taxpayer ID');
expect(screen.getByTestId(HINT).textContent).toBe(
tFormat('designer.field.labelTranslated', 'en-US', { label: 'Tax ID', language: 'en' }),
);
});

it('commits a typed label to the source `label` exactly as before', () => {
const { onPatch } = renderInspector({ language: 'en', bundle: { tax_id: 'Tax ID' } });

fireEvent.change(labelInput(), { target: { value: 'Employer ID' } });

expect(onPatch).toHaveBeenCalledTimes(1);
const fields = onPatch.mock.calls[0][0].fields as Record<string, Record<string, unknown>>;
expect(fields.tax_id).toEqual({ type: 'text', label: 'Employer ID' });
// The translation is never written into the draft.
expect(JSON.stringify(onPatch.mock.calls[0][0])).not.toContain('"Tax ID"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
import { ValueDomainSchema } from '@objectstack/spec/shared';
import type { MetadataInspectorProps } from '../inspector-registry.js';
import { MetadataClient } from '@object-ui/data-objectstack';
import { useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
import { useMetadataClient } from '../useMetadata.js';
import {
InspectorShell,
Expand Down Expand Up @@ -926,6 +927,12 @@ export function ObjectFieldInspector({
disabled={readOnly}
testId="field-label-input"
/>
<LabelTranslationHint
objectName={objectName}
fieldName={entry.name}
sourceLabel={typeof def.label === 'string' ? (def.label as string) : ''}
locale={locale}
/>
<InspectorSelectField
label={tr('designer.field.type')}
value={type}
Expand Down Expand Up @@ -1275,6 +1282,55 @@ function Section({ title, children }: { title: string; children: React.ReactNode
);
}

/**
* The label this field is SHOWN with, when a translation overrides the source
* label the Label input edits (objectui#11782).
*
* The input edits `def.label`, the field's source label. Every surface that
* shows the field reads its label through `@object-ui/i18n`'s
* `useSafeFieldLabel().fieldLabel(object, field, fallback)`, and a translation
* for the active language wins over the source there: the Data pillar's grid
* headers (`ObjectGrid`) and its form canvas (`ObjectFormDesigner`) both call
* it. So the showcase's `showcase_account.tax_id` read "Tax ID" on the canvas
* beside an input reading "Tax ID (EIN)", and nothing on screen said why.
*
* Three readings keep this line from disagreeing with the canvas:
*
* • The label comes from that SAME resolver, never from a second lookup.
* • The fallback handed to it is `''`. The resolver returns the fallback
* exactly when no translation exists, so an empty answer means "nothing
* overrides the source label", whatever the input currently holds.
* • The language named is the i18next language the resolver reads its bundle
* for, not the designer's `locale` prop: that one picks between this
* designer's two string tables and is `'en-US'` for every language that is
* not zh (`useMetadataLocale`), so a `ja` session would be told "en".
*
* Shown only when the translation differs from the input's CURRENT value. A
* field with no translation, or one whose translation equals the label being
* edited, renders nothing. How translations resolve is not changed here.
*/
function LabelTranslationHint({
objectName,
fieldName,
sourceLabel,
locale,
}: {
objectName: string;
fieldName: string;
sourceLabel: string;
locale?: string;
}) {
const { fieldLabel } = useSafeFieldLabel();
const { i18n } = useObjectTranslation();
const translated = objectName ? fieldLabel(objectName, fieldName, '') : '';
if (!translated || translated === sourceLabel) return null;
return (
<p className="-mt-1 text-[11px] leading-4 text-muted-foreground" data-testid="field-label-translation-hint">
{tFormat('designer.field.labelTranslated', locale, { label: translated, language: i18n.language })}
</p>
);
}

/** Type-aware default-value editor. Stores the literal on `Field.defaultValue`. */
function DefaultValueField({
kind,
Expand Down
Loading