@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,233 +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
|
-
* Dependent (cascading) lookups — #2215.
|
|
11
|
-
*
|
|
12
|
-
* A lookup with `depends_on` must:
|
|
13
|
-
* 1. gate every candidate surface while a dependency is empty,
|
|
14
|
-
* 2. unlock as soon as the dependent values arrive (the form renderer
|
|
15
|
-
* injects live form values via the `dependentValues` prop), and
|
|
16
|
-
* 3. scope EVERY candidate query — quick-select popover, Level-2 table
|
|
17
|
-
* picker and PeoplePicker — with the dependent-lookup chain as a hard
|
|
18
|
-
* `$filter` no user filter input can override.
|
|
19
|
-
*
|
|
20
|
-
* Pre-fix, `dependentValues` was never injected by the form renderer (the
|
|
21
|
-
* context fallback read a member that does not exist), so create-mode
|
|
22
|
-
* cascades stayed gated forever; and the table picker only received
|
|
23
|
-
* `lookupFilters`, listing the full unfiltered record set.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
27
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
28
|
-
import { LookupField } from './LookupField';
|
|
29
|
-
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
30
|
-
import { PeoplePicker } from './PeoplePicker';
|
|
31
|
-
|
|
32
|
-
const contacts = [
|
|
33
|
-
{ id: 'c1', name: 'Nora Field', account: 'a1' },
|
|
34
|
-
{ id: 'c2', name: 'Oscar Grant', account: 'a2' },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
function makeDataSource() {
|
|
38
|
-
const find = vi.fn(async (_obj: string, params: any) => {
|
|
39
|
-
const account = params?.$filter?.account ?? params?.$filter?.account_id;
|
|
40
|
-
const data = account ? contacts.filter(c => c.account === account) : contacts;
|
|
41
|
-
return { data, total: data.length };
|
|
42
|
-
});
|
|
43
|
-
return { find } as any;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
beforeEach(() => {
|
|
47
|
-
// jsdom has no matchMedia; PeoplePicker's useIsMobile needs it.
|
|
48
|
-
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
|
|
49
|
-
window.matchMedia = ((query: string) => ({
|
|
50
|
-
matches: false,
|
|
51
|
-
media: query,
|
|
52
|
-
onchange: null,
|
|
53
|
-
addEventListener: () => {},
|
|
54
|
-
removeEventListener: () => {},
|
|
55
|
-
addListener: () => {},
|
|
56
|
-
removeListener: () => {},
|
|
57
|
-
dispatchEvent: () => false,
|
|
58
|
-
})) as any;
|
|
59
|
-
try {
|
|
60
|
-
localStorage.clear();
|
|
61
|
-
} catch {
|
|
62
|
-
/* jsdom */
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe('LookupField — depends_on gating and cascade filter (#2215)', () => {
|
|
67
|
-
const dependentField = {
|
|
68
|
-
name: 'contact',
|
|
69
|
-
label: 'Contact',
|
|
70
|
-
reference_to: 'contacts',
|
|
71
|
-
reference_field: 'name',
|
|
72
|
-
depends_on: ['account'],
|
|
73
|
-
} as any;
|
|
74
|
-
|
|
75
|
-
it('gates the trigger while the dependency is empty', () => {
|
|
76
|
-
render(
|
|
77
|
-
<LookupField
|
|
78
|
-
field={dependentField}
|
|
79
|
-
value={undefined}
|
|
80
|
-
onChange={vi.fn()}
|
|
81
|
-
readonly={false}
|
|
82
|
-
dataSource={makeDataSource()}
|
|
83
|
-
{...({ dependentValues: { account: null } } as any)}
|
|
84
|
-
/>,
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
const trigger = screen.getByTestId('lookup-trigger-gated');
|
|
88
|
-
expect(trigger).toBeDisabled();
|
|
89
|
-
expect(trigger).toHaveTextContent(/select account first/i);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('unlocks when dependentValues carry the parent value and scopes the popover query', async () => {
|
|
93
|
-
const ds = makeDataSource();
|
|
94
|
-
render(
|
|
95
|
-
<LookupField
|
|
96
|
-
field={dependentField}
|
|
97
|
-
value={undefined}
|
|
98
|
-
onChange={vi.fn()}
|
|
99
|
-
readonly={false}
|
|
100
|
-
dataSource={ds}
|
|
101
|
-
{...({ dependentValues: { account: 'a1' } } as any)}
|
|
102
|
-
/>,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
const trigger = screen.getByRole('button', { name: /select/i });
|
|
106
|
-
expect(trigger).not.toBeDisabled();
|
|
107
|
-
|
|
108
|
-
await act(async () => {
|
|
109
|
-
fireEvent.click(trigger);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
await waitFor(() => {
|
|
113
|
-
expect(ds.find).toHaveBeenCalledWith(
|
|
114
|
-
'contacts',
|
|
115
|
-
expect.objectContaining({ $filter: expect.objectContaining({ account: 'a1' }) }),
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
await waitFor(() => {
|
|
120
|
-
expect(screen.getByText('Nora Field')).toBeInTheDocument();
|
|
121
|
-
expect(screen.queryByText('Oscar Grant')).not.toBeInTheDocument();
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('maps the explicit { field, param } shape onto the remote filter field', async () => {
|
|
126
|
-
const ds = makeDataSource();
|
|
127
|
-
const explicitField = {
|
|
128
|
-
...dependentField,
|
|
129
|
-
depends_on: [{ field: 'account', param: 'account_id' }],
|
|
130
|
-
};
|
|
131
|
-
render(
|
|
132
|
-
<LookupField
|
|
133
|
-
field={explicitField}
|
|
134
|
-
value={undefined}
|
|
135
|
-
onChange={vi.fn()}
|
|
136
|
-
readonly={false}
|
|
137
|
-
dataSource={ds}
|
|
138
|
-
{...({ dependentValues: { account: 'a1' } } as any)}
|
|
139
|
-
/>,
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
await act(async () => {
|
|
143
|
-
fireEvent.click(screen.getByRole('button', { name: /select/i }));
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
await waitFor(() => {
|
|
147
|
-
expect(ds.find).toHaveBeenCalledWith(
|
|
148
|
-
'contacts',
|
|
149
|
-
expect.objectContaining({ $filter: expect.objectContaining({ account_id: 'a1' }) }),
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe('RecordPickerDialog — baseFilter is a hard constraint (#2215)', () => {
|
|
156
|
-
it('applies baseFilter over the lookupFilters base on the same field', async () => {
|
|
157
|
-
const ds = makeDataSource();
|
|
158
|
-
render(
|
|
159
|
-
<RecordPickerDialog
|
|
160
|
-
open
|
|
161
|
-
onOpenChange={vi.fn()}
|
|
162
|
-
dataSource={ds}
|
|
163
|
-
objectName="contacts"
|
|
164
|
-
onSelect={vi.fn()}
|
|
165
|
-
lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
|
|
166
|
-
baseFilter={{ account: 'a1' }}
|
|
167
|
-
/>,
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
await waitFor(() => {
|
|
171
|
-
expect(ds.find).toHaveBeenCalled();
|
|
172
|
-
});
|
|
173
|
-
const lastParams = ds.find.mock.calls[ds.find.mock.calls.length - 1][1];
|
|
174
|
-
expect(lastParams.$filter).toMatchObject({ account: 'a1' });
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('cannot be widened back out by filter-bar input on the same field', async () => {
|
|
178
|
-
const ds = makeDataSource();
|
|
179
|
-
render(
|
|
180
|
-
<RecordPickerDialog
|
|
181
|
-
open
|
|
182
|
-
onOpenChange={vi.fn()}
|
|
183
|
-
dataSource={ds}
|
|
184
|
-
objectName="contacts"
|
|
185
|
-
onSelect={vi.fn()}
|
|
186
|
-
lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
|
|
187
|
-
baseFilter={{ account: 'a1' }}
|
|
188
|
-
/>,
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
await waitFor(() => {
|
|
192
|
-
expect(ds.find).toHaveBeenCalled();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// Open the filter bar (auto-derived from lookupFilters) and try to
|
|
196
|
-
// override the cascaded field by hand.
|
|
197
|
-
fireEvent.click(screen.getByRole('button', { name: /filters/i }));
|
|
198
|
-
const panel = await screen.findByTestId('record-picker-filter-panel');
|
|
199
|
-
const input = panel.querySelector('input')!;
|
|
200
|
-
fireEvent.change(input, { target: { value: 'a2' } });
|
|
201
|
-
|
|
202
|
-
// The hard constraint wins: no query may ever go out with the user's
|
|
203
|
-
// widened value.
|
|
204
|
-
await waitFor(() => {
|
|
205
|
-
for (const call of ds.find.mock.calls) {
|
|
206
|
-
expect(call[1]?.$filter?.account).toBe('a1');
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
describe('PeoplePicker — baseFilter scopes the candidate query (#2215)', () => {
|
|
213
|
-
it('merges baseFilter after lookupFilters so the cascade wins', async () => {
|
|
214
|
-
const ds = makeDataSource();
|
|
215
|
-
render(
|
|
216
|
-
<PeoplePicker
|
|
217
|
-
open
|
|
218
|
-
onOpenChange={vi.fn()}
|
|
219
|
-
dataSource={ds}
|
|
220
|
-
objectName="contacts"
|
|
221
|
-
onSelect={vi.fn()}
|
|
222
|
-
lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
|
|
223
|
-
baseFilter={{ account: 'a1' }}
|
|
224
|
-
/>,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
await waitFor(() => {
|
|
228
|
-
expect(ds.find).toHaveBeenCalled();
|
|
229
|
-
});
|
|
230
|
-
const lastParams = ds.find.mock.calls[ds.find.mock.calls.length - 1][1];
|
|
231
|
-
expect(lastParams.$filter).toMatchObject({ account: 'a1' });
|
|
232
|
-
});
|
|
233
|
-
});
|
|
@@ -1,135 +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
|
-
* A gated lookup names its controlling field by LABEL — objectstack#5407.
|
|
11
|
-
*
|
|
12
|
-
* The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
|
|
13
|
-
* its `{{fields}}` interpolation came straight off `depends_on`, which holds
|
|
14
|
-
* API names. So the rendered hint was localized around a raw identifier in
|
|
15
|
-
* every locale, English included:
|
|
16
|
-
*
|
|
17
|
-
* en Select crm_account first
|
|
18
|
-
* zh-CN 请先选择crm_account
|
|
19
|
-
*
|
|
20
|
-
* The `en` row is why this is not merely an i18n bug: an English user was shown
|
|
21
|
-
* an internal identifier. The form renderer knows the label (it already
|
|
22
|
-
* resolves the same map for the fixed-option widgets' `emptyHint`) and now
|
|
23
|
-
* hands it over as `dependsOnLabels`.
|
|
24
|
-
*
|
|
25
|
-
* All three gated surfaces are asserted — the trigger's visible text, the
|
|
26
|
-
* trigger's `title`, and the "browse all" button's `title` — because they were
|
|
27
|
-
* three independent copies of the same expression and a fix applied to one of
|
|
28
|
-
* them looks identical from the outside.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { render, screen, cleanup } from '@testing-library/react';
|
|
32
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
33
|
-
import { LookupField } from './LookupField';
|
|
34
|
-
|
|
35
|
-
/** Mirrors the reported metadata: the parent field's API name IS an object-ish
|
|
36
|
-
* name (`crm_account`), which is exactly what made the leak so legible. */
|
|
37
|
-
const gatedField = {
|
|
38
|
-
name: 'contact',
|
|
39
|
-
label: 'Contact',
|
|
40
|
-
reference_to: 'crm_contact',
|
|
41
|
-
reference_field: 'name',
|
|
42
|
-
depends_on: ['crm_account'],
|
|
43
|
-
} as any;
|
|
44
|
-
|
|
45
|
-
const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
|
|
46
|
-
|
|
47
|
-
function renderGated(extra: Record<string, unknown>) {
|
|
48
|
-
return render(
|
|
49
|
-
<LookupField
|
|
50
|
-
field={gatedField}
|
|
51
|
-
value={undefined}
|
|
52
|
-
onChange={vi.fn()}
|
|
53
|
-
readonly={false}
|
|
54
|
-
dataSource={dataSource}
|
|
55
|
-
{...({ dependentValues: { crm_account: null }, ...extra } as any)}
|
|
56
|
-
/>,
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
|
|
62
|
-
window.matchMedia = ((query: string) => ({
|
|
63
|
-
matches: false,
|
|
64
|
-
media: query,
|
|
65
|
-
onchange: null,
|
|
66
|
-
addEventListener: () => {},
|
|
67
|
-
removeEventListener: () => {},
|
|
68
|
-
addListener: () => {},
|
|
69
|
-
removeListener: () => {},
|
|
70
|
-
dispatchEvent: () => false,
|
|
71
|
-
})) as any;
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => cleanup());
|
|
75
|
-
|
|
76
|
-
describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
|
|
77
|
-
it('interpolates the label the host supplied, not the API name', () => {
|
|
78
|
-
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
79
|
-
|
|
80
|
-
const trigger = screen.getByTestId('lookup-trigger-gated');
|
|
81
|
-
expect(trigger).toHaveTextContent('Select Account first');
|
|
82
|
-
// The identifier must be gone from the copy entirely, not merely
|
|
83
|
-
// accompanied by the label.
|
|
84
|
-
expect(trigger).not.toHaveTextContent('crm_account');
|
|
85
|
-
expect(trigger).toHaveAttribute('title', 'Select Account first');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('labels the gated "browse all" button the same way', () => {
|
|
89
|
-
renderGated({ dependsOnLabels: { crm_account: 'Account' } });
|
|
90
|
-
|
|
91
|
-
const browse = screen.getByTestId('browse-all-records');
|
|
92
|
-
expect(browse).toBeDisabled();
|
|
93
|
-
expect(browse).toHaveAttribute('title', 'Select Account first');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('joins several controlling fields by their labels', () => {
|
|
97
|
-
render(
|
|
98
|
-
<LookupField
|
|
99
|
-
field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
|
|
100
|
-
value={undefined}
|
|
101
|
-
onChange={vi.fn()}
|
|
102
|
-
readonly={false}
|
|
103
|
-
dataSource={dataSource}
|
|
104
|
-
{...({
|
|
105
|
-
dependentValues: { crm_account: null, lead_source: null },
|
|
106
|
-
dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
|
|
107
|
-
} as any)}
|
|
108
|
-
/>,
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
112
|
-
'Select Account, Lead Source first',
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('falls back to the API name for a field the host did not map', () => {
|
|
117
|
-
// Standalone use (no host form) has to keep rendering what it rendered
|
|
118
|
-
// before — the map is an enrichment, not a new requirement. This is also
|
|
119
|
-
// the assertion that would go red if `dependsOnLabels` were consulted for
|
|
120
|
-
// its VALUES rather than keyed by field name.
|
|
121
|
-
renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
|
|
122
|
-
|
|
123
|
-
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
124
|
-
'Select crm_account first',
|
|
125
|
-
);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('falls back to the API name when the host passes no map at all', () => {
|
|
129
|
-
renderGated({});
|
|
130
|
-
|
|
131
|
-
expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
|
|
132
|
-
'Select crm_account first',
|
|
133
|
-
);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Regression (#3108): a multi-value lookup hydrated its committed ids with a
|
|
5
|
-
* SERIAL `findOne` per id — 41 selected users meant 41 sequential round-trips,
|
|
6
|
-
* during which the trigger rendered the bare "Select…" placeholder,
|
|
7
|
-
* indistinguishable from having no value at all.
|
|
8
|
-
*
|
|
9
|
-
* Fixed behaviour under test:
|
|
10
|
-
* 1. unresolved ids are fetched in ONE batched `$in` query (chunked), not
|
|
11
|
-
* one findOne per id;
|
|
12
|
-
* 2. while hydration is in flight the trigger shows a loading indicator and
|
|
13
|
-
* the committed-value count instead of the empty placeholder;
|
|
14
|
-
* 3. the loading state settles (falls back to placeholder) when hydration
|
|
15
|
-
* fails, instead of spinning forever.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import * as React from 'react';
|
|
19
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
20
|
-
import { render, cleanup, waitFor, screen } from '@testing-library/react';
|
|
21
|
-
import { LookupField } from './LookupField';
|
|
22
|
-
|
|
23
|
-
afterEach(cleanup);
|
|
24
|
-
|
|
25
|
-
const USERS = [
|
|
26
|
-
{ id: 'u1', name: 'Ada Lovelace' },
|
|
27
|
-
{ id: 'u2', name: 'Grace Hopper' },
|
|
28
|
-
{ id: 'u3', name: 'Margaret Hamilton' },
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
describe('LookupField — multi-value hydration batches and shows loading (#3108)', () => {
|
|
32
|
-
it('hydrates several ids with one $in query instead of a findOne per id', async () => {
|
|
33
|
-
const find = vi.fn(async (_obj: string, params: any) => {
|
|
34
|
-
const wanted: any[] = params?.$filter?.id?.$in ?? [];
|
|
35
|
-
return { data: USERS.filter((u) => wanted.includes(u.id)) };
|
|
36
|
-
});
|
|
37
|
-
const findOne = vi.fn(async () => null);
|
|
38
|
-
render(
|
|
39
|
-
<LookupField
|
|
40
|
-
value={['u1', 'u2', 'u3']}
|
|
41
|
-
onChange={() => {}}
|
|
42
|
-
dataSource={{ find, findOne } as never}
|
|
43
|
-
field={{ reference_to: 'sys_user', multiple: true } as never}
|
|
44
|
-
/>,
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
|
|
48
|
-
expect(screen.getByText('Grace Hopper')).toBeTruthy();
|
|
49
|
-
expect(screen.getByText('Margaret Hamilton')).toBeTruthy();
|
|
50
|
-
|
|
51
|
-
// One batched round-trip, zero per-id GETs.
|
|
52
|
-
expect(findOne).not.toHaveBeenCalled();
|
|
53
|
-
const hydrationCalls = find.mock.calls.filter((c) => c[1]?.$filter?.id?.$in);
|
|
54
|
-
expect(hydrationCalls).toHaveLength(1);
|
|
55
|
-
expect(hydrationCalls[0][1].$filter.id.$in).toEqual(['u1', 'u2', 'u3']);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('shows the committed count + spinner while hydration is in flight, never the empty placeholder', async () => {
|
|
59
|
-
let resolveFind!: (v: any) => void;
|
|
60
|
-
const find = vi.fn(
|
|
61
|
-
() => new Promise((resolve) => { resolveFind = resolve; }),
|
|
62
|
-
);
|
|
63
|
-
render(
|
|
64
|
-
<LookupField
|
|
65
|
-
value={['u1', 'u2', 'u3']}
|
|
66
|
-
onChange={() => {}}
|
|
67
|
-
dataSource={{ find } as never}
|
|
68
|
-
field={{ reference_to: 'sys_user', multiple: true } as never}
|
|
69
|
-
/>,
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
// In-flight: loading indicator + raw committed count, no "Select…".
|
|
73
|
-
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
|
|
74
|
-
expect(screen.queryByText('Select…')).toBeNull();
|
|
75
|
-
expect(screen.getByText(/3/)).toBeTruthy();
|
|
76
|
-
|
|
77
|
-
resolveFind({ data: USERS });
|
|
78
|
-
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
|
|
79
|
-
expect(screen.queryByTestId('lookup-hydrating')).toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('settles (no infinite spinner) when the hydration fetch fails', async () => {
|
|
83
|
-
const find = vi.fn(async (_obj: string, params: any) => {
|
|
84
|
-
if (params?.$filter?.id?.$in) throw new Error('boom');
|
|
85
|
-
return { data: [] };
|
|
86
|
-
});
|
|
87
|
-
render(
|
|
88
|
-
<LookupField
|
|
89
|
-
value={['u1', 'u2']}
|
|
90
|
-
onChange={() => {}}
|
|
91
|
-
dataSource={{ find } as never}
|
|
92
|
-
field={{ reference_to: 'sys_user', multiple: true } as never}
|
|
93
|
-
/>,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
await waitFor(() => expect(find).toHaveBeenCalled());
|
|
97
|
-
// The failed attempt is marked settled — the spinner clears instead of
|
|
98
|
-
// spinning forever (pre-existing fallback: unresolvable ids drop).
|
|
99
|
-
await waitFor(() => expect(screen.queryByTestId('lookup-hydrating')).toBeNull());
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('readonly mode shows the loading count instead of an empty value while hydrating', async () => {
|
|
103
|
-
let resolveFind!: (v: any) => void;
|
|
104
|
-
const find = vi.fn(
|
|
105
|
-
() => new Promise((resolve) => { resolveFind = resolve; }),
|
|
106
|
-
);
|
|
107
|
-
render(
|
|
108
|
-
<LookupField
|
|
109
|
-
value={['u1', 'u2']}
|
|
110
|
-
onChange={() => {}}
|
|
111
|
-
readonly
|
|
112
|
-
dataSource={{ find } as never}
|
|
113
|
-
field={{ reference_to: 'sys_user', multiple: true } as never}
|
|
114
|
-
/>,
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
|
|
118
|
-
|
|
119
|
-
resolveFind({ data: USERS.slice(0, 2) });
|
|
120
|
-
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('keeps the cheap findOne GET for a single unresolved primary id', async () => {
|
|
124
|
-
const find = vi.fn(async () => ({ data: [] }));
|
|
125
|
-
const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
|
|
126
|
-
render(
|
|
127
|
-
<LookupField
|
|
128
|
-
value={['u1']}
|
|
129
|
-
onChange={() => {}}
|
|
130
|
-
dataSource={{ find, findOne } as never}
|
|
131
|
-
field={{ reference_to: 'sys_user', multiple: true } as never}
|
|
132
|
-
/>,
|
|
133
|
-
);
|
|
134
|
-
await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
|
|
135
|
-
});
|
|
136
|
-
});
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Regression (objectstack #3508): a lookup whose committed value is NOT the
|
|
5
|
-
* primary id (`id_field: 'name'` — e.g. approval `position` approvers, which
|
|
6
|
-
* the engine routes by machine name) must hydrate its display label by
|
|
7
|
-
* FILTERING on that field. The old path always called `findOne(object, value)`
|
|
8
|
-
* — a primary-id GET — so a stored machine name never resolved and the field
|
|
9
|
-
* showed an empty placeholder over a real value.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import * as React from 'react';
|
|
13
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
14
|
-
import { render, cleanup, waitFor } from '@testing-library/react';
|
|
15
|
-
import { LookupField } from './LookupField';
|
|
16
|
-
|
|
17
|
-
afterEach(cleanup);
|
|
18
|
-
|
|
19
|
-
describe('LookupField — id_field hydration (#3508)', () => {
|
|
20
|
-
it('hydrates by filtering on id_field when it is not the primary id', async () => {
|
|
21
|
-
const find = vi.fn(async () => ({
|
|
22
|
-
data: [{ id: 'pos_1', name: 'sales_manager', label: 'Sales Manager' }],
|
|
23
|
-
}));
|
|
24
|
-
const findOne = vi.fn(async () => null);
|
|
25
|
-
render(
|
|
26
|
-
<LookupField
|
|
27
|
-
value="sales_manager"
|
|
28
|
-
onChange={() => {}}
|
|
29
|
-
dataSource={{ find, findOne } as never}
|
|
30
|
-
field={{
|
|
31
|
-
reference_to: 'sys_position',
|
|
32
|
-
id_field: 'name',
|
|
33
|
-
display_field: 'label',
|
|
34
|
-
multiple: false,
|
|
35
|
-
} as never}
|
|
36
|
-
/>,
|
|
37
|
-
);
|
|
38
|
-
await waitFor(() => expect(find).toHaveBeenCalled());
|
|
39
|
-
// The stored machine name is not a primary id — findOne(object, name)
|
|
40
|
-
// would 404; hydration must go through $filter on the committed field.
|
|
41
|
-
expect(findOne).not.toHaveBeenCalled();
|
|
42
|
-
const hydration = find.mock.calls.find(
|
|
43
|
-
(c) => (c as unknown[])[1] && ((c as never[])[1] as { $filter?: Record<string, unknown> }).$filter?.name === 'sales_manager',
|
|
44
|
-
);
|
|
45
|
-
expect(hydration).toBeTruthy();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('keeps findOne hydration for the default primary-id lookup', async () => {
|
|
49
|
-
const find = vi.fn(async () => ({ data: [] }));
|
|
50
|
-
const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
|
|
51
|
-
render(
|
|
52
|
-
<LookupField
|
|
53
|
-
value="u1"
|
|
54
|
-
onChange={() => {}}
|
|
55
|
-
dataSource={{ find, findOne } as never}
|
|
56
|
-
field={{ reference_to: 'sys_user', multiple: false } as never}
|
|
57
|
-
/>,
|
|
58
|
-
);
|
|
59
|
-
await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
|
|
60
|
-
});
|
|
61
|
-
});
|