@object-ui/plugin-view 3.0.3 → 3.1.1

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.
@@ -541,4 +541,101 @@ describe('FilterUI', () => {
541
541
  window.removeEventListener('filter:changed', spy);
542
542
  });
543
543
  });
544
+
545
+ // -------------------------------------------------------------------------
546
+ // 9. Multi-select filter type
547
+ // -------------------------------------------------------------------------
548
+ describe('multi-select filter type', () => {
549
+ const multiSelectFilters: FilterUISchema['filters'] = [
550
+ {
551
+ field: 'tags',
552
+ label: 'Tags',
553
+ type: 'multi-select',
554
+ options: [
555
+ { label: 'Frontend', value: 'frontend' },
556
+ { label: 'Backend', value: 'backend' },
557
+ { label: 'DevOps', value: 'devops' },
558
+ ],
559
+ },
560
+ ];
561
+
562
+ it('renders checkboxes for multi-select type', () => {
563
+ render(
564
+ <FilterUI
565
+ schema={makeSchema({ filters: multiSelectFilters })}
566
+ />,
567
+ );
568
+
569
+ expect(screen.getByText('Tags')).toBeInTheDocument();
570
+ const checkboxes = screen.getAllByTestId('checkbox');
571
+ expect(checkboxes.length).toBe(3);
572
+ expect(screen.getByText('Frontend')).toBeInTheDocument();
573
+ expect(screen.getByText('Backend')).toBeInTheDocument();
574
+ expect(screen.getByText('DevOps')).toBeInTheDocument();
575
+ });
576
+
577
+ it('calls onChange with array when multi-select checkbox is toggled', () => {
578
+ const onChange = vi.fn();
579
+ render(
580
+ <FilterUI
581
+ schema={makeSchema({ filters: multiSelectFilters })}
582
+ onChange={onChange}
583
+ />,
584
+ );
585
+
586
+ const checkboxes = screen.getAllByTestId('checkbox');
587
+ fireEvent.click(checkboxes[0]); // Frontend
588
+ expect(onChange).toHaveBeenCalledWith({ tags: ['frontend'] });
589
+ });
590
+
591
+ it('adds to selection when another checkbox is checked', () => {
592
+ const onChange = vi.fn();
593
+ render(
594
+ <FilterUI
595
+ schema={makeSchema({
596
+ filters: multiSelectFilters,
597
+ values: { tags: ['frontend'] },
598
+ })}
599
+ onChange={onChange}
600
+ />,
601
+ );
602
+
603
+ const checkboxes = screen.getAllByTestId('checkbox');
604
+ fireEvent.click(checkboxes[1]); // Backend
605
+ expect(onChange).toHaveBeenCalledWith({ tags: ['frontend', 'backend'] });
606
+ });
607
+
608
+ it('removes from selection when checkbox is unchecked', () => {
609
+ const onChange = vi.fn();
610
+ render(
611
+ <FilterUI
612
+ schema={makeSchema({
613
+ filters: multiSelectFilters,
614
+ values: { tags: ['frontend', 'backend'] },
615
+ })}
616
+ onChange={onChange}
617
+ />,
618
+ );
619
+
620
+ // Uncheck Frontend (first checkbox)
621
+ const checkboxes = screen.getAllByTestId('checkbox');
622
+ fireEvent.click(checkboxes[0]); // Frontend
623
+ expect(onChange).toHaveBeenCalledWith({ tags: ['backend'] });
624
+ });
625
+
626
+ it('shows selected count in active badge for popover layout', () => {
627
+ render(
628
+ <FilterUI
629
+ schema={makeSchema({
630
+ layout: 'popover',
631
+ filters: multiSelectFilters,
632
+ values: { tags: ['frontend', 'backend'] },
633
+ })}
634
+ />,
635
+ );
636
+
637
+ // Active count should be 1 (tags field has a value)
638
+ expect(screen.getByText('1')).toBeInTheDocument();
639
+ });
640
+ });
544
641
  });
@@ -288,6 +288,59 @@ describe('ObjectView', () => {
288
288
 
289
289
  expect(onNavigate).toHaveBeenCalledWith('1', 'view');
290
290
  });
