@object-ui/fields 4.0.1 → 4.0.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.js +574 -574
  4. package/package.json +7 -7
  5. package/dist/dist-BoLAKgtw.cjs +0 -1
  6. package/dist/dist-qtNk7SzD.js +0 -32
  7. package/dist/packages/fields/src/__tests__/boolean-checkbox.test.d.ts +0 -0
  8. package/dist/packages/fields/src/__tests__/cell-renderers.test.d.ts +0 -0
  9. package/dist/packages/fields/src/__tests__/datetime-cell.test.d.ts +0 -0
  10. package/dist/packages/fields/src/__tests__/datetime-timezone.test.d.ts +0 -0
  11. package/dist/packages/fields/src/__tests__/extreme-inputs.test.d.ts +0 -0
  12. package/dist/packages/fields/src/__tests__/form-variants.test.d.ts +0 -0
  13. package/dist/packages/fields/src/lookup-depends-on.test.d.ts +0 -1
  14. package/dist/packages/fields/src/new-widgets.test.d.ts +0 -8
  15. package/dist/packages/fields/src/record-picker.test.d.ts +0 -1
  16. package/dist/packages/fields/src/remaining-widgets.test.d.ts +0 -1
  17. package/dist/packages/fields/src/text-rich-widgets.test.d.ts +0 -1
  18. package/dist/packages/fields/src/visual-widgets.test.d.ts +0 -1
  19. package/src/__tests__/boolean-checkbox.test.tsx +0 -74
  20. package/src/__tests__/cell-renderers.test.tsx +0 -1193
  21. package/src/__tests__/datetime-cell.test.tsx +0 -46
  22. package/src/__tests__/datetime-timezone.test.tsx +0 -770
  23. package/src/__tests__/extreme-inputs.test.tsx +0 -567
  24. package/src/__tests__/form-variants.test.tsx +0 -306
  25. package/src/lookup-depends-on.test.tsx +0 -130
  26. package/src/new-widgets.test.tsx +0 -302
  27. package/src/record-picker.test.tsx +0 -1703
  28. package/src/remaining-widgets.test.tsx +0 -211
  29. package/src/text-rich-widgets.test.tsx +0 -104
  30. package/src/visual-widgets.test.tsx +0 -97
  31. /package/dist/{ImageCropperDialog-DXLQcwWZ.cjs → ImageCropperDialog-CK1apFXU.cjs} +0 -0
  32. /package/dist/{ImageCropperDialog-BYRMqcYm.js → ImageCropperDialog-DNvktnyM.js} +0 -0
