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
10 changes: 10 additions & 0 deletions .changeset/form-remaining-hardcoded-english.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/components': patch
'@object-ui/i18n': patch
---

The form renderer's last user-visible English literals now go through i18n (#3272). The fullscreen long-text editor (`mobile_fullscreen`) was an entire untranslated dialog — title, screen-reader description, `Cancel` / `Done` footer buttons, and the expand trigger's accessible name — rendering English inside an otherwise translated zh/ja/ar form; it now reads the new `form.fullscreen.*` keys, shipped in all ten locale packs.

**Behaviour change worth reading if you author forms:** `submitLabel` and `cancelLabel` no longer default to the literals `'Submit'` and `'Cancel'` in the renderer. They default to *unset*, and the action bar falls back at render time to `common.submit` / `common.cancel`, so a form that declares no button copy now follows the session language instead of being silently frozen to English. A label you DO declare still wins verbatim in every locale — including an English one under a zh session, and including an explicit empty string (the fallback uses `??`, so `submitLabel: ''` renders a blank button rather than being overwritten). The only forms whose rendered text changes are those that never declared the labels and are viewed in a non-English session — which is the bug. `FormSchema.submitLabel` / `cancelLabel` stay optional strings; no spec or type change.

Also removed the built-in `select` branch's second `|| 'Select an option'` fallback. The single call site already supplies `t('common.selectOption')`, so the literal was reachable only through an authored `placeholder: ''` — where it replaced the author's deliberate blank with an untranslated English word.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Un-authored submit/cancel button copy follows the session locale — #3272.
*
* `submitLabel` / `cancelLabel` used to carry English DEFAULTS in the
* destructuring (`submitLabel = 'Submit'`). That made two different things
* indistinguishable — "the author said nothing" and "the author typed
* Submit" — and locked every un-labelled form's action bar to English in a
* zh/ja/ar session, which no amount of translating the rest of the form could
* undo. The default is now the ABSENCE of a value; the fallback happens at
* render through `common.submit` / `common.cancel`.
*
* The direction that matters most here is the SECOND one: an authored label
* must still win VERBATIM. A render-time fallback is only safe if it cannot
* reach a form whose author did declare the copy — otherwise the fix would
* have traded one silent override for another, translating text the author
* deliberately wrote. Both directions are pinned below, and the `''` case
* pins `??` (not `||`), which is the whole difference between "unset" and
* "explicitly blank".
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
// Module scope, not `beforeAll` (objectui#3010/#3021).
import '../../../renderers';

const fields = [{ name: 'name', label: 'Name', type: 'input' }];

function renderFormIn(language: string, schemaExtra: Record<string, unknown> = {}) {
const Form = ComponentRegistry.get('form')!;
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<Form schema={{ type: 'form', showCancel: true, fields, ...schemaExtra }} />
</I18nProvider>,
);
}

