@object-ui/fields 17.3.0 → 17.5.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 +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -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__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.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/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- 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/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- 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/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -7,6 +7,7 @@ import { FieldWidgetComponentProps } from './types';
|
|
|
7
7
|
import { toDomProps } from './toDomProps';
|
|
8
8
|
import { ImageLightbox } from './ImageLightbox';
|
|
9
9
|
import { useUploadingSignal } from './useUploadingSignal';
|
|
10
|
+
import { maxSizeError, type TranslateFn } from './file-size-guard';
|
|
10
11
|
import {
|
|
11
12
|
fileValueForSubmit,
|
|
12
13
|
readFileValues,
|
|
@@ -30,6 +31,12 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
30
31
|
const imageField = field as any;
|
|
31
32
|
const multiple = imageField?.multiple || false;
|
|
32
33
|
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
34
|
+
/**
|
|
35
|
+
* Max upload size in bytes. Delivered by `paramToField` for an action param
|
|
36
|
+
* and by the field config for a record field; enforced before any upload
|
|
37
|
+
* starts, exactly as FileField does (objectui#4141).
|
|
38
|
+
*/
|
|
39
|
+
const maxSize = imageField?.maxSize as number | undefined;
|
|
33
40
|
/**
|
|
34
41
|
* Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
|
|
35
42
|
*/
|
|
@@ -39,6 +46,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
39
46
|
const { upload } = useUpload();
|
|
40
47
|
const { t } = useObjectTranslation();
|
|
41
48
|
const [uploading, setUploading] = useState(false);
|
|
49
|
+
/** Client-side rejections (oversize picks), cleared on the next attempt. */
|
|
50
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
42
51
|
// Display details of just-uploaded images, keyed by their new `sys_file` id.
|
|
43
52
|
// Submitting the reference form means the field value no longer carries the
|
|
44
53
|
// URL a thumbnail needs; these keep the preview visible until the next read
|
|
@@ -59,6 +68,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
59
68
|
const handleCropConfirm = useCallback(
|
|
60
69
|
async (blob: Blob, name: string) => {
|
|
61
70
|
if (!cropTarget) return;
|
|
71
|
+
// The size that matters here is the CROP's, not the source image's: the
|
|
72
|
+
// cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a
|
|
73
|
+
// blob over the limit. Checked before `setUploading` so a rejected crop
|
|
74
|
+
// never flashes the spinner, and the dialog closes so the message lands
|
|
75
|
+
// in the field's error row rather than behind the open dialog.
|
|
76
|
+
const rejection = maxSizeError(t as TranslateFn, { name, size: blob.size }, maxSize);
|
|
77
|
+
if (rejection) {
|
|
78
|
+
setErrors([rejection]);
|
|
79
|
+
setCropTarget(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setErrors([]);
|
|
62
83
|
setUploading(true);
|
|
63
84
|
try {
|
|
64
85
|
const result = await upload(blob);
|
|
@@ -76,7 +97,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
76
97
|
setCropTarget(null);
|
|
77
98
|
}
|
|
78
99
|
},
|
|
79
|
-
[cropTarget, images, multiple, onChange, upload, remember],
|
|
100
|
+
[cropTarget, images, multiple, onChange, upload, remember, maxSize, t],
|
|
80
101
|
);
|
|
81
102
|
|
|
82
103
|
const openCropper = useCallback(
|
|
@@ -128,12 +149,32 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
128
149
|
|
|
129
150
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
130
151
|
const selectedFiles = Array.from(e.target.files || []);
|
|
152
|
+
// Reset the input up front rather than in `finally`: a pick rejected below
|
|
153
|
+
// returns before the upload runs, and leaving the old value in place would
|
|
154
|
+
// stop the user re-picking the same name after shrinking the image.
|
|
155
|
+
// `selectedFiles` already holds the File objects, so clearing is safe.
|
|
156
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
131
157
|
if (selectedFiles.length === 0) return;
|
|
132
158
|
|
|
159
|
+
// Enforce the declared limit before anything touches the network. Partial
|
|
160
|
+
// acceptance matches FileField: the in-limit picks of a multi-select still
|
|
161
|
+
// upload, and only the oversize ones are reported.
|
|
162
|
+
const rejections: string[] = [];
|
|
163
|
+
const validFiles = selectedFiles.filter((file) => {
|
|
164
|
+
const rejection = maxSizeError(t as TranslateFn, file, maxSize);
|
|
165
|
+
if (rejection) {
|
|
166
|
+
rejections.push(rejection);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
setErrors(rejections);
|
|
172
|
+
if (validFiles.length === 0) return;
|
|
173
|
+
|
|
133
174
|
setUploading(true);
|
|
134
175
|
try {
|
|
135
176
|
const imageObjects = await Promise.all(
|
|
136
|
-
|
|
177
|
+
validFiles.map(async (file) => {
|
|
137
178
|
const result = await upload(file);
|
|
138
179
|
remember(result, file.name);
|
|
139
180
|
return fileValueForSubmit(result, file.name);
|
|
@@ -147,8 +188,6 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
147
188
|
}
|
|
148
189
|
} finally {
|
|
149
190
|
setUploading(false);
|
|
150
|
-
// Reset input so picking the same file again still triggers change.
|
|
151
|
-
if (inputRef.current) inputRef.current.value = '';
|
|
152
191
|
}
|
|
153
192
|
};
|
|
154
193
|
|
|
@@ -237,6 +276,17 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
237
276
|
? t('fields.image.addMore')
|
|
238
277
|
: t('fields.image.upload')}
|
|
239
278
|
</Button>
|
|
279
|
+
|
|
280
|
+
{/* Client-side rejections (oversize picks). Same presentation as
|
|
281
|
+
FileField's error row — this widget previously had no surface for
|
|
282
|
+
them at all, because it never rejected anything (objectui#4141). */}
|
|
283
|
+
{errors.length > 0 && (
|
|
284
|
+
<div className="space-y-0.5">
|
|
285
|
+
{errors.map((err, i) => (
|
|
286
|
+
<p key={i} className="text-xs text-destructive">{err}</p>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
240
290
|
</div>
|
|
241
291
|
|
|
242
292
|
{cropEnabled && cropTarget && (
|
|
@@ -71,7 +71,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108
|
|
|
71
71
|
|
|
72
72
|
// In-flight: loading indicator + raw committed count, no "Select…".
|
|
73
73
|
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
|
|
74
|
-
expect(screen.queryByText('Select
|
|
74
|
+
expect(screen.queryByText('Select…')).toBeNull();
|
|
75
75
|
expect(screen.getByText(/3/)).toBeTruthy();
|
|
76
76
|
|
|
77
77
|
resolveFind({ data: USERS });
|
|
@@ -218,9 +218,13 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
|
|
|
218
218
|
|
|
219
219
|
const lookupField = field as any;
|
|
220
220
|
|
|
221
|
-
//
|
|
222
|
-
// metadata (
|
|
223
|
-
//
|
|
221
|
+
// The form renderer passes `field: field.field || field` — `.field` is the
|
|
222
|
+
// declared metadata slot (objectui#3090) — so the actual objectSchema field
|
|
223
|
+
// metadata (reference_to, display_field, etc.) can arrive nested at
|
|
224
|
+
// `lookupField.field`. Unwrap it so lookup-specific properties resolve
|
|
225
|
+
// correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper,
|
|
226
|
+
// which never produced the nesting and was removed in objectui#3910; the form
|
|
227
|
+
// path is the real producer.)
|
|
224
228
|
// ObjectStack convention uses `reference` while the types use `reference_to`,
|
|
225
229
|
// so we check for both property names.
|
|
226
230
|
const innerField = lookupField?.field;
|
|
@@ -1078,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
|
|
|
1078
1082
|
<div className="relative">
|
|
1079
1083
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
1080
1084
|
<Input
|
|
1081
|
-
placeholder={t('
|
|
1085
|
+
placeholder={t('table.search')}
|
|
1082
1086
|
value={searchQuery}
|
|
1083
1087
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
1084
1088
|
onKeyDown={handleSearchKeyDown}
|
|
@@ -3,6 +3,7 @@ import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
7
|
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
7
8
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
8
9
|
|
|
@@ -52,16 +53,56 @@ export function MultiSelectField({
|
|
|
52
53
|
// the scalar case we prune per-element rather than clearing the whole field.
|
|
53
54
|
useEffect(() => {
|
|
54
55
|
if (readonly) return;
|
|
56
|
+
// An empty offered set has THREE very different causes, and only one of them
|
|
57
|
+
// is a decision to prune against. This is the canonical copy of the guards;
|
|
58
|
+
// the other three option widgets carry the same pair.
|
|
59
|
+
//
|
|
60
|
+
// 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
|
|
61
|
+
// `options` at all. No decision was made anywhere, and the widget renders
|
|
62
|
+
// its "unfillable" state below. Pruning here is not a cascade, it is
|
|
63
|
+
// deleting the stored value of a field the user was only ever shown a
|
|
64
|
+
// hint for — measured on the detail page's inline editor, which stages
|
|
65
|
+
// that empty array into the record draft the moment the row enters edit
|
|
66
|
+
// mode, and on the grid's inline cell editor, which has always taken this
|
|
67
|
+
// path.
|
|
68
|
+
if (rawOptions.length === 0) return;
|
|
69
|
+
// 2. The list is GATED (objectui#4247) — an authored list withheld because a
|
|
70
|
+
// declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
|
|
71
|
+
// returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
|
|
72
|
+
// no information about which stored values are valid, which is exactly
|
|
73
|
+
// what the `OptionsEmptyState` below tells the user ("select Country
|
|
74
|
+
// first"). Clearing here fired on MOUNT with no interaction — a record
|
|
75
|
+
// that merely ARRIVES with the parent empty (a later-cleared parent, an
|
|
76
|
+
// import, a partially-migrated row) had its picklist silently staged for
|
|
77
|
+
// deletion. Read from the resolver's own `gated` flag rather than
|
|
78
|
+
// re-derived from `options.length === 0`, which would collide with case 1.
|
|
79
|
+
if (gated) return;
|
|
80
|
+
// 3. The list RESOLVED and genuinely excludes the value — a real cascade
|
|
81
|
+
// (the parent changed, a predicate flipped). That is the ADR-0058
|
|
82
|
+
// contract and it still prunes, below.
|
|
55
83
|
if (selected.length === 0) return;
|
|
56
84
|
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
57
85
|
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
58
86
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
87
|
}, [options, gated]);
|
|
60
88
|
|
|
89
|
+
// The host's group label has to be consumed on EVERY surface this widget can
|
|
90
|
+
// render, not only the editable one (objectui#3990): both branches below
|
|
91
|
+
// return before the editable container's `groupDomProps` spread, which is how
|
|
92
|
+
// a field-level `readonly: true` and a zero-option list ended up with a
|
|
93
|
+
// published label id and no consumer at all. See `toHostGroupProps`.
|
|
94
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
95
|
+
|
|
61
96
|
if (readonly) {
|
|
62
|
-
|
|
97
|
+
// A readonly set of values is still a set, so it takes the same `group`
|
|
98
|
+
// answer the editable chip row gives. `EmptyValue` is that surface with
|
|
99
|
+
// nothing in it: it publishes an `aria-label` ("No value") of its own, and
|
|
100
|
+
// `aria-labelledby` outranks that per accname — correctly, because on the
|
|
101
|
+
// `generic` role that placeholder span carries, an author name is prohibited
|
|
102
|
+
// and never exposed, so the real choice is the field's name or no name.
|
|
103
|
+
if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
|
|
63
104
|
return (
|
|
64
|
-
<div className="flex flex-wrap gap-1">
|
|
105
|
+
<div {...hostGroupProps} className="flex flex-wrap gap-1">
|
|
65
106
|
{selected.map((v) => {
|
|
66
107
|
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
67
108
|
// still renders its label rather than a bare id.
|
|
@@ -84,6 +125,9 @@ export function MultiSelectField({
|
|
|
84
125
|
dependsOnFields={dependsOnFields}
|
|
85
126
|
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
|
|
86
127
|
className="min-h-9"
|
|
128
|
+
// This box IS the field in this state, so it is what the host label
|
|
129
|
+
// names (objectui#3990).
|
|
130
|
+
hostGroupProps={hostGroupProps}
|
|
87
131
|
/>
|
|
88
132
|
);
|
|
89
133
|
}
|
|
@@ -105,10 +149,20 @@ export function MultiSelectField({
|
|
|
105
149
|
name: _domName,
|
|
106
150
|
...groupDomProps
|
|
107
151
|
} = toDomProps(props);
|
|
152
|
+
// When the host associated its visible label with this container by IDREF
|
|
153
|
+
// (`aria-labelledby`, objectui#3961 / #3975) the container IS the labelled
|
|
154
|
+
// group, so it answers with the matching role — without one, the name sits on
|
|
155
|
+
// a generic `div` and contributes nothing: `label for` pointing here was
|
|
156
|
+
// already inert (`HTMLLabelElement.control` is null for a div), which is the
|
|
157
|
+
// whole defect. Each chip keeps its own accessible name from its text content,
|
|
158
|
+
// which the group name does not override. Absent (standalone: the inline grid
|
|
159
|
+
// editor, a bare SDUI node) nothing is emitted and the markup is unchanged.
|
|
160
|
+
const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
|
|
108
161
|
|
|
109
162
|
return (
|
|
110
163
|
<div
|
|
111
164
|
{...groupDomProps}
|
|
165
|
+
role={isLabelledGroup ? 'group' : undefined}
|
|
112
166
|
className={cn('flex flex-wrap gap-1.5', className)}
|
|
113
167
|
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
|
|
114
168
|
>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { cn } from '@object-ui/components';
|
|
11
11
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
12
|
+
import type { HostGroupProps } from './toHostGroupProps';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The "this option list cannot be filled" state shared by every fixed-option
|
|
@@ -52,6 +53,19 @@ export interface OptionsEmptyStateProps {
|
|
|
52
53
|
testId?: string;
|
|
53
54
|
/** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
|
|
54
55
|
className?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The host label's IDREF plumbing, when this box is the whole rendered surface
|
|
58
|
+
* of a group-labelled field (objectui#3990) — a `checkboxes` / `radio` /
|
|
59
|
+
* `multiselect` whose option list came back empty renders NOTHING but this
|
|
60
|
+
* box, so the visible label named zero elements until it landed here.
|
|
61
|
+
*
|
|
62
|
+
* A closed three-key type rather than an open props tail: only `id`,
|
|
63
|
+
* `aria-labelledby` and the `role` that makes them meaningful may reach this
|
|
64
|
+
* element, and reopening a spread is what objectui#3291 exists to prevent.
|
|
65
|
+
* Absent for the single `SelectField` — it is not group-labelled, its label
|
|
66
|
+
* still uses a plain `for`, and it must keep emitting no such attributes.
|
|
67
|
+
*/
|
|
68
|
+
hostGroupProps?: HostGroupProps;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export function OptionsEmptyState({
|
|
@@ -60,6 +74,7 @@ export function OptionsEmptyState({
|
|
|
60
74
|
dependsOnFields,
|
|
61
75
|
testId,
|
|
62
76
|
className,
|
|
77
|
+
hostGroupProps,
|
|
63
78
|
}: OptionsEmptyStateProps) {
|
|
64
79
|
const { t } = useFieldTranslation();
|
|
65
80
|
// The host's hint when it computed one; otherwise this widget's own copy,
|
|
@@ -71,6 +86,7 @@ export function OptionsEmptyState({
|
|
|
71
86
|
: t('fields.options.empty'));
|
|
72
87
|
return (
|
|
73
88
|
<div
|
|
89
|
+
{...hostGroupProps}
|
|
74
90
|
data-testid={testId}
|
|
75
91
|
className={cn(
|
|
76
92
|
'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
|
|
@@ -3,6 +3,7 @@ import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/compon
|
|
|
3
3
|
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
7
|
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
7
8
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
8
9
|
|
|
@@ -49,17 +50,37 @@ export function RadioField({
|
|
|
49
50
|
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
50
51
|
useEffect(() => {
|
|
51
52
|
if (readonly) return;
|
|
53
|
+
// Never configured → nothing to prune against; see `MultiSelectField`'s
|
|
54
|
+
// copy of this guard for the measured failure (objectui#4220).
|
|
55
|
+
if (rawOptions.length === 0) return;
|
|
56
|
+
// Gated → the authored list is withheld until the `dependsOn` parent is
|
|
57
|
+
// chosen, so the empty offered set is missing information, not a verdict on
|
|
58
|
+
// the stored value; clearing here fired on mount (objectui#4247). Same
|
|
59
|
+
// reasoning, at length, in `MultiSelectField`.
|
|
60
|
+
if (gated) return;
|
|
52
61
|
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
53
62
|
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
54
63
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
64
|
}, [options, gated]);
|
|
56
65
|
|
|
66
|
+
// The host's group label has to be consumed on EVERY surface this widget can
|
|
67
|
+
// render, not only the `RadioGroup` (objectui#3990): both branches below return
|
|
68
|
+
// before that spread, which is how a field-level `readonly: true` and a
|
|
69
|
+
// zero-option list ended up with a published label id and no consumer at all.
|
|
70
|
+
// See `toHostGroupProps` — note the role it emits is `group`, not the
|
|
71
|
+
// `radiogroup` the editable branch keeps: there is not one radio left in
|
|
72
|
+
// either of these surfaces.
|
|
73
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
74
|
+
|
|
57
75
|
if (readonly) {
|
|
58
|
-
|
|
76
|
+
// `EmptyValue`'s own `aria-label` ("No value") is outranked by
|
|
77
|
+
// `aria-labelledby` per accname, and on the `generic` role that placeholder
|
|
78
|
+
// span carries it was never exposed as a name anyway.
|
|
79
|
+
if (value == null || value === '') return <EmptyValue {...hostGroupProps} />;
|
|
59
80
|
// Label from the raw set so a stored value hidden by `visibleWhen` still
|
|
60
81
|
// renders its label rather than a bare id.
|
|
61
82
|
const opt = rawOptions.find((o) => o.value === value);
|
|
62
|
-
return <span className="text-sm">{opt?.label || String(value)}</span>;
|
|
83
|
+
return <span {...hostGroupProps} className="text-sm">{opt?.label || String(value)}</span>;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
// No offered options is unfillable — surface a legible state instead of an
|
|
@@ -74,12 +95,23 @@ export function RadioField({
|
|
|
74
95
|
dependsOnFields={dependsOnFields}
|
|
75
96
|
testId={fieldName ? `radio-empty-${fieldName}` : undefined}
|
|
76
97
|
className="min-h-9"
|
|
98
|
+
// This box IS the field in this state, so it is what the host label
|
|
99
|
+
// names (objectui#3990).
|
|
100
|
+
hostGroupProps={hostGroupProps}
|
|
77
101
|
/>
|
|
78
102
|
);
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
return (
|
|
82
|
-
// DOM pass-through onto the radiogroup (objectui#3318).
|
|
106
|
+
// DOM pass-through onto the radiogroup (objectui#3318). It also carries the
|
|
107
|
+
// host's group label: when the form renderer associates its `<FormLabel>` by
|
|
108
|
+
// IDREF (`aria-labelledby`, objectui#3961) that key rides in this same
|
|
109
|
+
// whitelist spread — `toDomProps` forwards the whole `aria-` family — so this
|
|
110
|
+
// widget needs no branch of its own. `radiogroup` is already a member of the
|
|
111
|
+
// group role family, so nothing here overrides it with `role="group"`: it is
|
|
112
|
+
// the more specific role AND the correct carrier of `aria-invalid` below.
|
|
113
|
+
//
|
|
114
|
+
// Unlike Radix
|
|
83
115
|
// `Select.Root` (#3306) this Root IS a real DOM element — a
|
|
84
116
|
// `<div role="radiogroup">` — and `radiogroup` is exactly the role
|
|
85
117
|
// WAI-ARIA designates to carry `aria-invalid` for a set of radios
|
|
@@ -3,6 +3,7 @@ import { Star } from 'lucide-react';
|
|
|
3
3
|
import { cn } from '@object-ui/components';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Rating field widget - provides a star rating input
|
|
@@ -19,8 +20,11 @@ export function RatingField({ value, onChange, field, readonly, className, error
|
|
|
19
20
|
const displayValue = hoverValue !== null ? hoverValue : currentValue;
|
|
20
21
|
|
|
21
22
|
if (readonly) {
|
|
23
|
+
// The readonly star row is the field's whole rendered surface, so it — not
|
|
24
|
+
// only the editable row below — consumes the host's group label
|
|
25
|
+
// (objectui#3990). See `toHostGroupProps`.
|
|
22
26
|
return (
|
|
23
|
-
<div className={cn("flex items-center gap-1", className)}>
|
|
27
|
+
<div {...toHostGroupProps(props)} className={cn("flex items-center gap-1", className)}>
|
|
24
28
|
{Array.from({ length: max }, (_, i) => (
|
|
25
29
|
<Star
|
|
26
30
|
key={i}
|
|
@@ -50,9 +54,18 @@ export function RatingField({ value, onChange, field, readonly, className, error
|
|
|
50
54
|
name: _domName,
|
|
51
55
|
...groupDomProps
|
|
52
56
|
} = toDomProps(props);
|
|
57
|
+
// When the host named this container by IDREF (`aria-labelledby`,
|
|
58
|
+
// objectui#3961) it IS the labelled group of stars, so it answers with the
|
|
59
|
+
// matching role; a `label for` pointing at this `div` was inert. Standalone
|
|
60
|
+
// (no host label) the markup is unchanged.
|
|
61
|
+
const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
|
|
53
62
|
|
|
54
63
|
return (
|
|
55
|
-
<div
|
|
64
|
+
<div
|
|
65
|
+
{...groupDomProps}
|
|
66
|
+
role={isLabelledGroup ? 'group' : undefined}
|
|
67
|
+
className={cn("flex items-center gap-1", className)}
|
|
68
|
+
>
|
|
56
69
|
{Array.from({ length: max }, (_, i) => (
|
|
57
70
|
<button
|
|
58
71
|
key={i}
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
* `RecordPickerDialog.baseFilter` accepts TWO shapes — objectui#3831.
|
|
11
|
+
*
|
|
12
|
+
* The slot was declared `Record< string, any >` and merged by object spread,
|
|
13
|
+
* which serves the dependent-lookup chain (#2215) exactly right and cannot
|
|
14
|
+
* carry a spec `ViewFilterRule[]` at all: TypeScript accepts an array there
|
|
15
|
+
* (an array satisfies a string index of `any`), the spread flattens it to
|
|
16
|
+
* `{"0": rule, "1": rule}`, and the query then filters on columns literally
|
|
17
|
+
* named `0`/`1` — green types, wrong query, no diagnostic. That is the slot
|
|
18
|
+
* `record:related_list.add.picker.filter` has to reach.
|
|
19
|
+
*
|
|
20
|
+
* So the merge now discriminates on shape, and both directions are pinned here:
|
|
21
|
+
*
|
|
22
|
+
* - **record form** keeps KEY-OVERWRITE precedence, byte for byte. A cascaded
|
|
23
|
+
* parent value must REPLACE a stale same-field `lookupFilters` entry, not
|
|
24
|
+
* intersect with it — `account = 'stale' AND account = 'a1'` returns nothing.
|
|
25
|
+
* (`LookupField.dependsOn.test.tsx` owns the #2215 behaviour end-to-end and
|
|
26
|
+
* is deliberately left untouched; this file pins the same precedence at the
|
|
27
|
+
* dialog's own boundary, so a future merge change cannot pass by only
|
|
28
|
+
* satisfying the array path.)
|
|
29
|
+
* - **rule array** lowers through `mergeFilterNodes`, the repo's single filter
|
|
30
|
+
* sink, so all 19 `VIEW_FILTER_OPERATORS` survive — including the four
|
|
31
|
+
* (`before`, `after`, `is_empty`, `is_not_empty`) the MongoDB-style record
|
|
32
|
+
* form has no `$op` for and which a hand-rolled second lowering would have
|
|
33
|
+
* had to drop or throw on.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { render, waitFor } from '@testing-library/react';
|
|
37
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
38
|
+
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
39
|
+
|
|
40
|
+
const records = [{ id: 'r1', name: 'One' }];
|
|
41
|
+
|
|
42
|
+
function makeDataSource() {
|
|
43
|
+
const find = vi.fn(async () => ({ data: records, total: records.length }));
|
|
44
|
+
return { find } as any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `$filter` of the most recent query. */
|
|
48
|
+
function lastFilter(ds: any) {
|
|
49
|
+
const calls = ds.find.mock.calls;
|
|
50
|
+
return calls[calls.length - 1][1]?.$filter;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('RecordPickerDialog baseFilter — record form (#2215 precedence)', () => {
|
|
54
|
+
it('spreads a record baseFilter last, overwriting lookupFilters on the same field', async () => {
|
|
55
|
+
const ds = makeDataSource();
|
|
56
|
+
render(
|
|
57
|
+
<RecordPickerDialog
|
|
58
|
+
open
|
|
59
|
+
onOpenChange={vi.fn()}
|
|
60
|
+
dataSource={ds}
|
|
61
|
+
objectName="contacts"
|
|
62
|
+
onSelect={vi.fn()}
|
|
63
|
+
lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
|
|
64
|
+
baseFilter={{ account: 'a1' }}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
69
|
+
// Still the plain record form — NOT lowered to an AST, and emphatically not
|
|
70
|
+
// an `and` of the stale value with the cascaded one.
|
|
71
|
+
expect(lastFilter(ds)).toEqual({ account: 'a1' });
|
|
72
|
+
expect(Array.isArray(lastFilter(ds))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sends no $filter at all when nothing is constrained', async () => {
|
|
76
|
+
const ds = makeDataSource();
|
|
77
|
+
render(
|
|
78
|
+
<RecordPickerDialog
|
|
79
|
+
open
|
|
80
|
+
onOpenChange={vi.fn()}
|
|
81
|
+
dataSource={ds}
|
|
82
|
+
objectName="contacts"
|
|
83
|
+
onSelect={vi.fn()}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
88
|
+
expect(lastFilter(ds)).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('RecordPickerDialog baseFilter — spec ViewFilterRule[] (#3831)', () => {
|
|
93
|
+
it('lowers a rule array to AST nodes instead of spreading it into index keys', async () => {
|
|
94
|
+
const ds = makeDataSource();
|
|
95
|
+
render(
|
|
96
|
+
<RecordPickerDialog
|
|
97
|
+
open
|
|
98
|
+
onOpenChange={vi.fn()}
|
|
99
|
+
dataSource={ds}
|
|
100
|
+
objectName="sys_position"
|
|
101
|
+
onSelect={vi.fn()}
|
|
102
|
+
baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
107
|
+
expect(lastFilter(ds)).toEqual([['is_active', 'equals', true]]);
|
|
108
|
+
// The old object-spread failure mode, pinned by structure rather than by
|
|
109
|
+
// key: the spread produced a PLAIN OBJECT whose values were still rule
|
|
110
|
+
// objects (`{"0": {field, operator, value}}`), so the query asked for a
|
|
111
|
+
// column named `0`. Asserting on `Object.keys` cannot tell the two apart —
|
|
112
|
+
// an array's keys are its indices too — so what is pinned is that each
|
|
113
|
+
// element is an AST TRIPLE and no longer carries a `field` member.
|
|
114
|
+
const filter = lastFilter(ds) as unknown[];
|
|
115
|
+
expect(Array.isArray(filter)).toBe(true);
|
|
116
|
+
expect(Array.isArray(filter[0])).toBe(true);
|
|
117
|
+
expect(filter[0]).not.toHaveProperty('field');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('preserves the four operators the record form cannot spell', async () => {
|
|
121
|
+
const ds = makeDataSource();
|
|
122
|
+
render(
|
|
123
|
+
<RecordPickerDialog
|
|
124
|
+
open
|
|
125
|
+
onOpenChange={vi.fn()}
|
|
126
|
+
dataSource={ds}
|
|
127
|
+
objectName="sys_license"
|
|
128
|
+
onSelect={vi.fn()}
|
|
129
|
+
baseFilter={[
|
|
130
|
+
{ field: 'starts_at', operator: 'before', value: '2026-01-01' },
|
|
131
|
+
{ field: 'expires_at', operator: 'after', value: '2026-01-01' },
|
|
132
|
+
{ field: 'revoked_at', operator: 'is_empty' },
|
|
133
|
+
{ field: 'assigned_to', operator: 'is_not_empty' },
|
|
134
|
+
]}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
139
|
+
expect(lastFilter(ds)).toEqual([
|
|
140
|
+
['starts_at', 'before', '2026-01-01'],
|
|
141
|
+
['expires_at', 'after', '2026-01-01'],
|
|
142
|
+
// A rule with no `value` stays two-element: inventing `null` here would be
|
|
143
|
+
// a real `{revoked_at: null}` predicate, i.e. a different question.
|
|
144
|
+
['revoked_at', 'is_empty'],
|
|
145
|
+
['assigned_to', 'is_not_empty'],
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps a record baseFilter and a rule array as separate `and` children', async () => {
|
|
150
|
+
const ds = makeDataSource();
|
|
151
|
+
render(
|
|
152
|
+
<RecordPickerDialog
|
|
153
|
+
open
|
|
154
|
+
onOpenChange={vi.fn()}
|
|
155
|
+
dataSource={ds}
|
|
156
|
+
objectName="sys_position"
|
|
157
|
+
onSelect={vi.fn()}
|
|
158
|
+
// A rule array is the author's restriction; the record-form base still
|
|
159
|
+
// arrives via `lookupFilters` here, and the two must BOTH apply.
|
|
160
|
+
lookupFilters={[{ field: 'company', operator: 'eq', value: 'c1' }]}
|
|
161
|
+
baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
166
|
+
expect(lastFilter(ds)).toEqual([
|
|
167
|
+
'and',
|
|
168
|
+
['company', '=', 'c1'],
|
|
169
|
+
[['is_active', 'equals', true]],
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sends no $filter for an empty rule array', async () => {
|
|
174
|
+
const ds = makeDataSource();
|
|
175
|
+
render(
|
|
176
|
+
<RecordPickerDialog
|
|
177
|
+
open
|
|
178
|
+
onOpenChange={vi.fn()}
|
|
179
|
+
dataSource={ds}
|
|
180
|
+
objectName="sys_position"
|
|
181
|
+
onSelect={vi.fn()}
|
|
182
|
+
baseFilter={[]}
|
|
183
|
+
/>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
187
|
+
// An empty restriction is no restriction — not an empty `$filter` the wire
|
|
188
|
+
// has to interpret.
|
|
189
|
+
expect(lastFilter(ds)).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|