@object-ui/plugin-list 3.0.3 → 3.1.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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, it, expect, vi, beforeEach } from 'vitest';
10
10
  import { render, screen, fireEvent } from '@testing-library/react';
11
- import { ListView } from '../ListView';
11
+ import { ListView, evaluateConditionalFormatting } from '../ListView';
12
12
  import type { ListViewSchema } from '@object-ui/types';
13
13
  import { SchemaRendererProvider } from '@object-ui/react';
14
14
 
@@ -66,7 +66,7 @@ describe('ListView', () => {
66
66
  expect(container).toBeTruthy();
67
67
  });
68
68
 
69
- it('should render search button', () => {
69
+ it('should render search icon button', () => {
70
70
  const schema: ListViewSchema = {
71
71
  type: 'list-view',
72
72
  objectName: 'contacts',
@@ -75,8 +75,7 @@ describe('ListView', () => {
75
75
  };
76
76
 
77
77
  renderWithProvider(<ListView schema={schema} />);
78
- const searchButton = screen.getByRole('button', { name: /search/i });
79
- expect(searchButton).toBeInTheDocument();
78
+ expect(screen.getByTestId('search-icon-button')).toBeInTheDocument();
80
79
  });
81
80
 
82
81
  it('should expand search and call onSearchChange when search input changes', () => {
@@ -90,11 +89,9 @@ describe('ListView', () => {
90
89
 
91
90
  renderWithProvider(<ListView schema={schema} onSearchChange={onSearchChange} />);
92
91
 
93
- // Click search button to expand
94
- const searchButton = screen.getByRole('button', { name: /search/i });
95
- fireEvent.click(searchButton);
96
-
97
- const searchInput = screen.getByPlaceholderText(/find/i);
92
+ // Click the search icon to open the popover
93
+ fireEvent.click(screen.getByTestId('search-icon-button'));
94
+ const searchInput = screen.getByPlaceholderText(/search/i);
98
95
  fireEvent.change(searchInput, { target: { value: 'test' } });
99
96
  expect(onSearchChange).toHaveBeenCalledWith('test');
100
97
  });
@@ -201,24 +198,1892 @@ describe('ListView', () => {
201
198
 
202
199
  renderWithProvider(<ListView schema={schema} />);
203
200
 
204
- // Click search button to expand search input
205
- const searchButton = screen.getByRole('button', { name: /search/i });
206
- fireEvent.click(searchButton);
207
-
208
- const searchInput = screen.getByPlaceholderText(/find/i) as HTMLInputElement;
201
+ // Open search popover
202
+ fireEvent.click(screen.getByTestId('search-icon-button'));
203
+ const searchInput = screen.getByPlaceholderText(/search/i) as HTMLInputElement;
209
204
 
210
205
  // Type in search
211
206
  fireEvent.change(searchInput, { target: { value: 'test' } });
212
207
  expect(searchInput.value).toBe('test');
213
208
 
214
- // Find and click clear button (the X button inside the expanded search)
215
- const buttons = screen.getAllByRole('button');
216
- const clearButton = buttons.find(btn =>
217
- btn.querySelector('svg') !== null && searchInput.value !== ''
218
- );
209
+ // Find and click clear button (the X button inside the search popover)
210
+ const popover = screen.getByTestId('search-popover');
211
+ const clearButton = popover.querySelector('button');
219
212
 
220
213
  if (clearButton) {
221
214
  fireEvent.click(clearButton);
222
215
  }
223
216
  });
217
+
218
+ it('should show default empty state when no data', async () => {
219
+ mockDataSource.find.mockResolvedValue([]);
220
+ const schema: ListViewSchema = {
221
+ type: 'list-view',
222
+ objectName: 'contacts',
223
+ viewType: 'grid',
224
+ fields: ['name', 'email'],
225
+ };
226
+
227
+ renderWithProvider(<ListView schema={schema} />);
228
+
229
+ // Wait for data fetch to complete
230
+ await vi.waitFor(() => {
231
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
232
+ });
233
+ expect(screen.getByText('No items found')).toBeInTheDocument();
234
+ });
235
+
236
+ it('should show custom empty state when configured', async () => {
237
+ mockDataSource.find.mockResolvedValue([]);
238
+ const schema: ListViewSchema = {
239
+ type: 'list-view',
240
+ objectName: 'contacts',
241
+ viewType: 'grid',
242
+ fields: ['name', 'email'],
243
+ emptyState: {
244
+ title: 'No contacts yet',
245
+ message: 'Add your first contact to get started.',
246
+ },
247
+ };
248
+
249
+ renderWithProvider(<ListView schema={schema} />);
250
+
251
+ await vi.waitFor(() => {
252
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
253
+ });
254
+ expect(screen.getByText('No contacts yet')).toBeInTheDocument();
255
+ expect(screen.getByText('Add your first contact to get started.')).toBeInTheDocument();
256
+ });
257
+
258
+ it('should render quick filters when configured', () => {
259
+ const schema: ListViewSchema = {
260
+ type: 'list-view',
261
+ objectName: 'contacts',
262
+ viewType: 'grid',
263
+ fields: ['name', 'email'],
264
+ quickFilters: [
265
+ { id: 'active', label: 'Active', filters: [['status', '=', 'active']] },
266
+ { id: 'vip', label: 'VIP', filters: [['vip', '=', true]], defaultActive: true },
267
+ ],
268
+ };
269
+
270
+ renderWithProvider(<ListView schema={schema} />);
271
+
272
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
273
+ expect(screen.getByText('Active')).toBeInTheDocument();
274
+ expect(screen.getByText('VIP')).toBeInTheDocument();
275
+ });
276
+
277
+ it('should render hide fields popover', () => {
278
+ const schema: ListViewSchema = {
279
+ type: 'list-view',
280
+ objectName: 'contacts',
281
+ viewType: 'grid',
282
+ fields: ['name', 'email', 'phone'],
283
+ showHideFields: true,
284
+ };
285
+
286
+ renderWithProvider(<ListView schema={schema} />);
287
+
288
+ const hideFieldsButton = screen.getByRole('button', { name: /hide fields/i });
289
+ expect(hideFieldsButton).toBeInTheDocument();
290
+ });
291
+
292
+ it('should render density mode button', () => {
293
+ const schema: ListViewSchema = {
294
+ type: 'list-view',
295
+ objectName: 'contacts',
296
+ viewType: 'grid',
297
+ fields: ['name', 'email'],
298
+ showDensity: true,
299
+ };
300
+
301
+ renderWithProvider(<ListView schema={schema} />);
302
+
303
+ // Default density mode is 'compact'
304
+ const densityButton = screen.getByTitle('Density: compact');
305
+ expect(densityButton).toBeInTheDocument();
306
+ });
307
+
308
+ it('should render export button when exportOptions configured', () => {
309
+ const schema: ListViewSchema = {
310
+ type: 'list-view',
311
+ objectName: 'contacts',
312
+ viewType: 'grid',
313
+ fields: ['name', 'email'],
314
+ exportOptions: {
315
+ formats: ['csv', 'json'],
316
+ },
317
+ };
318
+
319
+ renderWithProvider(<ListView schema={schema} />);
320
+
321
+ const exportButton = screen.getByRole('button', { name: /export/i });
322
+ expect(exportButton).toBeInTheDocument();
323
+ });
324
+
325
+ it('should not render export button when exportOptions not configured', () => {
326
+ const schema: ListViewSchema = {
327
+ type: 'list-view',
328
+ objectName: 'contacts',
329
+ viewType: 'grid',
330
+ fields: ['name', 'email'],
331
+ };
332
+
333
+ renderWithProvider(<ListView schema={schema} />);
334
+
335
+ const exportButtons = screen.queryAllByRole('button', { name: /export/i });
336
+ expect(exportButtons.length).toBe(0);
337
+ });
338
+
339
+ it('should apply hiddenFields to effective fields', () => {
340
+ const schema: ListViewSchema = {
341
+ type: 'list-view',
342
+ objectName: 'contacts',
343
+ viewType: 'grid',
344
+ fields: ['name', 'email', 'phone'],
345
+ hiddenFields: ['phone'],
346
+ };
347
+
348
+ const { container } = renderWithProvider(<ListView schema={schema} />);
349
+ expect(container).toBeTruthy();
350
+ });
351
+
352
+ it('should map rowHeight to density mode', () => {
353
+ const schema: ListViewSchema = {
354
+ type: 'list-view',
355
+ objectName: 'contacts',
356
+ viewType: 'grid',
357
+ fields: ['name', 'email'],
358
+ rowHeight: 'compact',
359
+ showDensity: true,
360
+ };
361
+
362
+ renderWithProvider(<ListView schema={schema} />);
363
+ const densityButton = screen.getByTitle('Density: compact');
364
+ expect(densityButton).toBeInTheDocument();
365
+ });
366
+
367
+ it('should prefer densityMode over rowHeight', () => {
368
+ const schema: ListViewSchema = {
369
+ type: 'list-view',
370
+ objectName: 'contacts',
371
+ viewType: 'grid',
372
+ fields: ['name', 'email'],
373
+ rowHeight: 'compact',
374
+ densityMode: 'spacious',
375
+ showDensity: true,
376
+ };
377
+
378
+ renderWithProvider(<ListView schema={schema} />);
379
+ const densityButton = screen.getByTitle('Density: spacious');
380
+ expect(densityButton).toBeInTheDocument();
381
+ });
382
+
383
+ it('should apply aria attributes to root container', () => {
384
+ const schema: ListViewSchema = {
385
+ type: 'list-view',
386
+ objectName: 'contacts',
387
+ viewType: 'grid',
388
+ fields: ['name', 'email'],
389
+ aria: {
390
+ label: 'Contacts List',
391
+ live: 'polite',
392
+ },
393
+ };
394
+
395
+ renderWithProvider(<ListView schema={schema} />);
396
+ const region = screen.getByRole('region', { name: 'Contacts List' });
397
+ expect(region).toBeInTheDocument();
398
+ expect(region).toHaveAttribute('aria-live', 'polite');
399
+ });
400
+
401
+ it('should render share button when sharing is enabled', () => {
402
+ const schema: ListViewSchema = {
403
+ type: 'list-view',
404
+ objectName: 'contacts',
405
+ viewType: 'grid',
406
+ fields: ['name', 'email'],
407
+ sharing: {
408
+ enabled: true,
409
+ visibility: 'team',
410
+ },
411
+ };
412
+
413
+ renderWithProvider(<ListView schema={schema} />);
414
+ const shareButton = screen.getByTestId('share-button');
415
+ expect(shareButton).toBeInTheDocument();
416
+ expect(shareButton).toHaveAttribute('title', 'Sharing: team');
417
+ });
418
+
419
+ it('should not render share button when sharing is not enabled', () => {
420
+ const schema: ListViewSchema = {
421
+ type: 'list-view',
422
+ objectName: 'contacts',
423
+ viewType: 'grid',
424
+ fields: ['name', 'email'],
425
+ };
426
+
427
+ renderWithProvider(<ListView schema={schema} />);
428
+ expect(screen.queryByTestId('share-button')).not.toBeInTheDocument();
429
+ });
430
+
431
+ it('should show record count bar when data is loaded', async () => {
432
+ const mockItems = [
433
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
434
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
435
+ { _id: '3', name: 'Charlie', email: 'charlie@test.com' },
436
+ ];
437
+ mockDataSource.find.mockResolvedValue(mockItems);
438
+
439
+ const schema: ListViewSchema = {
440
+ type: 'list-view',
441
+ objectName: 'contacts',
442
+ viewType: 'grid',
443
+ fields: ['name', 'email'],
444
+ };
445
+
446
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
447
+
448
+ await vi.waitFor(() => {
449
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
450
+ });
451
+ expect(screen.getByText('3 records')).toBeInTheDocument();
452
+ });
453
+
454
+ it('should not show record count bar when no data', async () => {
455
+ mockDataSource.find.mockResolvedValue([]);
456
+
457
+ const schema: ListViewSchema = {
458
+ type: 'list-view',
459
+ objectName: 'contacts',
460
+ viewType: 'grid',
461
+ fields: ['name', 'email'],
462
+ };
463
+
464
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
465
+
466
+ await vi.waitFor(() => {
467
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
468
+ });
469
+ expect(screen.queryByTestId('record-count-bar')).not.toBeInTheDocument();
470
+ });
471
+
472
+ // ============================================
473
+ // Auto-derived User Filters
474
+ // ============================================
475
+ describe('auto-derived userFilters', () => {
476
+ it('should render userFilters when schema.userFilters is explicitly configured', () => {
477
+ const schema: ListViewSchema = {
478
+ type: 'list-view',
479
+ objectName: 'contacts',
480
+ viewType: 'grid',
481
+ fields: ['name', 'status'],
482
+ userFilters: {
483
+ element: 'dropdown',
484
+ fields: [
485
+ { field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
486
+ ],
487
+ },
488
+ };
489
+
490
+ renderWithProvider(<ListView schema={schema} />);
491
+ expect(screen.getByTestId('user-filters')).toBeInTheDocument();
492
+ expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
493
+ });
494
+
495
+ it('should auto-derive userFilters from objectDef select/boolean fields', async () => {
496
+ const mockDs = {
497
+ find: vi.fn().mockResolvedValue([]),
498
+ findOne: vi.fn(),
499
+ create: vi.fn(),
500
+ update: vi.fn(),
501
+ delete: vi.fn(),
502
+ getObjectSchema: vi.fn().mockResolvedValue({
503
+ name: 'tasks',
504
+ fields: {
505
+ name: { type: 'text', label: 'Name' },
506
+ status: {
507
+ type: 'select',
508
+ label: 'Status',
509
+ options: [
510
+ { label: 'Open', value: 'open' },
511
+ { label: 'Closed', value: 'closed' },
512
+ ],
513
+ },
514
+ is_active: { type: 'boolean', label: 'Active' },
515
+ description: { type: 'text', label: 'Description' },
516
+ },
517
+ }),
518
+ };
519
+
520
+ const schema: ListViewSchema = {
521
+ type: 'list-view',
522
+ objectName: 'tasks',
523
+ viewType: 'grid',
524
+ fields: ['name', 'status', 'is_active'],
525
+ };
526
+
527
+ render(
528
+ <SchemaRendererProvider dataSource={mockDs}>
529
+ <ListView schema={schema} dataSource={mockDs} />
530
+ </SchemaRendererProvider>
531
+ );
532
+
533
+ // Wait for objectDef to load and userFilters to render
534
+ await vi.waitFor(() => {
535
+ expect(screen.getByTestId('user-filters')).toBeInTheDocument();
536
+ });
537
+ expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
538
+ // Should have badges for status and is_active (select + boolean)
539
+ expect(screen.getByTestId('filter-badge-status')).toBeInTheDocument();
540
+ expect(screen.getByTestId('filter-badge-is_active')).toBeInTheDocument();
541
+ });
542
+
543
+ it('should show Add filter button in userFilters', () => {
544
+ const schema: ListViewSchema = {
545
+ type: 'list-view',
546
+ objectName: 'contacts',
547
+ viewType: 'grid',
548
+ fields: ['name', 'status'],
549
+ userFilters: {
550
+ element: 'dropdown',
551
+ fields: [
552
+ { field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
553
+ ],
554
+ },
555
+ };
556
+
557
+ renderWithProvider(<ListView schema={schema} />);
558
+ expect(screen.getByTestId('user-filters-add')).toBeInTheDocument();
559
+ });
560
+
561
+ it('should not render userFilters when objectDef has no filterable fields', async () => {
562
+ const mockDs = {
563
+ find: vi.fn().mockResolvedValue([]),
564
+ findOne: vi.fn(),
565
+ create: vi.fn(),
566
+ update: vi.fn(),
567
+ delete: vi.fn(),
568
+ getObjectSchema: vi.fn().mockResolvedValue({
569
+ name: 'notes',
570
+ fields: {
571
+ title: { type: 'text', label: 'Title' },
572
+ body: { type: 'text', label: 'Body' },
573
+ },
574
+ }),
575
+ };
576
+
577
+ const schema: ListViewSchema = {
578
+ type: 'list-view',
579
+ objectName: 'notes',
580
+ viewType: 'grid',
581
+ fields: ['title', 'body'],
582
+ };
583
+
584
+ render(
585
+ <SchemaRendererProvider dataSource={mockDs}>
586
+ <ListView schema={schema} dataSource={mockDs} />
587
+ </SchemaRendererProvider>
588
+ );
589
+
590
+ // Wait for objectDef to load
591
+ await vi.waitFor(() => {
592
+ expect(mockDs.getObjectSchema).toHaveBeenCalled();
593
+ });
594
+ // userFilters should not render since no filterable fields
595
+ expect(screen.queryByTestId('user-filters')).not.toBeInTheDocument();
596
+ });
597
+ });
598
+
599
+ // ============================================
600
+ // Merged Toolbar Layout
601
+ // ============================================
602
+ describe('Merged toolbar layout', () => {
603
+ it('should render userFilters inline within the toolbar row', () => {
604
+ const schema: ListViewSchema = {
605
+ type: 'list-view',
606
+ objectName: 'contacts',
607
+ viewType: 'grid',
608
+ fields: ['name', 'status'],
609
+ userFilters: {
610
+ element: 'dropdown',
611
+ fields: [
612
+ { field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
613
+ ],
614
+ },
615
+ };
616
+
617
+ renderWithProvider(<ListView schema={schema} />);
618
+ // userFilters should be in the toolbar (not a separate row)
619
+ const userFilters = screen.getByTestId('user-filters');
620
+ expect(userFilters).toBeInTheDocument();
621
+ // Search icon should also be in the same toolbar
622
+ expect(screen.getByTestId('search-icon-button')).toBeInTheDocument();
623
+ });
624
+
625
+ it('should open search popover when search icon is clicked', () => {
626
+ const schema: ListViewSchema = {
627
+ type: 'list-view',
628
+ objectName: 'contacts',
629
+ viewType: 'grid',
630
+ fields: ['name', 'email'],
631
+ };
632
+
633
+ renderWithProvider(<ListView schema={schema} />);
634
+ fireEvent.click(screen.getByTestId('search-icon-button'));
635
+ expect(screen.getByTestId('search-popover')).toBeInTheDocument();
636
+ expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
637
+ });
638
+
639
+ it('should highlight search icon when search term is active', () => {
640
+ const schema: ListViewSchema = {
641
+ type: 'list-view',
642
+ objectName: 'contacts',
643
+ viewType: 'grid',
644
+ fields: ['name', 'email'],
645
+ };
646
+
647
+ renderWithProvider(<ListView schema={schema} />);
648
+ fireEvent.click(screen.getByTestId('search-icon-button'));
649
+ fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'test' } });
650
+ // The search icon button should have active styling class
651
+ const searchBtn = screen.getByTestId('search-icon-button');
652
+ expect(searchBtn.className).toContain('bg-primary');
653
+ });
654
+ });
655
+
656
+ // ============================
657
+ // Toolbar Toggle Visibility
658
+ // ============================
659
+ describe('Toolbar Toggle Visibility', () => {
660
+ it('should hide Search icon when showSearch is false', () => {
661
+ const schema: ListViewSchema = {
662
+ type: 'list-view',
663
+ objectName: 'contacts',
664
+ viewType: 'grid',
665
+ fields: ['name', 'email'],
666
+ showSearch: false,
667
+ };
668
+
669
+ renderWithProvider(<ListView schema={schema} />);
670
+ expect(screen.queryByTestId('search-icon-button')).not.toBeInTheDocument();
671
+ });
672
+
673
+ it('should show Search icon when showSearch is true', () => {
674
+ const schema: ListViewSchema = {
675
+ type: 'list-view',
676
+ objectName: 'contacts',
677
+ viewType: 'grid',
678
+ fields: ['name', 'email'],
679
+ showSearch: true,
680
+ };
681
+
682
+ renderWithProvider(<ListView schema={schema} />);
683
+ expect(screen.getByTestId('search-icon-button')).toBeInTheDocument();
684
+ });
685
+
686
+ it('should show Search icon when showSearch is undefined (default)', () => {
687
+ const schema: ListViewSchema = {
688
+ type: 'list-view',
689
+ objectName: 'contacts',
690
+ viewType: 'grid',
691
+ fields: ['name', 'email'],
692
+ };
693
+
694
+ renderWithProvider(<ListView schema={schema} />);
695
+ expect(screen.getByTestId('search-icon-button')).toBeInTheDocument();
696
+ });
697
+
698
+ it('should hide Filter button when showFilters is false', () => {
699
+ const schema: ListViewSchema = {
700
+ type: 'list-view',
701
+ objectName: 'contacts',
702
+ viewType: 'grid',
703
+ fields: ['name', 'email'],
704
+ showFilters: false,
705
+ };
706
+
707
+ renderWithProvider(<ListView schema={schema} />);
708
+ expect(screen.queryByRole('button', { name: /filter/i })).not.toBeInTheDocument();
709
+ });
710
+
711
+ it('should show Filter button when showFilters is true', () => {
712
+ const schema: ListViewSchema = {
713
+ type: 'list-view',
714
+ objectName: 'contacts',
715
+ viewType: 'grid',
716
+ fields: ['name', 'email'],
717
+ showFilters: true,
718
+ };
719
+
720
+ renderWithProvider(<ListView schema={schema} />);
721
+ expect(screen.getByRole('button', { name: /filter/i })).toBeInTheDocument();
722
+ });
723
+
724
+ it('should hide Sort button when showSort is false', () => {
725
+ const schema: ListViewSchema = {
726
+ type: 'list-view',
727
+ objectName: 'contacts',
728
+ viewType: 'grid',
729
+ fields: ['name', 'email'],
730
+ showSort: false,
731
+ };
732
+
733
+ renderWithProvider(<ListView schema={schema} />);
734
+ expect(screen.queryByRole('button', { name: /^sort$/i })).not.toBeInTheDocument();
735
+ });
736
+
737
+ it('should show Sort button when showSort is true', () => {
738
+ const schema: ListViewSchema = {
739
+ type: 'list-view',
740
+ objectName: 'contacts',
741
+ viewType: 'grid',
742
+ fields: ['name', 'email'],
743
+ showSort: true,
744
+ };
745
+
746
+ renderWithProvider(<ListView schema={schema} />);
747
+ expect(screen.getByRole('button', { name: /^sort$/i })).toBeInTheDocument();
748
+ });
749
+
750
+ // Hide Fields visibility
751
+ it('should hide Hide Fields button when showHideFields is false', () => {
752
+ const schema: ListViewSchema = {
753
+ type: 'list-view',
754
+ objectName: 'contacts',
755
+ viewType: 'grid',
756
+ fields: ['name', 'email', 'phone'],
757
+ showHideFields: false,
758
+ };
759
+
760
+ renderWithProvider(<ListView schema={schema} />);
761
+ expect(screen.queryByRole('button', { name: /hide fields/i })).not.toBeInTheDocument();
762
+ });
763
+
764
+ it('should hide Hide Fields button by default (showHideFields undefined)', () => {
765
+ const schema: ListViewSchema = {
766
+ type: 'list-view',
767
+ objectName: 'contacts',
768
+ viewType: 'grid',
769
+ fields: ['name', 'email', 'phone'],
770
+ };
771
+
772
+ renderWithProvider(<ListView schema={schema} />);
773
+ expect(screen.queryByRole('button', { name: /hide fields/i })).not.toBeInTheDocument();
774
+ });
775
+
776
+ // Group visibility
777
+ it('should hide Group button when showGroup is false', () => {
778
+ const schema: ListViewSchema = {
779
+ type: 'list-view',
780
+ objectName: 'contacts',
781
+ viewType: 'grid',
782
+ fields: ['name', 'email'],
783
+ showGroup: false,
784
+ };
785
+
786
+ renderWithProvider(<ListView schema={schema} />);
787
+ expect(screen.queryByRole('button', { name: /group/i })).not.toBeInTheDocument();
788
+ });
789
+
790
+ it('should show Group button by default (showGroup undefined)', () => {
791
+ const schema: ListViewSchema = {
792
+ type: 'list-view',
793
+ objectName: 'contacts',
794
+ viewType: 'grid',
795
+ fields: ['name', 'email'],
796
+ };
797
+
798
+ renderWithProvider(<ListView schema={schema} />);
799
+ expect(screen.getByRole('button', { name: /group/i })).toBeInTheDocument();
800
+ });
801
+
802
+ // Color visibility
803
+ it('should hide Color button when showColor is false', () => {
804
+ const schema: ListViewSchema = {
805
+ type: 'list-view',
806
+ objectName: 'contacts',
807
+ viewType: 'grid',
808
+ fields: ['name', 'email'],
809
+ showColor: false,
810
+ };
811
+
812
+ renderWithProvider(<ListView schema={schema} />);
813
+ expect(screen.queryByRole('button', { name: /color/i })).not.toBeInTheDocument();
814
+ });
815
+
816
+ it('should hide Color button by default (showColor undefined)', () => {
817
+ const schema: ListViewSchema = {
818
+ type: 'list-view',
819
+ objectName: 'contacts',
820
+ viewType: 'grid',
821
+ fields: ['name', 'email'],
822
+ };
823
+
824
+ renderWithProvider(<ListView schema={schema} />);
825
+ expect(screen.queryByRole('button', { name: /color/i })).not.toBeInTheDocument();
826
+ });
827
+
828
+ // Density visibility
829
+ it('should hide Density button when showDensity is false', () => {
830
+ const schema: ListViewSchema = {
831
+ type: 'list-view',
832
+ objectName: 'contacts',
833
+ viewType: 'grid',
834
+ fields: ['name', 'email'],
835
+ showDensity: false,
836
+ };
837
+
838
+ renderWithProvider(<ListView schema={schema} />);
839
+ expect(screen.queryByTitle(/density/i)).not.toBeInTheDocument();
840
+ });
841
+
842
+ it('should hide Density button by default (showDensity undefined)', () => {
843
+ const schema: ListViewSchema = {
844
+ type: 'list-view',
845
+ objectName: 'contacts',
846
+ viewType: 'grid',
847
+ fields: ['name', 'email'],
848
+ };
849
+
850
+ renderWithProvider(<ListView schema={schema} />);
851
+ expect(screen.queryByTitle(/density/i)).not.toBeInTheDocument();
852
+ });
853
+
854
+ // Export + allowExport
855
+ it('should hide Export button when allowExport is false even with exportOptions', () => {
856
+ const schema: ListViewSchema = {
857
+ type: 'list-view',
858
+ objectName: 'contacts',
859
+ viewType: 'grid',
860
+ fields: ['name', 'email'],
861
+ exportOptions: { formats: ['csv', 'json'] },
862
+ allowExport: false,
863
+ };
864
+
865
+ renderWithProvider(<ListView schema={schema} />);
866
+ expect(screen.queryByRole('button', { name: /export/i })).not.toBeInTheDocument();
867
+ });
868
+ });
869
+
870
+ // ============================
871
+ // Schema prop forwarding to child views
872
+ // ============================
873
+ describe('Schema prop forwarding', () => {
874
+ it('should pass striped to child view schema', () => {
875
+ const schema: ListViewSchema = {
876
+ type: 'list-view',
877
+ objectName: 'contacts',
878
+ viewType: 'grid',
879
+ fields: ['name', 'email'],
880
+ striped: true,
881
+ };
882
+
883
+ const { container } = renderWithProvider(<ListView schema={schema} />);
884
+ expect(container).toBeTruthy();
885
+ });
886
+
887
+ it('should pass bordered to child view schema', () => {
888
+ const schema: ListViewSchema = {
889
+ type: 'list-view',
890
+ objectName: 'contacts',
891
+ viewType: 'grid',
892
+ fields: ['name', 'email'],
893
+ bordered: true,
894
+ };
895
+
896
+ const { container } = renderWithProvider(<ListView schema={schema} />);
897
+ expect(container).toBeTruthy();
898
+ });
899
+
900
+ it('should pass wrapHeaders to grid view schema', () => {
901
+ const schema: ListViewSchema = {
902
+ type: 'list-view',
903
+ objectName: 'contacts',
904
+ viewType: 'grid',
905
+ fields: ['name', 'email'],
906
+ wrapHeaders: true,
907
+ };
908
+
909
+ const { container } = renderWithProvider(<ListView schema={schema} />);
910
+ expect(container).toBeTruthy();
911
+ });
912
+
913
+ it('should pass inlineEdit as editable to grid view schema', () => {
914
+ const schema: ListViewSchema = {
915
+ type: 'list-view',
916
+ objectName: 'contacts',
917
+ viewType: 'grid',
918
+ fields: ['name', 'email'],
919
+ inlineEdit: true,
920
+ };
921
+
922
+ const { container } = renderWithProvider(<ListView schema={schema} />);
923
+ expect(container).toBeTruthy();
924
+ });
925
+ });
926
+
927
+ // ============================
928
+ // showRecordCount flag
929
+ // ============================
930
+ describe('showRecordCount flag', () => {
931
+ it('should hide record count bar when showRecordCount is false', async () => {
932
+ const mockItems = [
933
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
934
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
935
+ ];
936
+ mockDataSource.find.mockResolvedValue(mockItems);
937
+
938
+ const schema: ListViewSchema = {
939
+ type: 'list-view',
940
+ objectName: 'contacts',
941
+ viewType: 'grid',
942
+ fields: ['name', 'email'],
943
+ showRecordCount: false,
944
+ };
945
+
946
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
947
+
948
+ // Wait for data fetch
949
+ await vi.waitFor(() => {
950
+ expect(mockDataSource.find).toHaveBeenCalled();
951
+ });
952
+ // Give time for state update
953
+ await vi.waitFor(() => {
954
+ expect(screen.queryByTestId('record-count-bar')).not.toBeInTheDocument();
955
+ });
956
+ });
957
+
958
+ it('should show record count bar by default (showRecordCount undefined)', async () => {
959
+ const mockItems = [
960
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
961
+ ];
962
+ mockDataSource.find.mockResolvedValue(mockItems);
963
+
964
+ const schema: ListViewSchema = {
965
+ type: 'list-view',
966
+ objectName: 'contacts',
967
+ viewType: 'grid',
968
+ fields: ['name', 'email'],
969
+ };
970
+
971
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
972
+
973
+ await vi.waitFor(() => {
974
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
975
+ });
976
+ });
977
+ });
978
+
979
+ // ============================
980
+ // rowHeight short/extra_tall mapping
981
+ // ============================
982
+ describe('rowHeight enum gaps', () => {
983
+ it('should map rowHeight short to compact density', () => {
984
+ const schema: ListViewSchema = {
985
+ type: 'list-view',
986
+ objectName: 'contacts',
987
+ viewType: 'grid',
988
+ fields: ['name', 'email'],
989
+ rowHeight: 'short',
990
+ showDensity: true,
991
+ };
992
+
993
+ renderWithProvider(<ListView schema={schema} />);
994
+ const densityButton = screen.getByTitle('Density: compact');
995
+ expect(densityButton).toBeInTheDocument();
996
+ });
997
+
998
+ it('should map rowHeight extra_tall to spacious density', () => {
999
+ const schema: ListViewSchema = {
1000
+ type: 'list-view',
1001
+ objectName: 'contacts',
1002
+ viewType: 'grid',
1003
+ fields: ['name', 'email'],
1004
+ rowHeight: 'extra_tall',
1005
+ showDensity: true,
1006
+ };
1007
+
1008
+ renderWithProvider(<ListView schema={schema} />);
1009
+ const densityButton = screen.getByTitle('Density: spacious');
1010
+ expect(densityButton).toBeInTheDocument();
1011
+ });
1012
+ });
1013
+
1014
+ // ============================
1015
+ // sort legacy string format
1016
+ // ============================
1017
+ describe('sort legacy string format', () => {
1018
+ it('should accept sort items as string format "field desc"', () => {
1019
+ const schema: ListViewSchema = {
1020
+ type: 'list-view',
1021
+ objectName: 'contacts',
1022
+ viewType: 'grid',
1023
+ fields: ['name', 'email'],
1024
+ sort: ['name desc' as any],
1025
+ };
1026
+
1027
+ const { container } = renderWithProvider(<ListView schema={schema} />);
1028
+ expect(container).toBeTruthy();
1029
+ // Should show sort button with badge indicating 1 active sort
1030
+ const sortButton = screen.getByRole('button', { name: /sort/i });
1031
+ expect(sortButton).toBeInTheDocument();
1032
+ });
1033
+ });
1034
+
1035
+ // ============================
1036
+ // description rendering
1037
+ // ============================
1038
+ describe('description rendering', () => {
1039
+ it('should render view description when provided', () => {
1040
+ const schema: ListViewSchema = {
1041
+ type: 'list-view',
1042
+ objectName: 'contacts',
1043
+ viewType: 'grid',
1044
+ fields: ['name', 'email'],
1045
+ description: 'A list of all company contacts',
1046
+ };
1047
+
1048
+ renderWithProvider(<ListView schema={schema} />);
1049
+ expect(screen.getByTestId('view-description')).toBeInTheDocument();
1050
+ expect(screen.getByText('A list of all company contacts')).toBeInTheDocument();
1051
+ });
1052
+
1053
+ it('should hide description when appearance.showDescription is false', () => {
1054
+ const schema: ListViewSchema = {
1055
+ type: 'list-view',
1056
+ objectName: 'contacts',
1057
+ viewType: 'grid',
1058
+ fields: ['name', 'email'],
1059
+ description: 'A list of all company contacts',
1060
+ appearance: { showDescription: false },
1061
+ };
1062
+
1063
+ renderWithProvider(<ListView schema={schema} />);
1064
+ expect(screen.queryByTestId('view-description')).not.toBeInTheDocument();
1065
+ });
1066
+
1067
+ it('should not render description when not provided', () => {
1068
+ const schema: ListViewSchema = {
1069
+ type: 'list-view',
1070
+ objectName: 'contacts',
1071
+ viewType: 'grid',
1072
+ fields: ['name', 'email'],
1073
+ };
1074
+
1075
+ renderWithProvider(<ListView schema={schema} />);
1076
+ expect(screen.queryByTestId('view-description')).not.toBeInTheDocument();
1077
+ });
1078
+ });
1079
+
1080
+ // ============================
1081
+ // allowPrinting button
1082
+ // ============================
1083
+ describe('allowPrinting', () => {
1084
+ it('should render print button when allowPrinting is true', () => {
1085
+ const schema: ListViewSchema = {
1086
+ type: 'list-view',
1087
+ objectName: 'contacts',
1088
+ viewType: 'grid',
1089
+ fields: ['name', 'email'],
1090
+ allowPrinting: true,
1091
+ };
1092
+
1093
+ renderWithProvider(<ListView schema={schema} />);
1094
+ expect(screen.getByTestId('print-button')).toBeInTheDocument();
1095
+ });
1096
+
1097
+ it('should not render print button when allowPrinting is false', () => {
1098
+ const schema: ListViewSchema = {
1099
+ type: 'list-view',
1100
+ objectName: 'contacts',
1101
+ viewType: 'grid',
1102
+ fields: ['name', 'email'],
1103
+ allowPrinting: false,
1104
+ };
1105
+
1106
+ renderWithProvider(<ListView schema={schema} />);
1107
+ expect(screen.queryByTestId('print-button')).not.toBeInTheDocument();
1108
+ });
1109
+
1110
+ it('should not render print button by default', () => {
1111
+ const schema: ListViewSchema = {
1112
+ type: 'list-view',
1113
+ objectName: 'contacts',
1114
+ viewType: 'grid',
1115
+ fields: ['name', 'email'],
1116
+ };
1117
+
1118
+ renderWithProvider(<ListView schema={schema} />);
1119
+ expect(screen.queryByTestId('print-button')).not.toBeInTheDocument();
1120
+ });
1121
+ });
1122
+
1123
+ // ============================
1124
+ // addRecord button
1125
+ // ============================
1126
+ describe('addRecord button', () => {
1127
+ it('should render add record button when addRecord.enabled is true', () => {
1128
+ const schema: ListViewSchema = {
1129
+ type: 'list-view',
1130
+ objectName: 'contacts',
1131
+ viewType: 'grid',
1132
+ fields: ['name', 'email'],
1133
+ addRecord: { enabled: true },
1134
+ };
1135
+
1136
+ renderWithProvider(<ListView schema={schema} />);
1137
+ expect(screen.getByTestId('add-record-button')).toBeInTheDocument();
1138
+ });
1139
+
1140
+ it('should not render add record button when addRecord.enabled is false', () => {
1141
+ const schema: ListViewSchema = {
1142
+ type: 'list-view',
1143
+ objectName: 'contacts',
1144
+ viewType: 'grid',
1145
+ fields: ['name', 'email'],
1146
+ addRecord: { enabled: false },
1147
+ };
1148
+
1149
+ renderWithProvider(<ListView schema={schema} />);
1150
+ expect(screen.queryByTestId('add-record-button')).not.toBeInTheDocument();
1151
+ });
1152
+
1153
+ it('should not render add record button by default', () => {
1154
+ const schema: ListViewSchema = {
1155
+ type: 'list-view',
1156
+ objectName: 'contacts',
1157
+ viewType: 'grid',
1158
+ fields: ['name', 'email'],
1159
+ };
1160
+
1161
+ renderWithProvider(<ListView schema={schema} />);
1162
+ expect(screen.queryByTestId('add-record-button')).not.toBeInTheDocument();
1163
+ });
1164
+
1165
+ it('should hide add record button when userActions.addRecordForm is false', () => {
1166
+ const schema: ListViewSchema = {
1167
+ type: 'list-view',
1168
+ objectName: 'contacts',
1169
+ viewType: 'grid',
1170
+ fields: ['name', 'email'],
1171
+ addRecord: { enabled: true },
1172
+ userActions: { addRecordForm: false },
1173
+ };
1174
+
1175
+ renderWithProvider(<ListView schema={schema} />);
1176
+ expect(screen.queryByTestId('add-record-button')).not.toBeInTheDocument();
1177
+ });
1178
+
1179
+ it('should render add record button at bottom when position is bottom', () => {
1180
+ const schema: ListViewSchema = {
1181
+ type: 'list-view',
1182
+ objectName: 'contacts',
1183
+ viewType: 'grid',
1184
+ fields: ['name', 'email'],
1185
+ addRecord: { enabled: true, position: 'bottom' },
1186
+ };
1187
+
1188
+ renderWithProvider(<ListView schema={schema} />);
1189
+ const btn = screen.getByTestId('add-record-button');
1190
+ expect(btn).toBeInTheDocument();
1191
+ // The bottom button is wrapped in a border-t div outside the toolbar
1192
+ expect(btn.closest('div.border-t')).toBeTruthy();
1193
+ });
1194
+
1195
+ it('should render add record button in toolbar when position is top', () => {
1196
+ const schema: ListViewSchema = {
1197
+ type: 'list-view',
1198
+ objectName: 'contacts',
1199
+ viewType: 'grid',
1200
+ fields: ['name', 'email'],
1201
+ addRecord: { enabled: true, position: 'top' },
1202
+ };
1203
+
1204
+ renderWithProvider(<ListView schema={schema} />);
1205
+ const btn = screen.getByTestId('add-record-button');
1206
+ expect(btn).toBeInTheDocument();
1207
+ // The top button is inside the toolbar border-b div
1208
+ expect(btn.closest('div.border-b')).toBeTruthy();
1209
+ });
1210
+ });
1211
+
1212
+ // ============================
1213
+ // tabs rendering
1214
+ // ============================
1215
+ describe('tabs rendering', () => {
1216
+ it('should render view tabs when configured', () => {
1217
+ const schema: ListViewSchema = {
1218
+ type: 'list-view',
1219
+ objectName: 'contacts',
1220
+ viewType: 'grid',
1221
+ fields: ['name', 'email'],
1222
+ tabs: [
1223
+ { name: 'all', label: 'All Records', isDefault: true },
1224
+ { name: 'active', label: 'Active' },
1225
+ ],
1226
+ };
1227
+
1228
+ renderWithProvider(<ListView schema={schema} />);
1229
+ expect(screen.getByTestId('view-tabs')).toBeInTheDocument();
1230
+ expect(screen.getByTestId('view-tab-all')).toBeInTheDocument();
1231
+ expect(screen.getByTestId('view-tab-active')).toBeInTheDocument();
1232
+ expect(screen.getByText('All Records')).toBeInTheDocument();
1233
+ expect(screen.getByText('Active')).toBeInTheDocument();
1234
+ });
1235
+
1236
+ it('should not render tabs when not configured', () => {
1237
+ const schema: ListViewSchema = {
1238
+ type: 'list-view',
1239
+ objectName: 'contacts',
1240
+ viewType: 'grid',
1241
+ fields: ['name', 'email'],
1242
+ };
1243
+
1244
+ renderWithProvider(<ListView schema={schema} />);
1245
+ expect(screen.queryByTestId('view-tabs')).not.toBeInTheDocument();
1246
+ });
1247
+
1248
+ it('should filter out hidden tabs', () => {
1249
+ const schema: ListViewSchema = {
1250
+ type: 'list-view',
1251
+ objectName: 'contacts',
1252
+ viewType: 'grid',
1253
+ fields: ['name', 'email'],
1254
+ tabs: [
1255
+ { name: 'all', label: 'All Records' },
1256
+ { name: 'hidden', label: 'Hidden Tab', visible: 'false' },
1257
+ ],
1258
+ };
1259
+
1260
+ renderWithProvider(<ListView schema={schema} />);
1261
+ expect(screen.getByTestId('view-tabs')).toBeInTheDocument();
1262
+ expect(screen.getByText('All Records')).toBeInTheDocument();
1263
+ expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument();
1264
+ });
1265
+ });
1266
+
1267
+ // ============================
1268
+ // userActions toolbar control
1269
+ // ============================
1270
+ describe('userActions toolbar control', () => {
1271
+ it('should hide Search when userActions.search is false', () => {
1272
+ const schema: ListViewSchema = {
1273
+ type: 'list-view',
1274
+ objectName: 'contacts',
1275
+ viewType: 'grid',
1276
+ fields: ['name', 'email'],
1277
+ userActions: { search: false },
1278
+ };
1279
+
1280
+ renderWithProvider(<ListView schema={schema} />);
1281
+ expect(screen.queryByTestId('search-icon-button')).not.toBeInTheDocument();
1282
+ });
1283
+
1284
+ it('should hide Sort when userActions.sort is false', () => {
1285
+ const schema: ListViewSchema = {
1286
+ type: 'list-view',
1287
+ objectName: 'contacts',
1288
+ viewType: 'grid',
1289
+ fields: ['name', 'email'],
1290
+ userActions: { sort: false },
1291
+ };
1292
+
1293
+ renderWithProvider(<ListView schema={schema} />);
1294
+ expect(screen.queryByRole('button', { name: /^sort$/i })).not.toBeInTheDocument();
1295
+ });
1296
+
1297
+ it('should hide Filter when userActions.filter is false', () => {
1298
+ const schema: ListViewSchema = {
1299
+ type: 'list-view',
1300
+ objectName: 'contacts',
1301
+ viewType: 'grid',
1302
+ fields: ['name', 'email'],
1303
+ userActions: { filter: false },
1304
+ };
1305
+
1306
+ renderWithProvider(<ListView schema={schema} />);
1307
+ expect(screen.queryByRole('button', { name: /filter/i })).not.toBeInTheDocument();
1308
+ });
1309
+
1310
+ it('should hide Density when userActions.rowHeight is false', () => {
1311
+ const schema: ListViewSchema = {
1312
+ type: 'list-view',
1313
+ objectName: 'contacts',
1314
+ viewType: 'grid',
1315
+ fields: ['name', 'email'],
1316
+ userActions: { rowHeight: false },
1317
+ };
1318
+
1319
+ renderWithProvider(<ListView schema={schema} />);
1320
+ expect(screen.queryByTitle(/density/i)).not.toBeInTheDocument();
1321
+ });
1322
+
1323
+ it('should show toolbar buttons when userActions are true', () => {
1324
+ const schema: ListViewSchema = {
1325
+ type: 'list-view',
1326
+ objectName: 'contacts',
1327
+ viewType: 'grid',
1328
+ fields: ['name', 'email'],
1329
+ userActions: { search: true, sort: true, filter: true, rowHeight: true },
1330
+ };
1331
+
1332
+ renderWithProvider(<ListView schema={schema} />);
1333
+ expect(screen.getByTestId('search-icon-button')).toBeInTheDocument();
1334
+ expect(screen.getByRole('button', { name: /^sort$/i })).toBeInTheDocument();
1335
+ expect(screen.getByRole('button', { name: /filter/i })).toBeInTheDocument();
1336
+ expect(screen.getByTitle(/density/i)).toBeInTheDocument();
1337
+ });
1338
+
1339
+ it('userActions.search should override showSearch', () => {
1340
+ const schema: ListViewSchema = {
1341
+ type: 'list-view',
1342
+ objectName: 'contacts',
1343
+ viewType: 'grid',
1344
+ fields: ['name', 'email'],
1345
+ showSearch: true,
1346
+ userActions: { search: false },
1347
+ };
1348
+
1349
+ renderWithProvider(<ListView schema={schema} />);
1350
+ expect(screen.queryByTestId('search-icon-button')).not.toBeInTheDocument();
1351
+ });
1352
+ });
1353
+
1354
+ // ============================
1355
+ // appearance.allowedVisualizations
1356
+ // ============================
1357
+ describe('appearance.allowedVisualizations', () => {
1358
+ it('should restrict ViewSwitcher to allowedVisualizations', () => {
1359
+ const schema: ListViewSchema = {
1360
+ type: 'list-view',
1361
+ objectName: 'contacts',
1362
+ viewType: 'grid',
1363
+ fields: ['name', 'email'],
1364
+ appearance: { allowedVisualizations: ['grid', 'kanban'] },
1365
+ options: {
1366
+ kanban: { groupField: 'status' },
1367
+ calendar: { startDateField: 'date' },
1368
+ },
1369
+ };
1370
+
1371
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1372
+ // Should only show grid and kanban, not calendar
1373
+ expect(screen.getByLabelText('Grid')).toBeInTheDocument();
1374
+ expect(screen.getByLabelText('Kanban')).toBeInTheDocument();
1375
+ expect(screen.queryByLabelText('Calendar')).not.toBeInTheDocument();
1376
+ });
1377
+ });
1378
+
1379
+ // ============================
1380
+ // Spec config usage (kanban/gallery/timeline)
1381
+ // ============================
1382
+ describe('spec config usage', () => {
1383
+ it('should use spec kanban config over legacy options', () => {
1384
+ const schema: ListViewSchema = {
1385
+ type: 'list-view',
1386
+ objectName: 'contacts',
1387
+ viewType: 'grid',
1388
+ fields: ['name', 'email'],
1389
+ kanban: { groupField: 'priority' },
1390
+ };
1391
+
1392
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1393
+ // Should enable kanban view since kanban.groupField is set
1394
+ expect(screen.getByLabelText('Kanban')).toBeInTheDocument();
1395
+ });
1396
+
1397
+ it('should use spec gallery config over legacy options', () => {
1398
+ const schema: ListViewSchema = {
1399
+ type: 'list-view',
1400
+ objectName: 'contacts',
1401
+ viewType: 'grid',
1402
+ fields: ['name', 'email'],
1403
+ gallery: { coverField: 'photo', titleField: 'name' },
1404
+ };
1405
+
1406
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1407
+ expect(screen.getByLabelText('Gallery')).toBeInTheDocument();
1408
+ });
1409
+
1410
+ it('should use spec timeline config over legacy options', () => {
1411
+ const schema: ListViewSchema = {
1412
+ type: 'list-view',
1413
+ objectName: 'contacts',
1414
+ viewType: 'grid',
1415
+ fields: ['name', 'email'],
1416
+ timeline: { startDateField: 'created_at', titleField: 'name' },
1417
+ };
1418
+
1419
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1420
+ expect(screen.getByLabelText('Timeline')).toBeInTheDocument();
1421
+ });
1422
+
1423
+ it('should use spec calendar config over legacy options', () => {
1424
+ const schema: ListViewSchema = {
1425
+ type: 'list-view',
1426
+ objectName: 'contacts',
1427
+ viewType: 'grid',
1428
+ fields: ['name', 'email'],
1429
+ calendar: { startDateField: 'date', titleField: 'name' },
1430
+ };
1431
+
1432
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1433
+ expect(screen.getByLabelText('Calendar')).toBeInTheDocument();
1434
+ });
1435
+
1436
+ it('should use spec gantt config over legacy options', () => {
1437
+ const schema: ListViewSchema = {
1438
+ type: 'list-view',
1439
+ objectName: 'contacts',
1440
+ viewType: 'grid',
1441
+ fields: ['name', 'email'],
1442
+ gantt: { startDateField: 'start', endDateField: 'end' },
1443
+ };
1444
+
1445
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
1446
+ expect(screen.getByLabelText('Gantt')).toBeInTheDocument();
1447
+ });
1448
+ });
1449
+
1450
+ // ============================
1451
+ // pageSizeOptions UI
1452
+ // ============================
1453
+ describe('pageSizeOptions', () => {
1454
+ it('should render page size selector when pageSizeOptions is provided', async () => {
1455
+ const mockItems = [
1456
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1457
+ ];
1458
+ mockDataSource.find.mockResolvedValue(mockItems);
1459
+
1460
+ const schema: ListViewSchema = {
1461
+ type: 'list-view',
1462
+ objectName: 'contacts',
1463
+ viewType: 'grid',
1464
+ fields: ['name', 'email'],
1465
+ pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
1466
+ };
1467
+
1468
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1469
+
1470
+ await vi.waitFor(() => {
1471
+ expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1472
+ });
1473
+ });
1474
+
1475
+ it('should not render page size selector when pageSizeOptions is not provided', async () => {
1476
+ const mockItems = [
1477
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1478
+ ];
1479
+ mockDataSource.find.mockResolvedValue(mockItems);
1480
+
1481
+ const schema: ListViewSchema = {
1482
+ type: 'list-view',
1483
+ objectName: 'contacts',
1484
+ viewType: 'grid',
1485
+ fields: ['name', 'email'],
1486
+ pagination: { pageSize: 25 },
1487
+ };
1488
+
1489
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1490
+
1491
+ await vi.waitFor(() => {
1492
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
1493
+ });
1494
+ expect(screen.queryByTestId('page-size-selector')).not.toBeInTheDocument();
1495
+ });
1496
+ });
1497
+
1498
+ // ============================
1499
+ // searchableFields scoping
1500
+ // ============================
1501
+ describe('searchableFields scoping', () => {
1502
+ it('should pass $search and $searchFields to data query', async () => {
1503
+ mockDataSource.find.mockResolvedValue([]);
1504
+
1505
+ const schema: ListViewSchema = {
1506
+ type: 'list-view',
1507
+ objectName: 'contacts',
1508
+ viewType: 'grid',
1509
+ fields: ['name', 'email'],
1510
+ searchableFields: ['name', 'email'],
1511
+ };
1512
+
1513
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1514
+
1515
+ // Click search icon to open popover, then type search query
1516
+ fireEvent.click(screen.getByTestId('search-icon-button'));
1517
+ const searchInput = screen.getByPlaceholderText(/search/i);
1518
+ fireEvent.change(searchInput, { target: { value: 'alice' } });
1519
+
1520
+ // Wait for debounced fetch
1521
+ await vi.waitFor(() => {
1522
+ const lastCall = mockDataSource.find.mock.calls[mockDataSource.find.mock.calls.length - 1];
1523
+ expect(lastCall[1]).toHaveProperty('$search', 'alice');
1524
+ expect(lastCall[1]).toHaveProperty('$searchFields', ['name', 'email']);
1525
+ });
1526
+ });
1527
+ });
1528
+
1529
+ // ============================
1530
+ // data (ViewDataSchema) support
1531
+ // ============================
1532
+ describe('data (ViewDataSchema) support', () => {
1533
+ it('should use inline data when schema.data has provider value', async () => {
1534
+ const schema: ListViewSchema = {
1535
+ type: 'list-view',
1536
+ objectName: 'contacts',
1537
+ viewType: 'grid',
1538
+ fields: ['name', 'email'],
1539
+ data: {
1540
+ provider: 'value',
1541
+ items: [
1542
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1543
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
1544
+ ],
1545
+ } as any,
1546
+ };
1547
+
1548
+ mockDataSource.find.mockClear();
1549
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1550
+
1551
+ await vi.waitFor(() => {
1552
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
1553
+ });
1554
+ expect(screen.getByText('2 records')).toBeInTheDocument();
1555
+ expect(mockDataSource.find).not.toHaveBeenCalled();
1556
+ });
1557
+
1558
+ it('should use inline data when schema.data is a plain array', async () => {
1559
+ const schema: ListViewSchema = {
1560
+ type: 'list-view',
1561
+ objectName: 'contacts',
1562
+ viewType: 'grid',
1563
+ fields: ['name', 'email'],
1564
+ data: [
1565
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1566
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
1567
+ ] as any,
1568
+ };
1569
+
1570
+ mockDataSource.find.mockClear();
1571
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1572
+
1573
+ await vi.waitFor(() => {
1574
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
1575
+ });
1576
+ expect(screen.getByText('2 records')).toBeInTheDocument();
1577
+ expect(mockDataSource.find).not.toHaveBeenCalled();
1578
+ });
1579
+
1580
+ it('should fall back to dataSource.find when schema.data is not set', async () => {
1581
+ const mockItems = [
1582
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1583
+ ];
1584
+ mockDataSource.find.mockResolvedValue(mockItems);
1585
+
1586
+ const schema: ListViewSchema = {
1587
+ type: 'list-view',
1588
+ objectName: 'contacts',
1589
+ viewType: 'grid',
1590
+ fields: ['name', 'email'],
1591
+ };
1592
+
1593
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1594
+
1595
+ await vi.waitFor(() => {
1596
+ expect(mockDataSource.find).toHaveBeenCalled();
1597
+ });
1598
+ });
1599
+ });
1600
+
1601
+ // ============================
1602
+ // grouping popover
1603
+ // ============================
1604
+ describe('grouping popover', () => {
1605
+ it('should render enabled Group button (not disabled)', () => {
1606
+ const schema: ListViewSchema = {
1607
+ type: 'list-view',
1608
+ objectName: 'contacts',
1609
+ viewType: 'grid',
1610
+ fields: ['name', 'email'],
1611
+ };
1612
+
1613
+ renderWithProvider(<ListView schema={schema} />);
1614
+ const groupButton = screen.getByRole('button', { name: /group/i });
1615
+ expect(groupButton).toBeInTheDocument();
1616
+ expect(groupButton).not.toBeDisabled();
1617
+ });
1618
+
1619
+ it('should open grouping popover on click', async () => {
1620
+ const schema: ListViewSchema = {
1621
+ type: 'list-view',
1622
+ objectName: 'contacts',
1623
+ viewType: 'grid',
1624
+ fields: ['name', 'email'],
1625
+ };
1626
+
1627
+ renderWithProvider(<ListView schema={schema} />);
1628
+ const groupButton = screen.getByRole('button', { name: /group/i });
1629
+ fireEvent.click(groupButton);
1630
+
1631
+ await vi.waitFor(() => {
1632
+ expect(screen.getByText('Group By')).toBeInTheDocument();
1633
+ });
1634
+ expect(screen.getByTestId('group-field-list')).toBeInTheDocument();
1635
+ });
1636
+
1637
+ it('should render active grouping badge when groupingConfig is set via schema', () => {
1638
+ const schema: ListViewSchema = {
1639
+ type: 'list-view',
1640
+ objectName: 'contacts',
1641
+ viewType: 'grid',
1642
+ fields: ['name', 'email', 'status'],
1643
+ grouping: { fields: [{ field: 'status', order: 'asc' }] },
1644
+ };
1645
+
1646
+ renderWithProvider(<ListView schema={schema} />);
1647
+ const groupButton = screen.getByRole('button', { name: /group/i });
1648
+ // Badge showing count "1" should be inside the button
1649
+ expect(groupButton.textContent).toContain('1');
1650
+ });
1651
+ });
1652
+
1653
+ // ============================
1654
+ // rowColor popover
1655
+ // ============================
1656
+ describe('rowColor popover', () => {
1657
+ it('should render enabled Color button (not disabled)', () => {
1658
+ const schema: ListViewSchema = {
1659
+ type: 'list-view',
1660
+ objectName: 'contacts',
1661
+ viewType: 'grid',
1662
+ fields: ['name', 'email'],
1663
+ showColor: true,
1664
+ };
1665
+
1666
+ renderWithProvider(<ListView schema={schema} />);
1667
+ const colorButton = screen.getByRole('button', { name: /color/i });
1668
+ expect(colorButton).toBeInTheDocument();
1669
+ expect(colorButton).not.toBeDisabled();
1670
+ });
1671
+
1672
+ it('should open color popover on click', async () => {
1673
+ const schema: ListViewSchema = {
1674
+ type: 'list-view',
1675
+ objectName: 'contacts',
1676
+ viewType: 'grid',
1677
+ fields: ['name', 'email'],
1678
+ showColor: true,
1679
+ };
1680
+
1681
+ renderWithProvider(<ListView schema={schema} />);
1682
+ const colorButton = screen.getByRole('button', { name: /color/i });
1683
+ fireEvent.click(colorButton);
1684
+
1685
+ await vi.waitFor(() => {
1686
+ expect(screen.getByText('Row Color')).toBeInTheDocument();
1687
+ });
1688
+ expect(screen.getByTestId('color-field-select')).toBeInTheDocument();
1689
+ });
1690
+ });
1691
+
1692
+ // ============================
1693
+ // quickFilters spec format reconciliation
1694
+ // ============================
1695
+ describe('quickFilters spec format reconciliation', () => {
1696
+ it('should normalize spec format { field, operator, value } into ObjectUI format', () => {
1697
+ const schema: ListViewSchema = {
1698
+ type: 'list-view',
1699
+ objectName: 'contacts',
1700
+ viewType: 'grid',
1701
+ fields: ['name', 'email', 'status'],
1702
+ quickFilters: [
1703
+ { field: 'status', operator: 'equals', value: 'active', label: 'Active' },
1704
+ ],
1705
+ };
1706
+
1707
+ renderWithProvider(<ListView schema={schema} />);
1708
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1709
+ expect(screen.getByText('Active')).toBeInTheDocument();
1710
+ });
1711
+
1712
+ it('should still support ObjectUI format { id, label, filters[] }', () => {
1713
+ const schema: ListViewSchema = {
1714
+ type: 'list-view',
1715
+ objectName: 'contacts',
1716
+ viewType: 'grid',
1717
+ fields: ['name', 'email', 'status'],
1718
+ quickFilters: [
1719
+ { id: 'active', label: 'Active', filters: [['status', '=', 'active']] },
1720
+ { id: 'vip', label: 'VIP', filters: [['vip', '=', true]] },
1721
+ ],
1722
+ };
1723
+
1724
+ renderWithProvider(<ListView schema={schema} />);
1725
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1726
+ expect(screen.getByText('Active')).toBeInTheDocument();
1727
+ expect(screen.getByText('VIP')).toBeInTheDocument();
1728
+ });
1729
+
1730
+ it('should handle mixed format arrays (ObjectUI + Spec items together)', () => {
1731
+ const schema: ListViewSchema = {
1732
+ type: 'list-view',
1733
+ objectName: 'contacts',
1734
+ viewType: 'grid',
1735
+ fields: ['name', 'email', 'status'],
1736
+ quickFilters: [
1737
+ { id: 'active', label: 'Active', filters: [['status', '=', 'active']] },
1738
+ { field: 'priority', operator: 'eq', value: 'high', label: 'High Priority' },
1739
+ ],
1740
+ };
1741
+
1742
+ renderWithProvider(<ListView schema={schema} />);
1743
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1744
+ expect(screen.getByText('Active')).toBeInTheDocument();
1745
+ expect(screen.getByText('High Priority')).toBeInTheDocument();
1746
+ });
1747
+
1748
+ it('should handle spec shorthand operator "eq"', () => {
1749
+ const schema: ListViewSchema = {
1750
+ type: 'list-view',
1751
+ objectName: 'contacts',
1752
+ viewType: 'grid',
1753
+ fields: ['name', 'status'],
1754
+ quickFilters: [
1755
+ { field: 'status', operator: 'eq', value: 'active', label: 'Active' },
1756
+ ],
1757
+ };
1758
+
1759
+ renderWithProvider(<ListView schema={schema} />);
1760
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1761
+ expect(screen.getByText('Active')).toBeInTheDocument();
1762
+ });
1763
+
1764
+ it('should auto-generate label when label is omitted in spec format', () => {
1765
+ const schema: ListViewSchema = {
1766
+ type: 'list-view',
1767
+ objectName: 'contacts',
1768
+ viewType: 'grid',
1769
+ fields: ['name', 'status'],
1770
+ quickFilters: [
1771
+ { field: 'status', operator: 'eq', value: 'active' },
1772
+ ],
1773
+ };
1774
+
1775
+ renderWithProvider(<ListView schema={schema} />);
1776
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1777
+ // Auto-generated label: "status eq active"
1778
+ expect(screen.getByText('status eq active')).toBeInTheDocument();
1779
+ });
1780
+
1781
+ it('should handle spec format with missing value', () => {
1782
+ const schema: ListViewSchema = {
1783
+ type: 'list-view',
1784
+ objectName: 'contacts',
1785
+ viewType: 'grid',
1786
+ fields: ['name', 'archived'],
1787
+ quickFilters: [
1788
+ { field: 'archived', operator: 'eq', value: null, label: 'Not Archived' },
1789
+ ],
1790
+ };
1791
+
1792
+ renderWithProvider(<ListView schema={schema} />);
1793
+ expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1794
+ expect(screen.getByText('Not Archived')).toBeInTheDocument();
1795
+ });
1796
+ });
1797
+
1798
+ // ============================
1799
+ // exportOptions format reconciliation
1800
+ // ============================
1801
+ describe('exportOptions format reconciliation', () => {
1802
+ it('should render export button when exportOptions is a string array', () => {
1803
+ const schema: ListViewSchema = {
1804
+ type: 'list-view',
1805
+ objectName: 'contacts',
1806
+ viewType: 'grid',
1807
+ fields: ['name', 'email'],
1808
+ exportOptions: ['csv', 'json'] as any,
1809
+ };
1810
+
1811
+ renderWithProvider(<ListView schema={schema} />);
1812
+ const exportButton = screen.getByRole('button', { name: /export/i });
1813
+ expect(exportButton).toBeInTheDocument();
1814
+ });
1815
+
1816
+ it('should render export button when exportOptions is an object', () => {
1817
+ const schema: ListViewSchema = {
1818
+ type: 'list-view',
1819
+ objectName: 'contacts',
1820
+ viewType: 'grid',
1821
+ fields: ['name', 'email'],
1822
+ exportOptions: { formats: ['csv', 'json'] },
1823
+ };
1824
+
1825
+ renderWithProvider(<ListView schema={schema} />);
1826
+ const exportButton = screen.getByRole('button', { name: /export/i });
1827
+ expect(exportButton).toBeInTheDocument();
1828
+ });
1829
+ });
1830
+
1831
+ // ============================
1832
+ // conditionalFormatting spec format
1833
+ // ============================
1834
+ describe('conditionalFormatting spec format', () => {
1835
+ it('should evaluate spec format with condition and style', () => {
1836
+ const result = evaluateConditionalFormatting(
1837
+ { status: 'active', amount: 200 },
1838
+ [{ condition: '${data.status === "active"}', style: { backgroundColor: '#e0ffe0', color: '#0a0' } }] as any,
1839
+ );
1840
+ expect(result).toEqual({ backgroundColor: '#e0ffe0', color: '#0a0' });
1841
+ });
1842
+ });
1843
+
1844
+ // ============================
1845
+ // sharing spec format
1846
+ // ============================
1847
+ describe('sharing spec format', () => {
1848
+ it('should render share button when sharing.type is set (spec format)', () => {
1849
+ const schema: ListViewSchema = {
1850
+ type: 'list-view',
1851
+ objectName: 'contacts',
1852
+ viewType: 'grid',
1853
+ fields: ['name', 'email'],
1854
+ sharing: { type: 'collaborative' } as any,
1855
+ };
1856
+
1857
+ renderWithProvider(<ListView schema={schema} />);
1858
+ const shareButton = screen.getByTestId('share-button');
1859
+ expect(shareButton).toBeInTheDocument();
1860
+ expect(shareButton).toHaveAttribute('title', 'Sharing: collaborative');
1861
+ });
1862
+ });
1863
+
1864
+ // ============================
1865
+ // bulkActions bar
1866
+ // ============================
1867
+ describe('bulkActions bar', () => {
1868
+ it('should not render bulk actions bar when no rows are selected', () => {
1869
+ const schema: ListViewSchema = {
1870
+ type: 'list-view',
1871
+ objectName: 'contacts',
1872
+ viewType: 'grid',
1873
+ fields: ['name', 'email'],
1874
+ bulkActions: ['delete', 'archive'] as any,
1875
+ };
1876
+
1877
+ renderWithProvider(<ListView schema={schema} />);
1878
+ expect(screen.queryByTestId('bulk-actions-bar')).not.toBeInTheDocument();
1879
+ });
1880
+ });
1881
+
1882
+ // ============================
1883
+ // pageSizeOptions dynamic integration
1884
+ // ============================
1885
+ describe('pageSizeOptions dynamic integration', () => {
1886
+ it('should render page size selector as controlled component', async () => {
1887
+ const mockItems = [
1888
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1889
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
1890
+ ];
1891
+ mockDataSource.find.mockResolvedValue(mockItems);
1892
+
1893
+ const schema: ListViewSchema = {
1894
+ type: 'list-view',
1895
+ objectName: 'contacts',
1896
+ viewType: 'grid',
1897
+ fields: ['name', 'email'],
1898
+ pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50] },
1899
+ };
1900
+
1901
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1902
+
1903
+ await vi.waitFor(() => {
1904
+ expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1905
+ });
1906
+
1907
+ const selector = screen.getByTestId('page-size-selector');
1908
+ expect(selector).toHaveValue('25');
1909
+ });
1910
+
1911
+ it('should re-fetch data when page size changes', async () => {
1912
+ const mockItems = [
1913
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1914
+ { _id: '2', name: 'Bob', email: 'bob@test.com' },
1915
+ ];
1916
+ mockDataSource.find.mockResolvedValue(mockItems);
1917
+
1918
+ const onPageSizeChange = vi.fn();
1919
+ const schema: ListViewSchema = {
1920
+ type: 'list-view',
1921
+ objectName: 'contacts',
1922
+ viewType: 'grid',
1923
+ fields: ['name', 'email'],
1924
+ pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
1925
+ };
1926
+
1927
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} onPageSizeChange={onPageSizeChange} />);
1928
+
1929
+ await vi.waitFor(() => {
1930
+ expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1931
+ });
1932
+
1933
+ const fetchCountBefore = mockDataSource.find.mock.calls.length;
1934
+
1935
+ // Change page size to 50
1936
+ const selector = screen.getByTestId('page-size-selector');
1937
+ fireEvent.change(selector, { target: { value: '50' } });
1938
+
1939
+ expect(onPageSizeChange).toHaveBeenCalledWith(50);
1940
+
1941
+ // Data should be re-fetched with the new page size
1942
+ await vi.waitFor(() => {
1943
+ expect(mockDataSource.find.mock.calls.length).toBeGreaterThan(fetchCountBefore);
1944
+ });
1945
+ });
1946
+
1947
+ it('should render all page size options in the selector', async () => {
1948
+ const mockItems = [
1949
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1950
+ ];
1951
+ mockDataSource.find.mockResolvedValue(mockItems);
1952
+
1953
+ const schema: ListViewSchema = {
1954
+ type: 'list-view',
1955
+ objectName: 'contacts',
1956
+ viewType: 'grid',
1957
+ fields: ['name', 'email'],
1958
+ pagination: { pageSize: 10, pageSizeOptions: [10, 25, 50, 100] },
1959
+ };
1960
+
1961
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1962
+
1963
+ await vi.waitFor(() => {
1964
+ expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1965
+ });
1966
+
1967
+ const options = screen.getByTestId('page-size-selector').querySelectorAll('option');
1968
+ expect(options).toHaveLength(4);
1969
+ expect(options[0]).toHaveValue('10');
1970
+ expect(options[1]).toHaveValue('25');
1971
+ expect(options[2]).toHaveValue('50');
1972
+ expect(options[3]).toHaveValue('100');
1973
+ });
1974
+
1975
+ it('should not render page size selector when pageSizeOptions is not configured', async () => {
1976
+ const mockItems = [
1977
+ { _id: '1', name: 'Alice', email: 'alice@test.com' },
1978
+ ];
1979
+ mockDataSource.find.mockResolvedValue(mockItems);
1980
+
1981
+ const schema: ListViewSchema = {
1982
+ type: 'list-view',
1983
+ objectName: 'contacts',
1984
+ viewType: 'grid',
1985
+ fields: ['name', 'email'],
1986
+ pagination: { pageSize: 25 },
1987
+ };
1988
+
1989
+ renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1990
+
1991
+ await vi.waitFor(() => {
1992
+ expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
1993
+ });
1994
+
1995
+ expect(screen.queryByTestId('page-size-selector')).not.toBeInTheDocument();
1996
+ });
1997
+ });
1998
+
1999
+ // ============================
2000
+ // sharing spec format — additional tests
2001
+ // ============================
2002
+ describe('sharing spec format — additional', () => {
2003
+ it('should render share button with spec personal type', () => {
2004
+ const schema: ListViewSchema = {
2005
+ type: 'list-view',
2006
+ objectName: 'contacts',
2007
+ viewType: 'grid',
2008
+ fields: ['name', 'email'],
2009
+ sharing: { type: 'personal' },
2010
+ };
2011
+
2012
+ renderWithProvider(<ListView schema={schema} />);
2013
+ const shareButton = screen.getByTestId('share-button');
2014
+ expect(shareButton).toBeInTheDocument();
2015
+ });
2016
+
2017
+ it('should display lockedBy in sharing tooltip when set', () => {
2018
+ const schema: ListViewSchema = {
2019
+ type: 'list-view',
2020
+ objectName: 'contacts',
2021
+ viewType: 'grid',
2022
+ fields: ['name', 'email'],
2023
+ sharing: { type: 'collaborative', lockedBy: 'admin@example.com' },
2024
+ };
2025
+
2026
+ renderWithProvider(<ListView schema={schema} />);
2027
+ const shareButton = screen.getByTestId('share-button');
2028
+ expect(shareButton).toBeInTheDocument();
2029
+ expect(shareButton).toHaveAttribute('title', 'Sharing: collaborative');
2030
+ });
2031
+ });
2032
+
2033
+ // ============================
2034
+ // filterableFields whitelist
2035
+ // ============================
2036
+ describe('filterableFields', () => {
2037
+ it('should render with filterableFields whitelist restricting available fields', () => {
2038
+ const schema: ListViewSchema = {
2039
+ type: 'list-view',
2040
+ objectName: 'contacts',
2041
+ viewType: 'grid',
2042
+ fields: [
2043
+ { name: 'name', label: 'Name', type: 'text' },
2044
+ { name: 'email', label: 'Email', type: 'text' },
2045
+ { name: 'phone', label: 'Phone', type: 'text' },
2046
+ ] as any,
2047
+ filterableFields: ['name', 'email'],
2048
+ };
2049
+
2050
+ renderWithProvider(<ListView schema={schema} />);
2051
+ // Filter button should still be visible
2052
+ const filterButton = screen.getByRole('button', { name: /filter/i });
2053
+ expect(filterButton).toBeInTheDocument();
2054
+ });
2055
+
2056
+ it('should render filter button when filterableFields is not set', () => {
2057
+ const schema: ListViewSchema = {
2058
+ type: 'list-view',
2059
+ objectName: 'contacts',
2060
+ viewType: 'grid',
2061
+ fields: [
2062
+ { name: 'name', label: 'Name', type: 'text' },
2063
+ { name: 'email', label: 'Email', type: 'text' },
2064
+ ] as any,
2065
+ };
2066
+
2067
+ renderWithProvider(<ListView schema={schema} />);
2068
+ const filterButton = screen.getByRole('button', { name: /filter/i });
2069
+ expect(filterButton).toBeInTheDocument();
2070
+ });
2071
+
2072
+ it('should render filter button when filterableFields is empty array', () => {
2073
+ const schema: ListViewSchema = {
2074
+ type: 'list-view',
2075
+ objectName: 'contacts',
2076
+ viewType: 'grid',
2077
+ fields: [
2078
+ { name: 'name', label: 'Name', type: 'text' },
2079
+ { name: 'email', label: 'Email', type: 'text' },
2080
+ ] as any,
2081
+ filterableFields: [],
2082
+ };
2083
+
2084
+ renderWithProvider(<ListView schema={schema} />);
2085
+ const filterButton = screen.getByRole('button', { name: /filter/i });
2086
+ expect(filterButton).toBeInTheDocument();
2087
+ });
2088
+ });
224
2089
  });