diff --git a/.changeset/option-widgets-empty-hint-single-source.md b/.changeset/option-widgets-empty-hint-single-source.md new file mode 100644 index 000000000..52e98ffce --- /dev/null +++ b/.changeset/option-widgets-empty-hint-single-source.md @@ -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. diff --git a/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx b/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx new file mode 100644 index 000000000..5ee40bfa9 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx @@ -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 ( +
+ {props.emptyHint === undefined ? 'NO-HINT' : String(props.emptyHint)} +
+ ); +} + +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(
); +} + +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:`. + 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'); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 116f01f20..c2ef273a6 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -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', @@ -266,7 +269,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende mobile_fullscreen: _mobileFullscreen, fullscreen: _fullscreen, dependentValues, - emptyHint: _emptyHint, + emptyHint, schema: _schema, ...fieldProps } = props; @@ -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 } : {}), }; } @@ -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' @@ -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. diff --git a/packages/fields/src/widgets/CheckboxesField.tsx b/packages/fields/src/widgets/CheckboxesField.tsx index 8a00ff33a..30e07d09b 100644 --- a/packages/fields/src/widgets/CheckboxesField.tsx +++ b/packages/fields/src/widgets/CheckboxesField.tsx @@ -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; @@ -27,7 +28,7 @@ export function CheckboxesField({ schema, dependentValues, dependsOn: dependsOnProp, - emptyHint: _emptyHint, + emptyHint, dataSource: _dataSource, ...props }: FieldWidgetComponentProps) { @@ -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 ( -
- {hint} -
+ ); } diff --git a/packages/fields/src/widgets/MultiSelectField.tsx b/packages/fields/src/widgets/MultiSelectField.tsx index 53e9d402e..b537a4e77 100644 --- a/packages/fields/src/widgets/MultiSelectField.tsx +++ b/packages/fields/src/widgets/MultiSelectField.tsx @@ -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 } @@ -29,7 +30,7 @@ export function MultiSelectField({ schema, dependentValues, dependsOn: dependsOnProp, - emptyHint: _emptyHint, + emptyHint, dataSource: _dataSource, ...props }: FieldWidgetComponentProps) { @@ -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 ( -
- {hint} -
+ ); } diff --git a/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx b/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx new file mode 100644 index 000000000..3ab8945a1 --- /dev/null +++ b/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx @@ -0,0 +1,88 @@ +/** + * 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 option widgets' empty/gate copy with NO `I18nProvider` mounted + * (objectui#3231) — standalone/embedded usage, and every test in the repo that + * renders a field widget bare. + * + * Routing the copy through `useFieldTranslation()` must not turn it into a raw + * i18n key when nothing is configured: `createSafeTranslation` probes the + * instance and falls back to its English defaults map. That is the behaviour + * the removed literals used to provide unconditionally, so it gets its own pin. + * + * Why a separate FILE rather than another case in `OptionsEmptyState.test.tsx`: + * mounting `I18nProvider` calls `initReactI18next`, which installs that + * instance as react-i18next's GLOBAL default. Once any sibling test has done + * so there is no "no provider" state left to observe in that module graph, and + * the assertion would silently read the last locale mounted instead. Vitest's + * per-file isolation (`isolate: true` on the `dom` project) is what makes this + * file's observation honest. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SelectField } from './SelectField'; +import { MultiSelectField } from './MultiSelectField'; +import { RadioField } from './RadioField'; +import { CheckboxesField } from './CheckboxesField'; + +const WIDGETS = [ + { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' }, + { + label: 'MultiSelectField', + Widget: MultiSelectField, + type: 'multiselect', + testId: 'multiselect-empty-province', + }, + { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' }, + { + label: 'CheckboxesField', + Widget: CheckboxesField, + type: 'checkboxes', + testId: 'checkboxes-empty-province', + }, +] as const; + +const gatedField = (type: string) => + ({ + name: 'province', + type, + dependsOn: 'country', + options: [{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }], + }) as any; + +const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any; + +function renderWidget(Widget: React.ComponentType, field: any) { + return render( + , + ); +} + +describe('option widgets — English fallback survives with no i18n configured', () => { + it.each(WIDGETS)('$label renders the gate sentence, not a raw key', ({ Widget, type, testId }) => { + renderWidget(Widget, gatedField(type)); + const box = screen.getByTestId(testId); + expect(box).toHaveTextContent('Select country first'); + expect(box.textContent).not.toContain('fields.options'); + }); + + it.each(WIDGETS)('$label renders the empty sentence, not a raw key', ({ Widget, type, testId }) => { + renderWidget(Widget, unconfiguredField(type)); + const box = screen.getByTestId(testId); + expect(box).toHaveTextContent('No options available'); + expect(box.textContent).not.toContain('fields.options'); + }); +}); diff --git a/packages/fields/src/widgets/OptionsEmptyState.test.tsx b/packages/fields/src/widgets/OptionsEmptyState.test.tsx new file mode 100644 index 000000000..ca6cfd590 --- /dev/null +++ b/packages/fields/src/widgets/OptionsEmptyState.test.tsx @@ -0,0 +1,169 @@ +/** + * 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 empty / dependency-gated state of the fixed-option widgets (objectui#3231). + * + * Two invariants, pinned for ALL FOUR widgets — they are what regressed: + * + * 1. **declared ⇒ delivered.** `emptyHint` is declared on + * `FieldWidgetComponentProps` and computed by the form renderer. Every one + * of the four widgets used to destructure it into `_emptyHint` and drop it + * on the floor, so the host's hint could never reach a user once the field + * went through the `field:` registry (i.e. always, in the real console). + * 2. **the fallback is translated.** With no host hint the widget renders its + * own copy — which used to be a hardcoded English literal + * (`'No options available'`, `` `Select ${…} first` ``) even though + * `useFieldTranslation()` was already imported in one of the same files. + * + * All four are covered deliberately: they were four independent copies of the + * same block, which is exactly why they drifted together and why one widget's + * test would have proven nothing about the other three. They now share + * `OptionsEmptyState`; these tests keep that true from the outside, so the + * guarantee survives someone re-inlining the block. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { SelectField } from './SelectField'; +import { MultiSelectField } from './MultiSelectField'; +import { RadioField } from './RadioField'; +import { CheckboxesField } from './CheckboxesField'; + +interface WidgetCase { + label: string; + Widget: React.ComponentType; + type: string; + testId: string; +} + +const WIDGETS: WidgetCase[] = [ + { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' }, + { + label: 'MultiSelectField', + Widget: MultiSelectField, + type: 'multiselect', + testId: 'multiselect-empty-province', + }, + { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' }, + { + label: 'CheckboxesField', + Widget: CheckboxesField, + type: 'checkboxes', + testId: 'checkboxes-empty-province', + }, +]; + +/** Gated: `country` is empty, so no option is offered yet (#2284). */ +const gatedField = (type: string) => + ({ + name: 'province', + type, + dependsOn: 'country', + options: [ + { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }, + { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" }, + ], + }) as any; + +/** Not gated, simply nothing to offer — the other way a list ends up empty. */ +const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any; + +function renderWidget( + { Widget }: WidgetCase, + field: any, + extra: Record = {}, + language?: string, +) { + const element = ( + + ); + return render( + language ? ( + + {element} + + ) : ( + element + ), + ); +} + +describe('option widgets — the host-supplied `emptyHint` reaches the user (objectui#3231)', () => { + it.each(WIDGETS)( + '$label renders the host hint instead of its own gate copy', + (widget) => { + // What the form renderer actually computes: a sentence built from the + // controlling field's human LABEL, which the widget cannot know (its + // metadata only carries the name `country`). + renderWidget(widget, gatedField(widget.type), { + emptyHint: 'Choose the Country field first', + }); + + const box = screen.getByTestId(widget.testId); + expect(box).toHaveTextContent('Choose the Country field first'); + // …and NOT the widget's own copy, which is what used to render. + expect(box.textContent).not.toMatch(/select country first/i); + }, + ); + + it.each(WIDGETS)( + '$label renders the host hint for an unconfigured (ungated) list too', + (widget) => { + // `emptyHint` is a declared prop of the widget contract, not a private + // channel of one form-renderer branch: supplied ⇒ used, always. + renderWidget(widget, unconfiguredField(widget.type), { + emptyHint: 'Options come from the price book', + }); + + expect(screen.getByTestId(widget.testId)).toHaveTextContent( + 'Options come from the price book', + ); + }, + ); + + it.each(WIDGETS)('$label prefers a host hint over its translated fallback', (widget) => { + // Even under a non-English locale the HOST wins — it is the single source. + renderWidget(widget, gatedField(widget.type), { emptyHint: 'Pick a Country first' }, 'zh'); + + const box = screen.getByTestId(widget.testId); + expect(box).toHaveTextContent('Pick a Country first'); + expect(box.textContent).not.toContain('请先选择'); + }); +}); + +describe('option widgets — the fallback copy is translated, never a hardcoded literal', () => { + it.each(WIDGETS)('$label translates the gate copy when no host hint is given', (widget) => { + renderWidget(widget, gatedField(widget.type), {}, 'zh'); + + const box = screen.getByTestId(widget.testId); + expect(box).toHaveTextContent('请先选择country'); + expect(box.textContent).not.toMatch(/select .* first/i); + }); + + it.each(WIDGETS)('$label translates the empty copy when no host hint is given', (widget) => { + renderWidget(widget, unconfiguredField(widget.type), {}, 'zh'); + + const box = screen.getByTestId(widget.testId); + expect(box).toHaveTextContent('暂无可选项'); + expect(box.textContent).not.toMatch(/no options available/i); + }); +}); + +// The no-provider fallback lives in `OptionsEmptyState.no-provider.test.tsx`: +// mounting `I18nProvider` here installs its instance as react-i18next's global +// default (`initReactI18next`), so after any test above there is no longer a +// "no provider" state to observe within this module graph. + diff --git a/packages/fields/src/widgets/OptionsEmptyState.tsx b/packages/fields/src/widgets/OptionsEmptyState.tsx new file mode 100644 index 000000000..555b2c0e5 --- /dev/null +++ b/packages/fields/src/widgets/OptionsEmptyState.tsx @@ -0,0 +1,83 @@ +/** + * 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. + */ + +import React from 'react'; +import { cn } from '@object-ui/components'; +import { useFieldTranslation } from './useFieldTranslation'; + +/** + * The "this option list cannot be filled" state shared by every fixed-option + * widget (`SelectField` single, `MultiSelectField`, `RadioField`, + * `CheckboxesField`) — objectui#3231. + * + * ## One producer, one consumer + * + * `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by + * the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to + * these widgets. **When the host supplies it, it wins**: the host resolves the + * controlling fields to their human LABELS ("Select Country first"), where a + * widget on its own only knows the raw metadata names ("Select country first"). + * Absent a host hint — a standalone widget, the inline grid editor, an action + * param dialog — the widget falls back to its own copy. + * + * Each of the four widgets used to inline this box, destructure `emptyHint` + * into `_emptyHint` and throw it away, then render a hardcoded English literal. + * Four independent copies is precisely why they drifted together, so the box + * AND the copy live here only: adding a fifth option widget cannot re-introduce + * the gap without deliberately re-implementing it. + * + * ## Fallback copy is translated + * + * The fallback goes through `useFieldTranslation()` (`fields.options.*`, + * present in all ten locale packs) rather than an English string literal — the + * gate sentence is the SAME i18n key the form renderer uses, so the two can + * never say different things in the same locale. + */ +export interface OptionsEmptyStateProps { + /** + * Host-computed hint. Wins over the widget's own copy whenever it is a + * non-empty string — this is the whole point of the prop. + */ + emptyHint?: string; + /** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */ + gated: boolean; + /** Controlling field names, used by the fallback gate sentence. */ + dependsOnFields: readonly string[]; + /** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */ + testId?: string; + /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */ + className?: string; +} + +export function OptionsEmptyState({ + emptyHint, + gated, + dependsOnFields, + testId, + className, +}: OptionsEmptyStateProps) { + const { t } = useFieldTranslation(); + // The host's hint when it computed one; otherwise this widget's own copy, + // translated. Never an English literal — that was the reported defect. + const hint = + emptyHint || + (gated + ? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') }) + : t('fields.options.empty')); + return ( +
+ {hint} +
+ ); +} diff --git a/packages/fields/src/widgets/RadioField.tsx b/packages/fields/src/widgets/RadioField.tsx index c3d1e6f8e..3171eccf4 100644 --- a/packages/fields/src/widgets/RadioField.tsx +++ b/packages/fields/src/widgets/RadioField.tsx @@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react'; import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components'; import { isValueStillOffered, type OptionLike } from '@object-ui/core'; import { FieldWidgetComponentProps } from './types'; +import { OptionsEmptyState } from './OptionsEmptyState'; import { useCascadingOptions } from './useCascadingOptions'; type Option = OptionLike; @@ -27,7 +28,7 @@ export function RadioField({ schema, dependentValues, dependsOn: dependsOnProp, - emptyHint: _emptyHint, + emptyHint, dataSource: _dataSource, ...props }: FieldWidgetComponentProps) { @@ -61,19 +62,18 @@ export function RadioField({ } // No offered options is unfillable — surface a legible state instead of an - // empty radio group: a dependency-gated list prompts for its controlling - // field; an unconfigured / fully-filtered list says so. Mirrors the select. + // empty radio group: the host's `emptyHint` when it computed one, else this + // widget's own translated copy. Shared with the select / multiselect / + // checkboxes so the four cannot drift again (objectui#3231). if (options.length === 0) { - const hint = gated - ? `Select ${dependsOnFields.join(' / ')} first` - : 'No options available'; return ( -
- {hint} -
+ ); } diff --git a/packages/fields/src/widgets/SelectField.tsx b/packages/fields/src/widgets/SelectField.tsx index 5ac85b54c..e3635eb85 100644 --- a/packages/fields/src/widgets/SelectField.tsx +++ b/packages/fields/src/widgets/SelectField.tsx @@ -12,6 +12,7 @@ import { SelectFieldMetadata } from '@object-ui/types'; import { useFieldTranslation } from './useFieldTranslation'; import { FieldWidgetComponentProps } from './types'; import { MultiSelectField } from './MultiSelectField'; +import { OptionsEmptyState } from './OptionsEmptyState'; import { useCascadingOptions } from './useCascadingOptions'; /** @@ -56,7 +57,7 @@ function SingleSelectField({ schema, dependentValues, dependsOn: dependsOnProp, - emptyHint: _emptyHint, + emptyHint, dataSource: _dataSource, ...props }: FieldWidgetComponentProps) { @@ -93,19 +94,18 @@ function SingleSelectField({ // A select with no options is unfillable — a silently-empty Radix dropdown // reads as "broken widget" and hides the real cause. Surface a legible state: - // a dependency-gated list prompts for its controlling field; an unconfigured - // list says so. Mirrors the inline form renderer's behaviour. + // the host's `emptyHint` when it computed one (it knows the controlling + // fields' LABELS), else this widget's own translated copy. Shared with the + // other option widgets so the four cannot drift again (objectui#3231). if (options.length === 0) { - const hint = gated - ? `Select ${dependsOnFields.join(' / ')} first` - : 'No options available'; return ( -
- {hint} -
+ ); } diff --git a/packages/fields/src/widgets/types.ts b/packages/fields/src/widgets/types.ts index 21e3af451..f427e8452 100644 --- a/packages/fields/src/widgets/types.ts +++ b/packages/fields/src/widgets/types.ts @@ -99,9 +99,13 @@ export type FieldWidgetComponentProps = { */ dependsOn?: DependsOnInput; /** - * Hint shown when a dependency-gated option list is still waiting on its - * controlling field. Forwarded by the form renderer; the option widgets in - * this package currently discard it and render their own message instead. + * Hint shown when an option list cannot be filled — typically a + * dependency-gated list still waiting on its controlling field (#2284). + * Forwarded by the form renderer, which resolves the controlling fields to + * their human LABELS. **When supplied it wins**; the option widgets fall back + * to their own translated copy only when a host computed none (objectui#3231 + * — they used to discard this prop and always render their own hardcoded + * English). See `OptionsEmptyState`, the single consumer. */ emptyHint?: string; /** diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 01dd9c503..1edefa47d 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -39,6 +39,12 @@ const FIELD_DEFAULTS: Record = { 'lookup.nextPage': 'Next page', 'lookup.jumpToPage': 'Jump to page', 'lookup.retry': 'Retry', + // objectui#3231 — the empty / dependency-gated state of the fixed-option + // widgets (select, multiselect, radio, checkboxes). Only used when the host + // supplies no `emptyHint`; the gate sentence shares its key with the form + // renderer so both cannot drift apart in a locale. + 'fields.options.empty': 'No options available', + 'fields.options.selectFirst': 'Select {{fields}} first', // objectstack#3821 — sharing-rule authoring widgets (object-ref / // recipient-picker / filter-condition). The recipient placeholder is keyed // PER TYPE rather than interpolating the enum value into an English diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c863d3649..fc16ce393 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -172,6 +172,10 @@ const ar = { basicEditorHint: "محرر النص الغني (أساسي)", placeholder: "اكتب شيئاً...", }, + options: { + empty: "لا توجد خيارات متاحة", + selectFirst: "اختر {{fields}} أولاً", + }, objectRef: { loading: "جارٍ تحميل الكائنات…", placeholder: "اختر كائناً", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3a7bd4160..cd0f2b1ad 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -172,6 +172,10 @@ const de = { basicEditorHint: "Rich-Text-Editor (einfach)", placeholder: "Text eingeben...", }, + options: { + empty: "Keine Optionen verfügbar", + selectFirst: "Zuerst {{fields}} auswählen", + }, objectRef: { loading: "Objekte werden geladen…", placeholder: "Objekt auswählen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 49e07da4e..006506be3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -178,6 +178,14 @@ const en = { basicEditorHint: 'Rich text editor (basic)', placeholder: 'Enter text...', }, + // objectui#3231 — the "this option list cannot be filled" copy shared by + // the fixed-option widgets (select / multiselect / radio / checkboxes) AND + // by the form renderer's gate hint. One key, one sentence: the renderer + // interpolates field LABELS, a standalone widget its raw field names. + options: { + empty: 'No options available', + selectFirst: 'Select {{fields}} first', + }, objectRef: { loading: 'Loading objects…', placeholder: 'Select an object', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f048de42a..2ca12106d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -172,6 +172,10 @@ const es = { basicEditorHint: "Editor de texto enriquecido (básico)", placeholder: "Escribe algo...", }, + options: { + empty: "No hay opciones disponibles", + selectFirst: "Seleccione primero {{fields}}", + }, objectRef: { loading: "Cargando objetos…", placeholder: "Seleccionar un objeto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5a3a99691..d89034537 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -172,6 +172,10 @@ const fr = { basicEditorHint: "Éditeur de texte enrichi (basique)", placeholder: "Écrivez quelque chose...", }, + options: { + empty: "Aucune option disponible", + selectFirst: "Sélectionnez d’abord {{fields}}", + }, objectRef: { loading: "Chargement des objets…", placeholder: "Sélectionner un objet", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 4fa0c897e..3d6dda287 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -172,6 +172,10 @@ const ja = { basicEditorHint: "リッチテキストエディター(基本)", placeholder: "テキストを入力...", }, + options: { + empty: "選択できる項目がありません", + selectFirst: "まず {{fields}} を選択してください", + }, objectRef: { loading: "オブジェクトを読み込み中…", placeholder: "オブジェクトを選択", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0276cbef8..2bcdddc1a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -172,6 +172,10 @@ const ko = { basicEditorHint: "서식 있는 텍스트 편집기 (기본)", placeholder: "내용을 입력하세요...", }, + options: { + empty: "선택할 수 있는 옵션이 없습니다", + selectFirst: "먼저 {{fields}}을(를) 선택하세요", + }, objectRef: { loading: "객체를 불러오는 중…", placeholder: "객체 선택", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 76b4b8fb6..e6e2d6c0b 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -172,6 +172,10 @@ const pt = { basicEditorHint: "Editor de texto rico (básico)", placeholder: "Digite algo...", }, + options: { + empty: "Nenhuma opção disponível", + selectFirst: "Selecione primeiro {{fields}}", + }, objectRef: { loading: "Carregando objetos…", placeholder: "Selecionar um objeto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8019dc616..cb65afaaf 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -172,6 +172,10 @@ const ru = { basicEditorHint: "Редактор форматированного текста (базовый)", placeholder: "Введите текст...", }, + options: { + empty: "Нет доступных вариантов", + selectFirst: "Сначала выберите {{fields}}", + }, objectRef: { loading: "Загрузка объектов…", placeholder: "Выберите объект", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6f45ff45e..a18d651a3 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -177,6 +177,10 @@ const zh = { basicEditorHint: '富文本编辑器(基础)', placeholder: '请输入文字...', }, + options: { + empty: '暂无可选项', + selectFirst: '请先选择{{fields}}', + }, objectRef: { loading: '正在加载对象…', placeholder: '请选择对象',