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
21 changes: 21 additions & 0 deletions .changeset/10993-object-form-i18nlabel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/plugin-form': patch
'@object-ui/plugin-view': patch
'@object-ui/types': minor
---

An `object-form` whose `title`, `description`, `submitText`, `cancelText`, `nextText`, `prevText` or `successMessage` is a per-locale map now shows the viewer's language instead of failing to render (objectui#10993).

**What it was.** `@objectstack/spec` types these seven members of `object-form` as `I18nLabel`: a plain string or an inline per-locale map such as `{ en: 'Save order', 'zh-CN': '保存订单' }`. `ObjectForm` handed all seven raw to the presentation its `formType` picked, and every presentation that displays one renders it as a React child. A map therefore threw "Objects are not valid as a React child", and the node rendered `Component "form" failed to render` instead of a form.

**What changed, in observable terms.**

- `ObjectForm` resolves the seven with `pickLocalized` against the active UI language (`useObjectTranslation().language`), once, before it picks a presentation. So the simple, tabbed, split and wizard forms, the drawer and modal presentations, and the master-detail route all receive the entry for the viewer's language, with the fallback chain `pickLocalized` applies.
- A plain string renders exactly as authored. With nothing authored, every presentation shows the default label it showed before.
- A map with no string entry resolves to nothing, so the presentation's default label shows.
- `object-view`'s `form` slot takes `ObjectFormSchema`'s keys, so its `form.title` and `form.description` are `I18nLabel` too. `ObjectView` draws its own drawer and modal header around the form, and it now resolves both the same way, against the same UI language, where it used to read them raw.
- The `object-form` registration declares both arms for the seven 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.

**Types.** In `@object-ui/types`, `ObjectFormSchema.title`, `.description`, `.submitText`, `.cancelText`, `.nextText`, `.prevText` and `.successMessage` widen from `string` to `I18nLabel`, matching the spec row. The zod mirror's `title`, `description`, `submitText`, `cancelText` and `successMessage` widen from `z.string()` to the spec's `I18nLabelSchema`, by reference, so `safeValidateSchema` accepts a locale map on them; a number is still refused at the member. `nextText` and `prevText` stay unmirrored, as before. Code that writes these members compiles unchanged. Code that reads one of them off an `ObjectFormSchema` and uses it as a `string` no longer compiles: resolve it first, for example with `pickLocalized` from `@object-ui/i18n`.

**Clause-②: yes** — seven members of the exported `ObjectFormSchema` type, and five members of its zod mirror, widen from a string to `I18nLabel`, and the registration's `inputs` for the seven keys widen from `'string'` to `['string', 'object']`. Nothing that was accepted before is refused now.
89 changes: 89 additions & 0 deletions apps/console/src/__tests__/objectFormI18nLabelManifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* 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-form`'s seven `I18nLabel` members through the manifest's PUBLIC door
* (objectui#10993).
*
* `@objectstack/spec` types `title`, `description`, `submitText`, `cancelText`,
* `nextText`, `prevText` and `successMessage` as `I18nLabel`: a plain string or
* an inline per-locale map. `ObjectForm` resolves a map against the active UI
* language (`plugin-form`'s `ObjectForm.i18nLabels.test.tsx` pins the render),
* so the registration declares both arms, `['string', 'object']`, as the
* `ComponentInput` docblock asks of a key whose render site resolves the map.
* While the seven declared only `'string'`, `validateTree` reported
* `type-mismatch` on a legal map: the manifest gate contradicting the spec row
* and the renderer on the same write.
*
* The door is the production one, `ComponentRegistry.getPublicConfigs()` →
* `manifestFromConfigs` → `validateTree`, where the block's key is the bare
* `object-form`, as in the sibling pin for `object-master-detail-form`
* (`masterDetailFormI18nLabelManifest.test.ts`).
*/
import { describe, it, expect } from 'vitest';
import { ComponentRegistry } from '@object-ui/core';
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser';
import '@object-ui/components';
import '../register-plugins';

const manifest = manifestFromConfigs(
ComponentRegistry.getPublicConfigs() as unknown as Parameters<typeof manifestFromConfigs>[0],
);

const BLOCK = 'object-form';
const LABELS = [
'title',
'description',
'submitText',
'cancelText',
'nextText',
'prevText',
'successMessage',
] as const;

/** `en` first, as in the render pin. */
const MAP = { en: 'Save order', 'zh-CN': '保存订单' };

const diagnose = (props: Record<string, unknown>): Diagnostic[] =>
validateTree(
{ type: BLOCK, objectName: 'order', ...props } as unknown as SchemaElement,
manifest,
).diagnostics;

/** The `type-mismatch` diagnostics that name one prop. */
const mismatchesOn = (props: Record<string, unknown>, key: string): Diagnostic[] =>
diagnose(props).filter((d) => d.code === 'type-mismatch' && d.message.includes(key));

describe('object-form — the I18nLabel members at the manifest public door (objectui#10993)', () => {
it('the block is in the public manifest under its bare type, and a minimal node is clean', () => {
// Reachability before absence: an unknown block would satisfy every
// "no type-mismatch" row below with `unknown-component` instead.
expect(manifest.components[BLOCK], `${BLOCK} is not in the public manifest`).toBeDefined();
expect(diagnose({})).toEqual([]);
});

it.each(LABELS)('`%s` declares the string arm and the locale-map arm', (key) => {
const input = manifest.components[BLOCK].inputs.find((i) => i.name === key);
expect(input, `${key} is not a declared input`).toBeDefined();
const arms = Array.isArray(input?.type) ? [...input.type].sort() : [input?.type];
expect(arms).toEqual(['object', 'string']);
});

it.each(LABELS)('a locale map on `%s` is not reported', (key) => {
expect(mismatchesOn({ [key]: MAP }, key)).toEqual([]);
});

it.each(LABELS)('CONTROL: a plain string on `%s` is not reported', (key) => {
expect(mismatchesOn({ [key]: 'Save order' }, key)).toEqual([]);
});

it.each(LABELS)('CONTROL: a value matching neither arm on `%s` is still reported', (key) => {
// The union is not a way out of the gate: a number is neither a string nor
// a map, and must still be a `type-mismatch`.
expect(mismatchesOn({ [key]: 42 }, key)).toHaveLength(1);
});
});
Loading
Loading