@object-ui/fields 11.2.0 → 11.4.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 +153 -0
- package/dist/FieldEditWidget.d.ts +21 -0
- package/dist/FieldEditWidget.test.d.ts +8 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2155 -1438
- package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
- package/dist/widgets/PeoplePicker.d.ts +63 -0
- package/dist/widgets/PeoplePicker.test.d.ts +8 -0
- package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
- package/dist/widgets/PersonRow.d.ts +23 -0
- package/dist/widgets/RecordPickerDialog.d.ts +9 -1
- package/dist/widgets/SelectionTray.d.ts +27 -0
- package/dist/widgets/UserField.d.ts +0 -13
- package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +12 -4
- package/dist/widgets/personDisplay.d.ts +37 -0
- package/dist/widgets/personDisplay.test.d.ts +8 -0
- package/dist/widgets/useRecordQuery.d.ts +87 -0
- package/dist/widgets/useRecordQuery.test.d.ts +8 -0
- package/package.json +11 -11
- package/src/FieldEditWidget.test.ts +61 -0
- package/src/FieldEditWidget.tsx +146 -0
- package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
- package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
- package/src/complex-widgets.test.tsx +241 -0
- package/src/index.tsx +33 -4
- package/src/widgets/GridField.test.tsx +14 -0
- package/src/widgets/GridField.tsx +7 -8
- package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
- package/src/widgets/LookupField.tsx +397 -199
- package/src/widgets/NumberField.tsx +4 -2
- package/src/widgets/PeoplePicker.test.tsx +334 -0
- package/src/widgets/PeoplePicker.tsx +552 -0
- package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
- package/src/widgets/PersonRow.tsx +117 -0
- package/src/widgets/RecordPickerDialog.tsx +58 -129
- package/src/widgets/SelectionTray.tsx +110 -0
- package/src/widgets/UserField.tsx +20 -1
- package/src/widgets/UserFieldPicker.test.tsx +78 -0
- package/src/widgets/deriveLookupColumns.test.ts +25 -0
- package/src/widgets/deriveLookupColumns.ts +24 -10
- package/src/widgets/personDisplay.test.ts +113 -0
- package/src/widgets/personDisplay.ts +92 -0
- package/src/widgets/useRecordQuery.test.ts +174 -0
- package/src/widgets/useRecordQuery.ts +264 -0
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
});
|