Skip to content

Commit 825bbe3

Browse files
fix(fields): option widgets render the host-computed emptyHint, translated (#3231) (#3262)
`emptyHint` was declared on `FieldWidgetComponentProps`, computed by the form renderer for a dependency-gated option list (#2284) and transported — then lost three times over, so no registered widget could ever render it: 1. `isOptionField` compared the raw resolved type against `'select'` / `'radio'` / `'multiselect'` / `'checkboxes'`. Object-derived forms emit `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of them, so the whole cascade block was skipped and no hint was computed at all for the normal console path. It now normalizes the `field:` prefix — the same normalization `stripRegisteredFieldProps` already applied below it. 2. `stripRegisteredFieldProps` removed the `emptyHint` key from what was left. It is now forwarded to the four cascade option types alongside `dependentValues` — an allow-list, because every other registered widget spreads leftover props onto a DOM node. 3. `SelectField` / `MultiSelectField` / `RadioField` / `CheckboxesField` each destructured it into `_emptyHint`, dropped it, and rendered a hardcoded English literal instead. The four inline copies of the empty state converge on one `OptionsEmptyState`: host hint when supplied, otherwise a TRANSLATED fallback (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale packs). The gate sentence is one i18n key shared with the renderer, so the two sides cannot word it differently. The built-in (unregistered) `select` branch, which already consumed `emptyHint`, is untouched. Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa Co-authored-by: Claude <noreply@anthropic.com>
1 parent acf37b1 commit 825bbe3

22 files changed

Lines changed: 654 additions & 59 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@object-ui/components": patch
3+
"@object-ui/fields": patch
4+
"@object-ui/i18n": patch
5+
---
6+
7+
The option widgets' "this list cannot be filled" message now has one source, and
8+
it is translated (objectui#3231).
9+
10+
FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
11+
carried their own copy of the empty/gated state, each destructured the declared
12+
`emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
13+
English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
14+
Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
15+
`emptyHint` when it supplied one, otherwise a translated fallback
16+
(`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
17+
packs).
18+
19+
`emptyHint` was declared, produced by the form renderer and transported, then
20+
lost three times over — so no registered widget could ever render it. All three
21+
breaks are fixed, because closing only the last one delivers nothing:
22+
23+
- `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
24+
`'multiselect'` / `'checkboxes'`. Object-derived forms emit
25+
`mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
26+
them, so for every option field coming from an object schema — the normal case
27+
in the console — the whole cascade block was skipped and no hint was computed
28+
at all. It now normalizes the `field:` prefix, the same normalization
29+
`stripRegisteredFieldProps` already applied a few lines below.
30+
- `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
31+
left. It is now forwarded to the four cascade option types, alongside
32+
`dependentValues`. This stays an allow-list rather than a blanket
33+
pass-through: every other registered widget spreads its leftover props onto a
34+
DOM node, where an unknown `emptyHint` attribute is a React warning.
35+
- the widgets themselves discarded it. Keeping it out of the `...props` spread
36+
was correct; not using it afterwards was not.
37+
38+
User-visible effect: a dependency-gated option list now prompts with the
39+
controlling field's **label** ("Select Country first") instead of its raw
40+
metadata name, in the session's language; an unconfigured list says so in the
41+
session's language too. The gate sentence is one i18n key shared by the renderer
42+
and the widget fallback, so the two sides cannot word it differently.
43+
44+
Untouched: the built-in (unregistered) `select` branch of the form renderer,
45+
which already consumed `emptyHint`. That is a separate live path.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The dependency-gate hint must actually REACH a registered option widget
11+
* (objectui#3231) — the producer half of "declared ≠ delivered".
12+
*
13+
* `emptyHint` was declared on the widget contract, computed here for a gated
14+
* option list (#2284) and handed to `renderFieldComponent` … and then lost
15+
* twice on the way out, so no registered widget could ever render it:
16+
*
17+
* 1. `isOptionField` compared the RAW resolved type against `'select'` etc.
18+
* Object-derived forms emit `mapFieldTypeToFormType`'s prefixed ids
19+
* (`field:select`), which matched nothing — so for every option field that
20+
* came from an object schema (the normal case in the console) the whole
21+
* cascade block was skipped and no hint was computed at all.
22+
* 2. `stripRegisteredFieldProps` then removed the `emptyHint` key from what
23+
* was left, so even the bare-type forms that DID compute a hint delivered
24+
* nothing.
25+
*
26+
* The strip is otherwise correct — every other registered widget spreads its
27+
* leftover props onto a DOM node, where an unknown `emptyHint` attribute is a
28+
* React warning — so the forward is an ALLOW-LIST over the cascade option
29+
* types. Both directions are pinned below.
30+
*/
31+
32+
import { describe, it, expect, beforeAll } from 'vitest';
33+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
34+
import { ComponentRegistry } from '@object-ui/core';
35+
// Module scope, not `beforeAll` — the cold transform must not be billed to
36+
// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
37+
import '../../../renderers';
38+
39+
/**
40+
* Surfaces the received `emptyHint` so the injection can be asserted — both
41+
* its VALUE and whether the key was passed at all. The renderer always sets
42+
* `emptyHint` on the props object (`undefined` when the list is not gated), so
43+
* key presence is what distinguishes "stripped" from "forwarded, empty".
44+
*/
45+
function EmptyHintProbe(props: any) {
46+
return (
47+
<div
48+
data-testid={`hint-probe-${props.name}`}
49+
data-has-key={'emptyHint' in props ? 'yes' : 'no'}
50+
>
51+
{props.emptyHint === undefined ? 'NO-HINT' : String(props.emptyHint)}
52+
</div>
53+
);
54+
}
55+
56+
const OPTION_TYPES = ['select', 'radio', 'multiselect', 'checkboxes'] as const;
57+
58+
beforeAll(() => {
59+
// The fields package owns the real widgets; components tests never load it,
60+
// so stand the probe in for each registered option widget.
61+
for (const type of OPTION_TYPES) {
62+
ComponentRegistry.register(`field:${type}`, EmptyHintProbe, { namespace: 'test' });
63+
}
64+
// A registered widget that is NOT an option field — the strip must still
65+
// hold for it (its props land on a DOM node).
66+
ComponentRegistry.register('field:lookup', EmptyHintProbe, { namespace: 'test' });
67+
}, 30000);
68+
69+
function renderForm(fields: any[]) {
70+
const Form = ComponentRegistry.get('form')!;
71+
return render(<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />);
72+
}
73+
74+
const gatedField = (type: string) => ({
75+
name: 'province',
76+
label: 'Province',
77+
type,
78+
dependsOn: 'country',
79+
options: [
80+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
81+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
82+
],
83+
});
84+
85+
const emptyParent = { name: 'country', label: 'Country', type: 'input', defaultValue: '' };
86+
87+
describe('form renderer — emptyHint delivery to registered option widgets (objectui#3231)', () => {
88+
// The prefixed ids are what `mapFieldTypeToFormType` emits, i.e. what every
89+
// object-derived form in the console actually renders.
90+
it.each(OPTION_TYPES)('a registered field:%s receives the computed gate hint', (type) => {
91+
renderForm([emptyParent, gatedField(`field:${type}`)]);
92+
93+
// Built from the controlling field's LABEL ("Country"), not its raw name —
94+
// that label resolution is the reason the host owns this string at all.
95+
const probe = screen.getByTestId('hint-probe-province');
96+
expect(probe).toHaveTextContent('Select Country first');
97+
expect(probe).toHaveAttribute('data-has-key', 'yes');
98+
});
99+
100+
// `select` is a BUILTIN_FIELD_TYPE, so a bare `type: 'select'` never reaches
101+
// the registry at all — it renders the inline branch, which already consumed
102+
// `emptyHint`. The other three do resolve through `field:<type>`.
103+
it.each(['radio', 'multiselect', 'checkboxes'] as const)(
104+
'a hand-written `type: %s` schema delivers it too',
105+
(type) => {
106+
renderForm([emptyParent, gatedField(type)]);
107+
108+
expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');
109+
},
110+
);
111+
112+
it('withdraws the hint once the gate lifts — an ungated list is not "empty"', async () => {
113+
renderForm([emptyParent, gatedField('field:select')]);
114+
115+
expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');
116+
117+
// Picking the parent lifts the gate, so the host has nothing to say and the
118+
// widget goes back to owning its own (now non-empty) list.
119+
fireEvent.change(screen.getByLabelText(/country/i), { target: { value: 'cn' } });
120+
await waitFor(() => {
121+
expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('NO-HINT');
122+
});
123+
});
124+
125+
it('still withholds the key from registered widgets that would spread it onto the DOM', () => {
126+
// The renderer sets `emptyHint` on the props of EVERY field, so without the
127+
// strip this probe would report `data-has-key="yes"` — and a real widget
128+
// would spread an unknown `emptyHint` attribute onto its DOM node. The
129+
// forward is an allow-list over the four cascade option types, not a
130+
// blanket "stop stripping it".
131+
renderForm([emptyParent, { name: 'contact', label: 'Contact', type: 'lookup', dependsOn: 'country' }]);
132+
133+
const probe = screen.getByTestId('hint-probe-contact');
134+
expect(probe).toHaveAttribute('data-has-key', 'no');
135+
expect(probe).toHaveTextContent('NO-HINT');
136+
});
137+
});

packages/components/src/renderers/form/form.tsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ const panePercent = (size: number | undefined): string | undefined =>
110110
const useSafeFormTranslation = createSafeTranslation(
111111
{
112112
'common.selectOption': 'Select an option',
113+
// objectui#3231 — the dependency-gate sentence (#2284). Shared with the
114+
// option widgets' own fallback so both sides render one wording.
115+
'fields.options.selectFirst': 'Select {{fields}} first',
113116
'validation.required': '{{field}} is required',
114117
'validation.minLength': '{{field}} must be at least {{min}} characters',
115118
'validation.maxLength': '{{field}} must be at most {{max}} characters',
@@ -266,7 +269,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
266269
mobile_fullscreen: _mobileFullscreen,
267270
fullscreen: _fullscreen,
268271
dependentValues,
269-
emptyHint: _emptyHint,
272+
emptyHint,
270273
schema: _schema,
271274
...fieldProps
272275
} = props;
@@ -275,7 +278,12 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
275278
return {
276279
...fieldProps,
277280
...(DATA_SOURCE_FIELD_TYPES.has(normalizedType) ? { dataSource, dependentValues } : {}),
278-
...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues } : {}),
281+
// The cascade option widgets own the gate hint's presentation, so they get
282+
// the computed `emptyHint` alongside the live record (objectui#3231). It is
283+
// stripped by default because every OTHER registered widget spreads its
284+
// leftover props onto a DOM node, where an unknown `emptyHint` attribute is
285+
// a React warning — hence an allow-list, not an unconditional pass-through.
286+
...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}),
279287
};
280288
}
281289

@@ -1203,11 +1211,15 @@ ComponentRegistry.register('form',
12031211
// the live record + `current_user`), and gate the whole control
12041212
// while a declared `dependsOn` parent is still empty — surfacing a
12051213
// "select the parent first" hint instead of an unfiltered list.
1206-
const isOptionField =
1207-
resolvedType === 'select' ||
1208-
resolvedType === 'radio' ||
1209-
resolvedType === 'multiselect' ||
1210-
resolvedType === 'checkboxes';
1214+
// `field:select` and `select` name the SAME field kind — the object-form
1215+
// path (`mapFieldTypeToFormType`) emits the prefixed id, hand-written
1216+
// form schemas the bare one. Comparing the raw string recognised only the
1217+
// bare form, so every option field coming from an object schema fell out
1218+
// of this block entirely and no gate hint was ever computed for it
1219+
// (objectui#3231). `normalizeFieldType` is the same normalization
1220+
// `stripRegisteredFieldProps` already applies a few lines down; the two
1221+
// must agree on what a `select` is.
1222+
const isOptionField = CASCADE_OPTION_FIELD_TYPES.has(normalizeFieldType(resolvedType));
12111223
const rawOptions = (fieldProps as any).options as SelectOption[] | undefined;
12121224
// Resolve gating + `visibleWhen` filtering through the shared
12131225
// core helper so this pre-filter can't drift from the widgets'
@@ -1219,8 +1231,15 @@ ComponentRegistry.register('form',
12191231
const optionGroupGated = cascade?.gated ?? false;
12201232
const dependsOnFields = cascade?.dependsOnFields ?? [];
12211233
const effectiveOptions = cascade ? cascade.options : rawOptions;
1234+
// Same i18n key the option widgets fall back to (`fields.options.
1235+
// selectFirst`, objectui#3231): one sentence, two callers — this one
1236+
// interpolates the controlling fields' LABELS, a standalone widget its
1237+
// raw metadata names — so the gate can never read differently depending
1238+
// on which side produced it.
12221239
const gatedHint = optionGroupGated
1223-
? `Select ${dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / ')} first`
1240+
? t('fields.options.selectFirst', {
1241+
fields: dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / '),
1242+
})
12241243
: undefined;
12251244

12261245
// colSpan classes for grid layout.

packages/fields/src/widgets/CheckboxesField.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
22
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
33
import type { OptionLike } from '@object-ui/core';
44
import { FieldWidgetComponentProps } from './types';
5+
import { OptionsEmptyState } from './OptionsEmptyState';
56
import { useCascadingOptions } from './useCascadingOptions';
67

78
type Option = OptionLike;
@@ -27,7 +28,7 @@ export function CheckboxesField({
2728
schema,
2829
dependentValues,
2930
dependsOn: dependsOnProp,
30-
emptyHint: _emptyHint,
31+
emptyHint,
3132
dataSource: _dataSource,
3233
...props
3334
}: FieldWidgetComponentProps<string[]>) {
@@ -70,19 +71,18 @@ export function CheckboxesField({
7071
}
7172

7273
// No offered options is unfillable — surface a legible state instead of an
73-
// empty checkbox list: a dependency-gated list prompts for its controlling
74-
// field; an unconfigured / fully-filtered list says so. Mirrors the select.
74+
// empty checkbox list: the host's `emptyHint` when it computed one, else this
75+
// widget's own translated copy. Shared with the select / multiselect / radio
76+
// so the four cannot drift again (objectui#3231).
7577
if (options.length === 0) {
76-
const hint = gated
77-
? `Select ${dependsOnFields.join(' / ')} first`
78-
: 'No options available';
7978
return (
80-
<div
81-
data-testid={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
82-
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
83-
>
84-
{hint}
85-
</div>
79+
<OptionsEmptyState
80+
emptyHint={emptyHint}
81+
gated={gated}
82+
dependsOnFields={dependsOnFields}
83+
testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
84+
className="min-h-9"
85+
/>
8686
);
8787
}
8888

packages/fields/src/widgets/MultiSelectField.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
22
import { Badge, EmptyValue, cn } from '@object-ui/components';
33
import type { OptionLike } from '@object-ui/core';
44
import { FieldWidgetComponentProps } from './types';
5+
import { OptionsEmptyState } from './OptionsEmptyState';
56
import { useCascadingOptions } from './useCascadingOptions';
67

78
interface Option extends OptionLike { color?: string }
@@ -29,7 +30,7 @@ export function MultiSelectField({
2930
schema,
3031
dependentValues,
3132
dependsOn: dependsOnProp,
32-
emptyHint: _emptyHint,
33+
emptyHint,
3334
dataSource: _dataSource,
3435
...props
3536
}: FieldWidgetComponentProps<string[]>) {
@@ -71,19 +72,18 @@ export function MultiSelectField({
7172
}
7273

7374
// No offered options is unfillable — surface a legible state instead of an
74-
// empty chip row: a dependency-gated list prompts for its controlling field;
75-
// an unconfigured / fully-filtered list says so. Mirrors the single select.
75+
// empty chip row: the host's `emptyHint` when it computed one, else this
76+
// widget's own translated copy. Shared with the single select / radio /
77+
// checkboxes so the four cannot drift again (objectui#3231).
7678
if (options.length === 0) {
77-
const hint = gated
78-
? `Select ${dependsOnFields.join(' / ')} first`
79-
: 'No options available';
8079
return (
81-
<div
82-
data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
83-
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
84-
>
85-
{hint}
86-
</div>
80+
<OptionsEmptyState
81+
emptyHint={emptyHint}
82+
gated={gated}
83+
dependsOnFields={dependsOnFields}
84+
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
85+
className="min-h-9"
86+
/>
8787
);
8888
}
8989

0 commit comments

Comments
 (0)