291
+
292
+ it('should open form in view mode when split navigation mode is clicked', () => {
293
+ const schema: ObjectViewSchema = {
294
+ type: 'object-view',
295
+ objectName: 'contacts',
296
+ navigation: { mode: 'split' },
297
+ };
298
+
299
+ render(<ObjectView schema={schema} dataSource={mockDataSource} />);
300
+
301
+ fireEvent.click(screen.getByTestId('grid-row'));
302
+
303
+ // Split mode renders NavigationOverlay with split panels including a close button
304
+ expect(screen.getByTestId('object-form')).toBeDefined();
305
+ expect(screen.getByLabelText('Close panel')).toBeDefined();
306
+ });
307
+
308
+ it('should open form in view mode when popover navigation mode is clicked', () => {
309
+ const schema: ObjectViewSchema = {
310
+ type: 'object-view',
311
+ objectName: 'contacts',
312
+ navigation: { mode: 'popover' },
313
+ };
314
+
315
+ render(<ObjectView schema={schema} dataSource={mockDataSource} />);
316
+
317
+ fireEvent.click(screen.getByTestId('grid-row'));
318
+
319
+ // Popover mode renders NavigationOverlay Dialog fallback (no popoverTrigger)
320
+ expect(screen.getByTestId('object-form')).toBeDefined();
321
+ expect(screen.getByRole('dialog')).toBeDefined();
322
+ });
323
+
324
+ it('should close split panel and return to normal view', () => {
325
+ const schema: ObjectViewSchema = {
326
+ type: 'object-view',
327
+ objectName: 'contacts',
328
+ navigation: { mode: 'split' },
329
+ };
330
+
331
+ render(<ObjectView schema={schema} dataSource={mockDataSource} />);
332
+
333
+ // Open split panel
334
+ fireEvent.click(screen.getByTestId('grid-row'));
335
+ expect(screen.getByLabelText('Close panel')).toBeDefined();
336
+
337
+ // Close split panel
338
+ fireEvent.click(screen.getByLabelText('Close panel'));
339
+
340
+ // Form should be gone, grid should remain
341
+ expect(screen.queryByLabelText('Close panel')).toBeNull();
342
+ expect(screen.getByTestId('object-grid')).toBeDefined();
343
+ });
291
344
  });
292
345
 
293
346
  // ============================
@@ -372,4 +425,241 @@ describe('ObjectView', () => {
372
425
  expect(screen.queryByText('Kanban')).toBeNull();
373
426
  });
374
427
  });
