@object-ui/fields 17.5.0 → 17.7.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 +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- 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/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- 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 +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Star } from 'lucide-react';
|
|
3
|
-
import { cn } from '@object-ui/components';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
import { toHostGroupProps } from './toHostGroupProps';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Rating field widget - provides a star rating input
|
|
10
|
-
* Supports numeric values from 0 to max (default 5)
|
|
11
|
-
*/
|
|
12
|
-
export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<number>) {
|
|
13
|
-
// Get rating-specific configuration from field metadata
|
|
14
|
-
const ratingField = field as any;
|
|
15
|
-
const max = ratingField?.max ?? 5;
|
|
16
|
-
const currentValue = value ?? 0;
|
|
17
|
-
|
|
18
|
-
const [hoverValue, setHoverValue] = React.useState<number | null>(null);
|
|
19
|
-
|
|
20
|
-
const displayValue = hoverValue !== null ? hoverValue : currentValue;
|
|
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`.
|
|
26
|
-
return (
|
|
27
|
-
<div {...toHostGroupProps(props)} className={cn("flex items-center gap-1", className)}>
|
|
28
|
-
{Array.from({ length: max }, (_, i) => (
|
|
29
|
-
<Star
|
|
30
|
-
key={i}
|
|
31
|
-
className={`w-5 h-5 ${
|
|
32
|
-
i < currentValue
|
|
33
|
-
? 'fill-yellow-400 text-yellow-400'
|
|
34
|
-
: 'text-muted-foreground'
|
|
35
|
-
}`}
|
|
36
|
-
/>
|
|
37
|
-
))}
|
|
38
|
-
<span className="ml-2 text-sm text-muted-foreground">
|
|
39
|
-
{currentValue} / {max}
|
|
40
|
-
</span>
|
|
41
|
-
</div>
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// DOM pass-through (objectui#3318): the container carries the form
|
|
46
|
-
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
47
|
-
// wrapper div is not where assistive tech reads the invalid state. That
|
|
48
|
-
// state goes onto every focusable star button below, computed from the
|
|
49
|
-
// published `error` slot (#3222). `name` is withheld too: it is only
|
|
50
|
-
// DOM-legal on form controls, and on this div it is exactly the leak
|
|
51
|
-
// #3291 sweeps for.
|
|
52
|
-
const {
|
|
53
|
-
'aria-invalid': _hostAriaInvalid,
|
|
54
|
-
name: _domName,
|
|
55
|
-
...groupDomProps
|
|
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;
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div
|
|
65
|
-
{...groupDomProps}
|
|
66
|
-
role={isLabelledGroup ? 'group' : undefined}
|
|
67
|
-
className={cn("flex items-center gap-1", className)}
|
|
68
|
-
>
|
|
69
|
-
{Array.from({ length: max }, (_, i) => (
|
|
70
|
-
<button
|
|
71
|
-
key={i}
|
|
72
|
-
type="button"
|
|
73
|
-
onClick={() => onChange(i + 1)}
|
|
74
|
-
onMouseEnter={() => setHoverValue(i + 1)}
|
|
75
|
-
onMouseLeave={() => setHoverValue(null)}
|
|
76
|
-
className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
|
|
77
|
-
disabled={readonly || props.disabled}
|
|
78
|
-
aria-invalid={!!error}
|
|
79
|
-
>
|
|
80
|
-
<Star
|
|
81
|
-
className={`w-5 h-5 transition-colors ${
|
|
82
|
-
i < displayValue
|
|
83
|
-
? 'fill-yellow-400 text-yellow-400 hover:fill-yellow-500 hover:text-yellow-500'
|
|
84
|
-
: 'text-muted-foreground hover:text-yellow-400'
|
|
85
|
-
}`}
|
|
86
|
-
/>
|
|
87
|
-
</button>
|
|
88
|
-
))}
|
|
89
|
-
<span className="ml-2 text-sm text-muted-foreground">
|
|
90
|
-
{currentValue} / {max}
|
|
91
|
-
</span>
|
|
92
|
-
</div>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
@@ -1,157 +0,0 @@
|
|
|
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
|
-
* RecipientPickerField — the sharing-rule recipient picker (objectstack#3821).
|
|
11
|
-
*
|
|
12
|
-
* Two regressions are pinned here:
|
|
13
|
-
* 1. the candidate query must use the STRUCTURED `$orderby` form. It used to
|
|
14
|
-
* pass the clause string `'name asc'`, which ApiDataSource walked
|
|
15
|
-
* character by character into `0 n,1 a,2 m,…`; the server then sorted by
|
|
16
|
-
* columns that don't exist and every recipient list came back empty, so
|
|
17
|
-
* the control read "No matches" forever and no rule could be saved.
|
|
18
|
-
* 2. the placeholder must come from a per-type i18n key, not from
|
|
19
|
-
* interpolating the enum value into an English sentence.
|
|
20
|
-
*/
|
|
21
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
22
|
-
import React from 'react';
|
|
23
|
-
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
24
|
-
import '@testing-library/jest-dom';
|
|
25
|
-
import { RecipientPickerField } from './RecipientPickerField';
|
|
26
|
-
|
|
27
|
-
const USERS = [
|
|
28
|
-
{ id: 'usr_1', name: 'Ada Auditor' },
|
|
29
|
-
{ id: 'usr_2', name: 'Bob Builder' },
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
function mockDataSource(rows: any[] = USERS) {
|
|
33
|
-
return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function renderPicker(recipientType: string, dataSource: any, value = '') {
|
|
37
|
-
return render(
|
|
38
|
-
<RecipientPickerField
|
|
39
|
-
value={value}
|
|
40
|
-
onChange={vi.fn()}
|
|
41
|
-
field={{ name: 'recipient_id' } as any}
|
|
42
|
-
dataSource={dataSource}
|
|
43
|
-
dependentValues={{ recipient_type: recipientType }}
|
|
44
|
-
/>,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe('RecipientPickerField — candidate query', () => {
|
|
49
|
-
it('orders candidates with the structured $orderby form, never a clause string', async () => {
|
|
50
|
-
const ds = mockDataSource();
|
|
51
|
-
renderPicker('user', ds);
|
|
52
|
-
|
|
53
|
-
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
54
|
-
const [object, params] = ds.find.mock.calls[0];
|
|
55
|
-
expect(object).toBe('sys_user');
|
|
56
|
-
expect(params.$orderby).toEqual({ name: 'asc' });
|
|
57
|
-
// A bare string is what regressed; assert the shape explicitly.
|
|
58
|
-
expect(typeof params.$orderby).not.toBe('string');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('queries the object mapped to each recipient type', async () => {
|
|
62
|
-
for (const [type, object] of [
|
|
63
|
-
['user', 'sys_user'],
|
|
64
|
-
['team', 'sys_team'],
|
|
65
|
-
['business_unit', 'sys_business_unit'],
|
|
66
|
-
['unit_and_subordinates', 'sys_business_unit'],
|
|
67
|
-
['position', 'sys_position'],
|
|
68
|
-
] as const) {
|
|
69
|
-
const ds = mockDataSource([]);
|
|
70
|
-
const { unmount } = renderPicker(type, ds);
|
|
71
|
-
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
72
|
-
expect(ds.find.mock.calls[0][0]).toBe(object);
|
|
73
|
-
unmount();
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('shows the loaded records as options once the list is opened', async () => {
|
|
78
|
-
const ds = mockDataSource();
|
|
79
|
-
renderPicker('user', ds);
|
|
80
|
-
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
81
|
-
|
|
82
|
-
fireEvent.click(screen.getByRole('combobox'));
|
|
83
|
-
expect(await screen.findByText('Ada Auditor')).toBeInTheDocument();
|
|
84
|
-
expect(screen.getByText('Bob Builder')).toBeInTheDocument();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe('RecipientPickerField — keeping the stored recipient', () => {
|
|
89
|
-
function renderControlled(initialType: string, value: string, onChange: any) {
|
|
90
|
-
const ds = mockDataSource();
|
|
91
|
-
const view = render(
|
|
92
|
-
<RecipientPickerField
|
|
93
|
-
value={value}
|
|
94
|
-
onChange={onChange}
|
|
95
|
-
field={{ name: 'recipient_id' } as any}
|
|
96
|
-
dataSource={ds}
|
|
97
|
-
dependentValues={{ recipient_type: initialType }}
|
|
98
|
-
/>,
|
|
99
|
-
);
|
|
100
|
-
const rerenderWith = (type: string) =>
|
|
101
|
-
view.rerender(
|
|
102
|
-
<RecipientPickerField
|
|
103
|
-
value={value}
|
|
104
|
-
onChange={onChange}
|
|
105
|
-
field={{ name: 'recipient_id' } as any}
|
|
106
|
-
dataSource={ds}
|
|
107
|
-
dependentValues={{ recipient_type: type }}
|
|
108
|
-
/>,
|
|
109
|
-
);
|
|
110
|
-
return { ...view, rerenderWith };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
it('keeps the saved recipient when the type arrives late (edit form hydrating)', async () => {
|
|
114
|
-
// The edit dialog mounts the widget before the record lands, so
|
|
115
|
-
// recipient_type is '' on the first render and 'user' on the next. That is
|
|
116
|
-
// hydration, not the admin changing the type — clearing there wiped the
|
|
117
|
-
// saved recipient and the blank got persisted on save.
|
|
118
|
-
const onChange = vi.fn();
|
|
119
|
-
const { rerenderWith } = renderControlled('', 'usr_1', onChange);
|
|
120
|
-
rerenderWith('user');
|
|
121
|
-
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('clears the recipient when the admin actually switches type', async () => {
|
|
125
|
-
const onChange = vi.fn();
|
|
126
|
-
const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
|
|
127
|
-
rerenderWith('team');
|
|
128
|
-
await waitFor(() => expect(onChange).toHaveBeenCalledWith(''));
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('does not clear when the type is unset back to empty', async () => {
|
|
132
|
-
const onChange = vi.fn();
|
|
133
|
-
const { rerenderWith } = renderControlled('user', 'usr_1', onChange);
|
|
134
|
-
rerenderWith('');
|
|
135
|
-
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe('RecipientPickerField — localizable copy', () => {
|
|
140
|
-
it('takes the placeholder from a per-type key rather than the raw enum value', async () => {
|
|
141
|
-
// With no I18nProvider the widget falls back to the English defaults, so
|
|
142
|
-
// the assertion is on the *wording*: 'Select a business unit' can only come
|
|
143
|
-
// from the keyed string — interpolating the enum produced
|
|
144
|
-
// 'Select a business unit' only by accident and 'Select a unit and
|
|
145
|
-
// subordinates' for the next type over.
|
|
146
|
-
const ds = mockDataSource([]);
|
|
147
|
-
renderPicker('unit_and_subordinates', ds);
|
|
148
|
-
await waitFor(() => expect(ds.find).toHaveBeenCalled());
|
|
149
|
-
expect(await screen.findByText('Select a business unit')).toBeInTheDocument();
|
|
150
|
-
expect(screen.queryByText(/unit and subordinates/i)).not.toBeInTheDocument();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('prompts for a recipient type before one is chosen', () => {
|
|
154
|
-
renderPicker('', mockDataSource([]));
|
|
155
|
-
expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
-
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* RecipientPickerField — dependent record picker for a polymorphic recipient
|
|
10
|
-
* reference (e.g. `sys_sharing_rule.recipient_id`), whose target object is
|
|
11
|
-
* decided by a sibling `recipient_type` select.
|
|
12
|
-
*
|
|
13
|
-
* Reached via the field `widget: 'recipient-picker'` hint (resolves as
|
|
14
|
-
* `field:recipient-picker`). Reads the live `recipient_type` from
|
|
15
|
-
* `dependentValues` (the form record), loads candidate records of the mapped
|
|
16
|
-
* object via `dataSource.find(...)`, and stores the value the sharing evaluator
|
|
17
|
-
* expects for that type:
|
|
18
|
-
*
|
|
19
|
-
* user → sys_user, store `id`
|
|
20
|
-
* team → sys_team, store `id`
|
|
21
|
-
* business_unit → sys_business_unit, store `id`
|
|
22
|
-
* unit_and_subordinates → sys_business_unit, store `id`
|
|
23
|
-
* position → sys_position, store `name` (matched against
|
|
24
|
-
* sys_user_position.position at evaluation time)
|
|
25
|
-
*
|
|
26
|
-
* When `recipient_type` changes after mount the stored id is reset (an id valid
|
|
27
|
-
* for one type is meaningless for another). Unknown types degrade to a plain
|
|
28
|
-
* text input so nothing breaks.
|
|
29
|
-
*/
|
|
30
|
-
interface RecipientMapping {
|
|
31
|
-
object: string;
|
|
32
|
-
/** Which record field to persist into recipient_id. */
|
|
33
|
-
storeField: 'id' | 'name';
|
|
34
|
-
/** Candidate display-label fields, in preference order. */
|
|
35
|
-
labelFields: string[];
|
|
36
|
-
/**
|
|
37
|
-
* i18n key for the "choose one" placeholder. Keyed per type rather than
|
|
38
|
-
* interpolating the enum value into an English sentence — "Select a
|
|
39
|
-
* business unit" and "选择业务单元" share no structure (objectstack#3821).
|
|
40
|
-
*/
|
|
41
|
-
placeholderKey: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
|
|
45
|
-
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' },
|
|
46
|
-
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
|
|
47
|
-
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
|
|
48
|
-
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
|
|
49
|
-
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export function RecipientPickerField({
|
|
53
|
-
value,
|
|
54
|
-
onChange,
|
|
55
|
-
readonly,
|
|
56
|
-
className,
|
|
57
|
-
error,
|
|
58
|
-
...props
|
|
59
|
-
}: FieldWidgetComponentProps<string>) {
|
|
60
|
-
const ctx = React.useContext(SchemaRendererContext);
|
|
61
|
-
const { t } = useFieldTranslation();
|
|
62
|
-
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
63
|
-
const disabled = props.disabled;
|
|
64
|
-
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
65
|
-
const recipientType = String(dependentValues.recipient_type ?? '');
|
|
66
|
-
const mapping = TYPE_TO_OBJECT[recipientType];
|
|
67
|
-
|
|
68
|
-
const [records, setRecords] = React.useState<any[] | null>(null);
|
|
69
|
-
|
|
70
|
-
// Reset the stored recipient when the admin PICKS a different type (an id for
|
|
71
|
-
// a user is not a valid team/business-unit id).
|
|
72
|
-
//
|
|
73
|
-
// "Different type" means one non-empty type replacing another. The empty
|
|
74
|
-
// string is not a type — it is the edit form before the record has hydrated,
|
|
75
|
-
// and treating `'' → 'user'` as a change wiped the saved recipient the moment
|
|
76
|
-
// an existing rule was opened for editing (objectstack#3821): the picker went
|
|
77
|
-
// blank, and saving persisted the blank. Only a non-empty predecessor can
|
|
78
|
-
// invalidate the stored id.
|
|
79
|
-
const prevType = React.useRef<string>('');
|
|
80
|
-
React.useEffect(() => {
|
|
81
|
-
if (prevType.current && recipientType && prevType.current !== recipientType && value) {
|
|
82
|
-
onChange('' as any);
|
|
83
|
-
}
|
|
84
|
-
prevType.current = recipientType;
|
|
85
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
-
}, [recipientType]);
|
|
87
|
-
|
|
88
|
-
React.useEffect(() => {
|
|
89
|
-
setRecords(null);
|
|
90
|
-
if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
|
|
91
|
-
let cancelled = false;
|
|
92
|
-
(async () => {
|
|
93
|
-
try {
|
|
94
|
-
// Object form, not the 'name asc' clause string: the clause string is
|
|
95
|
-
// supported again (objectstack#3821 fixed ApiDataSource walking it
|
|
96
|
-
// character by character), but the structured form can't regress that
|
|
97
|
-
// way for any data source.
|
|
98
|
-
const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
|
|
99
|
-
const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
|
|
100
|
-
if (!cancelled) setRecords(Array.isArray(list) ? list : []);
|
|
101
|
-
} catch {
|
|
102
|
-
if (!cancelled) setRecords([]);
|
|
103
|
-
}
|
|
104
|
-
})();
|
|
105
|
-
return () => {
|
|
106
|
-
cancelled = true;
|
|
107
|
-
};
|
|
108
|
-
}, [dataSource, mapping?.object]);
|
|
109
|
-
|
|
110
|
-
const labelOf = (r: any): string => {
|
|
111
|
-
for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
|
|
112
|
-
return String(r?.id ?? '');
|
|
113
|
-
};
|
|
114
|
-
const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? '');
|
|
115
|
-
|
|
116
|
-
const options = React.useMemo(() => {
|
|
117
|
-
const opts = (records ?? [])
|
|
118
|
-
.map((r) => ({ value: valueOf(r), label: labelOf(r) }))
|
|
119
|
-
.filter((o) => o.value);
|
|
120
|
-
if (value && !opts.some((o) => o.value === value)) opts.unshift({ value, label: value });
|
|
121
|
-
return opts;
|
|
122
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
123
|
-
}, [records, value, mapping]);
|
|
124
|
-
|
|
125
|
-
if (!recipientType) {
|
|
126
|
-
return (
|
|
127
|
-
<p className={cn('text-sm text-muted-foreground', className)}>
|
|
128
|
-
{t('fields.recipient.selectTypeFirst')}
|
|
129
|
-
</p>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!mapping) {
|
|
134
|
-
// Unknown / unsupported recipient type — keep a plain text input so the
|
|
135
|
-
// field is never un-editable.
|
|
136
|
-
return (
|
|
137
|
-
<input
|
|
138
|
-
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
139
|
-
{...toDomProps(props)}
|
|
140
|
-
className={cn(
|
|
141
|
-
'w-full rounded border bg-background px-2 py-1 text-sm',
|
|
142
|
-
className,
|
|
143
|
-
)}
|
|
144
|
-
value={value ?? ''}
|
|
145
|
-
disabled={disabled || readonly}
|
|
146
|
-
onChange={(e) => onChange(e.target.value as any)}
|
|
147
|
-
aria-invalid={!!error}
|
|
148
|
-
/>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (readonly) {
|
|
153
|
-
if (!value) return <EmptyValue />;
|
|
154
|
-
return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// DOM pass-through onto the combobox trigger — the widget's real focusable
|
|
158
|
-
// control (objectui#3318). `name` is withheld: the trigger is a button, not
|
|
159
|
-
// a submission control (same reasoning as #3306's SelectTrigger).
|
|
160
|
-
//
|
|
161
|
-
// NOTE this widget stays on the #3318 ledger regardless: its dependency-
|
|
162
|
-
// gated state (no `recipient_type` chosen yet — the state a fresh form and
|
|
163
|
-
// the registry sweep render) is a plain hint paragraph with no focusable
|
|
164
|
-
// control, so there is nothing there to carry the attribute.
|
|
165
|
-
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<Combobox
|
|
169
|
-
{...triggerDomProps}
|
|
170
|
-
options={options}
|
|
171
|
-
value={value ?? ''}
|
|
172
|
-
onValueChange={(v) => onChange(v as any)}
|
|
173
|
-
placeholder={
|
|
174
|
-
records === null
|
|
175
|
-
? t('fields.recipient.loading')
|
|
176
|
-
: t(mapping.placeholderKey ?? 'fields.recipient.select')
|
|
177
|
-
}
|
|
178
|
-
searchPlaceholder={t('fields.recipient.search')}
|
|
179
|
-
emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
|
|
180
|
-
disabled={disabled}
|
|
181
|
-
className={cn('w-full', className)}
|
|
182
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
183
|
-
aria-invalid={!!error}
|
|
184
|
-
/>
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export default RecipientPickerField;
|
|
@@ -1,191 +0,0 @@
|
|
|
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
|
-
});
|