describe('form renderer — action-bar labels fall back through i18n (objectui#3272)', () => {
it('renders the English words under an en provider', () => {
// Byte-identical to the literal defaults this replaced.
renderFormIn('en');

expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});

it('renders Chinese under a zh provider', () => {
renderFormIn('zh');

expect(screen.getByRole('button', { name: '提交' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument();
// The literals, asserted absent.
expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
});

it('renders Japanese under a ja provider', () => {
renderFormIn('ja');

expect(screen.getByRole('button', { name: '送信' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'キャンセル' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument();
});

it('an authored label wins VERBATIM over the locale fallback', () => {
// The nail this whole change hangs on: the fallback may only fill an
// absence. An authored string is the author's copy — including an English
// one under a zh session, and including one that happens to be a word the
// locale pack also knows.
renderFormIn('zh', { submitLabel: 'Create account', cancelLabel: 'Nevermind' });

expect(screen.getByRole('button', { name: 'Create account' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Nevermind' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '提交' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument();
});

it('an authored label wins verbatim in the OTHER direction too', () => {
// Authored Chinese under an `en` session — the fallback is not a
// "translate the button" step, it is a "fill the blank" step.
renderFormIn('en', { submitLabel: '立即提交' });

expect(screen.getByRole('button', { name: '立即提交' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument();
});

it('an authored empty label stays empty — `??`, not `||`', () => {
// `submitLabel: ''` is a declaration ("render no text"), not an absence.
// A `||` fallback would silently overwrite it with the locale word, which
// is the same class of override the English default was.
const { container } = renderFormIn('zh', { submitLabel: '', cancelLabel: '' });

const submit = container.querySelector('button[type="submit"]');
expect(submit).not.toBeNull();
expect(submit!.textContent).toBe('');
expect(screen.queryByRole('button', { name: '提交' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The built-in `select` branch has ONE placeholder source — objectui#3272.
*
* The branch used to render `placeholder || 'Select an option'` behind a call
* site that already supplies `t('common.selectOption')`, so the literal read
* as unreachable dead code. It was not quite: the call site defaults with `??`,
* which PRESERVES an authored `placeholder: ''`, and an empty string is falsy
* — so the one stack that reached the literal was an author who deliberately
* asked for a blank placeholder and got an untranslated English word instead.
*
* That is also the honest reverse-verification direction for this deletion:
* restoring `|| 'Select an option'` turns the `placeholder: ''` case below red
* and leaves the other two green, because those two never reached the literal
* in the first place. They are here as the surviving pin — if a later refactor
* drops the `t()` at the call site, there is no longer a second fallback to
* mask it, and the zh case fails instead of quietly rendering English.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
// Module scope, not `beforeAll` (objectui#3010/#3021).
import '../../../renderers';

const options = [
{ label: 'Zhejiang', value: 'zj' },
{ label: 'California', value: 'ca' },
];

function renderSelectIn(language: string, fieldExtra: Record<string, unknown> = {}) {
const Form = ComponentRegistry.get('form')!;
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<Form
schema={{
type: 'form',
showSubmit: false,
showCancel: false,
fields: [{ name: 'province', label: 'Province', type: 'select', options, ...fieldExtra }],
}}
/>
</I18nProvider>,
);
}

describe('form renderer — built-in select placeholder (objectui#3272)', () => {
it('uses the locale word when the author declared no placeholder (zh)', () => {
renderSelectIn('zh');

expect(screen.getByText('请选择')).toBeInTheDocument();
expect(screen.queryByText('Select an option')).not.toBeInTheDocument();
});

it('uses the English word under an en provider', () => {
renderSelectIn('en');

expect(screen.getByText('Select an option')).toBeInTheDocument();
});

it('honours an authored placeholder verbatim, in any locale', () => {
renderSelectIn('zh', { placeholder: 'Pick a province' });

expect(screen.getByText('Pick a province')).toBeInTheDocument();
expect(screen.queryByText('请选择')).not.toBeInTheDocument();
expect(screen.queryByText('Select an option')).not.toBeInTheDocument();
});

it('honours an authored EMPTY placeholder instead of substituting English', () => {
// The stack the deleted literal actually reached: `?? ` at the call site
// keeps `''`, then `||` in the branch overrode it. Asserted in BOTH
// locales because the defect was locale-independent — an `en` author who
// asked for a blank placeholder was overridden just the same.
renderSelectIn('zh', { placeholder: '' });
expect(screen.queryByText('Select an option')).not.toBeInTheDocument();
expect(screen.queryByText('请选择')).not.toBeInTheDocument();

renderSelectIn('en', { placeholder: '' });
expect(screen.queryByText('Select an option')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The fullscreen long-text editor speaks the session locale — objectui#3272.
*
* `FullscreenTextarea` is a whole dialog (title, sr-only description, footer
* buttons) plus the trigger's accessible name, and NONE of it went through
* `t()`: a zh/ja/ar session opened a dialog that read "Edit text" / "Cancel" /
* "Done" while every other sentence in the same form was translated.
*
* The fixture spells the flag `mobile_fullscreen` — the one carrier
* `ObjectForm` actually produces (#3245/#3300). The built-in branch also reads
* an aliased `fullscreen`, but that alias has no producer and is #3303's to
* remove; pinning the canonical spelling keeps this suite green either way.
*
* Every locale case asserts the English literal is GONE as well as that the
* translation is present: a re-inlined literal alongside a translated sibling
* would still satisfy a positive-only assertion.
*/
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
// Registered at module scope, NOT in a `beforeAll` — there the cold transform
// is billed to `hookTimeout` (objectui#3010/#3021).
import '../../../renderers';

const fields = [
{ name: 'notes', label: 'Notes', type: 'textarea', mobile_fullscreen: true },
];

function renderFormIn(language: string) {
const Form = ComponentRegistry.get('form')!;
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<Form
schema={{ type: 'form', showSubmit: false, showCancel: false, fields }}
/>
</I18nProvider>,
);
}

/** Open the dialog and hand back the trigger, so its name can be asserted too. */
function openFullscreen() {
const toggle = screen.getByTestId('form-textarea-fullscreen-toggle');
fireEvent.click(toggle);
return toggle;
}

describe('form renderer — fullscreen textarea dialog is translated (objectui#3272)', () => {
it('renders the English copy under an en provider', () => {
renderFormIn('en');
const toggle = openFullscreen();

// Byte-identical to the literals this replaced, so `en` is a no-op change.
expect(toggle).toHaveAttribute('aria-label', 'Edit text fullscreen');
expect(screen.getByText('Edit text')).toBeInTheDocument();
expect(
screen.getByText('Edit the full text value, then save or cancel your changes.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
});

it('renders the whole dialog in Chinese under a zh provider', () => {
renderFormIn('zh');
const toggle = openFullscreen();

expect(toggle).toHaveAttribute('aria-label', '全屏编辑文本');
expect(screen.getByText('编辑文本')).toBeInTheDocument();
expect(screen.getByText('编辑完整的文本内容,然后保存或取消更改。')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '完成' })).toBeInTheDocument();

// The four literals, asserted absent. `Edit text` is matched loosely
// because it was BOTH the dialog title and part of the trigger's name.
expect(screen.queryByText(/edit text/i)).not.toBeInTheDocument();
expect(screen.queryByText(/save or cancel your changes/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Done' })).not.toBeInTheDocument();
expect(toggle.getAttribute('aria-label')).not.toMatch(/fullscreen/i);
});

it('renders the whole dialog in Japanese under a ja provider', () => {
// A second non-en pack because the trigger's name is INTERPOLATED
// (`{{label}}` + a translated generic noun): ja puts the noun first
// ("テキストを全画面で編集"), en last. A pack that dropped the
// interpolation would still pass a zh-only assertion by accident.
renderFormIn('ja');
const toggle = openFullscreen();

expect(toggle).toHaveAttribute('aria-label', 'テキストを全画面で編集');
expect(screen.getByText('テキストを編集')).toBeInTheDocument();
expect(
screen.getByText('テキスト全体を編集してから、変更を保存またはキャンセルしてください。'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'キャンセル' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '完了' })).toBeInTheDocument();

expect(screen.queryByText(/edit text/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Done' })).not.toBeInTheDocument();
});

it('still commits the draft — translating the footer did not unwire it', () => {
// The `Done` button lost its literal child; this pins that the click
// handler still rides on the translated button rather than on some other
// node that happened to carry the old text.
renderFormIn('zh');
openFullscreen();

fireEvent.change(screen.getByTestId('form-textarea-fullscreen-input'), {
target: { value: 'hello' },
});
fireEvent.click(screen.getByRole('button', { name: '完成' }));

expect(screen.queryByTestId('form-textarea-fullscreen-dialog')).not.toBeInTheDocument();
expect(screen.getByLabelText('Notes')).toHaveValue('hello');
});
});
Loading
Loading