428
+
429
+ // ============================
430
+ // Live Preview — viewConfig sync
431
+ // ============================
432
+ describe('Live Preview', () => {
433
+ it('should re-render grid when views prop updates with new columns', async () => {
434
+ const schema: ObjectViewSchema = {
435
+ type: 'object-view',
436
+ objectName: 'contacts',
437
+ };
438
+
439
+ const initialViews = [
440
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name', 'email'] },
441
+ ];
442
+
443
+ const { rerender } = render(
444
+ <ObjectView schema={schema} dataSource={mockDataSource} views={initialViews} activeViewId="all" />,
445
+ );
446
+
447
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
448
+
449
+ // Simulate live preview: update views prop with new columns (as parent would after viewDraft change)
450
+ const updatedViews = [
451
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name', 'email', 'status'] },
452
+ ];
453
+
454
+ rerender(
455
+ <ObjectView schema={schema} dataSource={mockDataSource} views={updatedViews} activeViewId="all" />,
456
+ );
457
+
458
+ // Grid should still render (component did not crash on prop update)
459
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
460
+ });
461
+
462
+ it('should re-render when views prop updates with new sort config', async () => {
463
+ const schema: ObjectViewSchema = {
464
+ type: 'object-view',
465
+ objectName: 'contacts',
466
+ };
467
+
468
+ const initialViews = [
469
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'] },
470
+ ];
471
+
472
+ const { rerender } = render(
473
+ <ObjectView schema={schema} dataSource={mockDataSource} views={initialViews} activeViewId="all" />,
474
+ );
475
+
476
+ // Update with sort config — simulates live preview of sort changes
477
+ const updatedViews = [
478
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'], sort: [{ field: 'name', direction: 'desc' as const }] },
479
+ ];
480
+
481
+ rerender(
482
+ <ObjectView schema={schema} dataSource={mockDataSource} views={updatedViews} activeViewId="all" />,
483
+ );
484
+
485
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
486
+ });
487
+
488
+ it('should re-render when views prop updates with new filter', async () => {
489
+ const schema: ObjectViewSchema = {
490
+ type: 'object-view',
491
+ objectName: 'contacts',
492
+ };
493
+
494
+ const initialViews = [
495
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'] },
496
+ ];
497
+
498
+ const { rerender } = render(
499
+ <ObjectView schema={schema} dataSource={mockDataSource} views={initialViews} activeViewId="all" />,
500
+ );
501
+
502
+ // Update with filter — simulates live preview of filter changes
503
+ const updatedViews = [
504
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'], filter: [['status', '=', 'active']] },
505
+ ];
506
+
507
+ rerender(
508
+ <ObjectView schema={schema} dataSource={mockDataSource} views={updatedViews} activeViewId="all" />,
509
+ );
510
+
511
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
512
+ });
513
+
514
+ it('should re-render when views prop updates with appearance properties', async () => {
515
+ const schema: ObjectViewSchema = {
516
+ type: 'object-view',
517
+ objectName: 'contacts',
518
+ };
519
+
520
+ const initialViews = [
521
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'] },
522
+ ];
523
+
524
+ const { rerender } = render(
525
+ <ObjectView schema={schema} dataSource={mockDataSource} views={initialViews} activeViewId="all" />,
526
+ );
527
+
528
+ // Update with appearance changes — simulates live preview of rowHeight/striped/bordered
529
+ const updatedViews = [
530
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'], striped: true, bordered: true },
531
+ ];
532
+
533
+ rerender(
534
+ <ObjectView schema={schema} dataSource={mockDataSource} views={updatedViews} activeViewId="all" />,
535
+ );
536
+
537
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
538
+ });
539
+
540
+ it('should pass renderListView with updated schema when views change', async () => {
541
+ const schema: ObjectViewSchema = {
542
+ type: 'object-view',
543
+ objectName: 'contacts',
544
+ };
545
+
546
+ const renderListViewSpy = vi.fn(({ schema: listSchema }: any) => (
547
+ <div data-testid="custom-list" data-fields={JSON.stringify(listSchema.fields)}>
548
+ Custom ListView
549
+ </div>
550
+ ));
551
+
552
+ const initialViews = [
553
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name'] },
554
+ ];
555
+
556
+ const { rerender } = render(
557
+ <ObjectView
558
+ schema={schema}
559
+ dataSource={mockDataSource}
560
+ views={initialViews}
561
+ activeViewId="all"
562
+ renderListView={renderListViewSpy}
563
+ />,
564
+ );
565
+
566
+ expect(screen.getByTestId('custom-list')).toBeInTheDocument();
567
+ const firstCallSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema;
568
+ expect(firstCallSchema?.fields).toEqual(['name']);
569
+
570
+ // Update views — simulate live preview change
571
+ const updatedViews = [
572
+ { id: 'all', label: 'All', type: 'grid' as const, columns: ['name', 'email', 'status'] },
573
+ ];
574
+
575
+ rerender(
576
+ <ObjectView
577
+ schema={schema}
578
+ dataSource={mockDataSource}
579
+ views={updatedViews}
580
+ activeViewId="all"
581
+ renderListView={renderListViewSpy}
582
+ />,
583
+ );
584
+
585
+ // renderListView should have been called again with the updated columns
586
+ const lastCallIndex = renderListViewSpy.mock.calls.length - 1;
587
+ const lastCallSchema = renderListViewSpy.mock.calls[lastCallIndex]?.[0]?.schema;
588
+ expect(lastCallSchema?.fields).toEqual(['name', 'email', 'status']);
589
+ });
590
+
591
+ it('should pass showSort=false through schema to suppress sort UI', async () => {
592
+ const schema: ObjectViewSchema = {
593
+ type: 'object-view',
594
+ objectName: 'contacts',
595
+ showSort: false,
596
+ };
597
+
598
+ render(
599
+ <ObjectView schema={schema} dataSource={mockDataSource} />,
600
+ );
601
+
602
+ // Component renders without crash — showSort is respected
603
+ expect(screen.getByTestId('object-grid')).toBeInTheDocument();
604
+ });
605
+
606
+ it('should include showSearch/showFilters/showSort in renderListView schema', async () => {
607
+ const schema: ObjectViewSchema = {
608
+ type: 'object-view',
609
+ objectName: 'contacts',
610
+ showSearch: false,
611
+ showFilters: false,
612
+ showSort: false,
613
+ };
614
+
615
+ const renderListViewSpy = vi.fn(({ schema: listSchema }: any) => (
616
+ <div data-testid="custom-list">Custom ListView</div>
617
+ ));
618
+
619
+ render(
620
+ <ObjectView
621
+ schema={schema}
622
+ dataSource={mockDataSource}
623
+ renderListView={renderListViewSpy}
624
+ />,
625
+ );
626
+
627
+ expect(renderListViewSpy).toHaveBeenCalled();
628
+ const callSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema;
629
+ expect(callSchema?.showSearch).toBe(false);
630
+ expect(callSchema?.showFilters).toBe(false);
631
+ expect(callSchema?.showSort).toBe(false);
632
+ });
633
+
634
+ it('should propagate showSearch/showFilters/showSort from activeView in renderListView', async () => {
635
+ const schema: ObjectViewSchema = {
636
+ type: 'object-view',
637
+ objectName: 'contacts',
638
+ };
639
+
640
+ const renderListViewSpy = vi.fn(({ schema: listSchema }: any) => (
641
+ <div data-testid="custom-list">Custom ListView</div>
642
+ ));
643
+
644
+ const views = [
645
+ { id: 'v1', label: 'View 1', type: 'grid' as const, showSearch: false, showFilters: false, showSort: false },
646
+ ];
647
+
648
+ render(
649
+ <ObjectView
650
+ schema={schema}
651
+ dataSource={mockDataSource}
652
+ views={views}
653
+ activeViewId="v1"
654
+ renderListView={renderListViewSpy}
655
+ />,
656
+ );
657
+
658
+ expect(renderListViewSpy).toHaveBeenCalled();
659
+ const callSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema;
660
+ expect(callSchema?.showSearch).toBe(false);
661
+ expect(callSchema?.showFilters).toBe(false);
662
+ expect(callSchema?.showSort).toBe(false);
663
+ });
664
+ });
375
665
  });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