@@ -1,1703 +0,0 @@
1
- import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2
- import { describe, it, expect, vi, beforeEach } from 'vitest';
3
- import { RecordPickerDialog } from './widgets/RecordPickerDialog';
4
- import { LookupField } from './widgets/LookupField';
5
- import type { FieldWidgetProps } from './widgets/types';
6
-
7
- // ------------- Mocks & Setup -------------
8
-
9
- const mockDataSource = {
10
- find: vi.fn(),
11
- findOne: vi.fn(),
12
- create: vi.fn(),
13
- update: vi.fn(),
14
- delete: vi.fn(),
15
- };
16
-
17
- beforeEach(() => {
18
- vi.clearAllMocks();
19
- });
20
-
21
- // ------------- RecordPickerDialog Tests -------------
22
-
23
- describe('RecordPickerDialog', () => {
24
- const basePickerProps = {
25
- open: true,
26
- onOpenChange: vi.fn(),
27
- dataSource: mockDataSource as any,
28
- objectName: 'customers',
29
- onSelect: vi.fn(),
30
- };
31
-
32
- it('renders dialog with title and search input', async () => {
33
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
34
-
35
- render(<RecordPickerDialog {...basePickerProps} title="Select Customer" />);
36
-
37
- await waitFor(() => {
38
- expect(screen.getByText('Select Customer')).toBeInTheDocument();
39
- expect(screen.getByTestId('record-picker-search')).toBeInTheDocument();
40
- });
41
- });
42
-
43
- it('fetches and displays records in table format', async () => {
44
- mockDataSource.find.mockResolvedValue({
45
- data: [
46
- { id: '1', name: 'Acme Corp', email: 'acme@test.com' },
47
- { id: '2', name: 'Beta Inc', email: 'beta@test.com' },
48
- ],
49
- total: 2,
50
- });
51
-
52
- render(
53
- <RecordPickerDialog
54
- {...basePickerProps}
55
- columns={['name', 'email']}
56
- />,
57
- );
58
-
59
- await waitFor(() => {
60
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
61
- expect(screen.getByText('Beta Inc')).toBeInTheDocument();
62
- expect(screen.getByText('acme@test.com')).toBeInTheDocument();
63
- expect(screen.getByText('beta@test.com')).toBeInTheDocument();
64
- });
65
-
66
- // Should have column headers
67
- expect(screen.getByText('Name')).toBeInTheDocument();
68
- expect(screen.getByText('Email')).toBeInTheDocument();
69
- });
70
-
71
- it('shows loading state while fetching', async () => {
72
- mockDataSource.find.mockReturnValue(new Promise(() => {}));
73
-
74
- render(<RecordPickerDialog {...basePickerProps} />);
75
-
76
- await waitFor(() => {
77
- expect(screen.getByRole('status')).toBeInTheDocument();
78
- expect(screen.getByTestId('record-picker-skeleton')).toBeInTheDocument();
79
- });
80
- });
81
-
82
- it('shows error state with retry button', async () => {
83
- mockDataSource.find.mockRejectedValue(new Error('Connection failed'));
84
-
85
- render(<RecordPickerDialog {...basePickerProps} />);
86
-
87
- await waitFor(() => {
88
- expect(screen.getByRole('alert')).toBeInTheDocument();
89
- expect(screen.getByText('Connection failed')).toBeInTheDocument();
90
- expect(screen.getByText('Retry')).toBeInTheDocument();
91
- });
92
-
93
- // Click retry
94
- mockDataSource.find.mockResolvedValue({
95
- data: [{ id: '1', name: 'Acme Corp' }],
96
- total: 1,
97
- });
98
-
99
- await act(async () => {
100
- fireEvent.click(screen.getByText('Retry'));
101
- });
102
-
103
- await waitFor(() => {
104
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
105
- });
106
- });
107
-
108
- it('shows empty state when no records found', async () => {
109
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
110
-
111
- render(<RecordPickerDialog {...basePickerProps} />);
112
-
113
- await waitFor(() => {
114
- expect(screen.getByText('No records found')).toBeInTheDocument();
115
- });
116
- });
117
-
118
- it('selects a record in single-select mode and closes dialog', async () => {
119
- const onSelect = vi.fn();
120
- const onOpenChange = vi.fn();
121
-
122
- mockDataSource.find.mockResolvedValue({
123
- data: [
124
- { id: '1', name: 'Acme Corp' },
125
- { id: '2', name: 'Beta Inc' },
126
- ],
127
- total: 2,
128
- });
129
-
130
- render(
131
- <RecordPickerDialog
132
- {...basePickerProps}
133
- onSelect={onSelect}
134
- onOpenChange={onOpenChange}
135
- />,
136
- );
137
-
138
- await waitFor(() => {
139
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
140
- });
141
-
142
- await act(async () => {
143
- fireEvent.click(screen.getByTestId('record-row-1'));
144
- });
145
-
146
- expect(onSelect).toHaveBeenCalledWith('1');
147
- expect(onOpenChange).toHaveBeenCalledWith(false);
148
- });
149
-
150
- it('supports multi-select with confirm button', async () => {
151
- const onSelect = vi.fn();
152
- const onOpenChange = vi.fn();
153
-
154
- mockDataSource.find.mockResolvedValue({
155
- data: [
156
- { id: '1', name: 'Alpha' },
157
- { id: '2', name: 'Beta' },
158
- { id: '3', name: 'Gamma' },
159
- ],
160
- total: 3,
161
- });
162
-
163
- render(
164
- <RecordPickerDialog
165
- {...basePickerProps}
166
- multiple
167
- onSelect={onSelect}
168
- onOpenChange={onOpenChange}
169
- />,
170
- );
171
-
172
- await waitFor(() => {
173
- expect(screen.getByText('Alpha')).toBeInTheDocument();
174
- });
175
-
176
- // Select two records
177
- await act(async () => {
178
- fireEvent.click(screen.getByTestId('record-row-1'));
179
- });
180
- await act(async () => {
181
- fireEvent.click(screen.getByTestId('record-row-3'));
182
- });
183
-
184
- // Should show selection count
185
- expect(screen.getByText('2 selected')).toBeInTheDocument();
186
-
187
- // Click confirm
188
- await act(async () => {
189
- fireEvent.click(screen.getByText('Confirm'));
190
- });
191
-
192
- expect(onSelect).toHaveBeenCalledWith(expect.arrayContaining(['1', '3']));
193
- expect(onOpenChange).toHaveBeenCalledWith(false);
194
- });
195
-
196
- it('renders pagination with page navigation', async () => {
197
- mockDataSource.find.mockResolvedValue({
198
- data: Array.from({ length: 10 }, (_, i) => ({
199
- id: String(i + 1),
200
- name: `Record ${i + 1}`,
201
- })),
202
- total: 25,
203
- });
204
-
205
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
206
-
207
- await waitFor(() => {
208
- expect(screen.getByTestId('record-picker-pagination')).toBeInTheDocument();
209
- expect(screen.getByText(/25 records/)).toBeInTheDocument();
210
- expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument();
211
- });
212
-
213
- // Navigate to next page
214
- await act(async () => {
215
- fireEvent.click(screen.getByLabelText('Next page'));
216
- });
217
-
218
- await waitFor(() => {
219
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
220
- $top: 10,
221
- $skip: 10,
222
- });
223
- });
224
- });
225
-
226
- it('sends search query to DataSource with debounce', async () => {
227
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
228
-
229
- render(<RecordPickerDialog {...basePickerProps} />);
230
-
231
- await waitFor(() => {
232
- expect(mockDataSource.find).toHaveBeenCalledTimes(1);
233
- });
234
-
235
- // Type in search
236
- await act(async () => {
237
- fireEvent.change(screen.getByTestId('record-picker-search'), {
238
- target: { value: 'acme' },
239
- });
240
- });
241
-
242
- // Wait for debounce
243
- await waitFor(
244
- () => {
245
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
246
- $top: 10,
247
- $skip: 0,
248
- $search: 'acme',
249
- });
250
- },
251
- { timeout: 500 },
252
- );
253
- });
254
-
255
- it('supports custom column definitions with labels', async () => {
256
- mockDataSource.find.mockResolvedValue({
257
- data: [
258
- { id: '1', name: 'Acme', total_amount: 5000 },
259
- ],
260
- total: 1,
261
- });
262
-
263
- render(
264
- <RecordPickerDialog
265
- {...basePickerProps}
266
- columns={[
267
- { field: 'name', label: 'Customer Name' },
268
- { field: 'total_amount', label: 'Total' },
269
- ]}
270
- />,
271
- );
272
-
273
- await waitFor(() => {
274
- expect(screen.getByText('Customer Name')).toBeInTheDocument();
275
- expect(screen.getByText('Total')).toBeInTheDocument();
276
- expect(screen.getByText('Acme')).toBeInTheDocument();
277
- expect(screen.getByText('5000')).toBeInTheDocument();
278
- });
279
- });
280
-
281
- it('auto-infers column from displayField when no columns specified', async () => {
282
- mockDataSource.find.mockResolvedValue({
283
- data: [{ id: '1', full_name: 'John Doe' }],
284
- total: 1,
285
- });
286
-
287
- render(
288
- <RecordPickerDialog
289
- {...basePickerProps}
290
- displayField="full_name"
291
- />,
292
- );
293
-
294
- await waitFor(() => {
295
- // Auto-inferred header from field name
296
- expect(screen.getByText('Full Name')).toBeInTheDocument();
297
- expect(screen.getByText('John Doe')).toBeInTheDocument();
298
- });
299
- });
300
- });
301
-
302
- // ------------- LookupField — Show All Results Integration -------------
303
-
304
- describe('LookupField — Show All Results', () => {
305
- const mockField = {
306
- name: 'customer',
307
- label: 'Customer',
308
- reference_to: 'customers',
309
- reference_field: 'name',
310
- } as any;
311
-
312
- const baseProps: FieldWidgetProps<any> = {
313
- field: mockField,
314
- value: undefined,
315
- onChange: vi.fn(),
316
- readonly: false,
317
- dataSource: mockDataSource as any,
318
- };
319
-
320
- it('shows "Show All Results" button when total exceeds displayed count', async () => {
321
- mockDataSource.find.mockResolvedValue({
322
- data: Array.from({ length: 50 }, (_, i) => ({
323
- id: String(i),
324
- name: `Customer ${i}`,
325
- })),
326
- total: 200,
327
- });
328
-
329
- render(<LookupField {...baseProps} />);
330
-
331
- // Open dialog
332
- await act(async () => {
333
- fireEvent.click(screen.getByRole('button', { name: /Select/i }));
334
- });
335
-
336
- await waitFor(() => {
337
- expect(screen.getByTestId('show-all-results')).toBeInTheDocument();
338
- expect(screen.getByText(/Show All Results \(200\)/)).toBeInTheDocument();
339
- });
340
- });
341
-
342
- it('does not show "Show All Results" when all results fit in popover', async () => {
343
- mockDataSource.find.mockResolvedValue({
344
- data: [
345
- { id: '1', name: 'Acme Corp' },
346
- { id: '2', name: 'Beta Inc' },
347
- ],
348
- total: 2,
349
- });
350
-
351
- render(<LookupField {...baseProps} />);
352
-
353
- await act(async () => {
354
- fireEvent.click(screen.getByRole('button', { name: /Select/i }));
355
- });
356
-
357
- await waitFor(() => {
358
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
359
- });
360
-
361
- expect(screen.queryByTestId('show-all-results')).not.toBeInTheDocument();
362
- });
363
-
364
- it('opens RecordPickerDialog when "Show All Results" is clicked', async () => {
365
- mockDataSource.find.mockResolvedValue({
366
- data: Array.from({ length: 50 }, (_, i) => ({
367
- id: String(i),
368
- name: `Customer ${i}`,
369
- })),
370
- total: 200,
371
- });
372
-
373
- render(<LookupField {...baseProps} />);
374
-
375
- // Open quick-select dialog
376
- await act(async () => {
377
- fireEvent.click(screen.getByRole('button', { name: /Select/i }));
378
- });
379
-
380
- await waitFor(() => {
381
- expect(screen.getByTestId('show-all-results')).toBeInTheDocument();
382
- });
383
-
384
- // Click "Show All Results"
385
- await act(async () => {
386
- fireEvent.click(screen.getByTestId('show-all-results'));
387
- });
388
-
389
- // RecordPickerDialog should now be open
390
- await waitFor(() => {
391
- expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
392
- });
393
- });
394
-
395
- it('passes lookup_columns to RecordPickerDialog', async () => {
396
- const fieldWithColumns = {
397
- ...mockField,
398
- lookup_columns: ['name', 'email', 'status'],
399
- } as any;
400
-
401
- mockDataSource.find.mockResolvedValue({
402
- data: Array.from({ length: 50 }, (_, i) => ({
403
- id: String(i),
404
- name: `Customer ${i}`,
405
- email: `customer${i}@test.com`,
406
- status: 'active',
407
- })),
408
- total: 200,
409
- });
410
-
411
- render(
412
- <LookupField
413
- {...baseProps}
414
- field={fieldWithColumns}
415
- />,
416
- );
417
-
418
- // Open quick-select dialog
419
- await act(async () => {
420
- fireEvent.click(screen.getByRole('button', { name: /Select/i }));
421
- });
422
-
423
- await waitFor(() => {
424
- expect(screen.getByTestId('show-all-results')).toBeInTheDocument();
425
- });
426
-
427
- // Open Record Picker
428
- await act(async () => {
429
- fireEvent.click(screen.getByTestId('show-all-results'));
430
- });
431
-
432
- // Wait for RecordPickerDialog to render with column headers
433
- await waitFor(() => {
434
- expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
435
- expect(screen.getByText('Name')).toBeInTheDocument();
436
- expect(screen.getByText('Email')).toBeInTheDocument();
437
- expect(screen.getByText('Status')).toBeInTheDocument();
438
- });
439
- });
440
- });
441
-
442
- // ------------- LookupField — Browse All Button (Always Visible) -------------
443
-
444
- describe('LookupField — Browse All Button', () => {
445
- const mockField = {
446
- name: 'customer',
447
- label: 'Customer',
448
- reference_to: 'customers',
449
- reference_field: 'name',
450
- } as any;
451
-
452
- const baseProps: FieldWidgetProps<any> = {
453
- field: mockField,
454
- value: undefined,
455
- onChange: vi.fn(),
456
- readonly: false,
457
- dataSource: mockDataSource as any,
458
- };
459
-
460
- it('renders "Browse All" button when dataSource is available, even with <5 records', async () => {
461
- mockDataSource.find.mockResolvedValue({
462
- data: [
463
- { id: '1', name: 'Alpha' },
464
- { id: '2', name: 'Beta' },
465
- { id: '3', name: 'Gamma' },
466
- ],
467
- total: 3,
468
- });
469
-
470
- render(<LookupField {...baseProps} />);
471
-
472
- // "Browse All" button should always be visible (not inside popover)
473
- expect(screen.getByTestId('browse-all-records')).toBeInTheDocument();
474
- expect(screen.getByLabelText('Browse all records')).toBeInTheDocument();
475
- });
476
-
477
- it('opens RecordPickerDialog when "Browse All" is clicked with small dataset', async () => {
478
- mockDataSource.find.mockResolvedValue({
479
- data: [
480
- { id: '1', name: 'Alpha' },
481
- { id: '2', name: 'Beta' },
482
- ],
483
- total: 2,
484
- });
485
-
486
- render(<LookupField {...baseProps} />);
487
-
488
- // Click "Browse All" button directly (no need to open popover first)
489
- await act(async () => {
490
- fireEvent.click(screen.getByTestId('browse-all-records'));
491
- });
492
-
493
- // RecordPickerDialog should now be open
494
- await waitFor(() => {
495
- expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
496
- });
497
- });
498
-
499
- it('does not render "Browse All" button when no dataSource is available', () => {
500
- const propsWithoutDS: FieldWidgetProps<any> = {
501
- field: { ...mockField, options: [{ value: '1', label: 'Opt 1' }] } as any,
502
- value: undefined,
503
- onChange: vi.fn(),
504
- readonly: false,
505
- };
506
-
507
- render(<LookupField {...propsWithoutDS} />);
508
-
509
- expect(screen.queryByTestId('browse-all-records')).not.toBeInTheDocument();
510
- });
511
- });
512
-
513
- // ------------- RecordPickerDialog — Column Sorting -------------
514
-
515
- describe('RecordPickerDialog — Column Sorting', () => {
516
- const basePickerProps = {
517
- open: true,
518
- onOpenChange: vi.fn(),
519
- dataSource: mockDataSource as any,
520
- objectName: 'customers',
521
- onSelect: vi.fn(),
522
- };
523
-
524
- it('sends $orderby when a column header is clicked', async () => {
525
- mockDataSource.find.mockResolvedValue({
526
- data: [
527
- { id: '1', name: 'Acme Corp', email: 'acme@test.com' },
528
- { id: '2', name: 'Beta Inc', email: 'beta@test.com' },
529
- ],
530
- total: 2,
531
- });
532
-
533
- render(
534
- <RecordPickerDialog
535
- {...basePickerProps}
536
- columns={['name', 'email']}
537
- />,
538
- );
539
-
540
- await waitFor(() => {
541
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
542
- });
543
-
544
- // Click the "Name" column header to sort
545
- await act(async () => {
546
- fireEvent.click(screen.getByText('Name'));
547
- });
548
-
549
- await waitFor(() => {
550
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
551
- $top: 10,
552
- $skip: 0,
553
- $orderby: { name: 'asc' },
554
- });
555
- });
556
- });
557
-
558
- it('toggles sort direction on repeated column header click', async () => {
559
- mockDataSource.find.mockResolvedValue({
560
- data: [{ id: '1', name: 'Acme Corp' }],
561
- total: 1,
562
- });
563
-
564
- render(
565
- <RecordPickerDialog
566
- {...basePickerProps}
567
- columns={['name']}
568
- />,
569
- );
570
-
571
- await waitFor(() => {
572
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
573
- });
574
-
575
- // First click: asc
576
- await act(async () => {
577
- fireEvent.click(screen.getByText('Name'));
578
- });
579
-
580
- await waitFor(() => {
581
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', expect.objectContaining({
582
- $orderby: { name: 'asc' },
583
- }));
584
- });
585
-
586
- // Second click: desc
587
- await act(async () => {
588
- fireEvent.click(screen.getByText('Name'));
589
- });
590
-
591
- await waitFor(() => {
592
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', expect.objectContaining({
593
- $orderby: { name: 'desc' },
594
- }));
595
- });
596
- });
597
-
598
- it('renders sort indicators on column headers', async () => {
599
- mockDataSource.find.mockResolvedValue({
600
- data: [{ id: '1', name: 'Acme', email: 'test@test.com' }],
601
- total: 1,
602
- });
603
-
604
- render(
605
- <RecordPickerDialog
606
- {...basePickerProps}
607
- columns={['name', 'email']}
608
- />,
609
- );
610
-
611
- await waitFor(() => {
612
- expect(screen.getByText('Acme')).toBeInTheDocument();
613
- });
614
-
615
- // Column headers should have aria-sort="none" initially
616
- const nameHeader = screen.getByText('Name').closest('th');
617
- expect(nameHeader).toHaveAttribute('aria-sort', 'none');
618
-
619
- // Click to sort
620
- await act(async () => {
621
- fireEvent.click(screen.getByText('Name'));
622
- });
623
-
624
- await waitFor(() => {
625
- expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
626
- });
627
- });
628
- });
629
-
630
- // ------------- RecordPickerDialog — Keyboard Navigation -------------
631
-
632
- describe('RecordPickerDialog — Keyboard Navigation', () => {
633
- const basePickerProps = {
634
- open: true,
635
- onOpenChange: vi.fn(),
636
- dataSource: mockDataSource as any,
637
- objectName: 'customers',
638
- onSelect: vi.fn(),
639
- };
640
-
641
- it('navigates rows with arrow keys and selects with Enter', async () => {
642
- const onSelect = vi.fn();
643
- const onOpenChange = vi.fn();
644
-
645
- mockDataSource.find.mockResolvedValue({
646
- data: [
647
- { id: '1', name: 'Alpha' },
648
- { id: '2', name: 'Beta' },
649
- { id: '3', name: 'Gamma' },
650
- ],
651
- total: 3,
652
- });
653
-
654
- render(
655
- <RecordPickerDialog
656
- {...basePickerProps}
657
- onSelect={onSelect}
658
- onOpenChange={onOpenChange}
659
- />,
660
- );
661
-
662
- await waitFor(() => {
663
- expect(screen.getByText('Alpha')).toBeInTheDocument();
664
- });
665
-
666
- // Focus the table container (has role="grid")
667
- const gridContainer = screen.getByRole('grid');
668
- gridContainer.focus();
669
-
670
- // Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
671
- await act(async () => {
672
- fireEvent.keyDown(gridContainer, { key: 'ArrowDown' });
673
- });
674
- await act(async () => {
675
- fireEvent.keyDown(gridContainer, { key: 'ArrowDown' });
676
- });
677
-
678
- // Press Enter to select Beta
679
- await act(async () => {
680
- fireEvent.keyDown(gridContainer, { key: 'Enter' });
681
- });
682
-
683
- expect(onSelect).toHaveBeenCalledWith('2');
684
- expect(onOpenChange).toHaveBeenCalledWith(false);
685
- });
686
-
687
- it('renders responsive dialog with mobile-friendly width', async () => {
688
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
689
-
690
- render(<RecordPickerDialog {...basePickerProps} />);
691
-
692
- await waitFor(() => {
693
- const dialog = screen.getByTestId('record-picker-dialog');
694
- expect(dialog).toBeInTheDocument();
695
- // Check responsive classes are applied
696
- expect(dialog.className).toContain('w-[95vw]');
697
- });
698
- });
699
- });
700
-
701
- // ------------- RecordPickerDialog — lookup_filters consumption -------------
702
-
703
- describe('RecordPickerDialog — lookup_filters', () => {
704
- const basePickerProps = {
705
- open: true,
706
- onOpenChange: vi.fn(),
707
- dataSource: mockDataSource as any,
708
- objectName: 'customers',
709
- onSelect: vi.fn(),
710
- };
711
-
712
- it('injects lookup_filters into $filter on every query', async () => {
713
- mockDataSource.find.mockResolvedValue({
714
- data: [{ id: '1', name: 'Active Customer' }],
715
- total: 1,
716
- });
717
-
718
- render(
719
- <RecordPickerDialog
720
- {...basePickerProps}
721
- lookupFilters={[
722
- { field: 'status', operator: 'eq', value: 'active' },
723
- ]}
724
- />,
725
- );
726
-
727
- await waitFor(() => {
728
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
729
- $top: 10,
730
- $skip: 0,
731
- $filter: { status: 'active' },
732
- });
733
- });
734
- });
735
-
736
- it('supports multiple lookup_filters with different operators', async () => {
737
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
738
-
739
- render(
740
- <RecordPickerDialog
741
- {...basePickerProps}
742
- lookupFilters={[
743
- { field: 'status', operator: 'eq', value: 'active' },
744
- { field: 'category', operator: 'in', value: ['A', 'B'] },
745
- { field: 'amount', operator: 'gte', value: 100 },
746
- ]}
747
- />,
748
- );
749
-
750
- await waitFor(() => {
751
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
752
- $top: 10,
753
- $skip: 0,
754
- $filter: {
755
- status: 'active',
756
- category: { $in: ['A', 'B'] },
757
- amount: { $gte: 100 },
758
- },
759
- });
760
- });
761
- });
762
-
763
- it('preserves lookup_filters when search query is added', async () => {
764
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
765
-
766
- render(
767
- <RecordPickerDialog
768
- {...basePickerProps}
769
- lookupFilters={[
770
- { field: 'status', operator: 'eq', value: 'active' },
771
- ]}
772
- />,
773
- );
774
-
775
- await waitFor(() => {
776
- expect(mockDataSource.find).toHaveBeenCalledTimes(1);
777
- });
778
-
779
- // Type in search
780
- await act(async () => {
781
- fireEvent.change(screen.getByTestId('record-picker-search'), {
782
- target: { value: 'acme' },
783
- });
784
- });
785
-
786
- // Wait for debounce
787
- await waitFor(
788
- () => {
789
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
790
- $top: 10,
791
- $skip: 0,
792
- $search: 'acme',
793
- $filter: { status: 'active' },
794
- });
795
- },
796
- { timeout: 500 },
797
- );
798
- });
799
- });
800
-
801
- // ------------- RecordPickerDialog — Cell Type Formatter -------------
802
-
803
- describe('RecordPickerDialog — Cell Type Formatter', () => {
804
- const basePickerProps = {
805
- open: true,
806
- onOpenChange: vi.fn(),
807
- dataSource: mockDataSource as any,
808
- objectName: 'items',
809
- onSelect: vi.fn(),
810
- };
811
-
812
- it('uses cellRenderer for columns with type defined', async () => {
813
- const mockCellRenderer = vi.fn().mockReturnValue(
814
- ({ value }: { value: any }) => <span data-testid="custom-rendered">{`FORMATTED:${value}`}</span>,
815
- );
816
-
817
- mockDataSource.find.mockResolvedValue({
818
- data: [{ id: '1', name: 'Widget', amount: 99.5 }],
819
- total: 1,
820
- });
821
-
822
- render(
823
- <RecordPickerDialog
824
- {...basePickerProps}
825
- columns={[
826
- { field: 'name', label: 'Name' },
827
- { field: 'amount', label: 'Amount', type: 'currency' },
828
- ]}
829
- cellRenderer={mockCellRenderer}
830
- />,
831
- );
832
-
833
- await waitFor(() => {
834
- expect(screen.getByText('Widget')).toBeInTheDocument();
835
- });
836
-
837
- // cellRenderer should have been called for the 'currency' type
838
- expect(mockCellRenderer).toHaveBeenCalledWith('currency');
839
-
840
- // The formatted cell should be in the document
841
- expect(screen.getByTestId('custom-rendered')).toBeInTheDocument();
842
- expect(screen.getByText('FORMATTED:99.5')).toBeInTheDocument();
843
- });
844
-
845
- it('falls back to plain text when no cellRenderer is provided', async () => {
846
- mockDataSource.find.mockResolvedValue({
847
- data: [{ id: '1', name: 'Widget', active: true }],
848
- total: 1,
849
- });
850
-
851
- render(
852
- <RecordPickerDialog
853
- {...basePickerProps}
854
- columns={[
855
- { field: 'name' },
856
- { field: 'active', type: 'boolean' },
857
- ]}
858
- />,
859
- );
860
-
861
- await waitFor(() => {
862
- expect(screen.getByText('Widget')).toBeInTheDocument();
863
- // Without cellRenderer, boolean should render as 'Yes'
864
- expect(screen.getByText('Yes')).toBeInTheDocument();
865
- });
866
- });
867
-
868
- it('falls back to plain text when column has no type', async () => {
869
- const mockCellRenderer = vi.fn();
870
-
871
- mockDataSource.find.mockResolvedValue({
872
- data: [{ id: '1', name: 'Widget' }],
873
- total: 1,
874
- });
875
-
876
- render(
877
- <RecordPickerDialog
878
- {...basePickerProps}
879
- columns={[{ field: 'name' }]}
880
- cellRenderer={mockCellRenderer}
881
- />,
882
- );
883
-
884
- await waitFor(() => {
885
- expect(screen.getByText('Widget')).toBeInTheDocument();
886
- });
887
-
888
- // cellRenderer should NOT be called for columns without type
889
- expect(mockCellRenderer).not.toHaveBeenCalled();
890
- });
891
- });
892
-
893
- // ------------- RecordPickerDialog — FilterUI bar integration -------------
894
-
895
- describe('RecordPickerDialog — Filter Bar', () => {
896
- const basePickerProps = {
897
- open: true,
898
- onOpenChange: vi.fn(),
899
- dataSource: mockDataSource as any,
900
- objectName: 'customers',
901
- onSelect: vi.fn(),
902
- };
903
-
904
- it('renders filter bar toggle when filterColumns are provided', async () => {
905
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
906
-
907
- render(
908
- <RecordPickerDialog
909
- {...basePickerProps}
910
- filterColumns={[
911
- { field: 'status', label: 'Status', type: 'text' },
912
- ]}
913
- />,
914
- );
915
-
916
- await waitFor(() => {
917
- expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument();
918
- expect(screen.getByText('Filters')).toBeInTheDocument();
919
- });
920
- });
921
-
922
- it('does not render filter bar when no filterColumns', async () => {
923
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
924
-
925
- render(<RecordPickerDialog {...basePickerProps} />);
926
-
927
- await waitFor(() => {
928
- expect(screen.queryByTestId('record-picker-filter-bar')).not.toBeInTheDocument();
929
- });
930
- });
931
-
932
- it('opens filter panel on toggle click', async () => {
933
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
934
-
935
- render(
936
- <RecordPickerDialog
937
- {...basePickerProps}
938
- filterColumns={[
939
- { field: 'name', label: 'Name', type: 'text' },
940
- ]}
941
- />,
942
- );
943
-
944
- await waitFor(() => {
945
- expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument();
946
- });
947
-
948
- // Filter panel should not be visible yet
949
- expect(screen.queryByTestId('record-picker-filter-panel')).not.toBeInTheDocument();
950
-
951
- // Click Filters button
952
- await act(async () => {
953
- fireEvent.click(screen.getByText('Filters'));
954
- });
955
-
956
- expect(screen.getByTestId('record-picker-filter-panel')).toBeInTheDocument();
957
- });
958
- });
959
-
960
- // ------------- RecordPickerDialog — Column Resize Handles -------------
961
-
962
- describe('RecordPickerDialog — Column Resize', () => {
963
- const basePickerProps = {
964
- open: true,
965
- onOpenChange: vi.fn(),
966
- dataSource: mockDataSource as any,
967
- objectName: 'customers',
968
- onSelect: vi.fn(),
969
- };
970
-
971
- it('renders resize handles on column headers', async () => {
972
- mockDataSource.find.mockResolvedValue({
973
- data: [{ id: '1', name: 'Test', email: 'test@test.com' }],
974
- total: 1,
975
- });
976
-
977
- render(
978
- <RecordPickerDialog
979
- {...basePickerProps}
980
- columns={['name', 'email']}
981
- />,
982
- );
983
-
984
- await waitFor(() => {
985
- expect(screen.getByText('Test')).toBeInTheDocument();
986
- });
987
-
988
- // Resize handles should be present
989
- expect(screen.getByTestId('resize-handle-name')).toBeInTheDocument();
990
- expect(screen.getByTestId('resize-handle-email')).toBeInTheDocument();
991
- });
992
-
993
- it('resize handles have col-resize cursor and separator role', async () => {
994
- mockDataSource.find.mockResolvedValue({
995
- data: [{ id: '1', name: 'Test' }],
996
- total: 1,
997
- });
998
-
999
- render(
1000
- <RecordPickerDialog
1001
- {...basePickerProps}
1002
- columns={['name']}
1003
- />,
1004
- );
1005
-
1006
- await waitFor(() => {
1007
- expect(screen.getByText('Test')).toBeInTheDocument();
1008
- });
1009
-
1010
- const handle = screen.getByTestId('resize-handle-name');
1011
- expect(handle).toHaveAttribute('role', 'separator');
1012
- expect(handle.className).toContain('cursor-col-resize');
1013
- });
1014
- });
1015
-
1016
- // ------------- RecordPickerDialog — renderFilterBar slot (FilterUI integration) ---
1017
-
1018
- describe('RecordPickerDialog — renderFilterBar slot', () => {
1019
- const basePickerProps = {
1020
- open: true,
1021
- onOpenChange: vi.fn(),
1022
- dataSource: mockDataSource as any,
1023
- objectName: 'customers',
1024
- onSelect: vi.fn(),
1025
- };
1026
-
1027
- it('calls renderFilterBar with correct props when provided', async () => {
1028
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
1029
-
1030
- const renderFilterBar = vi.fn().mockReturnValue(
1031
- <div data-testid="custom-filter-bar">Custom FilterUI</div>,
1032
- );
1033
-
1034
- render(
1035
- <RecordPickerDialog
1036
- {...basePickerProps}
1037
- filterColumns={[
1038
- { field: 'status', label: 'Status', type: 'select', options: [{ label: 'Active', value: 'active' }] },
1039
- ]}
1040
- renderFilterBar={renderFilterBar}
1041
- />,
1042
- );
1043
-
1044
- await waitFor(() => {
1045
- expect(screen.getByTestId('custom-filter-bar')).toBeInTheDocument();
1046
- });
1047
-
1048
- // renderFilterBar should have been called with FilterBarProps
1049
- expect(renderFilterBar).toHaveBeenCalledWith(
1050
- expect.objectContaining({
1051
- filterColumns: expect.arrayContaining([
1052
- expect.objectContaining({ field: 'status', type: 'select' }),
1053
- ]),
1054
- values: {},
1055
- onChange: expect.any(Function),
1056
- onClear: expect.any(Function),
1057
- activeCount: 0,
1058
- }),
1059
- );
1060
- });
1061
-
1062
- it('hides built-in filter bar when renderFilterBar is provided', async () => {
1063
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
1064
-
1065
- render(
1066
- <RecordPickerDialog
1067
- {...basePickerProps}
1068
- filterColumns={[{ field: 'name', label: 'Name', type: 'text' }]}
1069
- renderFilterBar={() => <div data-testid="external-filter">External</div>}
1070
- />,
1071
- );
1072
-
1073
- await waitFor(() => {
1074
- expect(screen.getByTestId('external-filter')).toBeInTheDocument();
1075
- });
1076
-
1077
- // Built-in filter panel toggle button should NOT be present
1078
- expect(screen.queryByTestId('record-picker-filter-panel')).not.toBeInTheDocument();
1079
- });
1080
- });
1081
-
1082
- // ------------- RecordPickerDialog — renderGrid slot (ObjectGrid reuse) ---
1083
-
1084
- describe('RecordPickerDialog — renderGrid slot', () => {
1085
- const basePickerProps = {
1086
- open: true,
1087
- onOpenChange: vi.fn(),
1088
- dataSource: mockDataSource as any,
1089
- objectName: 'customers',
1090
- onSelect: vi.fn(),
1091
- };
1092
-
1093
- it('renders external grid component via renderGrid slot', async () => {
1094
- mockDataSource.find.mockResolvedValue({
1095
- data: [{ id: '1', name: 'Acme Corp' }],
1096
- total: 1,
1097
- });
1098
-
1099
- const renderGrid = vi.fn().mockReturnValue(
1100
- <div data-testid="custom-grid">Custom ObjectGrid</div>,
1101
- );
1102
-
1103
- render(
1104
- <RecordPickerDialog
1105
- {...basePickerProps}
1106
- columns={['name']}
1107
- renderGrid={renderGrid}
1108
- />,
1109
- );
1110
-
1111
- await waitFor(() => {
1112
- expect(screen.getByTestId('record-picker-grid-slot')).toBeInTheDocument();
1113
- expect(screen.getByTestId('custom-grid')).toBeInTheDocument();
1114
- });
1115
-
1116
- // Wait for data to load, then check the last renderGrid call has records
1117
- await waitFor(() => {
1118
- expect(renderGrid).toHaveBeenLastCalledWith(
1119
- expect.objectContaining({
1120
- columns: expect.arrayContaining([
1121
- expect.objectContaining({ field: 'name' }),
1122
- ]),
1123
- records: expect.arrayContaining([
1124
- expect.objectContaining({ id: '1', name: 'Acme Corp' }),
1125
- ]),
1126
- loading: false,
1127
- totalCount: 1,
1128
- }),
1129
- );
1130
- });
1131
-
1132
- // Verify all expected props are present in the last call
1133
- const lastCallArgs = renderGrid.mock.calls[renderGrid.mock.calls.length - 1][0];
1134
- expect(lastCallArgs).toMatchObject({
1135
- currentPage: 1,
1136
- pageSize: 10,
1137
- sortField: null,
1138
- sortDirection: 'asc',
1139
- multiple: false,
1140
- idField: 'id',
1141
- });
1142
- expect(lastCallArgs.onSort).toBeTypeOf('function');
1143
- expect(lastCallArgs.onPageChange).toBeTypeOf('function');
1144
- expect(lastCallArgs.onRowClick).toBeTypeOf('function');
1145
- expect(lastCallArgs.isSelected).toBeTypeOf('function');
1146
- });
1147
-
1148
- it('hides built-in table when renderGrid is provided', async () => {
1149
- mockDataSource.find.mockResolvedValue({
1150
- data: [{ id: '1', name: 'Acme Corp' }],
1151
- total: 1,
1152
- });
1153
-
1154
- render(
1155
- <RecordPickerDialog
1156
- {...basePickerProps}
1157
- columns={['name']}
1158
- renderGrid={() => <div>Custom Grid</div>}
1159
- />,
1160
- );
1161
-
1162
- await waitFor(() => {
1163
- expect(screen.getByText('Custom Grid')).toBeInTheDocument();
1164
- });
1165
-
1166
- // Built-in table should NOT be present
1167
- expect(screen.queryByRole('grid')).not.toBeInTheDocument();
1168
- // Built-in pagination should NOT be present
1169
- expect(screen.queryByTestId('record-picker-pagination')).not.toBeInTheDocument();
1170
- });
1171
- });
1172
-
1173
- // ------------- RecordPickerDialog — Auto-generated filterColumns from lookupFilters ---
1174
-
1175
- describe('RecordPickerDialog — Auto-generated filter bar from lookupFilters', () => {
1176
- const basePickerProps = {
1177
- open: true,
1178
- onOpenChange: vi.fn(),
1179
- dataSource: mockDataSource as any,
1180
- objectName: 'customers',
1181
- onSelect: vi.fn(),
1182
- };
1183
-
1184
- it('auto-generates filter bar from lookupFilters when no filterColumns given', async () => {
1185
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
1186
-
1187
- render(
1188
- <RecordPickerDialog
1189
- {...basePickerProps}
1190
- lookupFilters={[
1191
- { field: 'status', operator: 'eq', value: 'active' },
1192
- { field: 'amount', operator: 'gte', value: 100 },
1193
- ]}
1194
- />,
1195
- );
1196
-
1197
- await waitFor(() => {
1198
- // Filter bar should appear because lookupFilters auto-generate filterColumns
1199
- expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument();
1200
- });
1201
- });
1202
-
1203
- it('prefers explicit filterColumns over auto-generated ones', async () => {
1204
- mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
1205
-
1206
- const renderFilterBar = vi.fn().mockReturnValue(<div>Filters</div>);
1207
-
1208
- render(
1209
- <RecordPickerDialog
1210
- {...basePickerProps}
1211
- lookupFilters={[{ field: 'status', operator: 'eq', value: 'active' }]}
1212
- filterColumns={[{ field: 'custom_field', label: 'Custom', type: 'text' }]}
1213
- renderFilterBar={renderFilterBar}
1214
- />,
1215
- );
1216
-
1217
- await waitFor(() => {
1218
- expect(renderFilterBar).toHaveBeenCalled();
1219
- });
1220
-
1221
- // Should use the explicit filterColumns, not auto-generated ones
1222
- const calledProps = renderFilterBar.mock.calls[0][0];
1223
- expect(calledProps.filterColumns[0].field).toBe('custom_field');
1224
- });
1225
- });
1226
-
1227
- // ------------- RecordPickerDialog — Skeleton Loading Screen -------------
1228
-
1229
- describe('RecordPickerDialog — Skeleton Loading', () => {
1230
- const basePickerProps = {
1231
- open: true,
1232
- onOpenChange: vi.fn(),
1233
- dataSource: mockDataSource as any,
1234
- objectName: 'customers',
1235
- onSelect: vi.fn(),
1236
- };
1237
-
1238
- it('renders skeleton table with correct number of rows during initial load', async () => {
1239
- mockDataSource.find.mockReturnValue(new Promise(() => {}));
1240
-
1241
- render(
1242
- <RecordPickerDialog
1243
- {...basePickerProps}
1244
- columns={['name', 'email']}
1245
- />,
1246
- );
1247
-
1248
- await waitFor(() => {
1249
- const skeleton = screen.getByTestId('record-picker-skeleton');
1250
- expect(skeleton).toBeInTheDocument();
1251
- expect(skeleton).toHaveAttribute('role', 'status');
1252
- // Should contain skeleton placeholders (animated pulse divs)
1253
- const pulses = skeleton.querySelectorAll('.animate-pulse');
1254
- // 2 header skeletons + 5 rows × 2 columns = 12 total skeleton elements
1255
- expect(pulses.length).toBe(12);
1256
- });
1257
- });
1258
-
1259
- it('shows skeleton columns matching the provided column count', async () => {
1260
- mockDataSource.find.mockReturnValue(new Promise(() => {}));
1261
-
1262
- render(
1263
- <RecordPickerDialog
1264
- {...basePickerProps}
1265
- columns={['name', 'email', 'status']}
1266
- />,
1267
- );
1268
-
1269
- await waitFor(() => {
1270
- const skeleton = screen.getByTestId('record-picker-skeleton');
1271
- expect(skeleton).toBeInTheDocument();
1272
- // 3 header skeletons + 5 rows × 3 columns = 18 total skeleton elements
1273
- const pulses = skeleton.querySelectorAll('.animate-pulse');
1274
- expect(pulses.length).toBe(18);
1275
- });
1276
- });
1277
- });
1278
-
1279
- // ------------- RecordPickerDialog — Sticky Table Header -------------
1280
-
1281
- describe('RecordPickerDialog — Sticky Header', () => {
1282
- const basePickerProps = {
1283
- open: true,
1284
- onOpenChange: vi.fn(),
1285
- dataSource: mockDataSource as any,
1286
- objectName: 'customers',
1287
- onSelect: vi.fn(),
1288
- };
1289
-
1290
- it('renders table header with sticky positioning', async () => {
1291
- mockDataSource.find.mockResolvedValue({
1292
- data: [
1293
- { id: '1', name: 'Acme Corp', email: 'acme@test.com' },
1294
- ],
1295
- total: 1,
1296
- });
1297
-
1298
- render(
1299
- <RecordPickerDialog
1300
- {...basePickerProps}
1301
- columns={['name', 'email']}
1302
- />,
1303
- );
1304
-
1305
- await waitFor(() => {
1306
- const stickyHeader = screen.getByTestId('record-picker-sticky-header');
1307
- expect(stickyHeader).toBeInTheDocument();
1308
- expect(stickyHeader.className).toContain('sticky');
1309
- expect(stickyHeader.className).toContain('top-0');
1310
- });
1311
- });
1312
- });
1313
-
1314
- // ------------- RecordPickerDialog — Page Jump Input -------------
1315
-
1316
- describe('RecordPickerDialog — Page Jump', () => {
1317
- const basePickerProps = {
1318
- open: true,
1319
- onOpenChange: vi.fn(),
1320
- dataSource: mockDataSource as any,
1321
- objectName: 'customers',
1322
- onSelect: vi.fn(),
1323
- };
1324
-
1325
- it('renders page jump input when multiple pages exist', async () => {
1326
- mockDataSource.find.mockResolvedValue({
1327
- data: Array.from({ length: 10 }, (_, i) => ({
1328
- id: String(i + 1),
1329
- name: `Record ${i + 1}`,
1330
- })),
1331
- total: 50,
1332
- });
1333
-
1334
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
1335
-
1336
- await waitFor(() => {
1337
- expect(screen.getByTestId('record-picker-page-jump')).toBeInTheDocument();
1338
- });
1339
- });
1340
-
1341
- it('navigates to specified page on Enter key', async () => {
1342
- mockDataSource.find.mockResolvedValue({
1343
- data: Array.from({ length: 10 }, (_, i) => ({
1344
- id: String(i + 1),
1345
- name: `Record ${i + 1}`,
1346
- })),
1347
- total: 50,
1348
- });
1349
-
1350
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
1351
-
1352
- await waitFor(() => {
1353
- expect(screen.getByTestId('record-picker-page-jump')).toBeInTheDocument();
1354
- });
1355
-
1356
- const pageInput = screen.getByTestId('record-picker-page-jump');
1357
-
1358
- // Type page number and press Enter
1359
- await act(async () => {
1360
- fireEvent.change(pageInput, { target: { value: '3' } });
1361
- });
1362
- await act(async () => {
1363
- fireEvent.keyDown(pageInput, { key: 'Enter' });
1364
- });
1365
-
1366
- await waitFor(() => {
1367
- expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
1368
- $top: 10,
1369
- $skip: 20,
1370
- });
1371
- });
1372
- });
1373
-
1374
- it('ignores invalid page numbers', async () => {
1375
- mockDataSource.find.mockResolvedValue({
1376
- data: Array.from({ length: 10 }, (_, i) => ({
1377
- id: String(i + 1),
1378
- name: `Record ${i + 1}`,
1379
- })),
1380
- total: 50,
1381
- });
1382
-
1383
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
1384
-
1385
- await waitFor(() => {
1386
- expect(screen.getByTestId('record-picker-page-jump')).toBeInTheDocument();
1387
- });
1388
-
1389
- const callCount = mockDataSource.find.mock.calls.length;
1390
-
1391
- // Type invalid page number and press Enter
1392
- const pageInput = screen.getByTestId('record-picker-page-jump');
1393
- await act(async () => {
1394
- fireEvent.change(pageInput, { target: { value: '99' } });
1395
- });
1396
- await act(async () => {
1397
- fireEvent.keyDown(pageInput, { key: 'Enter' });
1398
- });
1399
-
1400
- // Should not trigger a new fetch since page 99 > totalPages (5)
1401
- expect(mockDataSource.find).toHaveBeenCalledTimes(callCount);
1402
- });
1403
-
1404
- it('does not render page jump when only one page', async () => {
1405
- mockDataSource.find.mockResolvedValue({
1406
- data: [{ id: '1', name: 'Record 1' }],
1407
- total: 1,
1408
- });
1409
-
1410
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
1411
-
1412
- await waitFor(() => {
1413
- expect(screen.getByText('Record 1')).toBeInTheDocument();
1414
- });
1415
-
1416
- expect(screen.queryByTestId('record-picker-page-jump')).not.toBeInTheDocument();
1417
- });
1418
- });
1419
-
1420
- // ------------- RecordPickerDialog — Loading Overlay for Subsequent Fetches -------------
1421
-
1422
- describe('RecordPickerDialog — Loading Overlay', () => {
1423
- const basePickerProps = {
1424
- open: true,
1425
- onOpenChange: vi.fn(),
1426
- dataSource: mockDataSource as any,
1427
- objectName: 'customers',
1428
- onSelect: vi.fn(),
1429
- };
1430
-
1431
- it('shows loading overlay when fetching next page while records are visible', async () => {
1432
- // First load: resolves immediately
1433
- mockDataSource.find.mockResolvedValueOnce({
1434
- data: Array.from({ length: 10 }, (_, i) => ({
1435
- id: String(i + 1),
1436
- name: `Record ${i + 1}`,
1437
- })),
1438
- total: 25,
1439
- });
1440
-
1441
- render(<RecordPickerDialog {...basePickerProps} pageSize={10} />);
1442
-
1443
- await waitFor(() => {
1444
- expect(screen.getByText('Record 1')).toBeInTheDocument();
1445
- });
1446
-
1447
- // Set up next page to hang (never resolves)
1448
- mockDataSource.find.mockReturnValue(new Promise(() => {}));
1449
-
1450
- // Navigate to next page
1451
- await act(async () => {
1452
- fireEvent.click(screen.getByLabelText('Next page'));
1453
- });
1454
-
1455
- // Loading overlay should appear over existing records
1456
- await waitFor(() => {
1457
- expect(screen.getByTestId('record-picker-loading-overlay')).toBeInTheDocument();
1458
- });
1459
- });
1460
- });
1461
-
1462
- // ------------- RecordPickerDialog — onSelectRecords callback -------------
1463
-
1464
- describe('RecordPickerDialog — onSelectRecords callback', () => {
1465
- const basePickerProps = {
1466
- open: true,
1467
- onOpenChange: vi.fn(),
1468
- dataSource: mockDataSource as any,
1469
- objectName: 'customers',
1470
- onSelect: vi.fn(),
1471
- };
1472
-
1473
- it('calls onSelectRecords with the full record on single-select', async () => {
1474
- const onSelect = vi.fn();
1475
- const onSelectRecords = vi.fn();
1476
- const onOpenChange = vi.fn();
1477
-
1478
- mockDataSource.find.mockResolvedValue({
1479
- data: [
1480
- { id: '1', name: 'Acme Corp', email: 'acme@test.com' },
1481
- { id: '2', name: 'Beta Inc', email: 'beta@test.com' },
1482
- ],
1483
- total: 2,
1484
- });
1485
-
1486
- render(
1487
- <RecordPickerDialog
1488
- {...basePickerProps}
1489
- onSelect={onSelect}
1490
- onSelectRecords={onSelectRecords}
1491
- onOpenChange={onOpenChange}
1492
- />,
1493
- );
1494
-
1495
- await waitFor(() => {
1496
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1497
- });
1498
-
1499
- await act(async () => {
1500
- fireEvent.click(screen.getByTestId('record-row-1'));
1501
- });
1502
-
1503
- expect(onSelect).toHaveBeenCalledWith('1');
1504
- expect(onSelectRecords).toHaveBeenCalledWith([
1505
- expect.objectContaining({ id: '1', name: 'Acme Corp' }),
1506
- ]);
1507
- expect(onOpenChange).toHaveBeenCalledWith(false);
1508
- });
1509
-
1510
- it('calls onSelectRecords with full records on multi-select confirm', async () => {
1511
- const onSelect = vi.fn();
1512
- const onSelectRecords = vi.fn();
1513
- const onOpenChange = vi.fn();
1514
-
1515
- mockDataSource.find.mockResolvedValue({
1516
- data: [
1517
- { id: '1', name: 'Alpha' },
1518
- { id: '2', name: 'Beta' },
1519
- { id: '3', name: 'Gamma' },
1520
- ],
1521
- total: 3,
1522
- });
1523
-
1524
- render(
1525
- <RecordPickerDialog
1526
- {...basePickerProps}
1527
- multiple
1528
- onSelect={onSelect}
1529
- onSelectRecords={onSelectRecords}
1530
- onOpenChange={onOpenChange}
1531
- />,
1532
- );
1533
-
1534
- await waitFor(() => {
1535
- expect(screen.getByText('Alpha')).toBeInTheDocument();
1536
- });
1537
-
1538
- // Select two records
1539
- await act(async () => {
1540
- fireEvent.click(screen.getByTestId('record-row-1'));
1541
- });
1542
- await act(async () => {
1543
- fireEvent.click(screen.getByTestId('record-row-3'));
1544
- });
1545
-
1546
- // Click confirm
1547
- await act(async () => {
1548
- fireEvent.click(screen.getByText('Confirm'));
1549
- });
1550
-
1551
- expect(onSelect).toHaveBeenCalledWith(expect.arrayContaining(['1', '3']));
1552
- expect(onSelectRecords).toHaveBeenCalledWith(
1553
- expect.arrayContaining([
1554
- expect.objectContaining({ id: '1', name: 'Alpha' }),
1555
- expect.objectContaining({ id: '3', name: 'Gamma' }),
1556
- ]),
1557
- );
1558
- expect(onOpenChange).toHaveBeenCalledWith(false);
1559
- });
1560
- });
1561
-
1562
- // ------------- LookupField — Selection Display After RecordPickerDialog -------------
1563
-
1564
- describe('LookupField — Selection Display After RecordPickerDialog', () => {
1565
- const mockField = {
1566
- name: 'customer',
1567
- label: 'Customer',
1568
- reference_to: 'customers',
1569
- reference_field: 'name',
1570
- } as any;
1571
-
1572
- it('displays selected record label after single-select in RecordPickerDialog', async () => {
1573
- const onChange = vi.fn();
1574
- let currentValue: any = undefined;
1575
-
1576
- mockDataSource.find.mockResolvedValue({
1577
- data: Array.from({ length: 50 }, (_, i) => ({
1578
- id: String(i + 1),
1579
- name: `Customer ${i + 1}`,
1580
- })),
1581
- total: 200,
1582
- });
1583
-
1584
- const { rerender } = render(
1585
- <LookupField
1586
- field={mockField}
1587
- value={currentValue}
1588
- onChange={onChange}
1589
- readonly={false}
1590
- dataSource={mockDataSource as any}
1591
- />,
1592
- );
1593
-
1594
- // Open RecordPickerDialog via "Browse All" button
1595
- await act(async () => {
1596
- fireEvent.click(screen.getByTestId('browse-all-records'));
1597
- });
1598
-
1599
- await waitFor(() => {
1600
- expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
1601
- });
1602
-
1603
- // Wait for records to load
1604
- await waitFor(() => {
1605
- expect(screen.getByText('Customer 1')).toBeInTheDocument();
1606
- });
1607
-
1608
- // Select a record (single-select — should immediately close)
1609
- await act(async () => {
1610
- fireEvent.click(screen.getByTestId('record-row-1'));
1611
- });
1612
-
1613
- expect(onChange).toHaveBeenCalledWith('1');
1614
-
1615
- // Simulate parent re-rendering with the new value
1616
- currentValue = '1';
1617
- rerender(
1618
- <LookupField
1619
- field={mockField}
1620
- value={currentValue}
1621
- onChange={onChange}
1622
- readonly={false}
1623
- dataSource={mockDataSource as any}
1624
- />,
1625
- );
1626
-
1627
- // The selected record's name should be visible as a badge
1628
- await waitFor(() => {
1629
- expect(screen.getByText('Customer 1')).toBeInTheDocument();
1630
- });
1631
- });
1632
-
1633
- it('displays selected record badges after multi-select in RecordPickerDialog', async () => {
1634
- const multiField = { ...mockField, multiple: true } as any;
1635
- const onChange = vi.fn();
1636
- let currentValue: any = [];
1637
-
1638
- mockDataSource.find.mockResolvedValue({
1639
- data: Array.from({ length: 50 }, (_, i) => ({
1640
- id: String(i + 1),
1641
- name: `Customer ${i + 1}`,
1642
- })),
1643
- total: 200,
1644
- });
1645
-
1646
- const { rerender } = render(
1647
- <LookupField
1648
- field={multiField}
1649
- value={currentValue}
1650
- onChange={onChange}
1651
- readonly={false}
1652
- dataSource={mockDataSource as any}
1653
- />,
1654
- );
1655
-
1656
- // Open RecordPickerDialog via "Browse All" button
1657
- await act(async () => {
1658
- fireEvent.click(screen.getByTestId('browse-all-records'));
1659
- });
1660
-
1661
- await waitFor(() => {
1662
- expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
1663
- });
1664
-
1665
- // Wait for records to load
1666
- await waitFor(() => {
1667
- expect(screen.getByText('Customer 1')).toBeInTheDocument();
1668
- });
1669
-
1670
- // Select two records (multi-select — needs confirm)
1671
- await act(async () => {
1672
- fireEvent.click(screen.getByTestId('record-row-1'));
1673
- });
1674
- await act(async () => {
1675
- fireEvent.click(screen.getByTestId('record-row-3'));
1676
- });
1677
-
1678
- // Click confirm
1679
- await act(async () => {
1680
- fireEvent.click(screen.getByText('Confirm'));
1681
- });
1682
-
1683
- expect(onChange).toHaveBeenCalledWith(expect.arrayContaining(['1', '3']));
1684
-
1685
- // Simulate parent re-rendering with the new value
1686
- currentValue = ['1', '3'];
1687
- rerender(
1688
- <LookupField
1689
- field={multiField}
1690
- value={currentValue}
1691
- onChange={onChange}
1692
- readonly={false}
1693
- dataSource={mockDataSource as any}
1694
- />,
1695
- );
1696
-
1697
- // Both selected records should be visible as badges
1698
- await waitFor(() => {
1699
- expect(screen.getByText('Customer 1')).toBeInTheDocument();
1700
- expect(screen.getByText('Customer 3')).toBeInTheDocument();
1701
- });
1702
- });
1703
- });