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
4 changes: 2 additions & 2 deletions .changeset/10935-master-detail-i18n-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ A master-detail form whose `title`, `submitText` or `cancelText` is a per-locale

- Each of the three is resolved with `pickLocalized` against the active UI language (`useObjectTranslation().language`), the source `ObjectMetricWidget` resolves its own `I18nLabel` members against. A map shows the entry for the viewer's language, then falls back the way `pickLocalized` does.
- A plain string renders exactly as authored.
- With nothing authored, the English defaults are unchanged: 'Create' or 'Save' on the Save button, 'Cancel' on the Cancel button, and 'Created' or 'Saved' in the built-in save toast. These defaults are still English (author the label to show another language), and so is the " saved" the toast puts after an authored `title`.
- On the two buttons, an authored empty string, or a map with no string entry, now shows the English default. An authored empty string used to render an empty button; a map with no string entry used to throw as a React child.
- With nothing authored, this change leaves the defaults as they were: 'Create' or 'Save' on the Save button, 'Cancel' on the Cancel button, and 'Created' or 'Saved' in the built-in save toast. objectui#11039 moves those defaults, and the " saved" the toast puts after an authored `title`, into the locale packs, so they follow the session language.
- On the two buttons, an authored empty string, or a map with no string entry, now shows the default. An authored empty string used to render an empty button; a map with no string entry used to throw as a React child.
- The parent `ObjectForm` is handed the resolved `title` string, the `string` its `ObjectFormSchema.title` declares.
- The block's registration declares both arms for the three keys, `type: ['string', 'object']`, with descriptions that teach the per-locale map. The manifest built from `ComponentRegistry.getPublicConfigs()` therefore no longer makes `validateTree` report `type-mismatch` on a locale map for these keys. A value that matches neither arm, such as a number, is still reported.

Expand Down
29 changes: 29 additions & 0 deletions .changeset/11039-form-chrome-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/plugin-form': patch
'@object-ui/i18n': patch
'@object-ui/console': patch
---

The form family's own feedback chrome now reads the locale packs (objectui#11039). The default success toast, the thank-you heading, the loading line, the load-failure heading and the default submit and cancel labels were English literals, so a zh session saw `Created`, `Thanks!`, `Loading form...` and `Error loading form` beside a Chinese UI. They now resolve through the i18n catalogue, the way `WizardForm`'s footer has since objectui#10999.

**What moved.**

- `@object-ui/plugin-form`, across the presentations `object-form` routes to:
- The success toast when no `successMessage` is authored: `form.created` after a create, `form.saved` after an edit (the default arm and `WizardForm`), and `MasterDetailForm`'s built-in save toast, whose "… saved" after an authored `title` is `form.savedNamed`.
- The note that rides that toast when a declared `navigateOnSuccess` was refused: `form.navigateRefused`. The submitter reads it, as the toast's description.
- The thank-you heading when a `thank-you` submit behaviour declares no `title` (the default arm and `WizardForm`): `publicForm.thankYouTitle`.
- The loading line of the default arm, `TabbedForm`, `SplitForm`, `WizardForm` and `DrawerForm`: `publicForm.loading`.
- The heading of the load-failure panel in those five and `ModalForm`: `form.errorLoading`.
- The default submit label, `form.create` or `form.update`, in the default arm, `TabbedForm`, `SplitForm`, `DrawerForm` and `ModalForm`; the default Cancel of `DrawerForm` and `ModalForm`, `common.cancel`; and `MasterDetailForm`'s default Save, Create and Cancel, `common.save`, `form.create` and `common.cancel`.
- `@object-ui/console`: the form page (`/f/:slug` and `/forms/:name`) reads its loading line from `common.loading`, its success toast from `form.submitted`, and its default thank-you heading and message from `publicForm.thankYouTitle` and `publicForm.thankYouMessage`, through the `I18nProvider` that `main.tsx` mounts above every console route.
- `@object-ui/i18n`: six keys are new under `form.` in all ten packs: `created`, `saved`, `savedNamed` (`{{title}}`), `submitted`, `errorLoading` and `navigateRefused`. The three `publicForm.` keys were already in all ten packs with nothing reading them (`pnpm check:i18n-dead-keys` listed them as confirmed dead), and say the same thing. The zh value of `publicForm.thankYouTitle` ends with a full-width exclamation mark instead of an ASCII one.