11
+ import userEvent from '@testing-library/user-event';
12
+ import React from 'react';
13
+
14
+ vi.mock('@object-ui/components', () => ({
15
+ cn: (...args: any[]) => args.filter(Boolean).join(' '),
16
+ Button: ({ children, onClick, ...props }: any) => (
17
+ <button onClick={onClick} {...props}>{children}</button>
18
+ ),
19
+ Badge: ({ children, ...props }: any) => <span {...props}>{children}</span>,
20
+ Input: React.forwardRef(({ ...props }: any, ref: any) => <input ref={ref} {...props} />),
21
+ Popover: ({ children }: any) => <div>{children}</div>,
22
+ PopoverContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
23
+ PopoverTrigger: ({ children, asChild }: any) => <>{children}</>,
24
+ }));
25
+
26
+ vi.mock('lucide-react', () => ({
27
+ Share2: () => <span>ShareIcon</span>,
28
+ Copy: () => <span>CopyIcon</span>,
29
+ Check: () => <span>CheckIcon</span>,
30
+ Lock: () => <span>LockIcon</span>,
31
+ Calendar: () => <span>CalendarIcon</span>,
32
+ }));
33
+
34
+ import { SharedViewLink } from '../SharedViewLink';
35
+
36
+ describe('SharedViewLink - Password & Expiration', () => {
37
+ beforeEach(() => {
38
+ vi.restoreAllMocks();
39
+ });
40
+
41
+ it('renders the Share button', () => {
42
+ render(<SharedViewLink objectName="tasks" />);
43
+ expect(screen.getByText('Share')).toBeInTheDocument();
44
+ });
45
+
46
+ it('renders password input field in the popover', () => {
47
+ render(<SharedViewLink objectName="tasks" />);
48
+ const passwordInput = screen.getByPlaceholderText('Enter password...');
49
+ expect(passwordInput).toBeInTheDocument();
50
+ expect(passwordInput).toHaveAttribute('type', 'password');
51
+ });
52
+
53
+ it('renders expiration dropdown with options', () => {
54
+ render(<SharedViewLink objectName="tasks" />);
55
+
56
+ const select = screen.getByRole('combobox');
57
+ expect(select).toBeInTheDocument();
58
+
59
+ // Verify options are present
60
+ const options = screen.getAllByRole('option');
61
+ const optionTexts = options.map(o => o.textContent);
62
+ expect(optionTexts).toContain('Never');
63
+ expect(optionTexts).toContain('1 day');
64
+ expect(optionTexts).toContain('7 days');
65
+ expect(optionTexts).toContain('30 days');
66
+ expect(optionTexts).toContain('90 days');
67
+ });
68
+
69
+ it('shows "Password protected" badge after generating link with a password', async () => {
70
+ render(<SharedViewLink objectName="tasks" baseUrl="https://example.com" />);
71
+
72
+ // Type a password
73
+ const passwordInput = screen.getByPlaceholderText('Enter password...');
74
+ fireEvent.change(passwordInput, { target: { value: 'secret123' } });
75
+
76
+ // Click Generate Link
77
+ const generateBtn = screen.getByText('Generate Link');
78
+ fireEvent.click(generateBtn);
79
+
80
+ // After link is generated, "Password protected" badge should appear
81
+ await waitFor(() => {
82
+ expect(screen.getByText('Password protected')).toBeInTheDocument();
83
+ });
84
+ });
85
+
86
+ it('shows expiration badge after generating link with expiration set', async () => {
87
+ render(<SharedViewLink objectName="tasks" baseUrl="https://example.com" />);
88
+
89
+ // Select 7 days expiration
90
+ const select = screen.getByRole('combobox');
91
+ fireEvent.change(select, { target: { value: '7' } });
92
+
93
+ // Click Generate Link
94
+ const generateBtn = screen.getByText('Generate Link');
95
+ fireEvent.click(generateBtn);
96
+
97
+ // After link is generated, expiration badge should appear
98
+ await waitFor(() => {
99
+ expect(screen.getByText(/Expires in 7 days/)).toBeInTheDocument();
100
+ });
101
+ });
102
+
103
+ it('shows singular "day" for 1 day expiration', async () => {
104
+ render(<SharedViewLink objectName="tasks" baseUrl="https://example.com" />);
105
+
106
+ const select = screen.getByRole('combobox');
107
+ fireEvent.change(select, { target: { value: '1' } });
108
+
109
+ const generateBtn = screen.getByText('Generate Link');
110
+ fireEvent.click(generateBtn);
111
+
112
+ await waitFor(() => {
113
+ expect(screen.getByText(/Expires in 1 day$/)).toBeInTheDocument();
114
+ });
115
+ });
116
+
117
+ it('calls onShare callback with password and expiresAt options', async () => {
118
+ const onShare = vi.fn();
119
+ render(
120
+ <SharedViewLink
121
+ objectName="tasks"
122
+ baseUrl="https://example.com"
123
+ onShare={onShare}
124
+ />
125
+ );
126
+
127
+ // Set password
128
+ const passwordInput = screen.getByPlaceholderText('Enter password...');
129
+ fireEvent.change(passwordInput, { target: { value: 'mypass' } });
130
+
131
+ // Set expiration to 30 days
132
+ const select = screen.getByRole('combobox');
133
+ fireEvent.change(select, { target: { value: '30' } });
134
+
135
+ // Generate
136
+ const generateBtn = screen.getByText('Generate Link');
137
+ fireEvent.click(generateBtn);
138
+
139
+ expect(onShare).toHaveBeenCalledTimes(1);
140
+ const [url, options] = onShare.mock.calls[0];
141
+
142
+ // URL should contain share path
143
+ expect(url).toContain('/share/tasks/');
144
+ expect(url).toContain('token=');
145
+
146
+ // Options should include password and expiresAt
147
+ expect(options.password).toBe('mypass');
148
+ expect(options.expiresAt).toBeDefined();
149
+ // expiresAt should be a valid ISO date string roughly 30 days from now
150
+ const expiresAt = new Date(options.expiresAt);
151
+ expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
152
+ });
153
+
154
+ it('calls onShare without password and expiresAt when neither is set', () => {
155
+ const onShare = vi.fn();
156
+ render(
157
+ <SharedViewLink
158
+ objectName="tasks"
159
+ baseUrl="https://example.com"
160
+ onShare={onShare}
161
+ />
162
+ );
163
+
164
+ const generateBtn = screen.getByText('Generate Link');
165
+ fireEvent.click(generateBtn);
166
+
167
+ expect(onShare).toHaveBeenCalledTimes(1);
168
+ const [, options] = onShare.mock.calls[0];
169
+ expect(options.password).toBeUndefined();
170
+ expect(options.expiresAt).toBeUndefined();
171
+ });
172
+ });