@object-ui/fields 3.1.2 → 3.1.4
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/dist/AddressField-B6fPb2Fs.js +3 -0
- package/dist/AddressField-Ctsxkj35.cjs +1 -0
- package/dist/AutoNumberField-DXIYJfiB.js +3 -0
- package/dist/AutoNumberField-f76XU1bR.cjs +1 -0
- package/dist/AvatarField-BHpex3Wa.js +3 -0
- package/dist/AvatarField-CXOdYavl.cjs +1 -0
- package/dist/BooleanField-Bs0urhsx.js +3 -0
- package/dist/BooleanField-DeexI_O2.cjs +1 -0
- package/dist/CodeField-DDOMH8E8.cjs +1 -0
- package/dist/CodeField-tRACqngO.js +3 -0
- package/dist/ColorField-Bar6bOJw.js +3 -0
- package/dist/ColorField-CM9CxZ9h.cjs +1 -0
- package/dist/CurrencyField-CezTX_rA.cjs +1 -0
- package/dist/CurrencyField-D4VwXI9k.js +3 -0
- package/dist/DateField-DDUllj7w.cjs +1 -0
- package/dist/DateField-DjGiK6BH.js +3 -0
- package/dist/DateTimeField-DBh-dYdR.js +3 -0
- package/dist/DateTimeField-DDqgVN3b.cjs +1 -0
- package/dist/EmailField-B-3nY8Pr.cjs +1 -0
- package/dist/EmailField-Cp6yJlpH.js +3 -0
- package/dist/FileField-C1bcL7tu.cjs +1 -0
- package/dist/FileField-DUgt6ai3.js +3 -0
- package/dist/FormulaField-BLV1KgkQ.js +3 -0
- package/dist/FormulaField-CsbQIZ7S.cjs +1 -0
- package/dist/GeolocationField-BbIRH51u.js +3 -0
- package/dist/GeolocationField-UFu9VR-x.cjs +1 -0
- package/dist/GridField-2m74haE0.cjs +1 -0
- package/dist/GridField-O3Jd1NuJ.js +3 -0
- package/dist/ImageField-Cna7g6KL.cjs +1 -0
- package/dist/ImageField-rc1Nzcvb.js +3 -0
- package/dist/LocationField-DpRe9QlH.cjs +1 -0
- package/dist/LocationField-QZPPYo0J.js +3 -0
- package/dist/LookupField-BX4sK8NG.js +904 -0
- package/dist/LookupField-C9rPrHi_.cjs +1 -0
- package/dist/MasterDetailField-BXncIT1s.js +3 -0
- package/dist/MasterDetailField-TNP2825Z.cjs +1 -0
- package/dist/NumberField-DCHTzmDG.js +3 -0
- package/dist/NumberField-SqRwbcY8.cjs +1 -0
- package/dist/ObjectField-BEUHpn_X.js +3 -0
- package/dist/ObjectField-BWZwaFIG.cjs +1 -0
- package/dist/PasswordField-BZ_OeQyw.js +3 -0
- package/dist/PasswordField-kLp-ZyPm.cjs +1 -0
- package/dist/PercentField-B2t2knlc.js +3 -0
- package/dist/PercentField-BzhD6tQm.cjs +1 -0
- package/dist/PhoneField-CirvuysP.cjs +1 -0
- package/dist/PhoneField-CkrCFmcO.js +3 -0
- package/dist/QRCodeField-C4OOLCbE.cjs +1 -0
- package/dist/QRCodeField-CQoGow8y.js +3 -0
- package/dist/RatingField-CZG8SgQ8.cjs +1 -0
- package/dist/RatingField-DecDJkou.js +3 -0
- package/dist/RichTextField-Bog2i4NN.cjs +1 -0
- package/dist/RichTextField-b-8QbNCJ.js +3 -0
- package/dist/SelectField-CAa6c0cB.cjs +1 -0
- package/dist/SelectField-DMy5IxRc.js +3 -0
- package/dist/SignatureField-BvTPE7Dn.js +3 -0
- package/dist/SignatureField-jsb-0AYp.cjs +1 -0
- package/dist/SliderField-B7WvKFv7.js +3 -0
- package/dist/SliderField-DRjEJSxK.cjs +1 -0
- package/dist/SummaryField-DA16Edq3.cjs +1 -0
- package/dist/SummaryField-DVSVfKmY.js +3 -0
- package/dist/TextAreaField--ut87UiB.js +3 -0
- package/dist/TextAreaField-Cxp3DFC6.cjs +1 -0
- package/dist/TextField-CkEDGbkQ.js +3 -0
- package/dist/TextField-aLAzBaLZ.cjs +1 -0
- package/dist/TimeField-D0lF0y_I.cjs +1 -0
- package/dist/TimeField-DA_S6lQV.js +3 -0
- package/dist/UrlField-D0ugmD3f.cjs +1 -0
- package/dist/UrlField-DCnXHR1A.js +3 -0
- package/dist/UserField-DGlorzyG.cjs +1 -0
- package/dist/UserField-V0GmvyGV.js +3 -0
- package/dist/VectorField-BSD85ntU.cjs +1 -0
- package/dist/VectorField-CVi5yrrc.js +3 -0
- package/dist/fields/src/index.d.ts +8 -0
- package/dist/fields/src/record-picker-dialog.stories.d.ts +25 -0
- package/dist/fields/src/record-picker.test.d.ts +1 -0
- package/dist/fields/src/widgets/LookupField.d.ts +13 -2
- package/dist/fields/src/widgets/RecordPickerDialog.d.ts +166 -0
- package/dist/fields/src/widgets/_cell-renderer-bridge.d.ts +3 -0
- package/dist/index.cjs +4 -9
- package/dist/index.js +2229 -2682
- package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
- package/dist/jsx-runtime-MxADXuRH.js +189 -0
- package/package.json +8 -7
- package/src/__tests__/cell-renderers.test.tsx +133 -4
- package/src/complex-widgets.test.tsx +426 -2
- package/src/index.tsx +76 -22
- package/src/record-picker-dialog.stories.tsx +160 -0
- package/src/record-picker.test.tsx +1696 -0
- package/src/widgets/LookupField.tsx +485 -80
- package/src/widgets/RecordPickerDialog.tsx +1182 -0
- package/src/widgets/_cell-renderer-bridge.ts +19 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
3
|
import { LookupField } from './widgets/LookupField';
|
|
4
4
|
import { MasterDetailField } from './widgets/MasterDetailField';
|
|
5
5
|
import { GridField } from './widgets/GridField';
|
|
@@ -60,6 +60,430 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe('LookupField — Dynamic DataSource', () => {
|
|
64
|
+
const mockDataSource = {
|
|
65
|
+
find: vi.fn(),
|
|
66
|
+
findOne: vi.fn(),
|
|
67
|
+
create: vi.fn(),
|
|
68
|
+
update: vi.fn(),
|
|
69
|
+
delete: vi.fn(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const dynamicField = {
|
|
73
|
+
...mockField,
|
|
74
|
+
label: 'Customer',
|
|
75
|
+
reference_to: 'customers',
|
|
76
|
+
reference_field: 'name',
|
|
77
|
+
} as any;
|
|
78
|
+
|
|
79
|
+
const dynamicProps: FieldWidgetProps<any> = {
|
|
80
|
+
...baseProps,
|
|
81
|
+
field: dynamicField,
|
|
82
|
+
dataSource: mockDataSource,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fetches data from DataSource when dialog opens', async () => {
|
|
90
|
+
mockDataSource.find.mockResolvedValue({
|
|
91
|
+
data: [
|
|
92
|
+
{ id: '1', name: 'Acme Corp' },
|
|
93
|
+
{ id: '2', name: 'Beta Inc' },
|
|
94
|
+
],
|
|
95
|
+
total: 2,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
render(<LookupField {...dynamicProps} />);
|
|
99
|
+
|
|
100
|
+
// Open dialog
|
|
101
|
+
await act(async () => {
|
|
102
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
|
|
107
|
+
$top: 50,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText('Beta Inc')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('shows loading state while fetching', async () => {
|
|
118
|
+
// Make find never resolve during this test
|
|
119
|
+
mockDataSource.find.mockReturnValue(new Promise(() => {}));
|
|
120
|
+
|
|
121
|
+
render(<LookupField {...dynamicProps} />);
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
129
|
+
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('shows error state with retry on fetch failure', async () => {
|
|
134
|
+
mockDataSource.find.mockRejectedValue(new Error('Network error'));
|
|
135
|
+
|
|
136
|
+
render(<LookupField {...dynamicProps} />);
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
144
|
+
expect(screen.getByText('Network error')).toBeInTheDocument();
|
|
145
|
+
expect(screen.getByText('Retry')).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Click retry
|
|
149
|
+
mockDataSource.find.mockResolvedValue({
|
|
150
|
+
data: [{ id: '1', name: 'Acme Corp' }],
|
|
151
|
+
total: 1,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
fireEvent.click(screen.getByText('Retry'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('shows "No options found" when DataSource returns empty', async () => {
|
|
164
|
+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
|
|
165
|
+
|
|
166
|
+
render(<LookupField {...dynamicProps} />);
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
expect(screen.getByText('No options found')).toBeInTheDocument();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('sends $search param on search input', async () => {
|
|
178
|
+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
|
|
179
|
+
|
|
180
|
+
render(<LookupField {...dynamicProps} />);
|
|
181
|
+
|
|
182
|
+
await act(async () => {
|
|
183
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Wait for initial load
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(mockDataSource.find).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Type in search
|
|
192
|
+
await act(async () => {
|
|
193
|
+
fireEvent.change(screen.getByPlaceholderText('Search...'), {
|
|
194
|
+
target: { value: 'acme' },
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Wait for debounced search
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
|
|
201
|
+
$top: 50,
|
|
202
|
+
$search: 'acme',
|
|
203
|
+
});
|
|
204
|
+
}, { timeout: 500 });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('selects a dynamically loaded option', async () => {
|
|
208
|
+
const onChange = vi.fn();
|
|
209
|
+
mockDataSource.find.mockResolvedValue({
|
|
210
|
+
data: [
|
|
211
|
+
{ id: '1', name: 'Acme Corp' },
|
|
212
|
+
{ id: '2', name: 'Beta Inc' },
|
|
213
|
+
],
|
|
214
|
+
total: 2,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
render(<LookupField {...dynamicProps} onChange={onChange} />);
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
fireEvent.click(screen.getByText('Acme Corp'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(onChange).toHaveBeenCalledWith('1');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('falls back to static options when no DataSource', () => {
|
|
235
|
+
const staticField = {
|
|
236
|
+
...mockField,
|
|
237
|
+
options: [
|
|
238
|
+
{ value: 's1', label: 'Static 1' },
|
|
239
|
+
{ value: 's2', label: 'Static 2' },
|
|
240
|
+
],
|
|
241
|
+
} as any;
|
|
242
|
+
render(<LookupField {...baseProps} field={staticField} readonly value="s1" />);
|
|
243
|
+
expect(screen.getByText('Static 1')).toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('shows total count hint when more results available', async () => {
|
|
247
|
+
mockDataSource.find.mockResolvedValue({
|
|
248
|
+
data: Array.from({ length: 50 }, (_, i) => ({
|
|
249
|
+
id: String(i),
|
|
250
|
+
name: `Record ${i}`,
|
|
251
|
+
})),
|
|
252
|
+
total: 200,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
render(<LookupField {...dynamicProps} />);
|
|
256
|
+
|
|
257
|
+
await act(async () => {
|
|
258
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await waitFor(() => {
|
|
262
|
+
expect(screen.getByText(/Showing 50 of 200/)).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('displays description field for options', async () => {
|
|
267
|
+
mockDataSource.find.mockResolvedValue({
|
|
268
|
+
data: [
|
|
269
|
+
{ id: '1', name: 'Acme Corp', industry: 'Technology' },
|
|
270
|
+
{ id: '2', name: 'Beta Inc', industry: 'Finance' },
|
|
271
|
+
],
|
|
272
|
+
total: 2,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const fieldWithDesc = {
|
|
276
|
+
...dynamicField,
|
|
277
|
+
description_field: 'industry',
|
|
278
|
+
} as any;
|
|
279
|
+
|
|
280
|
+
render(<LookupField {...dynamicProps} field={fieldWithDesc} />);
|
|
281
|
+
|
|
282
|
+
await act(async () => {
|
|
283
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
288
|
+
expect(screen.getByText('Technology')).toBeInTheDocument();
|
|
289
|
+
expect(screen.getByText('Finance')).toBeInTheDocument();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('shows create-new button when no results and onCreateNew is provided', async () => {
|
|
294
|
+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
|
|
295
|
+
const onCreateNew = vi.fn();
|
|
296
|
+
|
|
297
|
+
render(<LookupField {...dynamicProps} onCreateNew={onCreateNew} />);
|
|
298
|
+
|
|
299
|
+
await act(async () => {
|
|
300
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await waitFor(() => {
|
|
304
|
+
expect(screen.getByText('No options found')).toBeInTheDocument();
|
|
305
|
+
expect(screen.getByText('Create new')).toBeInTheDocument();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
fireEvent.click(screen.getByText('Create new'));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(onCreateNew).toHaveBeenCalledWith('');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('navigates options with arrow keys and selects with Enter', async () => {
|
|
316
|
+
const onChange = vi.fn();
|
|
317
|
+
mockDataSource.find.mockResolvedValue({
|
|
318
|
+
data: [
|
|
319
|
+
{ id: '1', name: 'Alpha' },
|
|
320
|
+
{ id: '2', name: 'Beta' },
|
|
321
|
+
{ id: '3', name: 'Gamma' },
|
|
322
|
+
],
|
|
323
|
+
total: 3,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
render(<LookupField {...dynamicProps} onChange={onChange} />);
|
|
327
|
+
|
|
328
|
+
await act(async () => {
|
|
329
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await waitFor(() => {
|
|
333
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const searchInput = screen.getByPlaceholderText('Search...');
|
|
337
|
+
|
|
338
|
+
// Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
|
|
339
|
+
await act(async () => {
|
|
340
|
+
fireEvent.keyDown(searchInput, { key: 'ArrowDown' });
|
|
341
|
+
});
|
|
342
|
+
await act(async () => {
|
|
343
|
+
fireEvent.keyDown(searchInput, { key: 'ArrowDown' });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Press Enter to select
|
|
347
|
+
await act(async () => {
|
|
348
|
+
fireEvent.keyDown(searchInput, { key: 'Enter' });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(onChange).toHaveBeenCalledWith('2');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('resolves reference_to from nested field.field (createFieldRenderer wrapper)', async () => {
|
|
355
|
+
// Simulates how createFieldRenderer wraps the field: the real metadata
|
|
356
|
+
// (reference_to, reference_field, etc.) is nested inside field.field.
|
|
357
|
+
const onChange = vi.fn();
|
|
358
|
+
mockDataSource.find.mockResolvedValue({
|
|
359
|
+
data: [
|
|
360
|
+
{ id: 'o1', name: 'Order 001' },
|
|
361
|
+
{ id: 'o2', name: 'Order 002' },
|
|
362
|
+
],
|
|
363
|
+
total: 2,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const wrappedField = {
|
|
367
|
+
name: 'order',
|
|
368
|
+
label: 'Order',
|
|
369
|
+
// In the wrapper, the actual objectSchema metadata is nested
|
|
370
|
+
field: {
|
|
371
|
+
name: 'order',
|
|
372
|
+
type: 'lookup',
|
|
373
|
+
reference_to: 'orders',
|
|
374
|
+
reference_field: 'name',
|
|
375
|
+
},
|
|
376
|
+
// dataSource lands at the wrapper level
|
|
377
|
+
dataSource: mockDataSource,
|
|
378
|
+
} as any;
|
|
379
|
+
|
|
380
|
+
render(
|
|
381
|
+
<LookupField
|
|
382
|
+
value={null}
|
|
383
|
+
onChange={onChange}
|
|
384
|
+
field={wrappedField}
|
|
385
|
+
readonly={false}
|
|
386
|
+
/>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
await act(async () => {
|
|
390
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await waitFor(() => {
|
|
394
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('orders', { $top: 50 });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await waitFor(() => {
|
|
398
|
+
expect(screen.getByText('Order 001')).toBeInTheDocument();
|
|
399
|
+
expect(screen.getByText('Order 002')).toBeInTheDocument();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('supports ObjectStack "reference" convention (not just "reference_to")', async () => {
|
|
404
|
+
// ObjectStack backend uses `reference` instead of `reference_to`
|
|
405
|
+
const onChange = vi.fn();
|
|
406
|
+
mockDataSource.find.mockResolvedValue({
|
|
407
|
+
data: [
|
|
408
|
+
{ id: 'a1', name: 'Acme Corp' },
|
|
409
|
+
{ id: 'a2', name: 'Beta Inc' },
|
|
410
|
+
],
|
|
411
|
+
total: 2,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const wrappedField = {
|
|
415
|
+
name: 'account',
|
|
416
|
+
label: 'Account',
|
|
417
|
+
field: {
|
|
418
|
+
name: 'account',
|
|
419
|
+
type: 'lookup',
|
|
420
|
+
reference: 'account', // ObjectStack convention
|
|
421
|
+
},
|
|
422
|
+
dataSource: mockDataSource,
|
|
423
|
+
} as any;
|
|
424
|
+
|
|
425
|
+
render(
|
|
426
|
+
<LookupField
|
|
427
|
+
value={null}
|
|
428
|
+
onChange={onChange}
|
|
429
|
+
field={wrappedField}
|
|
430
|
+
readonly={false}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await act(async () => {
|
|
435
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await waitFor(() => {
|
|
439
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('account', { $top: 50 });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await waitFor(() => {
|
|
443
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
444
|
+
expect(screen.getByText('Beta Inc')).toBeInTheDocument();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('supports flat "reference" field without wrapper nesting', async () => {
|
|
449
|
+
// When field metadata is flat (no field.field nesting)
|
|
450
|
+
const onChange = vi.fn();
|
|
451
|
+
mockDataSource.find.mockResolvedValue({
|
|
452
|
+
data: [
|
|
453
|
+
{ id: 'p1', name: 'Product A' },
|
|
454
|
+
],
|
|
455
|
+
total: 1,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
render(
|
|
459
|
+
<LookupField
|
|
460
|
+
value={null}
|
|
461
|
+
onChange={onChange}
|
|
462
|
+
field={{
|
|
463
|
+
name: 'product',
|
|
464
|
+
label: 'Product',
|
|
465
|
+
type: 'lookup',
|
|
466
|
+
reference: 'products', // ObjectStack convention, flat field
|
|
467
|
+
} as any}
|
|
468
|
+
readonly={false}
|
|
469
|
+
dataSource={mockDataSource}
|
|
470
|
+
/>
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
await act(async () => {
|
|
474
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await waitFor(() => {
|
|
478
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('products', { $top: 50 });
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await waitFor(() => {
|
|
482
|
+
expect(screen.getByText('Product A')).toBeInTheDocument();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
63
487
|
describe('MasterDetailField', () => {
|
|
64
488
|
const items = [
|
|
65
489
|
{ id: '1', label: 'Item 1' },
|
package/src/index.tsx
CHANGED
|
@@ -60,6 +60,39 @@ export interface CellRendererProps {
|
|
|
60
60
|
onChange?: (value: any) => void;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Coerce a value to a safe primitive for rendering.
|
|
65
|
+
* Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
|
|
66
|
+
* reference objects, and arrays so that no raw object is ever passed as
|
|
67
|
+
* a React child — preventing React error #310.
|
|
68
|
+
*/
|
|
69
|
+
export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
|
|
70
|
+
if (value == null) return value as null | undefined;
|
|
71
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
72
|
+
if (value instanceof Date) return value.toISOString();
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((v) => {
|
|
75
|
+
if (v != null && typeof v === 'object') {
|
|
76
|
+
const obj = v as Record<string, unknown>;
|
|
77
|
+
return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
|
|
78
|
+
}
|
|
79
|
+
return String(v);
|
|
80
|
+
}).join(', ');
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'object') {
|
|
83
|
+
const obj = value as Record<string, unknown>;
|
|
84
|
+
// MongoDB numeric wrapper: { $numberDecimal: "250000" }
|
|
85
|
+
if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
|
|
86
|
+
// MongoDB ObjectId wrapper: { $oid: "abc123" }
|
|
87
|
+
if ('$oid' in obj) return String(obj.$oid);
|
|
88
|
+
// MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
|
|
89
|
+
if ('$date' in obj) return String(obj.$date);
|
|
90
|
+
// Expanded reference / general object: extract name/label/id
|
|
91
|
+
return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
|
|
92
|
+
}
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
/**
|
|
64
97
|
* Format currency value
|
|
65
98
|
*/
|
|
@@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string {
|
|
|
192
225
|
* Text field cell renderer
|
|
193
226
|
*/
|
|
194
227
|
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
195
|
-
|
|
228
|
+
const safe = coerceToSafeValue(value);
|
|
229
|
+
return <span className="truncate">{(safe != null && safe !== '') ? String(safe) : '-'}</span>;
|
|
196
230
|
}
|
|
197
231
|
|
|
198
232
|
/**
|
|
@@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
201
235
|
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
202
236
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
203
237
|
|
|
238
|
+
const safe = coerceToSafeValue(value);
|
|
204
239
|
const numField = field as any;
|
|
205
240
|
const precision = numField.precision ?? 0;
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
:
|
|
241
|
+
const num = Number(safe);
|
|
242
|
+
const formatted = !isNaN(num)
|
|
243
|
+
? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
|
|
244
|
+
: String(safe);
|
|
209
245
|
|
|
210
246
|
return <span className="tabular-nums">{formatted}</span>;
|
|
211
247
|
}
|
|
@@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
216
252
|
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
217
253
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
218
254
|
|
|
255
|
+
const safe = coerceToSafeValue(value);
|
|
219
256
|
const currencyField = field as any;
|
|
220
257
|
const currency = currencyField.currency || 'USD';
|
|
221
|
-
const
|
|
258
|
+
const num = Number(safe);
|
|
259
|
+
const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe);
|
|
222
260
|
|
|
223
261
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
224
262
|
}
|
|
@@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
|
232
270
|
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
233
271
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
234
272
|
|
|
273
|
+
const safe = coerceToSafeValue(value);
|
|
235
274
|
const percentField = field as any;
|
|
236
275
|
const precision = percentField.precision ?? 0;
|
|
237
|
-
const numValue = Number(
|
|
276
|
+
const numValue = Number(safe);
|
|
277
|
+
if (isNaN(numValue)) {
|
|
278
|
+
return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
|
|
279
|
+
}
|
|
238
280
|
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
|
|
239
281
|
// Fields like "progress" or "completion" store values as 0-100, not 0-1
|
|
240
282
|
const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
|
|
@@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
319
361
|
*/
|
|
320
362
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
321
363
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
|
364
|
+
const safe = coerceToSafeValue(value);
|
|
322
365
|
const dateField = field as any;
|
|
323
366
|
const style = dateField.format || 'relative';
|
|
324
|
-
const formatted = formatDate(
|
|
367
|
+
const formatted = formatDate(safe as string | Date, style);
|
|
325
368
|
|
|
326
369
|
// Determine if date is overdue (in the past)
|
|
327
|
-
const date = typeof
|
|
370
|
+
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
328
371
|
const isValidDate = date instanceof Date && !isNaN(date.getTime());
|
|
329
372
|
const startOfToday = new Date();
|
|
330
373
|
startOfToday.setHours(0, 0, 0, 0);
|
|
331
374
|
const isOverdue = isValidDate && date < startOfToday;
|
|
332
|
-
const isoString = isValidDate ? date.toISOString() : String(
|
|
375
|
+
const isoString = isValidDate ? date.toISOString() : String(safe);
|
|
333
376
|
|
|
334
377
|
return (
|
|
335
378
|
<span
|
|
@@ -346,8 +389,9 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
346
389
|
*/
|
|
347
390
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
348
391
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
|
349
|
-
const
|
|
350
|
-
|
|
392
|
+
const safe = coerceToSafeValue(value);
|
|
393
|
+
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
394
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
|
|
351
395
|
|
|
352
396
|
const datePart = date.toLocaleDateString(undefined, {
|
|
353
397
|
month: 'numeric',
|
|
@@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
478
522
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
479
523
|
if (!value) return <span>-</span>;
|
|
480
524
|
|
|
525
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
481
526
|
const [copied, setCopied] = React.useState(false);
|
|
482
527
|
|
|
483
528
|
const handleCopy = (e: React.MouseEvent) => {
|
|
484
529
|
e.stopPropagation();
|
|
485
530
|
e.preventDefault();
|
|
486
|
-
navigator.clipboard.writeText(
|
|
531
|
+
navigator.clipboard.writeText(safe).then(() => {
|
|
487
532
|
setCopied(true);
|
|
488
533
|
setTimeout(() => setCopied(false), 2000);
|
|
489
534
|
}).catch(() => { /* clipboard not available */ });
|
|
@@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
497
542
|
asChild
|
|
498
543
|
>
|
|
499
544
|
<a
|
|
500
|
-
href={`mailto:${
|
|
545
|
+
href={`mailto:${safe}`}
|
|
501
546
|
onClick={(e) => e.stopPropagation()}
|
|
502
547
|
>
|
|
503
|
-
{
|
|
548
|
+
{safe}
|
|
504
549
|
</a>
|
|
505
550
|
</Button>
|
|
506
551
|
<button
|
|
@@ -525,6 +570,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
525
570
|
export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
526
571
|
if (!value) return <span>-</span>;
|
|
527
572
|
|
|
573
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
528
574
|
return (
|
|
529
575
|
<Button
|
|
530
576
|
variant="link"
|
|
@@ -532,12 +578,12 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
532
578
|
asChild
|
|
533
579
|
>
|
|
534
580
|
<a
|
|
535
|
-
href={
|
|
581
|
+
href={safe}
|
|
536
582
|
target="_blank"
|
|
537
583
|
rel="noopener noreferrer"
|
|
538
584
|
onClick={(e) => e.stopPropagation()}
|
|
539
585
|
>
|
|
540
|
-
{
|
|
586
|
+
{safe}
|
|
541
587
|
</a>
|
|
542
588
|
</Button>
|
|
543
589
|
);
|
|
@@ -549,12 +595,13 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
549
595
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
550
596
|
if (!value) return <span>-</span>;
|
|
551
597
|
|
|
598
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
552
599
|
const [copied, setCopied] = React.useState(false);
|
|
553
600
|
|
|
554
601
|
const handleCopy = (e: React.MouseEvent) => {
|
|
555
602
|
e.stopPropagation();
|
|
556
603
|
e.preventDefault();
|
|
557
|
-
navigator.clipboard.writeText(
|
|
604
|
+
navigator.clipboard.writeText(safe).then(() => {
|
|
558
605
|
setCopied(true);
|
|
559
606
|
setTimeout(() => setCopied(false), 2000);
|
|
560
607
|
}).catch(() => { /* clipboard not available */ });
|
|
@@ -563,12 +610,12 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
563
610
|
return (
|
|
564
611
|
<span className="inline-flex items-center gap-1 group/phone">
|
|
565
612
|
<a
|
|
566
|
-
href={`tel:${
|
|
613
|
+
href={`tel:${safe}`}
|
|
567
614
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
|
|
568
615
|
onClick={(e) => e.stopPropagation()}
|
|
569
616
|
>
|
|
570
617
|
<PhoneIcon className="h-3 w-3" />
|
|
571
|
-
{
|
|
618
|
+
{safe}
|
|
572
619
|
</a>
|
|
573
620
|
<button
|
|
574
621
|
type="button"
|
|
@@ -667,7 +714,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
667
714
|
{value.map((item, idx) => {
|
|
668
715
|
let label: string;
|
|
669
716
|
if (item != null && typeof item === 'object') {
|
|
670
|
-
label = item.name || item.label || item._id || String(item);
|
|
717
|
+
label = item.name || item.label || item.id || item._id || String(item);
|
|
671
718
|
} else {
|
|
672
719
|
label = resolveLabel(item);
|
|
673
720
|
}
|
|
@@ -685,7 +732,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
685
732
|
}
|
|
686
733
|
|
|
687
734
|
if (typeof value === 'object' && value !== null) {
|
|
688
|
-
const label = value.name || value.label || value._id || '[Object]';
|
|
735
|
+
const label = value.name || value.label || value.id || value._id || '[Object]';
|
|
689
736
|
return <span className="truncate">{label}</span>;
|
|
690
737
|
}
|
|
691
738
|
|
|
@@ -697,9 +744,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
697
744
|
* Formula field cell renderer (read-only)
|
|
698
745
|
*/
|
|
699
746
|
export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
747
|
+
const safe = coerceToSafeValue(value);
|
|
700
748
|
return (
|
|
701
749
|
<span className="text-gray-700 font-mono text-sm">
|
|
702
|
-
{
|
|
750
|
+
{safe != null ? String(safe) : '-'}
|
|
703
751
|
</span>
|
|
704
752
|
);
|
|
705
753
|
}
|
|
@@ -840,6 +888,11 @@ registerFieldRenderer('status', SelectCellRenderer);
|
|
|
840
888
|
registerFieldRenderer('user', UserCellRenderer);
|
|
841
889
|
registerFieldRenderer('owner', UserCellRenderer);
|
|
842
890
|
|
|
891
|
+
// Register getCellRenderer in the bridge so RecordPickerDialog can access it
|
|
892
|
+
// via LookupField without circular imports.
|
|
893
|
+
import { setCellRendererResolver } from './widgets/_cell-renderer-bridge';
|
|
894
|
+
setCellRendererResolver(getCellRenderer);
|
|
895
|
+
|
|
843
896
|
|
|
844
897
|
|
|
845
898
|
/**
|
|
@@ -1279,6 +1332,7 @@ export * from './widgets/PasswordField';
|
|
|
1279
1332
|
export * from './widgets/TextAreaField';
|
|
1280
1333
|
export * from './widgets/RichTextField';
|
|
1281
1334
|
export * from './widgets/LookupField';
|
|
1335
|
+
export * from './widgets/RecordPickerDialog';
|
|
1282
1336
|
export * from './widgets/FileField';
|
|
1283
1337
|
export * from './widgets/ImageField';
|
|
1284
1338
|
export * from './widgets/LocationField';
|