@object-ui/fields 17.2.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 +592 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -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/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2881 -2566
- package/dist/widgets/AddressField.d.ts +1 -1
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- 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/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- 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 +19 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- 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 +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- 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/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- 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 +29 -15
- 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 +22 -9
- package/src/index.tsx +74 -26
- package/src/widgets/AddressField.tsx +31 -12
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +3 -3
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +31 -14
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +5 -4
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +13 -3
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +1 -1
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +21 -6
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +1 -1
- package/src/widgets/ImageField.tsx +9 -2
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +41 -6
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +30 -14
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- 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 +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +24 -14
- package/src/widgets/RatingField.tsx +18 -3
- package/src/widgets/RecipientPickerField.tsx +18 -0
- 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 +151 -24
- package/src/widgets/SelectField.tsx +48 -17
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- 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 +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +1 -1
- package/src/widgets/VectorField.tsx +1 -1
- 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/toDomProps.ts +175 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -11,6 +11,7 @@ import { cn,
|
|
|
11
11
|
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
12
12
|
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
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 }: FieldWidgetComponentProps<any>) {
|
|
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,6 +280,23 @@ 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
301
|
const dependentValuesProp = props.dependentValues;
|
|
284
302
|
|
|
@@ -912,19 +930,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
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
953
|
disabled={dependenciesMissing || props.disabled}
|
|
924
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).
|
|
@@ -1218,7 +1252,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
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
|
)}
|
|
@@ -25,7 +25,7 @@ export function MasterDetailField({
|
|
|
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
|
|
@@ -2,6 +2,8 @@ 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
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,14 +28,14 @@ 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
37
|
}: FieldWidgetComponentProps<string[]>) {
|
|
36
|
-
const config =
|
|
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
41
|
const fieldName = props.name || config?.name || props.id || '';
|
|
@@ -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
|
>
|
|
@@ -109,6 +124,7 @@ export function MultiSelectField({
|
|
|
109
124
|
onClick={() => toggle(value)}
|
|
110
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',
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* NumberField - Numeric input with optional decimal precision
|
|
@@ -12,7 +13,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
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
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 }: FieldWidgetComponentProps<any>) {
|
|
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
|
}
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -31,6 +32,7 @@ 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);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
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 empty / dependency-gated state of the fixed-option widgets (objectui#3231).
|
|
11
|
+
*
|
|
12
|
+
* Two invariants, pinned for ALL FOUR widgets — they are what regressed:
|
|
13
|
+
*
|
|
14
|
+
* 1. **declared ⇒ delivered.** `emptyHint` is declared on
|
|
15
|
+
* `FieldWidgetComponentProps` and computed by the form renderer. Every one
|
|
16
|
+
* of the four widgets used to destructure it into `_emptyHint` and drop it
|
|
17
|
+
* on the floor, so the host's hint could never reach a user once the field
|
|
18
|
+
* went through the `field:` registry (i.e. always, in the real console).
|
|
19
|
+
* 2. **the fallback is translated.** With no host hint the widget renders its
|
|
20
|
+
* own copy — which used to be a hardcoded English literal
|
|
21
|
+
* (`'No options available'`, `` `Select ${…} first` ``) even though
|
|
22
|
+
* `useFieldTranslation()` was already imported in one of the same files.
|
|
23
|
+
*
|
|
24
|
+
* All four are covered deliberately: they were four independent copies of the
|
|
25
|
+
* same block, which is exactly why they drifted together and why one widget's
|
|
26
|
+
* test would have proven nothing about the other three. They now share
|
|
27
|
+
* `OptionsEmptyState`; these tests keep that true from the outside, so the
|
|
28
|
+
* guarantee survives someone re-inlining the block.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { render, screen } from '@testing-library/react';
|
|
33
|
+
import '@testing-library/jest-dom';
|
|
34
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
35
|
+
import { SelectField } from './SelectField';
|
|
36
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
37
|
+
import { RadioField } from './RadioField';
|
|
38
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
39
|
+
|
|
40
|
+
interface WidgetCase {
|
|
41
|
+
label: string;
|
|
42
|
+
Widget: React.ComponentType<any>;
|
|
43
|
+
type: string;
|
|
44
|
+
testId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WIDGETS: WidgetCase[] = [
|
|
48
|
+
{ label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
|
|
49
|
+
{
|
|
50
|
+
label: 'MultiSelectField',
|
|
51
|
+
Widget: MultiSelectField,
|
|
52
|
+
type: 'multiselect',
|
|
53
|
+
testId: 'multiselect-empty-province',
|
|
54
|
+
},
|
|
55
|
+
{ label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
|
|
56
|
+
{
|
|
57
|
+
label: 'CheckboxesField',
|
|
58
|
+
Widget: CheckboxesField,
|
|
59
|
+
type: 'checkboxes',
|
|
60
|
+
testId: 'checkboxes-empty-province',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Gated: `country` is empty, so no option is offered yet (#2284). */
|
|
65
|
+
const gatedField = (type: string) =>
|
|
66
|
+
({
|
|
67
|
+
name: 'province',
|
|
68
|
+
type,
|
|
69
|
+
dependsOn: 'country',
|
|
70
|
+
options: [
|
|
71
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
72
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
73
|
+
],
|
|
74
|
+
}) as any;
|
|
75
|
+
|
|
76
|
+
/** Not gated, simply nothing to offer — the other way a list ends up empty. */
|
|
77
|
+
const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
|
|
78
|
+
|
|
79
|
+
function renderWidget(
|
|
80
|
+
{ Widget }: WidgetCase,
|
|
81
|
+
field: any,
|
|
82
|
+
extra: Record<string, unknown> = {},
|
|
83
|
+
language?: string,
|
|
84
|
+
) {
|
|
85
|
+
const element = (
|
|
86
|
+
<Widget
|
|
87
|
+
value={undefined}
|
|
88
|
+
onChange={vi.fn()}
|
|
89
|
+
field={field}
|
|
90
|
+
{...({ name: 'province', dependentValues: {}, ...extra } as any)}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
return render(
|
|
94
|
+
language ? (
|
|
95
|
+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
|
|
96
|
+
{element}
|
|
97
|
+
</I18nProvider>
|
|
98
|
+
) : (
|
|
99
|
+
element
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('option widgets — the host-supplied `emptyHint` reaches the user (objectui#3231)', () => {
|
|
105
|
+
it.each(WIDGETS)(
|
|
106
|
+
'$label renders the host hint instead of its own gate copy',
|
|
107
|
+
(widget) => {
|
|
108
|
+
// What the form renderer actually computes: a sentence built from the
|
|
109
|
+
// controlling field's human LABEL, which the widget cannot know (its
|
|
110
|
+
// metadata only carries the name `country`).
|
|
111
|
+
renderWidget(widget, gatedField(widget.type), {
|
|
112
|
+
emptyHint: 'Choose the Country field first',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const box = screen.getByTestId(widget.testId);
|
|
116
|
+
expect(box).toHaveTextContent('Choose the Country field first');
|
|
117
|
+
// …and NOT the widget's own copy, which is what used to render.
|
|
118
|
+
expect(box.textContent).not.toMatch(/select country first/i);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it.each(WIDGETS)(
|
|
123
|
+
'$label renders the host hint for an unconfigured (ungated) list too',
|
|
124
|
+
(widget) => {
|
|
125
|
+
// `emptyHint` is a declared prop of the widget contract, not a private
|
|
126
|
+
// channel of one form-renderer branch: supplied ⇒ used, always.
|
|
127
|
+
renderWidget(widget, unconfiguredField(widget.type), {
|
|
128
|
+
emptyHint: 'Options come from the price book',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(screen.getByTestId(widget.testId)).toHaveTextContent(
|
|
132
|
+
'Options come from the price book',
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
it.each(WIDGETS)('$label prefers a host hint over its translated fallback', (widget) => {
|
|
138
|
+
// Even under a non-English locale the HOST wins — it is the single source.
|
|
139
|
+
renderWidget(widget, gatedField(widget.type), { emptyHint: 'Pick a Country first' }, 'zh');
|
|
140
|
+
|
|
141
|
+
const box = screen.getByTestId(widget.testId);
|
|
142
|
+
expect(box).toHaveTextContent('Pick a Country first');
|
|
143
|
+
expect(box.textContent).not.toContain('请先选择');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('option widgets — the fallback copy is translated, never a hardcoded literal', () => {
|
|
148
|
+
it.each(WIDGETS)('$label translates the gate copy when no host hint is given', (widget) => {
|
|
149
|
+
renderWidget(widget, gatedField(widget.type), {}, 'zh');
|
|
150
|
+
|
|
151
|
+
const box = screen.getByTestId(widget.testId);
|
|
152
|
+
expect(box).toHaveTextContent('请先选择country');
|
|
153
|
+
expect(box.textContent).not.toMatch(/select .* first/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it.each(WIDGETS)('$label translates the empty copy when no host hint is given', (widget) => {
|
|
157
|
+
renderWidget(widget, unconfiguredField(widget.type), {}, 'zh');
|
|
158
|
+
|
|
159
|
+
const box = screen.getByTestId(widget.testId);
|
|
160
|
+
expect(box).toHaveTextContent('暂无可选项');
|
|
161
|
+
expect(box.textContent).not.toMatch(/no options available/i);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The no-provider fallback lives in `OptionsEmptyState.no-provider.test.tsx`:
|
|
166
|
+
// mounting `I18nProvider` here installs its instance as react-i18next's global
|
|
167
|
+
// default (`initReactI18next`), so after any test above there is no longer a
|
|
168
|
+
// "no provider" state to observe within this module graph.
|
|
169
|
+
|