From dfff0591c8c4b76edffd7b7543b60bd2bc20a16d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:19:24 +0000 Subject: [PATCH 1/5] fix(plugin-form): MasterDetailForm resolves its I18nLabel title, submitText and cancelText (objectui#10935) `@objectstack/spec` types the three as `I18nLabel` in `ComponentPropsMap['object-master-detail-form']`, and the validator arms the node with that row, so a per-locale map is an accepted document. The form read them raw: a map as a Button child threw, and a map `title` toasted "[object Object] saved". Each now goes through `pickLocalized` against `useObjectTranslation().language`, as `ObjectMetricWidget` does, and `MasterDetailFormSchema` types them `I18nLabel`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../src/MasterDetailForm.i18nLabels.test.tsx | 218 ++++++++++++++++++ packages/plugin-form/src/MasterDetailForm.tsx | 50 ++-- 2 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx diff --git a/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx b/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx new file mode 100644 index 0000000000..ec4797bf9b --- /dev/null +++ b/packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx @@ -0,0 +1,218 @@ +/** + * 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. + */ + +/** + * `object-master-detail-form`'s three `I18nLabel` members — `title`, + * `submitText` and `cancelText` — resolved before they reach the screen + * (objectui#10935). + * + * `@objectstack/spec` types all three as `I18nLabel` in + * `ComponentPropsMap['object-master-detail-form']`, and objectui's validator + * arms the node with that row by reference (objectui#10927). So a per-locale + * map is a document the validator accepts, and the first row below asserts + * exactly that for the documents the other rows mount. `MasterDetailForm` used + * to read the three raw: a map rendered as a Button child threw "Objects are + * not valid as a React child", and a map `title` toasted "[object Object] + * saved". + * + * Every row mounts the node through the real `SchemaRenderer` and the real + * registry, in the `{ type, properties }` form the spec row describes, under an + * `I18nProvider` whose UI language is `zh`. Every map lists `en` FIRST, so a + * resolver that fell back to `en` or to the first entry would paint English + * and fail the row: the rows can pass only by following the active language. + * + * The plain-string rows and the nothing-authored rows are the controls. A + * string stays exactly what was authored, and the English defaults stay + * byte-identical. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react'; + +const { toasts, fakeToast } = vi.hoisted(() => { + const toasts: Array<{ type: string; message: string }> = []; + const raise = + (type: string) => + (message: unknown): string => { + toasts.push({ type, message: String(message) }); + return `toast:${toasts.length}`; + }; + const fakeToast: any = Object.assign(raise('message'), { + success: raise('success'), + error: raise('error'), + info: raise('info'), + warning: raise('warning'), + loading: raise('loading'), + custom: raise('custom'), + promise: (p: unknown) => p, + dismiss: () => undefined, + }); + return { toasts, fakeToast }; +}); + +vi.mock('@object-ui/components/ui/sonner', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, toast: fakeToast }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { registerAllFields } from '@object-ui/fields'; +// Registers `object-master-detail-form` — the block under test. +import './index'; + +registerAllFields(); + +const PO_SCHEMA = { name: 'po', fields: { ref: { type: 'text', label: 'Ref' } } }; +const LINE_SCHEMA = { + name: 'po_line', + fields: { + qty: { type: 'number', label: 'Qty' }, + po: { type: 'master_detail', label: 'PO', reference: 'po' }, + }, +}; + +const DETAILS = [ + { + childObject: 'po_line', + relationshipField: 'po', + columns: [{ name: 'qty', label: 'Qty', type: 'number' }], + }, +]; + +/** `en` first on purpose — see the file header. */ +const TITLE_MAP = { en: 'Purchase order', 'zh-CN': '采购单' }; +const SUBMIT_MAP = { en: 'Save order', 'zh-CN': '保存订单' }; +const CANCEL_MAP = { en: 'Back', 'zh-CN': '返回' }; + +function makeDataSource() { + return { + getObjectSchema: vi.fn(async (o: string) => (o === 'po_line' ? LINE_SCHEMA : PO_SCHEMA)), + findOne: vi.fn().mockResolvedValue({ id: 'po1', ref: 'PO-1' }), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + batchTransaction: vi.fn(async (ops: Array<{ id?: string; data?: Record }>) => ({ + results: ops.map((op) => ({ id: op.id ?? 'new1', ...op.data })), + })), + } as any; +} + +/** A JSON document: the node and its `properties` bag, nothing a host adds. */ +const doc = (properties: Record) => ({ + type: 'object-master-detail-form', + properties: { objectName: 'po', fields: ['ref'], details: DETAILS, ...properties }, +}); + +/** + * Mount a node through the real `SchemaRenderer` under a `zh` UI. `host` is + * what a host adds beside the document: `onCancel` is a runtime slot a JSON + * document cannot author, and the Cancel button renders only when it is set. + */ +function mount(properties: Record, host: Record = {}) { + const ds = makeDataSource(); + const view = render( + + + + + , + ); + return { ...view, ds }; +} + +const submitButton = () => screen.findByTestId('md-form-submit'); +const cancelButton = () => screen.findByTestId('md-form-cancel'); + +/** Edit mode: wait for the stored parent, press Save, return the toasts. */ +async function saveEdit(container: HTMLElement, ds: any) { + await waitFor(() => { + const ref = container.querySelector('input[name="ref"]') as HTMLInputElement | null; + if (!ref || ref.value !== 'PO-1') throw new Error('parent record not loaded'); + }); + const save = (await submitButton()) as HTMLButtonElement; + await waitFor(() => expect(save.disabled).toBe(false)); + await act(async () => { + fireEvent.click(save); + }); + await waitFor(() => expect(ds.batchTransaction).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(toasts.filter((t) => t.type === 'success')).toHaveLength(1)); + return toasts.filter((t) => t.type === 'success').map((t) => t.message); +} + +beforeEach(() => { + toasts.length = 0; +}); + +afterEach(() => { + cleanup(); +}); + +describe('object-master-detail-form — `title` / `submitText` / `cancelText` are I18nLabel (objectui#10935)', () => { + it('the locale-map documents these rows mount are ones the validator accepts', () => { + const documents = [ + doc({ submitText: SUBMIT_MAP }), + doc({ cancelText: CANCEL_MAP }), + doc({ mode: 'edit', recordId: 'po1', title: TITLE_MAP }), + ]; + for (const d of documents) { + expect(safeValidateSchema(d).success, JSON.stringify(d.properties)).toBe(true); + } + }); + + it('a locale-map `submitText` renders the active locale\'s text on the Save button', async () => { + const { container } = mount({ submitText: SUBMIT_MAP }); + + expect((await submitButton()).textContent).toBe('保存订单'); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('a locale-map `cancelText` renders the active locale\'s text on the Cancel button', async () => { + const onCancel = vi.fn(); + const { container } = mount({ cancelText: CANCEL_MAP }, { onCancel }); + + const cancel = await cancelButton(); + expect(cancel.textContent).toBe('返回'); + expect(container.innerHTML).not.toContain('[object Object]'); + // The button is the host's: it still runs the host's handler. + fireEvent.click(cancel); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('a locale-map `title` reads in the active locale in the edit-save toast', async () => { + const { container, ds } = mount({ mode: 'edit', recordId: 'po1', title: TITLE_MAP }); + + expect(await saveEdit(container, ds)).toEqual(['采购单 saved']); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('CONTROL: plain strings render exactly as authored', async () => { + const { container, ds } = mount( + { mode: 'edit', recordId: 'po1', title: 'PO', submitText: 'Save PO', cancelText: 'Discard' }, + { onCancel: vi.fn() }, + ); + + expect((await submitButton()).textContent).toBe('Save PO'); + expect((await cancelButton()).textContent).toBe('Discard'); + expect(await saveEdit(container, ds)).toEqual(['PO saved']); + }); + + it('CONTROL: with nothing authored, the English defaults are unchanged', async () => { + const create = mount({}, { onCancel: vi.fn() }); + expect((await submitButton()).textContent).toBe('Create'); + expect((await cancelButton()).textContent).toBe('Cancel'); + create.unmount(); + + const { container, ds } = mount({ mode: 'edit', recordId: 'po1' }); + expect((await submitButton()).textContent).toBe('Save'); + expect(await saveEdit(container, ds)).toEqual(['Saved']); + }); +}); diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 1a74a580d7..80947252f1 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -31,11 +31,11 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { BatchTransactionOperation, DataSource } from '@object-ui/types'; +import type { BatchTransactionOperation, DataSource, I18nLabel } from '@object-ui/types'; import { runBatchTransaction } from '@object-ui/core'; import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; -import { useDisplayLocale } from '@object-ui/i18n'; +import { pickLocalized, useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { dataChangeMatches, subscribeDataChanges } from '@object-ui/react'; import { ObjectForm } from './ObjectForm'; @@ -105,11 +105,26 @@ export interface MasterDetailFormSchema { sections?: any[]; fields?: any[]; formType?: 'simple' | 'tabbed'; - title?: string; - submitText?: string; - /** Label for the Cancel button in the action bar. i18n is the host's job - * (this plugin is locale-agnostic); defaults to English 'Cancel'. */ - cancelText?: string; + /** + * `title`, `submitText` and `cancelText` are the spec's `I18nLabel`, as + * `ComponentPropsMap['object-master-detail-form']` in `@objectstack/spec` + * declares them: a plain string or an inline per-locale map such as + * `{ en: 'Purchase order', 'zh-CN': '采购单' }`. The form resolves each one + * with `pickLocalized` against the active UI language + * (`useObjectTranslation().language`) before it reaches the screen + * (objectui#10935). + * + * `title` names the record in the built-in edit-save toast ("… saved"), + * which shows only when the host supplies no `onSuccess`. + */ + title?: I18nLabel; + /** Label of the Save button. Defaults to English 'Save' (edit) or 'Create'. */ + submitText?: I18nLabel; + /** Label for the Cancel button in the action bar, which renders only when the + * host supplies `onCancel`. Defaults to English 'Cancel'. The English + * defaults are not translated here: a host that wants another language + * authors the label, as a string or a per-locale map. */ + cancelText?: I18nLabel; /** Hide the bottom Save/Cancel action bar — e.g. a non-persisting design * preview. Defaults to shown (the form owns the only Save in this layout). */ showSubmit?: boolean; @@ -760,6 +775,13 @@ export const MasterDetailForm: React.FC = ({ }) => { const rawDetails = schema.details || []; const isEdit = schema.mode === 'edit' && !!schema.recordId; + // `title` / `submitText` / `cancelText` are `I18nLabel` (see the schema + // above), so a locale map is resolved here, against the UI language — the + // source `ObjectMetricWidget` resolves its own `I18nLabel` members against. + // Read raw, a map threw "Objects are not valid as a React child" as a Button + // child and toasted "[object Object] saved" (objectui#10935). + const { language } = useObjectTranslation(); + const titleText = pickLocalized(schema.title, language); // A detail can be configured with just `{ childObject }` — the relationship // FK and grid columns are then derived from the child object's metadata @@ -1297,7 +1319,7 @@ export const MasterDetailForm: React.FC = ({ async (parent: any) => { releaseSave(); if (!schema.onSuccess) { - toast.success(isEdit ? (schema.title ? `${schema.title} saved` : 'Saved') : 'Created', { + toast.success(isEdit ? (titleText ? `${titleText} saved` : 'Saved') : 'Created', { id: outcomeToastId, }); } @@ -1313,7 +1335,7 @@ export const MasterDetailForm: React.FC = ({ await schema.onSuccess?.(parent); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [isEdit, schema.onSuccess, schema.title, entries.length, releaseSave, outcomeToastId], + [isEdit, schema.onSuccess, titleText, entries.length, releaseSave, outcomeToastId], ); /** @@ -1497,7 +1519,8 @@ export const MasterDetailForm: React.FC = ({ formType: schema.formType, sections: schema.sections, fields: schema.fields, - title: schema.title, + // The resolved string: `ObjectFormSchema.title` is a `string`. + title: titleText || undefined, showSubmit: false, showCancel: false, // ObjectForm validates + hands the parent values to submitViaBatch (which @@ -1507,11 +1530,12 @@ export const MasterDetailForm: React.FC = ({ onSuccess: handleSaved, onError: handleError, }), - [schema, submitViaBatch, handleSaved, handleError], + [schema, titleText, submitViaBatch, handleSaved, handleError], ); const formHostRef = useRef(null); - const submitText = schema.submitText ?? (isEdit ? 'Save' : 'Create'); + const submitText = pickLocalized(schema.submitText, language) || (isEdit ? 'Save' : 'Create'); + const cancelText = pickLocalized(schema.cancelText, language) || 'Cancel'; // Upload-in-flight gate (objectui#10166), and this host is the reason the // scope CHAINS rather than shadows. The parent fields and every expanded row @@ -1661,7 +1685,7 @@ export const MasterDetailForm: React.FC = ({
{schema.onCancel && ( )}