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
45 changes: 45 additions & 0 deletions .changeset/option-widgets-empty-hint-single-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@object-ui/components": patch
"@object-ui/fields": patch
"@object-ui/i18n": patch
---

The option widgets' "this list cannot be filled" message now has one source, and
it is translated (objectui#3231).

FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
carried their own copy of the empty/gated state, each destructured the declared
`emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
`emptyHint` when it supplied one, otherwise a translated fallback
(`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
packs).

`emptyHint` was declared, produced by the form renderer and transported, then
lost three times over — so no registered widget could ever render it. All three
breaks are fixed, because closing only the last one delivers nothing:

- `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 for every option field coming from an object schema — the normal case
in the console — the whole cascade block was skipped and no hint was computed
at all. It now normalizes the `field:` prefix, the same normalization
`stripRegisteredFieldProps` already applied a few lines below.
- `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
left. It is now forwarded to the four cascade option types, alongside
`dependentValues`. This stays an allow-list rather than a blanket
pass-through: every other registered widget spreads its leftover props onto a
DOM node, where an unknown `emptyHint` attribute is a React warning.
- the widgets themselves discarded it. Keeping it out of the `...props` spread
was correct; not using it afterwards was not.

User-visible effect: a dependency-gated option list now prompts with the
controlling field's **label** ("Select Country first") instead of its raw
metadata name, in the session's language; an unconfigured list says so in the
session's language too. The gate sentence is one i18n key shared by the renderer
and the widget fallback, so the two sides cannot word it differently.

Untouched: the built-in (unregistered) `select` branch of the form renderer,
which already consumed `emptyHint`. That is a separate live path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* 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.
*/

/**
* The dependency-gate hint must actually REACH a registered option widget
* (objectui#3231) — the producer half of "declared ≠ delivered".
*
* `emptyHint` was declared on the widget contract, computed here for a gated
* option list (#2284) and handed to `renderFieldComponent` … and then lost
* twice on the way out, so no registered widget could ever render it:
*
* 1. `isOptionField` compared the RAW resolved type against `'select'` etc.
* Object-derived forms emit `mapFieldTypeToFormType`'s prefixed ids
* (`field:select`), which matched nothing — so for every option field that
* came from an object schema (the normal case in the console) the whole
* cascade block was skipped and no hint was computed at all.
* 2. `stripRegisteredFieldProps` then removed the `emptyHint` key from what
* was left, so even the bare-type forms that DID compute a hint delivered
* nothing.
*
* The strip is otherwise correct — every other registered widget spreads its
* leftover props onto a DOM node, where an unknown `emptyHint` attribute is a
* React warning — so the forward is an ALLOW-LIST over the cascade option
* types. Both directions are pinned below.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Module scope, not `beforeAll` — the cold transform must not be billed to
// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
import '../../../renderers';

/**
* Surfaces the received `emptyHint` so the injection can be asserted — both
* its VALUE and whether the key was passed at all. The renderer always sets
* `emptyHint` on the props object (`undefined` when the list is not gated), so
* key presence is what distinguishes "stripped" from "forwarded, empty".
*/
function EmptyHintProbe(props: any) {
return (
<div
data-testid={`hint-probe-${props.name}`}
data-has-key={'emptyHint' in props ? 'yes' : 'no'}
>
{props.emptyHint === undefined ? 'NO-HINT' : String(props.emptyHint)}
</div>
);
}

const OPTION_TYPES = ['select', 'radio', 'multiselect', 'checkboxes'] as const;

beforeAll(() => {
// The fields package owns the real widgets; components tests never load it,
// so stand the probe in for each registered option widget.
for (const type of OPTION_TYPES) {
ComponentRegistry.register(`field:${type}`, EmptyHintProbe, { namespace: 'test' });
}
// A registered widget that is NOT an option field — the strip must still
// hold for it (its props land on a DOM node).
ComponentRegistry.register('field:lookup', EmptyHintProbe, { namespace: 'test' });
}, 30000);

function renderForm(fields: any[]) {
const Form = ComponentRegistry.get('form')!;
return render(<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />);
}

const gatedField = (type: string) => ({
name: 'province',
label: 'Province',
type,
dependsOn: 'country',
options: [
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
],
});

const emptyParent = { name: 'country', label: 'Country', type: 'input', defaultValue: '' };