**Authored values still win.** An authored `successMessage`, `submitText` or `cancelText`, a plain string or a per-locale map, and a `thank-you` behaviour's own `title` and `message`, render as authored in every locale. Only the defaults are localized. No schema key is added, renamed or retyped.

**Three English strings change their rendered text**, because each is now the value of a key whose English differs from the old literal:

- the loading line, `Loading form...` with three ASCII full stops, is now `Loading form…` with the typographic ellipsis;
- the thank-you heading, `Thanks!`, is now `Thank you!`, in the default arm, `WizardForm` and the form page;
- the form page's default thank-you message, `Your submission has been received.`, is now `Your submission has been received successfully.`

Every other English default renders the same text as before. With no i18n provider mounted, plugin-form's forms still render English from their defaults tables, which `pnpm check:i18n-keys` holds byte-identical to the `en` pack.
163 changes: 163 additions & 0 deletions apps/console/src/components/FormPage.chromeI18n-11039.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11039 — the form page's feedback chrome speaks the session locale.
*
* `/f/:slug` (and the internal `/forms/:name`) rendered its loading line, its
* success toast and its thank-you panel from English literals, so a zh visitor
* who submitted a published form read `Submitted`, `Thanks!` and `Your
* submission has been received.` under a Chinese form. They now resolve through
* the app's i18n catalogue, with the same pack keys plugin-form's forms read:
* `common.loading`, `form.submitted`, and `publicForm.thankYouTitle` /
* `publicForm.thankYouMessage`.
*
* Only the DEFAULTS move: a thank-you `title` / `message` the form declares
* still renders as declared (the `CONTROL` case).
*
* ## Why no `I18nProvider` appears in this file
*
* Same reason as `FormPage.sectionLabelI18n.test.tsx`: the route under test is
* exactly the one `App` declares, with nothing wrapped around it that
* production does not have. The catalogue reaches it the way it reaches every
* unwrapped consumer: `createI18n` — the factory `I18nProvider` itself calls —
* registers its instance as react-i18next's process-global, and the harness
* puts the pristine global back after every test (`installI18nGlobalReset`).
* The DOM setup makes every built-in catalogue resident, as `main.tsx`'s
* `preloadBootstrapLocale()` does before first paint.
*
* Measured against the merge-base `FormPage` (this file at this head): the three
* zh cases fail, because the literals render verbatim, and the `en` case fails
* at its first assertion, because two strings' English changed with their keys:
* `Thanks!` is now `Thank you!`, and the message gained `successfully`. The
* `CONTROL` case passes there too: declared copy always won.
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: vi.fn() } }));

/** The public `/forms/:slug` resolver payload. */
function publicPayload(submitBehavior?: unknown) {
return {
slug: 'contact-us',
object: 'showcase_inquiry',
label: 'Contact us',
form: {
type: 'simple',
sections: [{ fields: ['title'] }],
...(submitBehavior ? { submitBehavior } : {}),
},
objectSchema: { name: 'showcase_inquiry', fields: { title: { type: 'text', label: 'Title' } } },
};
}

/**
* Answers the resolver and the submit. With `holdLoad`, the resolver read never
* settles, so the page stays on its loading line.
*/
function stubFetch(payload: unknown, { holdLoad = false } = {}) {
return vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method !== 'POST' && holdLoad) return new Promise<Response>(() => {});
const body = init?.method === 'POST' ? { ok: true } : payload;
if (!String(url).includes('/forms/contact-us')) throw new Error(`unstubbed fetch: ${url}`);
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
});
}

function renderPublic() {
return render(
<MemoryRouter initialEntries={['/f/contact-us']}>
<Routes>
<Route path="/f/:slug" element={<FormPage mode="public" />} />
</Routes>
</MemoryRouter>,
);
}

