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
20 changes: 20 additions & 0 deletions .changeset/10935-master-detail-i18n-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-form': minor
---

A master-detail form whose `title`, `submitText` or `cancelText` is a per-locale map now shows the viewer's language instead of crashing or toasting "[object Object] saved" (objectui#10935).

**What it was.** `@objectstack/spec` types these three members of `object-master-detail-form` as `I18nLabel`, a plain string or an inline per-locale map such as `{ en: 'Purchase order', 'zh-CN': '采购单' }`, and objectui's validator accepts such a map in the node's `properties` (objectui#10927). `MasterDetailForm` read all three raw. `submitText` and `cancelText` are Button children, so a map threw "Objects are not valid as a React child" and took the form down. A map `title` went into the built-in edit-save toast as "[object Object] saved".

**What changed, in observable terms.**

- 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.
- 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.

**Types.** `MasterDetailFormSchema.title`, `.submitText` and `.cancelText` widen from `string` to `I18nLabel` (from `@object-ui/types`), matching the spec row. Code that writes these members compiles unchanged. Code that reads one of them and uses it as a `string` no longer compiles: resolve it first, for example with `pickLocalized` from `@object-ui/i18n`.

**Clause-②: yes** — three members of `MasterDetailFormSchema`, which the package entry exports, widen from `string` to `I18nLabel`, and the registration's `inputs` for the same three keys widen from `'string'` to `['string', 'object']`. Nothing that was accepted before is refused now.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* 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.title`, `.submitText` and `.cancelText` through
* the manifest's PUBLIC door (objectui#10935).
*
* `@objectstack/spec` types the three as `I18nLabel`: a plain string or an
* inline per-locale map. `MasterDetailForm` resolves a map against the active
* UI language (`MasterDetailForm.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 three declared only `'string'`, `validateTree` reported
* `type-mismatch` on a legal map: the manifest gate contradicting the spec row,
* the validator 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-master-detail-form`. (`getAllConfigs()` keys it by its namespace,
* `plugin-form:object-master-detail-form`, so a bare-typed node there reads as
* `unknown-component` and every "no type-mismatch" row would pass vacuously.)
*/
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-master-detail-form';
const LABELS = ['title', 'submitText', 'cancelText'] as const;

/** `en` first, as in the render pin. */
const MAP = { en: 'Purchase order', 'zh-CN': '采购单' };

const diagnose = (props: Record<string, unknown>): Diagnostic[] =>
validateTree(
{
type: BLOCK,
objectName: 'po',
details: [{ childObject: 'po_line', relationshipField: 'po' }],
...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-master-detail-form — the I18nLabel labels at the manifest public door (objectui#10935)', () => {
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]: 'Purchase 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);
});
});
12 changes: 12 additions & 0 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2509,6 +2509,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',
pins: 'ONE nested position and no more: `schema.grouping?.fields?.[0]?.field` is the FALLBACK source of `swimlaneField`, and that is the entire member contract this board carries for the key. Three rows make it a reading rather than a claim — the swimlane layout appears keyed by `fields[0].field` where without the key there is none; an explicit `swimlaneField` WINS over it; and a second `fields` entry changes nothing, which is what pins the read at `[0]` rather than at "the fields list". The declared description says the rest is inert precisely so the declaration does not recommend a write the renderer cannot honour — this file is what keeps that sentence true. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).',
},
'object-master-detail-form.cancelText': {
file: 'packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx',
pins: 'The I18nLabel object arm, resolved at the read site: through the real `SchemaRenderer`, a `properties.cancelText` map under a `zh` UI renders the map\'s `zh-CN` entry on the Cancel button, with `en` listed FIRST so an `en` or first-entry fallback fails the row. Before objectui#10935 the map was a raw Button child and threw "Objects are not valid as a React child". The button renders only when the host supplies `onCancel`, so the row adds one beside the document and asserts the host handler still runs. A plain-string control and the nothing-authored control (`Cancel`) stay green, and the first row asserts the mounted documents pass `safeValidateSchema`. The manifest half (a map is not a `type-mismatch`) is `masterDetailFormI18nLabelManifest.test.ts` in this directory (objectui#10935).',
},
'object-master-detail-form.initialData': {
file: 'packages/plugin-form/src/__tests__/masterDetailInitialMembers-8071.test.tsx',
pins: 'Members are PARENT FIELD NAMES and each member value is that control\'s opening value \u2014 pinned in ONE file with `initialValues`, because this block\'s own registration declares them a pair ("Alternate spelling of `initialValues` the renderer also reads", with new schemas told to prefer `initialValues`) and a pair pinned apart leaves the PRECEDENCE stated nowhere. The precedence is PER MEMBER since objectui#9760: one shared `resolveInitialRecord(schema)` merges `{ ...initialValues, ...initialData }`, so with both authored a member `initialData` says nothing about keeps its `initialValues` value, and an EMPTY `initialData` contributes nothing rather than blanking the parent form. It used to be whole-object (`schema.initialData || schema.initialValues`, where `||` tests the object and `{}` is truthy); objectui#8071 wrote pins only, so it recorded that and handed the defect back, and the maintainer ruling on objectui#9760 repaired it \u2014 the two rows were FLIPPED there, \u26d4 not deleted. \u2b50 The two rows no sibling pin can make are the block\'s own: the seed reaches the PARENT LEG of the atomic batch and nothing else (a member naming a detail column seeds no child row and the posted batch still carries exactly one operation), and in `edit` mode with a `recordId` the fetched record REPLACES both keys wholesale, so a member the record omits opens empty rather than falling back to the seed. Neither key has a read site in this block at all: `MasterDetailForm`\'s `parentSchema` memo copies them key by key onto an `object-form`-shaped object rendered through a DIRECTLY imported `<ObjectForm>` \u2014 no `SchemaRenderer`, no registry lookup \u2014 which is exactly why the pin is taken here, since that hand-written map can drop a key while every declaration still reads correct (the failure `object-form.sections` records twice, objectui#9779 / objectui#9834). A no-keys row renders the same controls empty as the non-vacuity control. Both keys are registered `type: \'object\'` and typed `Record<string, any>`, so every object parses on both declared sides and the read site is the whole member contract. New file (objectui#8071 slice 15).',
Expand All @@ -2521,6 +2525,14 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx',
pins: 'Members are section OBJECTS shaping the PARENT half only \u2014 a member\'s `fields` are parent field names drawn in the member\'s AUTHORED order (FLIPPED with objectui#10475, which moved `SimpleObjectForm` off a name filter that let the OBJECT\'s field order win), and the block\'s `details` collections keep their own columns through every row. A member that resolves to NO parent field is dropped WHOLE, heading included, measured with a DETAIL column name as the member because that is the mistake this composition invites: one node declares two field vocabularies and only one of them is this key\'s. \u2b50 The sharp row, and it now AGREES with the block\'s own registration. `ObjectForm` builds the parent pool from `schema.fields` FIRST and a section filters against that pool, so authoring both INTERSECTS them \u2014 a section member outside `fields` does not render, and a section whose every member is outside it disappears heading and all. \u26a0 It used to record this as a DIVERGENCE, because the registration declared `fields` "Ignored when `sections` is given \u2014 sections carry their own field lists", and to record the loss as having NO DIAGNOSTIC; objectui#8071 slice 15 handed both back as a finding rather than acting on them, because acting changes either the renderer or the declaration and that card wrote pins only. objectui#9884 ruled it, and the DECLARATION was the wrong half: one `SimpleObjectForm` renders this block\'s parent half and `object-form` alike, and the three sibling `fields` registrations (`object-form`, `form`, `embeddable-form`) declare the key as the field selection with no such exemption, so honouring it would have falsified three declarations to satisfy one \u2014 on a pool that also feeds create defaults, the seed merge and the values a submit carries. The sentence was corrected and the SILENCE closed instead: `warnSectionMemberExcludedByFields` names every such drop, once per section-and-member pair, and is measured BEFORE `applyFieldPerms` so a perms-gated field is never reported as an authoring mistake. \u26d4 So the diagnostic is part of what this row pins now, and slice 15\'s two DOM assertions stand unchanged beside it \u2014 the rendered outcome did not move. Rows 3b, 3c and 3d carry the last-member case (with a counterfactual beside it), the firing control, and the leg keeping the warning off the DIFFERENT silence row 2 owns. The key has no read site in this block: `MasterDetailForm`\'s `parentSchema` memo copies it onto an `object-form`-shaped object rendered through a DIRECTLY imported `<ObjectForm>`, the same hand-written carrier whose dropped keys `object-form.sections` records twice (objectui#9779 / objectui#9834) \u2014 which is why the pin is taken here rather than delegated. \u26d4 Deliberately NOT re-asserted, because they are owned next door by `objectFormSectionMembers-8071.test.tsx` and duplicating them buys nothing: `collapsed` vs `collapsible`, the `name`-alone heading, the untitled trailing bucket and the `description` blurb; the `simple` / `tabbed` presentation routing is owned by `masterDetailFormTypeVocabulary.test.tsx`. A no-sections row is the non-vacuity control. Both declared sides are unconstrained (bare `type: \'array\'` with no `of`; `sections?: any[]`), so the read site is the whole member contract. New file (objectui#8071 slice 15).',
},
'object-master-detail-form.submitText': {
file: 'packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx',
pins: 'The I18nLabel object arm on the Save button — see `object-master-detail-form.cancelText` for the harness. A `properties.submitText` map under a `zh` UI renders its `zh-CN` entry, where the raw read threw "Objects are not valid as a React child" before objectui#10935. The controls are a plain string rendered as authored and, with nothing authored, the English defaults (`Create` in create mode, `Save` in edit mode) (objectui#10935).',
},
'object-master-detail-form.title': {
file: 'packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx',
pins: 'The I18nLabel object arm where `title` is displayed: the built-in toast after an EDIT save, which shows only when the host supplies no `onSuccess`. A `properties.title` map under a `zh` UI toasts `采购单 saved`, where the raw read toasted "[object Object] saved" before objectui#10935. The controls are a plain string (`PO saved`) and, with nothing authored, `Saved`. A compile-time row in the same file holds `MasterDetailFormSchema[\'title\']` (and the other two labels) equal to `I18nLabel | undefined` (objectui#10935).',
},
'object-metric.aggregate': {
file: 'packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx',
pins: 'A three-member options bag whose members are read TWICE — once into the adapter call, once back out of the response. `field`/`function`/`groupBy` become `ds.aggregate(object, { field, function, groupBy, filter })` with `groupBy` OPTIONAL and defaulting to `\'_all\'` (one bucket) and an authored value outranking it; the bag is asserted AS A WHOLE so a member added to or dropped from the call is red in either direction. The readback is the half that has no second chance: `function: \'count\'` sums `<field>_count` across EVERY returned row while any other function reads the FIRST row\'s `<field>_<function>`, and the two arms run on the SAME two-row response so only a readback reading both members can answer 120 on one and 7 on the other — a `field`/`function` that reached the query but not the readback paints 0 over a response that carried the right number. `<field>` unsuffixed is pinned as the chain\'s second limb. The key\'s ABSENCE is a member semantic in its own right and is the file\'s non-vacuity floor: with no `aggregate` the query VERB changes to `find()` and the value becomes the row count. Driven through the registered block, not the bare widget. New file (objectui#8071 slice 8).',
Expand Down
Loading
Loading