describe('form renderer — emptyHint delivery to registered option widgets (objectui#3231)', () => {
// The prefixed ids are what `mapFieldTypeToFormType` emits, i.e. what every
// object-derived form in the console actually renders.
it.each(OPTION_TYPES)('a registered field:%s receives the computed gate hint', (type) => {
renderForm([emptyParent, gatedField(`field:${type}`)]);

// Built from the controlling field's LABEL ("Country"), not its raw name —
// that label resolution is the reason the host owns this string at all.
const probe = screen.getByTestId('hint-probe-province');
expect(probe).toHaveTextContent('Select Country first');
expect(probe).toHaveAttribute('data-has-key', 'yes');
});

// `select` is a BUILTIN_FIELD_TYPE, so a bare `type: 'select'` never reaches
// the registry at all — it renders the inline branch, which already consumed
// `emptyHint`. The other three do resolve through `field:<type>`.
it.each(['radio', 'multiselect', 'checkboxes'] as const)(
'a hand-written `type: %s` schema delivers it too',
(type) => {
renderForm([emptyParent, gatedField(type)]);

expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');
},
);

it('withdraws the hint once the gate lifts — an ungated list is not "empty"', async () => {
renderForm([emptyParent, gatedField('field:select')]);

expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');

// Picking the parent lifts the gate, so the host has nothing to say and the
// widget goes back to owning its own (now non-empty) list.
fireEvent.change(screen.getByLabelText(/country/i), { target: { value: 'cn' } });
await waitFor(() => {
expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('NO-HINT');
});
});

it('still withholds the key from registered widgets that would spread it onto the DOM', () => {
// The renderer sets `emptyHint` on the props of EVERY field, so without the
// strip this probe would report `data-has-key="yes"` — and a real widget
// would spread an unknown `emptyHint` attribute onto its DOM node. The
// forward is an allow-list over the four cascade option types, not a
// blanket "stop stripping it".
renderForm([emptyParent, { name: 'contact', label: 'Contact', type: 'lookup', dependsOn: 'country' }]);

const probe = screen.getByTestId('hint-probe-contact');
expect(probe).toHaveAttribute('data-has-key', 'no');
expect(probe).toHaveTextContent('NO-HINT');
});
});
35 changes: 27 additions & 8 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ const panePercent = (size: number | undefined): string | undefined =>
const useSafeFormTranslation = createSafeTranslation(
{
'common.selectOption': 'Select an option',
// objectui#3231 — the dependency-gate sentence (#2284). Shared with the
// option widgets' own fallback so both sides render one wording.
'fields.options.selectFirst': 'Select {{fields}} first',
'validation.required': '{{field}} is required',
'validation.minLength': '{{field}} must be at least {{min}} characters',
'validation.maxLength': '{{field}} must be at most {{max}} characters',
Expand Down Expand Up @@ -266,7 +269,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
mobile_fullscreen: _mobileFullscreen,
fullscreen: _fullscreen,
dependentValues,
emptyHint: _emptyHint,
emptyHint,
schema: _schema,
...fieldProps
} = props;
Expand All @@ -275,7 +278,12 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
return {
...fieldProps,
...(DATA_SOURCE_FIELD_TYPES.has(normalizedType) ? { dataSource, dependentValues } : {}),
...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues } : {}),
// The cascade option widgets own the gate hint's presentation, so they get
// the computed `emptyHint` alongside the live record (objectui#3231). It is
// stripped by default because every OTHER registered widget spreads its
// leftover props onto a DOM node, where an unknown `emptyHint` attribute is
// a React warning — hence an allow-list, not an unconditional pass-through.
...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}),
};
}

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

// colSpan classes for grid layout.
Expand Down
24 changes: 12 additions & 12 deletions packages/fields/src/widgets/CheckboxesField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
import type { OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';

type Option = OptionLike;
Expand All @@ -27,7 +28,7 @@ export function CheckboxesField({
schema,
dependentValues,
dependsOn: dependsOnProp,
emptyHint: _emptyHint,
emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps<string[]>) {
Expand Down Expand Up @@ -70,19 +71,18 @@ export function CheckboxesField({
}

// No offered options is unfillable — surface a legible state instead of an
// empty checkbox list: a dependency-gated list prompts for its controlling
// field; an unconfigured / fully-filtered list says so. Mirrors the select.
// empty checkbox list: the host's `emptyHint` when it computed one, else this
// widget's own translated copy. Shared with the select / multiselect / radio
// so the four cannot drift again (objectui#3231).
if (options.length === 0) {
const hint = gated
? `Select ${dependsOnFields.join(' / ')} first`
: 'No options available';
return (
<div
data-testid={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
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"
>
{hint}
</div>
<OptionsEmptyState
emptyHint={emptyHint}
gated={gated}
dependsOnFields={dependsOnFields}
testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
className="min-h-9"
/>
);
}

Expand Down
24 changes: 12 additions & 12 deletions packages/fields/src/widgets/MultiSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { Badge, EmptyValue, cn } from '@object-ui/components';
import type { OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';

interface Option extends OptionLike { color?: string }
Expand Down Expand Up @@ -29,7 +30,7 @@ export function MultiSelectField({
schema,
dependentValues,
dependsOn: dependsOnProp,
emptyHint: _emptyHint,
emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps<string[]>) {
Expand Down Expand Up @@ -71,19 +72,18 @@ export function MultiSelectField({
}

// No offered options is unfillable — surface a legible state instead of an
// empty chip row: a dependency-gated list prompts for its controlling field;
// an unconfigured / fully-filtered list says so. Mirrors the single select.
// empty chip row: the host's `emptyHint` when it computed one, else this
// widget's own translated copy. Shared with the single select / radio /
// checkboxes so the four cannot drift again (objectui#3231).
if (options.length === 0) {
const hint = gated
? `Select ${dependsOnFields.join(' / ')} first`
: 'No options available';
return (
<div
data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
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"
>
{hint}
</div>
<OptionsEmptyState
emptyHint={emptyHint}
gated={gated}
dependsOnFields={dependsOnFields}
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
className="min-h-9"
/>
);
}

Expand Down
Loading
Loading