/** Submit the loaded form. The Submit button itself is out of this card's scope. */
async function submit() {
await screen.findByLabelText(/Title/);
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
}

function inLanguage(language: string) {
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
}

beforeEach(() => {
toastSuccess.mockClear();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('FormPage chrome resolves through the i18n catalogue (objectui#11039)', () => {
it('zh: the loading line is the zh pack\'s', async () => {
inLanguage('zh');
vi.stubGlobal('fetch', stubFetch(publicPayload(), { holdLoad: true }));
renderPublic();

expect(await screen.findByText('加载中…')).toBeInTheDocument();
expect(document.body.textContent).not.toContain('Loading');
});

it('zh: the success toast is 已提交', async () => {
inLanguage('zh');
vi.stubGlobal('fetch', stubFetch(publicPayload()));
renderPublic();
await submit();

await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1));
expect(toastSuccess.mock.calls[0][0]).toBe('已提交');
});

it('zh: the default thank-you panel is the zh pack\'s heading and message', async () => {
inLanguage('zh');
vi.stubGlobal('fetch', stubFetch(publicPayload()));
renderPublic();
await submit();

expect(await screen.findByRole('heading', { name: '感谢您的提交!' })).toBeInTheDocument();
expect(screen.getByText('我们已成功收到您的信息。')).toBeInTheDocument();
expect(document.body.textContent).not.toContain('Thank');
expect(document.body.textContent).not.toContain('submission has been received');
});

it('CONTROL zh: a declared thank-you title and message render as declared', async () => {
inLanguage('zh');
vi.stubGlobal(
'fetch',
stubFetch(publicPayload({ kind: 'thank-you', title: 'Merci', message: 'We will be in touch.' })),
);
renderPublic();
await submit();

expect(await screen.findByRole('heading', { name: 'Merci' })).toBeInTheDocument();
expect(screen.getByText('We will be in touch.')).toBeInTheDocument();
expect(document.body.textContent).not.toContain('感谢您的提交');
});

it('en: the English is the en pack\'s', async () => {
inLanguage('en');
vi.stubGlobal('fetch', stubFetch(publicPayload()));
renderPublic();
await submit();

expect(await screen.findByRole('heading', { name: 'Thank you!' })).toBeInTheDocument();
expect(screen.getByText('Your submission has been received successfully.')).toBeInTheDocument();
expect(toastSuccess.mock.calls[0][0]).toBe('Submitted');
});
});
9 changes: 8 additions & 1 deletion apps/console/src/components/FormPage.outcomeToast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

type ToastEntry = { type: string; message: string };
Expand Down Expand Up @@ -131,6 +132,12 @@ const renderPublic = () =>

