@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +11 -0
- package/dist/index.js +4382 -830
- package/dist/index.umd.cjs +6 -2
- package/dist/plugin-view/src/ObjectView.d.ts +8 -0
- package/dist/plugin-view/src/SharedViewLink.d.ts +23 -0
- package/dist/plugin-view/src/ViewSwitcher.d.ts +3 -0
- package/dist/plugin-view/src/ViewTabBar.d.ts +75 -0
- package/dist/plugin-view/src/index.d.ts +5 -1
- package/package.json +11 -8
- package/src/ObjectView.tsx +186 -22
- package/src/SharedViewLink.tsx +199 -0
- package/src/ViewSwitcher.tsx +69 -1
- package/src/ViewTabBar.tsx +656 -0
- package/src/__tests__/ObjectView.test.tsx +290 -0
- package/src/__tests__/SharedViewLinkPassword.test.tsx +172 -0
- package/src/__tests__/ViewTabBar.test.tsx +710 -0
- package/src/__tests__/config-sync-integration.test.tsx +588 -0
- package/src/index.tsx +21 -1
|
@@ -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
|
+
});
|