@object-ui/fields 17.1.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +45 -6
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
* A gated lookup names its controlling field by LABEL — objectstack#5407.
|
|
11
|
+
*
|
|
12
|
+
* The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
|
|
13
|
+
* its `{{fields}}` interpolation came straight off `depends_on`, which holds
|
|
14
|
+
* API names. So the rendered hint was localized around a raw identifier in
|
|
15
|
+
* every locale, English included:
|
|
16
|
+
*
|
|
17
|
+
* en Select crm_account first
|
|
18
|
+
* zh-CN 请先选择crm_account
|
|
19
|
+
*
|
|
20
|
+
* The `en` row is why this is not merely an i18n bug: an English user was shown
|
|
21
|
+
* an internal identifier. The form renderer knows the label (it already
|
|
22
|
+
* resolves the same map for the fixed-option widgets' `emptyHint`) and now
|
|
23
|
+
* hands it over as `dependsOnLabels`.
|
|
24
|
+
*
|
|
25
|
+
* All three gated surfaces are asserted — the trigger's visible text, the
|
|
26
|
+
* trigger's `title`, and the "browse all" button's `title` — because they were
|
|
27
|
+
* three independent copies of the same expression and a fix applied to one of
|
|
28
|
+
* them looks identical from the outside.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
32
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
33
|
+
import { LookupField } from './LookupField';
|
|
34
|
+
|
|
35
|
+
/** Mirrors the reported metadata: the parent field's API name IS an object-ish
|
|
36
|
+
* name (`crm_account`), which is exactly what made the leak so legible. */
|
|
37
|
+
const gatedField = {
|
|
38
|
+
name: 'contact',
|
|
39
|
+
label: 'Contact',
|
|
40
|
+
reference_to: 'crm_contact',
|
|
41
|
+
reference_field: 'name',
|
|
42
|
+
depends_on: ['crm_account'],
|
|
43
|
+
} as any;
|
|
44
|
+
|
|
45
|
+
const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
|
|
46
|
+
|
|
47
|
+
function renderGated(extra: Record<string, unknown>) {
|
|
48
|
+
return render(
|
|
49
|
+
<LookupField
|
|
50
|
+
field={gatedField}
|
|
51
|
+
value={undefined}
|
|
52
|
+
onChange={vi.fn()}
|
|
53
|
+
readonly={false}
|
|
54
|
+
dataSource={dataSource}
|
|
55
|
+
{...({ dependentValues: { crm_account: null }, ...extra } as any)}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
|
|
62
|
+
window.matchMedia = ((query: string) => ({
|
|
63
|
+
matches: false,
|
|
64
|
+
media: query,
|
|
65
|
+
onchange: null,
|
|
66
|
+
addEventListener: () => {},
|
|
67
|
+
removeEventListener: () => {},
|
|
68
|
+
addListener: () => {},
|
|
69
|
+
removeListener: () => {},
|
|
70
|
+
dispatchEvent: () => false,
|
|
71
|
+
})) as any;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => cleanup());
|
|
75
|
+
|
|
76
|
+
describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
|
|
77
|
+
it('interpolates the label the host supplied, not the API name', () => {
|
|
78
|
+
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
79
|
+
|
|
80
|
+
const trigger = screen.getByTestId('lookup-trigger-gated');
|
|
81
|
+
expect(trigger).toHaveTextContent('Select Account first');
|
|
82
|
+
// The identifier must be gone from the copy entirely, not merely
|
|
83
|
+
// accompanied by the label.
|
|
84
|
+
expect(trigger).not.toHaveTextContent('crm_account');
|
|
85
|
+
expect(trigger).toHaveAttribute('title', 'Select Account first');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('labels the gated "browse all" button the same way', () => {
|
|
89
|
+
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
90
|
+
|
|
91
|
+
const browse = screen.getByTestId('browse-all-records');
|
|
92
|
+
expect(browse).toBeDisabled();
|
|
93
|
+
expect(browse).toHaveAttribute('title', 'Select Account first');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('joins several controlling fields by their labels', () => {
|
|
97
|
+
render(
|
|
98
|
+
<LookupField
|
|
99
|
+
field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
|
|
100
|
+
value={undefined}
|
|
101
|
+
onChange={vi.fn()}
|
|
102
|
+
readonly={false}
|
|
103
|
+
dataSource={dataSource}
|
|
104
|
+
{...({
|
|
105
|
+
dependentValues: { crm_account: null, lead_source: null },
|
|
106
|
+
dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
|
|
107
|
+
} as any)}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
112
|
+
'Select Account, Lead Source first',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to the API name for a field the host did not map', () => {
|
|
117
|
+
// Standalone use (no host form) has to keep rendering what it rendered
|
|
118
|
+
// before — the map is an enrichment, not a new requirement. This is also
|
|
119
|
+
// the assertion that would go red if `dependsOnLabels` were consulted for
|
|
120
|
+
// its VALUES rather than keyed by field name.
|
|
121
|
+
renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
|
|
122
|
+
|
|
123
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
124
|
+
'Select crm_account first',
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('falls back to the API name when the host passes no map at all', () => {
|
|
129
|
+
renderGated({});
|
|
130
|
+
|
|
131
|
+
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
132
|
+
'Select crm_account first',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -10,7 +10,8 @@ import { cn,
|
|
|
10
10
|
PopoverTrigger,
|
|
11
11
|
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
12
12
|
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
|
-
import {
|
|
13
|
+
import { FieldWidgetComponentProps } from './types';
|
|
14
|
+
import { toDomProps } from './toDomProps';
|
|
14
15
|
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
15
16
|
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
16
17
|
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
@@ -191,7 +192,7 @@ function mapFieldTypeToFilterType(
|
|
|
191
192
|
* from the referenced object using `DataSource.find()`.
|
|
192
193
|
* Falls back to static `options` when no DataSource is available.
|
|
193
194
|
*/
|
|
194
|
-
export function LookupField({ value, onChange, field, readonly, ...props }:
|
|
195
|
+
export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
|
|
195
196
|
const [isOpen, setIsOpen] = useState(false);
|
|
196
197
|
const { t } = useFieldTranslation();
|
|
197
198
|
const listboxId = React.useId();
|
|
@@ -215,7 +216,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
215
216
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
216
217
|
const listRef = useRef<HTMLDivElement>(null);
|
|
217
218
|
|
|
218
|
-
const lookupField =
|
|
219
|
+
const lookupField = field as any;
|
|
219
220
|
|
|
220
221
|
// When rendered via createFieldRenderer wrapper the actual objectSchema field
|
|
221
222
|
// metadata (reference_to, display_field, etc.) lives at lookupField.field.
|
|
@@ -279,14 +280,31 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
279
280
|
return [];
|
|
280
281
|
}, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
|
|
281
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The gate sentence's `{{fields}}` — the controlling fields named the way the
|
|
285
|
+
* user sees them on the form, not the way the metadata spells them.
|
|
286
|
+
*
|
|
287
|
+
* `depends_on` holds API names, and this used to interpolate them straight
|
|
288
|
+
* into the sentence, so every locale — `en` included — read "Select
|
|
289
|
+
* crm_account first" (objectstack#5407): an internal identifier in the UI,
|
|
290
|
+
* not merely an untranslated word. The host form supplies the name→label map
|
|
291
|
+
* (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
|
|
292
|
+
* standalone widget with no host renders exactly what it did before.
|
|
293
|
+
*/
|
|
294
|
+
const dependsOnLabelsProp = props.dependsOnLabels;
|
|
295
|
+
const dependsOnFieldsText = useMemo(
|
|
296
|
+
() => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
|
|
297
|
+
[dependsOn, dependsOnLabelsProp],
|
|
298
|
+
);
|
|
299
|
+
|
|
282
300
|
// Resolve dependent field values from explicit prop or SchemaRendererContext.data
|
|
283
|
-
const dependentValuesProp =
|
|
301
|
+
const dependentValuesProp = props.dependentValues;
|
|
284
302
|
|
|
285
303
|
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
286
304
|
const ctx = useContext(SchemaRendererContext);
|
|
287
305
|
const contextDataSource = ctx?.dataSource ?? null;
|
|
288
306
|
const dataSource: DataSource | null =
|
|
289
|
-
(props as
|
|
307
|
+
(props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
|
|
290
308
|
|
|
291
309
|
/** Resolve dependent values from the explicit prop (preferred), the form-data
|
|
292
310
|
* context provided by @object-ui/react, or finally `ctx.data` (record scope). */
|
|
@@ -369,7 +387,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
369
387
|
|
|
370
388
|
// Optional create-new callback
|
|
371
389
|
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
372
|
-
|
|
390
|
+
props.onCreateNew ?? lookupField?.onCreateNew;
|
|
373
391
|
|
|
374
392
|
// State for the full Record Picker dialog (Level 2)
|
|
375
393
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
@@ -657,7 +675,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
657
675
|
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
658
676
|
// unit_price/description when the item is chosen. When provided (single
|
|
659
677
|
// select), it drives the update and the host owns the resulting value change.
|
|
660
|
-
const onSelectRecord =
|
|
678
|
+
const onSelectRecord = props.onSelectRecord;
|
|
661
679
|
|
|
662
680
|
const handleSelect = useCallback(
|
|
663
681
|
(option: LookupOption) => {
|
|
@@ -906,25 +924,41 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
906
924
|
// Compact mode (e.g. inside a line-item grid cell): show the selected value
|
|
907
925
|
// INSIDE a borderless trigger on a single line — no chip stacked above a
|
|
908
926
|
// separate "Select…" button (which double-stacks and wastes the row height).
|
|
909
|
-
const compact = !!
|
|
927
|
+
const compact = !!props.compact;
|
|
910
928
|
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
911
929
|
|
|
912
930
|
// Shared field trigger — the anchor for either the inline PeoplePicker
|
|
913
931
|
// (search fields) or the classic quick-select popover. No onClick: the Radix
|
|
914
932
|
// trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
|
|
933
|
+
//
|
|
934
|
+
// DOM pass-through onto this button — the widget's real focusable control —
|
|
935
|
+
// per objectui#3318. `name` is withheld: no native control here takes part
|
|
936
|
+
// in form submission, and a stray `name` on a button invites exactly the
|
|
937
|
+
// accidental-submitter semantics #3306 kept it off the SelectTrigger for.
|
|
938
|
+
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
915
939
|
const triggerButton = (
|
|
916
940
|
<Button
|
|
941
|
+
{...triggerDomProps}
|
|
917
942
|
variant="outline"
|
|
918
943
|
className={cn(
|
|
919
|
-
|
|
944
|
+
// max-w-full: in a BLOCK parent (detail-section inline edit) the
|
|
945
|
+
// inline-flex button is content-sized — a long selected label pushed
|
|
946
|
+
// it past the card edge; bounding it lets the inner `truncate` span
|
|
947
|
+
// clip instead (objectui#3466). flex-1/min-w-0 keep handling the
|
|
948
|
+
// flex-parent (form row) case.
|
|
949
|
+
'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
|
|
920
950
|
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
921
951
|
)}
|
|
922
952
|
type="button"
|
|
923
|
-
disabled={dependenciesMissing ||
|
|
924
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((
|
|
953
|
+
disabled={dependenciesMissing || props.disabled}
|
|
954
|
+
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
|
|
925
955
|
title={dependenciesMissing
|
|
926
|
-
? t('lookup.selectFirst', { fields:
|
|
956
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
927
957
|
: undefined}
|
|
958
|
+
// AFTER the spread so this widget's own computation wins (#3222):
|
|
959
|
+
// `fieldError` is the published validation slot — NOT the popover's
|
|
960
|
+
// fetch error, which is a widget-internal state named `error` below.
|
|
961
|
+
aria-invalid={!!fieldError}
|
|
928
962
|
>
|
|
929
963
|
{hydrating ? (
|
|
930
964
|
<Loader2
|
|
@@ -936,7 +970,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
936
970
|
)}
|
|
937
971
|
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
938
972
|
{dependenciesMissing
|
|
939
|
-
? t('lookup.selectFirst', { fields:
|
|
973
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
940
974
|
: hydrating
|
|
941
975
|
// The value EXISTS but its labels are still loading — say so
|
|
942
976
|
// instead of the empty placeholder (#3108).
|
|
@@ -1214,11 +1248,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1214
1248
|
type="button"
|
|
1215
1249
|
// Gated exactly like the main trigger (#2215) — pre-fix this button
|
|
1216
1250
|
// opened the full unscoped table while the dependency was missing.
|
|
1217
|
-
disabled={dependenciesMissing ||
|
|
1251
|
+
disabled={dependenciesMissing || props.disabled}
|
|
1218
1252
|
onClick={() => setIsPickerOpen(true)}
|
|
1219
1253
|
aria-label={t('lookup.browseAll')}
|
|
1220
1254
|
title={dependenciesMissing
|
|
1221
|
-
? t('lookup.selectFirst', { fields:
|
|
1255
|
+
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
1222
1256
|
: t('lookup.browseAll')}
|
|
1223
1257
|
data-testid="browse-all-records"
|
|
1224
1258
|
>
|
|
@@ -1248,6 +1282,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1248
1282
|
lookupFilters={lookupFilters}
|
|
1249
1283
|
baseFilter={dependentFilter}
|
|
1250
1284
|
cellRenderer={getCellRendererResolver()}
|
|
1285
|
+
fieldsMeta={refObjectSchema?.fields}
|
|
1251
1286
|
filterColumns={filterColumns}
|
|
1252
1287
|
/>
|
|
1253
1288
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Button, Badge, cn } from '@object-ui/components';
|
|
3
3
|
import { Plus, X, ExternalLink } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Master-Detail relationship data structure
|
|
@@ -23,9 +23,9 @@ export function MasterDetailField({
|
|
|
23
23
|
readonly,
|
|
24
24
|
className,
|
|
25
25
|
...props
|
|
26
|
-
}:
|
|
26
|
+
}: FieldWidgetComponentProps<MasterDetailValue[]>) {
|
|
27
27
|
const items = value || [];
|
|
28
|
-
const config = field
|
|
28
|
+
const config = field;
|
|
29
29
|
|
|
30
30
|
const handleAdd = () => {
|
|
31
31
|
// This would typically open a dialog to select/create related records
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React, { useEffect } from 'react';
|
|
2
2
|
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
5
7
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
8
|
|
|
7
9
|
interface Option extends OptionLike { color?: string }
|
|
@@ -26,17 +28,17 @@ export function MultiSelectField({
|
|
|
26
28
|
field,
|
|
27
29
|
readonly,
|
|
28
30
|
className,
|
|
29
|
-
schema,
|
|
30
31
|
dependentValues,
|
|
31
32
|
dependsOn: dependsOnProp,
|
|
32
|
-
emptyHint
|
|
33
|
+
emptyHint,
|
|
33
34
|
dataSource: _dataSource,
|
|
35
|
+
error,
|
|
34
36
|
...props
|
|
35
|
-
}:
|
|
36
|
-
const config =
|
|
37
|
+
}: FieldWidgetComponentProps<string[]>) {
|
|
38
|
+
const config = field as any;
|
|
37
39
|
const rawOptions: Option[] = config?.options || [];
|
|
38
40
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
39
|
-
const fieldName =
|
|
41
|
+
const fieldName = props.name || config?.name || props.id || '';
|
|
40
42
|
|
|
41
43
|
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
42
44
|
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
@@ -71,19 +73,18 @@ export function MultiSelectField({
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
// No offered options is unfillable — surface a legible state instead of an
|
|
74
|
-
// empty chip row:
|
|
75
|
-
//
|
|
76
|
+
// empty chip row: the host's `emptyHint` when it computed one, else this
|
|
77
|
+
// widget's own translated copy. Shared with the single select / radio /
|
|
78
|
+
// checkboxes so the four cannot drift again (objectui#3231).
|
|
76
79
|
if (options.length === 0) {
|
|
77
|
-
const hint = gated
|
|
78
|
-
? `Select ${dependsOnFields.join(' / ')} first`
|
|
79
|
-
: 'No options available';
|
|
80
80
|
return (
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{
|
|
86
|
-
|
|
81
|
+
<OptionsEmptyState
|
|
82
|
+
emptyHint={emptyHint}
|
|
83
|
+
gated={gated}
|
|
84
|
+
dependsOnFields={dependsOnFields}
|
|
85
|
+
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
|
|
86
|
+
className="min-h-9"
|
|
87
|
+
/>
|
|
87
88
|
);
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -92,8 +93,22 @@ export function MultiSelectField({
|
|
|
92
93
|
onChange(next);
|
|
93
94
|
};
|
|
94
95
|
|
|
96
|
+
// DOM pass-through (objectui#3318): the container carries the form
|
|
97
|
+
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
98
|
+
// wrapper div is not where assistive tech reads the invalid state. That
|
|
99
|
+
// state goes onto every focusable chip button below, computed from the
|
|
100
|
+
// published `error` slot (#3222), so whichever chip the user tabs to
|
|
101
|
+
// announces the failure. `name` is withheld too: it is only DOM-legal on
|
|
102
|
+
// form controls, and on this div it is exactly the leak #3291 sweeps for.
|
|
103
|
+
const {
|
|
104
|
+
'aria-invalid': _hostAriaInvalid,
|
|
105
|
+
name: _domName,
|
|
106
|
+
...groupDomProps
|
|
107
|
+
} = toDomProps(props);
|
|
108
|
+
|
|
95
109
|
return (
|
|
96
110
|
<div
|
|
111
|
+
{...groupDomProps}
|
|
97
112
|
className={cn('flex flex-wrap gap-1.5', className)}
|
|
98
113
|
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
|
|
99
114
|
>
|
|
@@ -107,8 +122,9 @@ export function MultiSelectField({
|
|
|
107
122
|
type="button"
|
|
108
123
|
key={value}
|
|
109
124
|
onClick={() => toggle(value)}
|
|
110
|
-
disabled={
|
|
125
|
+
disabled={props.disabled}
|
|
111
126
|
aria-pressed={active}
|
|
127
|
+
aria-invalid={!!error}
|
|
112
128
|
data-testid={`multiselect-option-${opt.value}`}
|
|
113
129
|
className={cn(
|
|
114
130
|
'rounded-full border px-3 py-1 text-sm transition-colors',
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* NumberField - Numeric input with optional decimal precision
|
|
8
9
|
* Supports min/max/step constraints and configurable decimal precision
|
|
9
10
|
*/
|
|
10
|
-
export function NumberField({ value, onChange, field, readonly, ...props }:
|
|
11
|
+
export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
11
12
|
if (readonly) {
|
|
12
13
|
return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const numberField =
|
|
16
|
+
const numberField = field as NumberFieldMetadata;
|
|
16
17
|
// Step follows `scale` (decimal places), not `precision` (total digit count):
|
|
17
18
|
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
18
19
|
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
|
|
@@ -25,8 +26,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
25
26
|
? Math.pow(10, -scale)
|
|
26
27
|
: 'any';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
const { inputType, ...domProps } = props as any;
|
|
29
|
+
const domProps = toDomProps(props);
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
32
|
<Input
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ObjectField - JSON object editor
|
|
7
8
|
* Allows editing structured JSON data
|
|
8
9
|
*/
|
|
9
|
-
export function ObjectField({ value, onChange, field, readonly, ...props }:
|
|
10
|
-
const config = field
|
|
10
|
+
export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
11
|
+
const config = field;
|
|
11
12
|
|
|
12
13
|
// Initialize string state based on value
|
|
13
14
|
const getInitialJsonString = () => {
|
|
@@ -16,7 +17,9 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
const [jsonString, setJsonString] = useState(getInitialJsonString);
|
|
19
|
-
|
|
20
|
+
// Named `parseError`, NOT `error`: `error` is the published validation slot
|
|
21
|
+
// on the widget contract (#3222) and is destructured above.
|
|
22
|
+
const [parseError, setParseError] = useState<string | null>(null);
|
|
20
23
|
|
|
21
24
|
// Sync internal string state when value changes externally
|
|
22
25
|
// This is a controlled component pattern where we need to sync external changes
|
|
@@ -53,33 +56,39 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
53
56
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
54
57
|
const str = e.target.value;
|
|
55
58
|
setJsonString(str);
|
|
56
|
-
|
|
59
|
+
setParseError(null);
|
|
57
60
|
|
|
58
61
|
if (!str.trim()) {
|
|
59
62
|
onChange(null);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
|
-
|
|
65
|
+
|
|
63
66
|
try {
|
|
64
67
|
const parsed = JSON.parse(str);
|
|
65
68
|
onChange(parsed);
|
|
66
69
|
} catch (e) {
|
|
67
70
|
// Invalid JSON - don't propagate change to parent, but keep local state
|
|
68
|
-
|
|
71
|
+
setParseError("Invalid JSON");
|
|
69
72
|
}
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
return (
|
|
73
76
|
<div className="space-y-1">
|
|
74
77
|
<Textarea
|
|
78
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
79
|
+
{...toDomProps(props)}
|
|
75
80
|
value={jsonString}
|
|
76
81
|
onChange={handleChange}
|
|
77
82
|
placeholder={config?.placeholder || '{\n "key": "value"\n}'}
|
|
78
83
|
disabled={readonly || props.disabled}
|
|
79
|
-
className={cn("font-mono text-xs",
|
|
84
|
+
className={cn("font-mono text-xs", parseError ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
|
|
80
85
|
rows={6}
|
|
86
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
87
|
+
// Invalid = the form's validation slot OR this widget's own unparsable
|
|
88
|
+
// draft (which already draws the red border above).
|
|
89
|
+
aria-invalid={!!error || !!parseError}
|
|
81
90
|
/>
|
|
82
|
-
{
|
|
91
|
+
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
|
|
83
92
|
</div>
|
|
84
93
|
);
|
|
85
94
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -31,12 +32,13 @@ export function ObjectRefField({
|
|
|
31
32
|
onChange,
|
|
32
33
|
readonly,
|
|
33
34
|
className,
|
|
35
|
+
error,
|
|
34
36
|
...props
|
|
35
|
-
}:
|
|
37
|
+
}: FieldWidgetComponentProps<string>) {
|
|
36
38
|
const ctx = React.useContext(SchemaRendererContext);
|
|
37
39
|
const { t } = useFieldTranslation();
|
|
38
|
-
const dataSource: any =
|
|
39
|
-
const disabled =
|
|
40
|
+
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
41
|
+
const disabled = props.disabled;
|
|
40
42
|
|
|
41
43
|
const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
|
|
42
44
|
|
|
@@ -91,8 +93,14 @@ export function ObjectRefField({
|
|
|
91
93
|
return <span className={className}>{label}</span>;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// DOM pass-through onto the combobox trigger — the widget's real focusable
|
|
97
|
+
// control (objectui#3318). `name` is withheld: the trigger is a button, not
|
|
98
|
+
// a submission control (same reasoning as #3306's SelectTrigger).
|
|
99
|
+
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
100
|
+
|
|
94
101
|
return (
|
|
95
102
|
<Combobox
|
|
103
|
+
{...triggerDomProps}
|
|
96
104
|
options={options}
|
|
97
105
|
value={value ?? ''}
|
|
98
106
|
onValueChange={(v) => onChange(v as any)}
|
|
@@ -104,6 +112,8 @@ export function ObjectRefField({
|
|
|
104
112
|
// `w-[200px]` is a component default that left this control stranded at
|
|
105
113
|
// a third of the row while `名称` beside it ran full width.
|
|
106
114
|
className={cn('w-full', className)}
|
|
115
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
116
|
+
aria-invalid={!!error}
|
|
107
117
|
/>
|
|
108
118
|
);
|
|
109
119
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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 option widgets' empty/gate copy with NO `I18nProvider` mounted
|
|
11
|
+
* (objectui#3231) — standalone/embedded usage, and every test in the repo that
|
|
12
|
+
* renders a field widget bare.
|
|
13
|
+
*
|
|
14
|
+
* Routing the copy through `useFieldTranslation()` must not turn it into a raw
|
|
15
|
+
* i18n key when nothing is configured: `createSafeTranslation` probes the
|
|
16
|
+
* instance and falls back to its English defaults map. That is the behaviour
|
|
17
|
+
* the removed literals used to provide unconditionally, so it gets its own pin.
|
|
18
|
+
*
|
|
19
|
+
* Why a separate FILE rather than another case in `OptionsEmptyState.test.tsx`:
|
|
20
|
+
* mounting `I18nProvider` calls `initReactI18next`, which installs that
|
|
21
|
+
* instance as react-i18next's GLOBAL default. Once any sibling test has done
|
|
22
|
+
* so there is no "no provider" state left to observe in that module graph, and
|
|
23
|
+
* the assertion would silently read the last locale mounted instead. Vitest's
|
|
24
|
+
* per-file isolation (`isolate: true` on the `dom` project) is what makes this
|
|
25
|
+
* file's observation honest.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import { render, screen } from '@testing-library/react';
|
|
30
|
+
import '@testing-library/jest-dom';
|
|
31
|
+
import { SelectField } from './SelectField';
|
|
32
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
33
|
+
import { RadioField } from './RadioField';
|
|
34
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
35
|
+
|
|
36
|
+
const WIDGETS = [
|
|
37
|
+
{ label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
|
|
38
|
+
{
|
|
39
|
+
label: 'MultiSelectField',
|
|
40
|
+
Widget: MultiSelectField,
|
|
41
|
+
type: 'multiselect',
|
|
42
|
+
testId: 'multiselect-empty-province',
|
|
43
|
+
},
|
|
44
|
+
{ label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
|
|
45
|
+
{
|
|
46
|
+
label: 'CheckboxesField',
|
|
47
|
+
Widget: CheckboxesField,
|
|
48
|
+
type: 'checkboxes',
|
|
49
|
+
testId: 'checkboxes-empty-province',
|
|
50
|
+
},
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
const gatedField = (type: string) =>
|
|
54
|
+
({
|
|
55
|
+
name: 'province',
|
|
56
|
+
type,
|
|
57
|
+
dependsOn: 'country',
|
|
58
|
+
options: [{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }],
|
|
59
|
+
}) as any;
|
|
60
|
+
|
|
61
|
+
const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
|
|
62
|
+
|
|
63
|
+
function renderWidget(Widget: React.ComponentType<any>, field: any) {
|
|
64
|
+
return render(
|
|
65
|
+
<Widget
|
|
66
|
+
value={undefined}
|
|
67
|
+
onChange={vi.fn()}
|
|
68
|
+
field={field}
|
|
69
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('option widgets — English fallback survives with no i18n configured', () => {
|
|
75
|
+
it.each(WIDGETS)('$label renders the gate sentence, not a raw key', ({ Widget, type, testId }) => {
|
|
76
|
+
renderWidget(Widget, gatedField(type));
|
|
77
|
+
const box = screen.getByTestId(testId);
|
|
78
|
+
expect(box).toHaveTextContent('Select country first');
|
|
79
|
+
expect(box.textContent).not.toContain('fields.options');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(WIDGETS)('$label renders the empty sentence, not a raw key', ({ Widget, type, testId }) => {
|
|
83
|
+
renderWidget(Widget, unconfiguredField(type));
|
|
84
|
+
const box = screen.getByTestId(testId);
|
|
85
|
+
expect(box).toHaveTextContent('No options available');
|
|
86
|
+
expect(box.textContent).not.toContain('fields.options');
|
|
87
|
+
});
|
|
88
|
+
});
|