@object-ui/plugin-view 3.0.2 → 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.
@@ -0,0 +1,710 @@
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 } from '@testing-library/react';
11
+ import { ViewTabBar, type ViewTabItem, type ViewTabBarProps } from '../ViewTabBar';
12
+
13
+ const createViews = (count: number): ViewTabItem[] =>
14
+ Array.from({ length: count }, (_, i) => ({
15
+ id: `view-${i}`,
16
+ label: `View ${i}`,
17
+ type: i === 0 ? 'grid' : i === 1 ? 'kanban' : 'calendar',
18
+ }));
19
+
20
+ const defaultProps: ViewTabBarProps = {
21
+ views: createViews(3),
22
+ activeViewId: 'view-0',
23
+ onViewChange: vi.fn(),
24
+ };
25
+
26
+ describe('ViewTabBar', () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ // ============================
32
+ // Basic Rendering
33
+ // ============================
34
+ describe('Basic Rendering', () => {
35
+ it('should render all view tabs', () => {
36
+ render(<ViewTabBar {...defaultProps} />);
37
+ expect(screen.getByTestId('view-tab-bar')).toBeDefined();
38
+ expect(screen.getByTestId('view-tab-view-0')).toBeDefined();
39
+ expect(screen.getByTestId('view-tab-view-1')).toBeDefined();
40
+ expect(screen.getByTestId('view-tab-view-2')).toBeDefined();
41
+ });
42
+
43
+ it('should render view labels', () => {
44
+ render(<ViewTabBar {...defaultProps} />);
45
+ expect(screen.getByText('View 0')).toBeDefined();
46
+ expect(screen.getByText('View 1')).toBeDefined();
47
+ expect(screen.getByText('View 2')).toBeDefined();
48
+ });
49
+
50
+ it('should highlight active tab', () => {
51
+ render(<ViewTabBar {...defaultProps} />);
52
+ const activeTab = screen.getByTestId('view-tab-view-0');
53
+ expect(activeTab.className).toContain('border-primary');
54
+ });
55
+
56
+ it('should call onViewChange when tab is clicked', () => {
57
+ const onViewChange = vi.fn();
58
+ render(<ViewTabBar {...defaultProps} onViewChange={onViewChange} />);
59
+ fireEvent.click(screen.getByTestId('view-tab-view-1'));
60
+ expect(onViewChange).toHaveBeenCalledWith('view-1');
61
+ });
62
+ });
63
+
64
+ // ============================
65
+ // Inline "+" Add View Button
66
+ // ============================
67
+ describe('Add View Button', () => {
68
+ it('should render "+" button when onAddView is provided', () => {
69
+ const onAddView = vi.fn();
70
+ render(<ViewTabBar {...defaultProps} onAddView={onAddView} />);
71
+ expect(screen.getByTestId('view-tab-add')).toBeDefined();
72
+ });
73
+
74
+ it('should not render "+" button when showAddButton is false', () => {
75
+ render(
76
+ <ViewTabBar
77
+ {...defaultProps}
78
+ onAddView={vi.fn()}
79
+ config={{ showAddButton: false }}
80
+ />
81
+ );
82
+ expect(screen.queryByTestId('view-tab-add')).toBeNull();
83
+ });
84
+
85
+ it('should not render "+" button when onAddView is not provided', () => {
86
+ render(<ViewTabBar {...defaultProps} />);
87
+ expect(screen.queryByTestId('view-tab-add')).toBeNull();
88
+ });
89
+
90
+ it('should call onAddView when "+" button is clicked', () => {
91
+ const onAddView = vi.fn();
92
+ render(<ViewTabBar {...defaultProps} onAddView={onAddView} />);
93
+ fireEvent.click(screen.getByTestId('view-tab-add'));
94
+ expect(onAddView).toHaveBeenCalledOnce();
95
+ });
96
+ });
97
+
98
+ // ============================
99
+ // Tab Overflow ("More" Dropdown)
100
+ // ============================
101
+ describe('Tab Overflow', () => {
102
+ it('should show overflow button when views exceed maxVisibleTabs', () => {
103
+ render(
104
+ <ViewTabBar
105
+ {...defaultProps}
106
+ views={createViews(8)}
107
+ config={{ maxVisibleTabs: 6 }}
108
+ />
109
+ );
110
+ expect(screen.getByTestId('view-tab-overflow')).toBeDefined();
111
+ expect(screen.getByText('2 more')).toBeDefined();
112
+ });
113
+
114
+ it('should not show overflow when all views fit', () => {
115
+ render(
116
+ <ViewTabBar
117
+ {...defaultProps}
118
+ views={createViews(3)}
119
+ config={{ maxVisibleTabs: 6 }}
120
+ />
121
+ );
122
+ expect(screen.queryByTestId('view-tab-overflow')).toBeNull();
123
+ });
124
+
125
+ it('should only render maxVisibleTabs tabs directly', () => {
126
+ render(
127
+ <ViewTabBar
128
+ {...defaultProps}
129
+ views={createViews(8)}
130
+ config={{ maxVisibleTabs: 4 }}
131
+ />
132
+ );
133
+ // First 4 should be visible as direct tabs
134
+ expect(screen.getByTestId('view-tab-view-0')).toBeDefined();
135
+ expect(screen.getByTestId('view-tab-view-3')).toBeDefined();
136
+ // 5th should NOT be a direct tab
137
+ expect(screen.queryByTestId('view-tab-view-4')).toBeNull();
138
+ // But should appear in overflow
139
+ expect(screen.getByText('4 more')).toBeDefined();
140
+ });
141
+ });
142
+
143
+ // ============================
144
+ // Filter/Sort Indicator Badges
145
+ // ============================
146
+ describe('Filter/Sort Indicators', () => {
147
+ it('should show indicator dot when view has active filters', () => {
148
+ const views: ViewTabItem[] = [
149
+ { id: 'v1', label: 'Active', type: 'grid', hasActiveFilters: true },
150
+ { id: 'v2', label: 'All', type: 'grid' },
151
+ ];
152
+ render(<ViewTabBar {...defaultProps} views={views} />);
153
+ expect(screen.getByTestId('view-tab-indicator-v1')).toBeDefined();
154
+ expect(screen.queryByTestId('view-tab-indicator-v2')).toBeNull();
155
+ });
156
+
157
+ it('should show indicator dot when view has active sort', () => {
158
+ const views: ViewTabItem[] = [
159
+ { id: 'v1', label: 'Sorted', type: 'grid', hasActiveSort: true },
160
+ { id: 'v2', label: 'All', type: 'grid' },
161
+ ];
162
+ render(<ViewTabBar {...defaultProps} views={views} />);
163
+ expect(screen.getByTestId('view-tab-indicator-v1')).toBeDefined();
164
+ });
165
+
166
+ it('should not show indicators when showIndicators is false', () => {
167
+ const views: ViewTabItem[] = [
168
+ { id: 'v1', label: 'Active', type: 'grid', hasActiveFilters: true },
169
+ ];
170
+ render(
171
+ <ViewTabBar
172
+ {...defaultProps}
173
+ views={views}
174
+ config={{ showIndicators: false }}
175
+ />
176
+ );
177
+ expect(screen.queryByTestId('view-tab-indicator-v1')).toBeNull();
178
+ });
179
+ });
180
+
181
+ // ============================
182
+ // Context Menu
183
+ // ============================
184
+ describe('Context Menu', () => {
185
+ it('should show context menu items when right-clicking a tab', () => {
186
+ render(
187
+ <ViewTabBar
188
+ {...defaultProps}
189
+ onRenameView={vi.fn()}
190
+ onDuplicateView={vi.fn()}
191
+ onDeleteView={vi.fn()}
192
+ onSetDefaultView={vi.fn()}
193
+ onShareView={vi.fn()}
194
+ />
195
+ );
196
+ // Context menu is rendered but hidden by default via Radix
197
+ // Verify the tab is rendered as a trigger
198
+ const tab = screen.getByTestId('view-tab-view-0');
199
+ expect(tab).toBeDefined();
200
+ });
201
+
202
+ it('should not wrap with context menu when contextMenu is false', () => {
203
+ const { container } = render(
204
+ <ViewTabBar
205
+ {...defaultProps}
206
+ config={{ contextMenu: false }}
207
+ onRenameView={vi.fn()}
208
+ onDeleteView={vi.fn()}
209
+ />
210
+ );
211
+ // When contextMenu is disabled, no context menu wrapper
212
+ expect(container.querySelector('[data-testid="context-menu-rename-view-0"]')).toBeNull();
213
+ });
214
+ });
215
+
216
+ // ============================
217
+ // Save as View
218
+ // ============================
219
+ describe('Save as View', () => {
220
+ it('should show save-as-view indicator when hasUnsavedChanges is true', () => {
221
+ render(
222
+ <ViewTabBar
223
+ {...defaultProps}
224
+ hasUnsavedChanges={true}
225
+ onSaveAsView={vi.fn()}
226
+ />
227
+ );
228
+ expect(screen.getByTestId('view-tab-save-as')).toBeDefined();
229
+ });
230
+
231
+ it('should not show save-as-view indicator when hasUnsavedChanges is false', () => {
232
+ render(
233
+ <ViewTabBar
234
+ {...defaultProps}
235
+ hasUnsavedChanges={false}
236
+ onSaveAsView={vi.fn()}
237
+ />
238
+ );
239
+ expect(screen.queryByTestId('view-tab-save-as')).toBeNull();
240
+ });
241
+
242
+ it('should call onSaveAsView when Save button is clicked', () => {
243
+ const onSaveAsView = vi.fn();
244
+ render(
245
+ <ViewTabBar
246
+ {...defaultProps}
247
+ hasUnsavedChanges={true}
248
+ onSaveAsView={onSaveAsView}
249
+ />
250
+ );
251
+ fireEvent.click(screen.getByTestId('view-tab-save-as-btn'));
252
+ expect(onSaveAsView).toHaveBeenCalledOnce();
253
+ });
254
+
255
+ it('should call onResetChanges when Reset button is clicked', () => {
256
+ const onResetChanges = vi.fn();
257
+ render(
258
+ <ViewTabBar
259
+ {...defaultProps}
260
+ hasUnsavedChanges={true}
261
+ onSaveAsView={vi.fn()}
262
+ onResetChanges={onResetChanges}
263
+ />
264
+ );
265
+ fireEvent.click(screen.getByTestId('view-tab-reset-btn'));
266
+ expect(onResetChanges).toHaveBeenCalledOnce();
267
+ });
268
+
269
+ it('should not show save-as-view when showSaveAsView is false', () => {
270
+ render(
271
+ <ViewTabBar
272
+ {...defaultProps}
273
+ hasUnsavedChanges={true}
274
+ onSaveAsView={vi.fn()}
275
+ config={{ showSaveAsView: false }}
276
+ />
277
+ );
278
+ expect(screen.queryByTestId('view-tab-save-as')).toBeNull();
279
+ });
280
+ });
281
+
282
+ // ============================
283
+ // Inline Rename
284
+ // ============================
285
+ describe('Inline Rename', () => {
286
+ it('should enter rename mode on double-click', () => {
287
+ render(
288
+ <ViewTabBar
289
+ {...defaultProps}
290
+ onRenameView={vi.fn()}
291
+ />
292
+ );
293
+ const tab = screen.getByTestId('view-tab-view-0');
294
+ fireEvent.doubleClick(tab);
295
+ expect(screen.getByTestId('view-tab-rename-input-view-0')).toBeDefined();
296
+ });
297
+
298
+ it('should call onRenameView on Enter key', () => {
299
+ const onRenameView = vi.fn();
300
+ render(
301
+ <ViewTabBar
302
+ {...defaultProps}
303
+ onRenameView={onRenameView}
304
+ />
305
+ );
306
+ const tab = screen.getByTestId('view-tab-view-0');
307
+ fireEvent.doubleClick(tab);
308
+
309
+ const input = screen.getByTestId('view-tab-rename-input-view-0');
310
+ fireEvent.change(input, { target: { value: 'New Name' } });
311
+ fireEvent.keyDown(input, { key: 'Enter' });
312
+
313
+ expect(onRenameView).toHaveBeenCalledWith('view-0', 'New Name');
314
+ });
315
+
316
+ it('should cancel rename on Escape key', () => {
317
+ render(
318
+ <ViewTabBar
319
+ {...defaultProps}
320
+ onRenameView={vi.fn()}
321
+ />
322
+ );
323
+ const tab = screen.getByTestId('view-tab-view-0');
324
+ fireEvent.doubleClick(tab);
325
+
326
+ const input = screen.getByTestId('view-tab-rename-input-view-0');
327
+ fireEvent.keyDown(input, { key: 'Escape' });
328
+
329
+ // Should exit rename mode
330
+ expect(screen.queryByTestId('view-tab-rename-input-view-0')).toBeNull();
331
+ });
332
+
333
+ it('should not enter rename mode when inlineRename is false', () => {
334
+ render(
335
+ <ViewTabBar
336
+ {...defaultProps}
337
+ onRenameView={vi.fn()}
338
+ config={{ inlineRename: false }}
339
+ />
340
+ );
341
+ const tab = screen.getByTestId('view-tab-view-0');
342
+ fireEvent.doubleClick(tab);
343
+ expect(screen.queryByTestId('view-tab-rename-input-view-0')).toBeNull();
344
+ });
345
+ });
346
+
347
+ // ============================
348
+ // Default View Indicator
349
+ // ============================
350
+ describe('Default View Indicator', () => {
351
+ it('should show star icon for default view', () => {
352
+ const views: ViewTabItem[] = [
353
+ { id: 'v1', label: 'Default', type: 'grid', isDefault: true },
354
+ { id: 'v2', label: 'Other', type: 'grid' },
355
+ ];
356
+ const { container } = render(<ViewTabBar {...defaultProps} views={views} />);
357
+ // The default view tab should contain a star icon
358
+ const defaultTab = screen.getByTestId('view-tab-v1');
359
+ // Star icon rendered via lucide-react SVG
360
+ expect(defaultTab.querySelector('svg.text-amber-500')).toBeDefined();
361
+ });
362
+ });
363
+
364
+ // ============================
365
+ // Pin/Favorite Views (Phase 2)
366
+ // ============================
367
+ describe('Pin/Favorite Views', () => {
368
+ it('should show pin indicator on pinned views', () => {
369
+ const views: ViewTabItem[] = [
370
+ { id: 'v1', label: 'Pinned', type: 'grid', isPinned: true },
371
+ { id: 'v2', label: 'Normal', type: 'grid' },
372
+ ];
373
+ render(<ViewTabBar {...defaultProps} views={views} />);
374
+ expect(screen.getByTestId('view-tab-pin-indicator-v1')).toBeDefined();
375
+ expect(screen.queryByTestId('view-tab-pin-indicator-v2')).toBeNull();
376
+ });
377
+
378
+ it('should not show pin indicator when showPinnedSection is false', () => {
379
+ const views: ViewTabItem[] = [
380
+ { id: 'v1', label: 'Pinned', type: 'grid', isPinned: true },
381
+ ];
382
+ render(
383
+ <ViewTabBar
384
+ {...defaultProps}
385
+ views={views}
386
+ config={{ showPinnedSection: false }}
387
+ />
388
+ );
389
+ expect(screen.queryByTestId('view-tab-pin-indicator-v1')).toBeNull();
390
+ });
391
+
392
+ it('should sort pinned views to the front', () => {
393
+ const views: ViewTabItem[] = [
394
+ { id: 'v1', label: 'Normal', type: 'grid' },
395
+ { id: 'v2', label: 'Pinned', type: 'grid', isPinned: true },
396
+ { id: 'v3', label: 'Also Normal', type: 'grid' },
397
+ ];
398
+ render(<ViewTabBar {...defaultProps} views={views} />);
399
+ // Pinned view (v2) should appear before non-pinned views in DOM order
400
+ const v2 = screen.getByTestId('view-tab-v2');
401
+ const v1 = screen.getByTestId('view-tab-v1');
402
+ expect(v2.compareDocumentPosition(v1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
403
+ });
404
+
405
+ it('should render pin context menu item when onPinView is provided', () => {
406
+ const views: ViewTabItem[] = [
407
+ { id: 'v1', label: 'Test', type: 'grid' },
408
+ ];
409
+ render(
410
+ <ViewTabBar
411
+ {...defaultProps}
412
+ views={views}
413
+ onPinView={vi.fn()}
414
+ />
415
+ );
416
+ // Context menu renders off-screen via Radix, so just verify the tab is wrapped
417
+ expect(screen.getByTestId('view-tab-v1')).toBeDefined();
418
+ });
419
+ });
420
+
421
+ // ============================
422
+ // Personal vs. Shared Grouping (Phase 2)
423
+ // ============================
424
+ describe('Visibility Grouping', () => {
425
+ it('should show visibility icons when showVisibilityGroups is true', () => {
426
+ const views: ViewTabItem[] = [
427
+ { id: 'v1', label: 'Private', type: 'grid', visibility: 'private' },
428
+ { id: 'v2', label: 'Shared', type: 'grid', visibility: 'public' },
429
+ ];
430
+ render(
431
+ <ViewTabBar
432
+ {...defaultProps}
433
+ views={views}
434
+ config={{ showVisibilityGroups: true }}
435
+ />
436
+ );
437
+ expect(screen.getByTestId('view-tab-visibility-v1')).toBeDefined();
438
+ expect(screen.getByTestId('view-tab-visibility-v2')).toBeDefined();
439
+ });
440
+
441
+ it('should not show visibility icons when showVisibilityGroups is false', () => {
442
+ const views: ViewTabItem[] = [
443
+ { id: 'v1', label: 'Private', type: 'grid', visibility: 'private' },
444
+ ];
445
+ render(
446
+ <ViewTabBar
447
+ {...defaultProps}
448
+ views={views}
449
+ config={{ showVisibilityGroups: false }}
450
+ />
451
+ );
452
+ expect(screen.queryByTestId('view-tab-visibility-v1')).toBeNull();
453
+ });
454
+
455
+ it('should show separator between private and shared views', () => {
456
+ const views: ViewTabItem[] = [
457
+ { id: 'v1', label: 'Private', type: 'grid', visibility: 'private' },
458
+ { id: 'v2', label: 'Shared', type: 'grid', visibility: 'public' },
459
+ ];
460
+ render(
461
+ <ViewTabBar
462
+ {...defaultProps}
463
+ views={views}
464
+ config={{ showVisibilityGroups: true }}
465
+ />
466
+ );
467
+ expect(screen.getByTestId('view-tab-visibility-separator')).toBeDefined();
468
+ });
469
+
470
+ it('should sort private views before shared views', () => {
471
+ const views: ViewTabItem[] = [
472
+ { id: 'v1', label: 'Shared', type: 'grid', visibility: 'public' },
473
+ { id: 'v2', label: 'Private', type: 'grid', visibility: 'private' },
474
+ ];
475
+ render(
476
+ <ViewTabBar
477
+ {...defaultProps}
478
+ views={views}
479
+ config={{ showVisibilityGroups: true }}
480
+ />
481
+ );
482
+ // Private (v2) should appear before public (v1)
483
+ const v2 = screen.getByTestId('view-tab-v2');
484
+ const v1 = screen.getByTestId('view-tab-v1');
485
+ expect(v2.compareDocumentPosition(v1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
486
+ });
487
+ });
488
+
489
+ // ============================
490
+ // Drag-Reorder View Tabs (Phase 2)
491
+ // ============================
492
+ describe('Drag-Reorder', () => {
493
+ it('should render sortable container when reorderable is true', () => {
494
+ render(
495
+ <ViewTabBar
496
+ {...defaultProps}
497
+ config={{ reorderable: true }}
498
+ onReorderViews={vi.fn()}
499
+ />
500
+ );
501
+ expect(screen.getByTestId('view-tab-sortable-container')).toBeDefined();
502
+ });
503
+
504
+ it('should not render sortable container when reorderable is false', () => {
505
+ render(
506
+ <ViewTabBar
507
+ {...defaultProps}
508
+ config={{ reorderable: false }}
509
+ />
510
+ );
511
+ expect(screen.queryByTestId('view-tab-sortable-container')).toBeNull();
512
+ });
513
+
514
+ it('should show drag handles when reorderable is true', () => {
515
+ render(
516
+ <ViewTabBar
517
+ {...defaultProps}
518
+ config={{ reorderable: true }}
519
+ onReorderViews={vi.fn()}
520
+ />
521
+ );
522
+ expect(screen.getByTestId('view-tab-drag-handle-view-0')).toBeDefined();
523
+ expect(screen.getByTestId('view-tab-drag-handle-view-1')).toBeDefined();
524
+ });
525
+
526
+ it('should not show drag handles when reorderable is false', () => {
527
+ render(<ViewTabBar {...defaultProps} />);
528
+ expect(screen.queryByTestId('view-tab-drag-handle-view-0')).toBeNull();
529
+ });
530
+
531
+ it('should not render sortable container without onReorderViews', () => {
532
+ render(
533
+ <ViewTabBar
534
+ {...defaultProps}
535
+ config={{ reorderable: true }}
536
+ />
537
+ );
538
+ expect(screen.queryByTestId('view-tab-sortable-container')).toBeNull();
539
+ });
540
+ });
541
+
542
+ // ============================
543
+ // View Type Quick-Switch (Phase 2)
544
+ // ============================
545
+ describe('View Type Quick-Switch', () => {
546
+ const availableTypes = [
547
+ { type: 'grid', label: 'Grid', description: 'Rows and columns' },
548
+ { type: 'kanban', label: 'Kanban', description: 'Drag cards' },
549
+ { type: 'calendar', label: 'Calendar', description: 'Events on calendar' },
550
+ ];
551
+
552
+ it('should accept availableViewTypes and onChangeViewType props', () => {
553
+ render(
554
+ <ViewTabBar
555
+ {...defaultProps}
556
+ onChangeViewType={vi.fn()}
557
+ availableViewTypes={availableTypes}
558
+ />
559
+ );
560
+ // The tab bar should render without errors
561
+ expect(screen.getByTestId('view-tab-bar')).toBeDefined();
562
+ });
563
+
564
+ it('should not render change type context menu without onChangeViewType', () => {
565
+ const { container } = render(
566
+ <ViewTabBar
567
+ {...defaultProps}
568
+ availableViewTypes={availableTypes}
569
+ />
570
+ );
571
+ // No quick-switch submenu trigger should be in the DOM
572
+ expect(container.querySelector('[data-testid^="context-menu-change-type"]')).toBeNull();
573
+ });
574
+
575
+ it('should not render change type context menu without availableViewTypes', () => {
576
+ const { container } = render(
577
+ <ViewTabBar
578
+ {...defaultProps}
579
+ onChangeViewType={vi.fn()}
580
+ />
581
+ );
582
+ expect(container.querySelector('[data-testid^="context-menu-change-type"]')).toBeNull();
583
+ });
584
+ });
585
+
586
+ // ============================
587
+ // Combined Phase 2 Features
588
+ // ============================
589
+ describe('Combined Phase 2 Features', () => {
590
+ it('should handle pinned + visibility grouping together', () => {
591
+ const views: ViewTabItem[] = [
592
+ { id: 'v1', label: 'Shared Normal', type: 'grid', visibility: 'public' },
593
+ { id: 'v2', label: 'Private Normal', type: 'grid', visibility: 'private' },
594
+ { id: 'v3', label: 'Pinned Shared', type: 'grid', visibility: 'public', isPinned: true },
595
+ ];
596
+ render(
597
+ <ViewTabBar
598
+ {...defaultProps}
599
+ views={views}
600
+ config={{ showVisibilityGroups: true, showPinnedSection: true }}
601
+ />
602
+ );
603
+ // Verify order: Pinned first (v3), then private (v2), then public (v1)
604
+ const v3 = screen.getByTestId('view-tab-v3');
605
+ const v2 = screen.getByTestId('view-tab-v2');
606
+ const v1 = screen.getByTestId('view-tab-v1');
607
+ // Check DOM order via compareDocumentPosition
608
+ expect(v3.compareDocumentPosition(v2) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
609
+ expect(v2.compareDocumentPosition(v1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
610
+ });
611
+
612
+ it('should render all features together without errors', () => {
613
+ const views: ViewTabItem[] = [
614
+ { id: 'v1', label: 'All Tasks', type: 'grid', isPinned: true, visibility: 'public', hasActiveFilters: true },
615
+ { id: 'v2', label: 'My Tasks', type: 'kanban', visibility: 'private' },
616
+ { id: 'v3', label: 'Calendar', type: 'calendar' },
617
+ ];
618
+ render(
619
+ <ViewTabBar
620
+ views={views}
621
+ activeViewId="v1"
622
+ onViewChange={vi.fn()}
623
+ config={{
624
+ showPinnedSection: true,
625
+ showVisibilityGroups: true,
626
+ reorderable: true,
627
+ showIndicators: true,
628
+ showSaveAsView: true,
629
+ }}
630
+ onAddView={vi.fn()}
631
+ onRenameView={vi.fn()}
632
+ onDuplicateView={vi.fn()}
633
+ onDeleteView={vi.fn()}
634
+ onPinView={vi.fn()}
635
+ onReorderViews={vi.fn()}
636
+ onChangeViewType={vi.fn()}
637
+ availableViewTypes={[
638
+ { type: 'grid', label: 'Grid' },
639
+ { type: 'kanban', label: 'Kanban' },
640
+ ]}
641
+ hasUnsavedChanges={true}
642
+ onSaveAsView={vi.fn()}
643
+ />
644
+ );
645
+ expect(screen.getByTestId('view-tab-bar')).toBeDefined();
646
+ expect(screen.getByTestId('view-tab-v1')).toBeDefined();
647
+ expect(screen.getByTestId('view-tab-pin-indicator-v1')).toBeDefined();
648
+ expect(screen.getByTestId('view-tab-indicator-v1')).toBeDefined();
649
+ expect(screen.getByTestId('view-tab-save-as')).toBeDefined();
650
+ expect(screen.getByTestId('view-tab-sortable-container')).toBeDefined();
651
+ });
652
+ });
653
+
654
+ // ============================
655
+ // Config View Gear Icon
656
+ // ============================
657
+ describe('Config View Gear Icon', () => {
658
+ it('should show gear icon on active tab when onConfigView is provided', () => {
659
+ render(
660
+ <ViewTabBar
661
+ {...defaultProps}
662
+ onConfigView={vi.fn()}
663
+ />
664
+ );
665
+ expect(screen.getByTestId('view-tab-config-view-0')).toBeDefined();
666
+ });
667
+
668
+ it('should not show gear icon on inactive tabs', () => {
669
+ render(
670
+ <ViewTabBar
671
+ {...defaultProps}
672
+ onConfigView={vi.fn()}
673
+ />
674
+ );
675
+ expect(screen.queryByTestId('view-tab-config-view-1')).toBeNull();
676
+ expect(screen.queryByTestId('view-tab-config-view-2')).toBeNull();
677
+ });
678
+
679
+ it('should not show gear icon when onConfigView is not provided', () => {
680
+ render(<ViewTabBar {...defaultProps} />);
681
+ expect(screen.queryByTestId('view-tab-config-view-0')).toBeNull();
682
+ });
683
+
684
+ it('should call onConfigView with viewId when gear icon is clicked', () => {
685
+ const onConfigView = vi.fn();
686
+ render(
687
+ <ViewTabBar
688
+ {...defaultProps}
689
+ onConfigView={onConfigView}
690
+ />
691
+ );
692
+ fireEvent.click(screen.getByTestId('view-tab-config-view-0'));
693
+ expect(onConfigView).toHaveBeenCalledWith('view-0');
694
+ });
695
+
696
+ it('should not trigger onViewChange when gear icon is clicked', () => {
697
+ const onViewChange = vi.fn();
698
+ render(
699
+ <ViewTabBar
700
+ {...defaultProps}
701
+ onViewChange={onViewChange}
702
+ onConfigView={vi.fn()}
703
+ />
704
+ );
705
+ onViewChange.mockClear();
706
+ fireEvent.click(screen.getByTestId('view-tab-config-view-0'));
707
+ expect(onViewChange).not.toHaveBeenCalled();
708
+ });
709
+ });
710
+ });