beforeEach(() => {
toastRegistry.clear();
// FormPage's feedback chrome reads the i18n catalogue (objectui#11039)
// through the provider `main.tsx` mounts above this route. The app's own
// factory registers its instance as react-i18next's global, which is how
// this unwrapped route reaches the `en` pack; the harness restores the
// global after every test (`installI18nGlobalReset`).
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
Expand All @@ -152,7 +159,7 @@ describe('FormPage — the later submit outcome supersedes the earlier one', ()

// The retry the card describes: same form, same page, and it is accepted.
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
expect(await screen.findByText('Your submission has been received.')).toBeInTheDocument();
expect(await screen.findByText('Your submission has been received successfully.')).toBeInTheDocument();

await waitFor(() => expect(onScreen()).toEqual([{ type: 'success', message: 'Submitted' }]));
});
Expand Down
9 changes: 8 additions & 1 deletion apps/console/src/components/FormPage.recordId.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
Expand Down Expand Up @@ -167,6 +168,12 @@ function editRoutes(extra: Parameters<typeof stubFetch>[0] = []) {

beforeEach(() => {
calls = [];
// FormPage's feedback chrome reads the i18n catalogue (objectui#11039)
// through the provider `main.tsx` mounts above this route. The app's own
// factory registers its instance as react-i18next's global, which is how
// this unwrapped route reaches the `en` pack; the harness restores the
// global after every test (`installI18nGlobalReset`).
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
Expand Down Expand Up @@ -458,6 +465,6 @@ describe('control: the PUBLIC /f/:slug path is untouched by #4278', () => {
expect(calls.find((c) => c.method === 'POST')!.url).toContain('/forms/contact-us/submit');
expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(0);
// The anonymous confirmation, unchanged.
expect(await screen.findByText('Your submission has been received.')).toBeInTheDocument();
expect(await screen.findByText('Your submission has been received successfully.')).toBeInTheDocument();
});
});
9 changes: 8 additions & 1 deletion apps/console/src/components/FormPage.redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
Expand Down Expand Up @@ -152,6 +153,12 @@ function renderPublic() {
beforeEach(() => {
submits = [];
vi.mocked(toast.error).mockClear();
// FormPage's feedback chrome reads the i18n catalogue (objectui#11039)
// through the provider `main.tsx` mounts above this route. The app's own
// factory registers its instance as react-i18next's global, which is how
// this unwrapped route reaches the `en` pack; the harness restores the
// global after every test (`installI18nGlobalReset`).
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
Expand Down Expand Up @@ -319,7 +326,7 @@ describe('an out-of-contract destination is refused, not followed', () => {

// The write succeeded, so the submitter is told that too. Refusing the
// destination must not read as "your submission failed".
expect(screen.getByText('Your submission has been received.')).toBeInTheDocument();
expect(screen.getByText('Your submission has been received successfully.')).toBeInTheDocument();
expect(submits).toHaveLength(1);

// Nothing was navigated to, and no interstitial promises otherwise.
Expand Down
17 changes: 12 additions & 5 deletions apps/console/src/components/FormPage.submit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
Expand Down Expand Up @@ -140,6 +141,12 @@ function renderPublic() {

beforeEach(() => {
submits = [];
// FormPage's feedback chrome reads the i18n catalogue (objectui#11039)
// through the provider `main.tsx` mounts above this route. The app's own
// factory registers its instance as react-i18next's global, which is how
// this unwrapped route reaches the `en` pack; the harness restores the
// global after every test (`installI18nGlobalReset`).
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
Expand Down Expand Up @@ -173,7 +180,7 @@ describe('internal submit — default behaviour (ruling point 2)', () => {
expect(submits[0].url).toContain('/data/showcase_task');

// And emphatically NOT the anonymous confirmation.
expect(screen.queryByText('Your submission has been received.')).not.toBeInTheDocument();
expect(screen.queryByText('Your submission has been received successfully.')).not.toBeInTheDocument();
});

/**
Expand All @@ -195,7 +202,7 @@ describe('internal submit — default behaviour (ruling point 2)', () => {
await screen.findByLabelText(/Title/);
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));

expect(await screen.findByText('Your submission has been received.')).toBeInTheDocument();
expect(await screen.findByText('Your submission has been received successfully.')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toBe('/forms/showcase_task.edit');
});
});
Expand Down Expand Up @@ -237,7 +244,7 @@ describe('an explicitly declared submitBehavior still wins (ruling point 3)', ()

await waitFor(() => expect(submits).toHaveLength(1));
// `continue` keeps the form up with cleared values; no confirmation panel.
expect(screen.queryByText('Your submission has been received.')).not.toBeInTheDocument();
expect(screen.queryByText('Your submission has been received successfully.')).not.toBeInTheDocument();
expect(await screen.findByLabelText(/Title/)).toHaveValue('');
});
});
Expand All @@ -256,8 +263,8 @@ describe('public submit — default behaviour is unchanged', () => {
await screen.findByLabelText(/Title/);
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));

expect(await screen.findByText('Thanks!')).toBeInTheDocument();
expect(screen.getByText('Your submission has been received.')).toBeInTheDocument();
expect(await screen.findByText('Thank you!')).toBeInTheDocument();
expect(screen.getByText('Your submission has been received successfully.')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toBe('/f/contact-us');
});
});
Loading
Loading