@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +12 -0
- package/dist/index.js +4399 -836
- 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/FilterUI.tsx +33 -0
- package/src/ObjectView.tsx +163 -8
- package/src/SharedViewLink.tsx +199 -0
- package/src/ViewSwitcher.tsx +69 -1
- package/src/ViewTabBar.tsx +656 -0
- package/src/__tests__/FilterUI.test.tsx +97 -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,656 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* ViewTabBar Component
|
|
11
|
+
*
|
|
12
|
+
* A reusable view tab bar with:
|
|
13
|
+
* - Inline "+" Add View button
|
|
14
|
+
* - Right-click context menu (rename, duplicate, delete, set as default, share)
|
|
15
|
+
* - Overflow → "More" dropdown when maxVisibleTabs exceeded
|
|
16
|
+
* - Filter/sort indicator badges on tabs
|
|
17
|
+
* - "Save as View" button when user filters differ from saved state
|
|
18
|
+
* - Double-click to rename view tab inline
|
|
19
|
+
* - Drag-reorder view tabs (Phase 2)
|
|
20
|
+
* - Pin/favorite views (Phase 2)
|
|
21
|
+
* - View type quick-switch palette (Phase 2)
|
|
22
|
+
* - Personal vs. shared views grouping (Phase 2)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo, type ComponentType } from 'react';
|
|
26
|
+
import {
|
|
27
|
+
cn,
|
|
28
|
+
ContextMenu,
|
|
29
|
+
ContextMenuTrigger,
|
|
30
|
+
ContextMenuContent,
|
|
31
|
+
ContextMenuItem,
|
|
32
|
+
ContextMenuSeparator,
|
|
33
|
+
ContextMenuSub,
|
|
34
|
+
ContextMenuSubTrigger,
|
|
35
|
+
ContextMenuSubContent,
|
|
36
|
+
DropdownMenu,
|
|
37
|
+
DropdownMenuTrigger,
|
|
38
|
+
DropdownMenuContent,
|
|
39
|
+
DropdownMenuItem,
|
|
40
|
+
DropdownMenuSeparator,
|
|
41
|
+
Input,
|
|
42
|
+
Button,
|
|
43
|
+
Tooltip,
|
|
44
|
+
TooltipTrigger,
|
|
45
|
+
TooltipContent,
|
|
46
|
+
TooltipProvider,
|
|
47
|
+
} from '@object-ui/components';
|
|
48
|
+
import {
|
|
49
|
+
Plus,
|
|
50
|
+
MoreHorizontal,
|
|
51
|
+
Pencil,
|
|
52
|
+
Copy,
|
|
53
|
+
Trash2,
|
|
54
|
+
Star,
|
|
55
|
+
Share2,
|
|
56
|
+
Save,
|
|
57
|
+
Table as TableIcon,
|
|
58
|
+
Pin,
|
|
59
|
+
PinOff,
|
|
60
|
+
Lock,
|
|
61
|
+
Globe,
|
|
62
|
+
GripVertical,
|
|
63
|
+
LayoutGrid,
|
|
64
|
+
Settings2,
|
|
65
|
+
} from 'lucide-react';
|
|
66
|
+
import {
|
|
67
|
+
DndContext,
|
|
68
|
+
closestCenter,
|
|
69
|
+
KeyboardSensor,
|
|
70
|
+
PointerSensor,
|
|
71
|
+
useSensor,
|
|
72
|
+
useSensors,
|
|
73
|
+
type DragEndEvent,
|
|
74
|
+
} from '@dnd-kit/core';
|
|
75
|
+
import {
|
|
76
|
+
SortableContext,
|
|
77
|
+
horizontalListSortingStrategy,
|
|
78
|
+
useSortable,
|
|
79
|
+
arrayMove,
|
|
80
|
+
} from '@dnd-kit/sortable';
|
|
81
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
82
|
+
import type { ViewTabBarConfig } from '@object-ui/types';
|
|
83
|
+
|
|
84
|
+
/** Visibility group sort order: private → team → organization → public */
|
|
85
|
+
const VISIBILITY_ORDER: Record<string, number> = { private: 0, team: 1, organization: 2, public: 3 };
|
|
86
|
+
|
|
87
|
+
/** Minimum drag distance in pixels to activate reorder */
|
|
88
|
+
const DRAG_ACTIVATION_DISTANCE = 5;
|
|
89
|
+
|
|
90
|
+
/** A single view definition for the tab bar */
|
|
91
|
+
export interface ViewTabItem {
|
|
92
|
+
/** Unique view identifier */
|
|
93
|
+
id: string;
|
|
94
|
+
/** Display label */
|
|
95
|
+
label: string;
|
|
96
|
+
/** View type (grid, kanban, calendar, etc.) */
|
|
97
|
+
type: string;
|
|
98
|
+
/** Whether this view has active filters */
|
|
99
|
+
hasActiveFilters?: boolean;
|
|
100
|
+
/** Whether this view has active sort */
|
|
101
|
+
hasActiveSort?: boolean;
|
|
102
|
+
/** Whether this is the default view */
|
|
103
|
+
isDefault?: boolean;
|
|
104
|
+
/** Whether this view is pinned/favorited */
|
|
105
|
+
isPinned?: boolean;
|
|
106
|
+
/** View visibility for grouping */
|
|
107
|
+
visibility?: 'private' | 'team' | 'organization' | 'public';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Available view type for quick-switch palette */
|
|
111
|
+
export interface AvailableViewType {
|
|
112
|
+
type: string;
|
|
113
|
+
label: string;
|
|
114
|
+
description?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ViewTabBarProps {
|
|
118
|
+
/** Views to render as tabs */
|
|
119
|
+
views: ViewTabItem[];
|
|
120
|
+
/** Currently active view ID */
|
|
121
|
+
activeViewId: string;
|
|
122
|
+
/** Callback when a view tab is clicked */
|
|
123
|
+
onViewChange: (viewId: string) => void;
|
|
124
|
+
/** Icon map: view type → React component */
|
|
125
|
+
viewTypeIcons?: Record<string, ComponentType<{ className?: string }>>;
|
|
126
|
+
/** Configuration for the tab bar UX */
|
|
127
|
+
config?: ViewTabBarConfig;
|
|
128
|
+
|
|
129
|
+
// --- Action callbacks ---
|
|
130
|
+
/** Called when "+" button is clicked to add a new view */
|
|
131
|
+
onAddView?: () => void;
|
|
132
|
+
/** Called when a view is renamed via context menu or double-click */
|
|
133
|
+
onRenameView?: (viewId: string, newName: string) => void;
|
|
134
|
+
/** Called when a view is duplicated */
|
|
135
|
+
onDuplicateView?: (viewId: string) => void;
|
|
136
|
+
/** Called when a view is deleted */
|
|
137
|
+
onDeleteView?: (viewId: string) => void;
|
|
138
|
+
/** Called when a view is set as default */
|
|
139
|
+
onSetDefaultView?: (viewId: string) => void;
|
|
140
|
+
/** Called when a view is shared */
|
|
141
|
+
onShareView?: (viewId: string) => void;
|
|
142
|
+
/** Called when "Save as View" is clicked */
|
|
143
|
+
onSaveAsView?: () => void;
|
|
144
|
+
/** Called when a view is pinned/unpinned */
|
|
145
|
+
onPinView?: (viewId: string, pinned: boolean) => void;
|
|
146
|
+
/** Called when views are reordered via drag */
|
|
147
|
+
onReorderViews?: (viewIds: string[]) => void;
|
|
148
|
+
/** Called when a view type is changed via quick-switch */
|
|
149
|
+
onChangeViewType?: (viewId: string, newType: string) => void;
|
|
150
|
+
/** Called when user clicks the gear icon to configure a view */
|
|
151
|
+
onConfigView?: (viewId: string) => void;
|
|
152
|
+
|
|
153
|
+
/** Available view types for quick-switch palette */
|
|
154
|
+
availableViewTypes?: AvailableViewType[];
|
|
155
|
+
/** Whether user has unsaved filter/sort changes (shows "Save as View" indicator) */
|
|
156
|
+
hasUnsavedChanges?: boolean;
|
|
157
|
+
/** Called when user clicks "Reset" to discard changes */
|
|
158
|
+
onResetChanges?: () => void;
|
|
159
|
+
/** Additional CSS class */
|
|
160
|
+
className?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Sortable Tab wrapper ---
|
|
164
|
+
const SortableTab: React.FC<{
|
|
165
|
+
id: string;
|
|
166
|
+
disabled?: boolean;
|
|
167
|
+
children: (props: {
|
|
168
|
+
setNodeRef: (node: HTMLElement | null) => void;
|
|
169
|
+
style: React.CSSProperties;
|
|
170
|
+
listeners: Record<string, Function> | undefined;
|
|
171
|
+
attributes: Record<string, unknown>;
|
|
172
|
+
isDragging: boolean;
|
|
173
|
+
}) => React.ReactNode;
|
|
174
|
+
}> = ({ id, disabled, children }) => {
|
|
175
|
+
const {
|
|
176
|
+
attributes,
|
|
177
|
+
listeners,
|
|
178
|
+
setNodeRef,
|
|
179
|
+
transform,
|
|
180
|
+
transition,
|
|
181
|
+
isDragging,
|
|
182
|
+
} = useSortable({ id, disabled });
|
|
183
|
+
|
|
184
|
+
const style: React.CSSProperties = {
|
|
185
|
+
transform: CSS.Transform.toString(transform),
|
|
186
|
+
transition,
|
|
187
|
+
zIndex: isDragging ? 10 : undefined,
|
|
188
|
+
opacity: isDragging ? 0.5 : undefined,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return <>{children({ setNodeRef, style, listeners, attributes: attributes as unknown as Record<string, unknown>, isDragging })}</>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* ViewTabBar — Airtable/Salesforce-style view tab bar with management UX.
|
|
196
|
+
*/
|
|
197
|
+
export const ViewTabBar: React.FC<ViewTabBarProps> = ({
|
|
198
|
+
views,
|
|
199
|
+
activeViewId,
|
|
200
|
+
onViewChange,
|
|
201
|
+
viewTypeIcons = {},
|
|
202
|
+
config = {},
|
|
203
|
+
onAddView,
|
|
204
|
+
onRenameView,
|
|
205
|
+
onDuplicateView,
|
|
206
|
+
onDeleteView,
|
|
207
|
+
onSetDefaultView,
|
|
208
|
+
onShareView,
|
|
209
|
+
onSaveAsView,
|
|
210
|
+
onPinView,
|
|
211
|
+
onReorderViews,
|
|
212
|
+
onChangeViewType,
|
|
213
|
+
onConfigView,
|
|
214
|
+
availableViewTypes,
|
|
215
|
+
hasUnsavedChanges = false,
|
|
216
|
+
onResetChanges,
|
|
217
|
+
className,
|
|
218
|
+
}) => {
|
|
219
|
+
const {
|
|
220
|
+
showAddButton = true,
|
|
221
|
+
inlineRename = true,
|
|
222
|
+
contextMenu: enableContextMenu = true,
|
|
223
|
+
reorderable = false,
|
|
224
|
+
maxVisibleTabs = 6,
|
|
225
|
+
showIndicators = true,
|
|
226
|
+
showSaveAsView = true,
|
|
227
|
+
showPinnedSection = true,
|
|
228
|
+
showVisibilityGroups = false,
|
|
229
|
+
} = config;
|
|
230
|
+
|
|
231
|
+
// --- Inline rename state ---
|
|
232
|
+
const [renamingViewId, setRenamingViewId] = useState<string | null>(null);
|
|
233
|
+
const [renameValue, setRenameValue] = useState('');
|
|
234
|
+
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (renamingViewId && renameInputRef.current) {
|
|
238
|
+
renameInputRef.current.focus();
|
|
239
|
+
renameInputRef.current.select();
|
|
240
|
+
}
|
|
241
|
+
}, [renamingViewId]);
|
|
242
|
+
|
|
243
|
+
const startRename = useCallback((viewId: string) => {
|
|
244
|
+
if (!inlineRename || !onRenameView) return;
|
|
245
|
+
const view = views.find(v => v.id === viewId);
|
|
246
|
+
if (!view) return;
|
|
247
|
+
setRenamingViewId(viewId);
|
|
248
|
+
setRenameValue(view.label);
|
|
249
|
+
}, [inlineRename, onRenameView, views]);
|
|
250
|
+
|
|
251
|
+
const commitRename = useCallback(() => {
|
|
252
|
+
if (renamingViewId && renameValue.trim() && onRenameView) {
|
|
253
|
+
onRenameView(renamingViewId, renameValue.trim());
|
|
254
|
+
}
|
|
255
|
+
setRenamingViewId(null);
|
|
256
|
+
setRenameValue('');
|
|
257
|
+
}, [renamingViewId, renameValue, onRenameView]);
|
|
258
|
+
|
|
259
|
+
const cancelRename = useCallback(() => {
|
|
260
|
+
setRenamingViewId(null);
|
|
261
|
+
setRenameValue('');
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
// --- Sort views: pinned first → personal → shared ---
|
|
265
|
+
const sortedViews = useMemo(() => {
|
|
266
|
+
const sorted = [...views];
|
|
267
|
+
sorted.sort((a, b) => {
|
|
268
|
+
// Pinned views first
|
|
269
|
+
if (showPinnedSection) {
|
|
270
|
+
const aPinned = a.isPinned ? 1 : 0;
|
|
271
|
+
const bPinned = b.isPinned ? 1 : 0;
|
|
272
|
+
if (aPinned !== bPinned) return bPinned - aPinned;
|
|
273
|
+
}
|
|
274
|
+
// Visibility grouping: private → team → organization → public
|
|
275
|
+
if (showVisibilityGroups) {
|
|
276
|
+
const aOrder = VISIBILITY_ORDER[a.visibility || 'public'] ?? VISIBILITY_ORDER['public'];
|
|
277
|
+
const bOrder = VISIBILITY_ORDER[b.visibility || 'public'] ?? VISIBILITY_ORDER['public'];
|
|
278
|
+
if (aOrder !== bOrder) return aOrder - bOrder;
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
});
|
|
282
|
+
return sorted;
|
|
283
|
+
}, [views, showPinnedSection, showVisibilityGroups]);
|
|
284
|
+
|
|
285
|
+
// --- Overflow ---
|
|
286
|
+
const visibleViews = sortedViews.slice(0, maxVisibleTabs);
|
|
287
|
+
const overflowViews = sortedViews.slice(maxVisibleTabs);
|
|
288
|
+
|
|
289
|
+
// --- Drag-reorder sensors ---
|
|
290
|
+
const sensors = useSensors(
|
|
291
|
+
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
|
|
292
|
+
useSensor(KeyboardSensor),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
296
|
+
const { active, over } = event;
|
|
297
|
+
if (!over || active.id === over.id || !onReorderViews) return;
|
|
298
|
+
const oldIndex = sortedViews.findIndex(v => v.id === active.id);
|
|
299
|
+
const newIndex = sortedViews.findIndex(v => v.id === over.id);
|
|
300
|
+
if (oldIndex === -1 || newIndex === -1) return;
|
|
301
|
+
const reordered = arrayMove(sortedViews, oldIndex, newIndex);
|
|
302
|
+
onReorderViews(reordered.map(v => v.id));
|
|
303
|
+
}, [sortedViews, onReorderViews]);
|
|
304
|
+
|
|
305
|
+
const DefaultIcon = TableIcon;
|
|
306
|
+
|
|
307
|
+
// Determine if a visibility separator should appear before this view
|
|
308
|
+
const shouldShowVisibilitySeparator = useCallback((index: number) => {
|
|
309
|
+
if (!showVisibilityGroups || index === 0) return false;
|
|
310
|
+
const prev = visibleViews[index - 1];
|
|
311
|
+
const curr = visibleViews[index];
|
|
312
|
+
const isPrivate = (v: ViewTabItem) => v.visibility === 'private';
|
|
313
|
+
return isPrivate(prev) && !isPrivate(curr);
|
|
314
|
+
}, [showVisibilityGroups, visibleViews]);
|
|
315
|
+
|
|
316
|
+
// --- Render a single tab ---
|
|
317
|
+
const renderTab = (view: ViewTabItem, index: number) => {
|
|
318
|
+
const isActive = view.id === activeViewId;
|
|
319
|
+
const ViewIcon = viewTypeIcons[view.type] || DefaultIcon;
|
|
320
|
+
const isRenaming = renamingViewId === view.id;
|
|
321
|
+
const hasIndicator = showIndicators && (view.hasActiveFilters || view.hasActiveSort);
|
|
322
|
+
const showSeparator = shouldShowVisibilitySeparator(index);
|
|
323
|
+
|
|
324
|
+
const getVisibilityIcon = (view: ViewTabItem) => {
|
|
325
|
+
if (!showVisibilityGroups) return null;
|
|
326
|
+
if (view.visibility === 'private') {
|
|
327
|
+
return <Lock data-testid={`view-tab-visibility-${view.id}`} className="h-3 w-3 text-muted-foreground shrink-0" />;
|
|
328
|
+
}
|
|
329
|
+
if (view.visibility) {
|
|
330
|
+
return <Globe data-testid={`view-tab-visibility-${view.id}`} className="h-3 w-3 text-muted-foreground shrink-0" />;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const visibilityIcon = getVisibilityIcon(view);
|
|
336
|
+
|
|
337
|
+
const buildTabContent = (dragHandleProps?: {
|
|
338
|
+
listeners: Record<string, Function> | undefined;
|
|
339
|
+
attributes: Record<string, unknown>;
|
|
340
|
+
}) => (
|
|
341
|
+
<button
|
|
342
|
+
data-testid={`view-tab-${view.id}`}
|
|
343
|
+
onClick={() => !isRenaming && onViewChange(view.id)}
|
|
344
|
+
onDoubleClick={() => startRename(view.id)}
|
|
345
|
+
className={cn(
|
|
346
|
+
'inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap relative',
|
|
347
|
+
isActive
|
|
348
|
+
? 'border-primary text-primary'
|
|
349
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
350
|
+
)}
|
|
351
|
+
>
|
|
352
|
+
{reorderable && onReorderViews && (
|
|
353
|
+
<span
|
|
354
|
+
data-testid={`view-tab-drag-handle-${view.id}`}
|
|
355
|
+
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
|
356
|
+
{...(dragHandleProps?.listeners ?? {})}
|
|
357
|
+
{...(dragHandleProps?.attributes ?? {})}
|
|
358
|
+
>
|
|
359
|
+
<GripVertical className="h-3 w-3" />
|
|
360
|
+
</span>
|
|
361
|
+
)}
|
|
362
|
+
{showPinnedSection && view.isPinned && (
|
|
363
|
+
<Pin data-testid={`view-tab-pin-indicator-${view.id}`} className="h-3 w-3 text-primary shrink-0" />
|
|
364
|
+
)}
|
|
365
|
+
{visibilityIcon}
|
|
366
|
+
<ViewIcon className="h-3.5 w-3.5" />
|
|
367
|
+
{isRenaming ? (
|
|
368
|
+
<Input
|
|
369
|
+
ref={renameInputRef}
|
|
370
|
+
data-testid={`view-tab-rename-input-${view.id}`}
|
|
371
|
+
value={renameValue}
|
|
372
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
|
|
373
|
+
onBlur={commitRename}
|
|
374
|
+
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
375
|
+
if (e.key === 'Enter') commitRename();
|
|
376
|
+
if (e.key === 'Escape') cancelRename();
|
|
377
|
+
}}
|
|
378
|
+
className="h-5 w-24 px-1 py-0 text-sm border-none focus-visible:ring-1"
|
|
379
|
+
onClick={(e: React.MouseEvent<HTMLInputElement>) => e.stopPropagation()}
|
|
380
|
+
/>
|
|
381
|
+
) : (
|
|
382
|
+
<span>{view.label}</span>
|
|
383
|
+
)}
|
|
384
|
+
{hasIndicator && (
|
|
385
|
+
<Tooltip>
|
|
386
|
+
<TooltipTrigger asChild>
|
|
387
|
+
<span
|
|
388
|
+
data-testid={`view-tab-indicator-${view.id}`}
|
|
389
|
+
className="ml-1 inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-primary/15 text-[10px] font-medium text-primary px-1 shrink-0"
|
|
390
|
+
>
|
|
391
|
+
{[view.hasActiveFilters && 'F', view.hasActiveSort && 'S'].filter(Boolean).join('')}
|
|
392
|
+
</span>
|
|
393
|
+
</TooltipTrigger>
|
|
394
|
+
<TooltipContent side="bottom" className="text-xs">
|
|
395
|
+
{[view.hasActiveFilters && 'Active filters', view.hasActiveSort && 'Active sort'].filter(Boolean).join(', ')}
|
|
396
|
+
</TooltipContent>
|
|
397
|
+
</Tooltip>
|
|
398
|
+
)}
|
|
399
|
+
{view.isDefault && (
|
|
400
|
+
<Star className="h-3 w-3 text-amber-500 fill-amber-500 shrink-0" />
|
|
401
|
+
)}
|
|
402
|
+
{isActive && onConfigView && (
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
data-testid={`view-tab-config-${view.id}`}
|
|
406
|
+
className="ml-0.5 h-4 w-4 flex items-center justify-center rounded hover:bg-accent shrink-0 opacity-60 hover:opacity-100 transition-opacity"
|
|
407
|
+
onClick={(e) => { e.stopPropagation(); onConfigView(view.id); }}
|
|
408
|
+
aria-label={`Configure ${view.label}`}
|
|
409
|
+
>
|
|
410
|
+
<Settings2 className="h-3 w-3" />
|
|
411
|
+
</button>
|
|
412
|
+
)}
|
|
413
|
+
</button>
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const wrapWithContextMenu = (tabContent: React.ReactElement) => {
|
|
417
|
+
if (!enableContextMenu || isRenaming) return tabContent;
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<ContextMenu>
|
|
421
|
+
<ContextMenuTrigger asChild>
|
|
422
|
+
{tabContent}
|
|
423
|
+
</ContextMenuTrigger>
|
|
424
|
+
<ContextMenuContent>
|
|
425
|
+
{onRenameView && (
|
|
426
|
+
<ContextMenuItem
|
|
427
|
+
data-testid={`context-menu-rename-${view.id}`}
|
|
428
|
+
onClick={() => startRename(view.id)}
|
|
429
|
+
>
|
|
430
|
+
<Pencil className="h-4 w-4 mr-2" /> Rename
|
|
431
|
+
</ContextMenuItem>
|
|
432
|
+
)}
|
|
433
|
+
{onDuplicateView && (
|
|
434
|
+
<ContextMenuItem
|
|
435
|
+
data-testid={`context-menu-duplicate-${view.id}`}
|
|
436
|
+
onClick={() => onDuplicateView(view.id)}
|
|
437
|
+
>
|
|
438
|
+
<Copy className="h-4 w-4 mr-2" /> Duplicate View
|
|
439
|
+
</ContextMenuItem>
|
|
440
|
+
)}
|
|
441
|
+
{onShareView && (
|
|
442
|
+
<ContextMenuItem
|
|
443
|
+
data-testid={`context-menu-share-${view.id}`}
|
|
444
|
+
onClick={() => onShareView(view.id)}
|
|
445
|
+
>
|
|
446
|
+
<Share2 className="h-4 w-4 mr-2" /> Share View
|
|
447
|
+
</ContextMenuItem>
|
|
448
|
+
)}
|
|
449
|
+
{onSetDefaultView && (
|
|
450
|
+
<ContextMenuItem
|
|
451
|
+
data-testid={`context-menu-default-${view.id}`}
|
|
452
|
+
onClick={() => onSetDefaultView(view.id)}
|
|
453
|
+
>
|
|
454
|
+
<Star className="h-4 w-4 mr-2" /> Set as Default
|
|
455
|
+
</ContextMenuItem>
|
|
456
|
+
)}
|
|
457
|
+
{onPinView && (
|
|
458
|
+
<ContextMenuItem
|
|
459
|
+
data-testid={`context-menu-pin-${view.id}`}
|
|
460
|
+
onClick={() => onPinView(view.id, !view.isPinned)}
|
|
461
|
+
>
|
|
462
|
+
{view.isPinned
|
|
463
|
+
? <><PinOff className="h-4 w-4 mr-2" /> Unpin View</>
|
|
464
|
+
: <><Pin className="h-4 w-4 mr-2" /> Pin View</>
|
|
465
|
+
}
|
|
466
|
+
</ContextMenuItem>
|
|
467
|
+
)}
|
|
468
|
+
{onChangeViewType && availableViewTypes && availableViewTypes.length > 0 && (
|
|
469
|
+
<>
|
|
470
|
+
<ContextMenuSeparator />
|
|
471
|
+
<ContextMenuSub>
|
|
472
|
+
<ContextMenuSubTrigger data-testid={`context-menu-change-type-${view.id}`}>
|
|
473
|
+
<LayoutGrid className="h-4 w-4 mr-2" /> Change View Type
|
|
474
|
+
</ContextMenuSubTrigger>
|
|
475
|
+
<ContextMenuSubContent data-testid={`context-menu-type-submenu-${view.id}`}>
|
|
476
|
+
{availableViewTypes.map((vt) => {
|
|
477
|
+
const TypeIcon = viewTypeIcons[vt.type] || DefaultIcon;
|
|
478
|
+
return (
|
|
479
|
+
<ContextMenuItem
|
|
480
|
+
key={vt.type}
|
|
481
|
+
data-testid={`context-menu-type-${view.id}-${vt.type}`}
|
|
482
|
+
disabled={vt.type === view.type}
|
|
483
|
+
onClick={() => onChangeViewType(view.id, vt.type)}
|
|
484
|
+
>
|
|
485
|
+
<TypeIcon className="h-4 w-4 mr-2" />
|
|
486
|
+
<div className="flex flex-col">
|
|
487
|
+
<span>{vt.label}</span>
|
|
488
|
+
{vt.description && (
|
|
489
|
+
<span className="text-xs text-muted-foreground">{vt.description}</span>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
</ContextMenuItem>
|
|
493
|
+
);
|
|
494
|
+
})}
|
|
495
|
+
</ContextMenuSubContent>
|
|
496
|
+
</ContextMenuSub>
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
{onDeleteView && (
|
|
500
|
+
<>
|
|
501
|
+
<ContextMenuSeparator />
|
|
502
|
+
<ContextMenuItem
|
|
503
|
+
data-testid={`context-menu-delete-${view.id}`}
|
|
504
|
+
onClick={() => onDeleteView(view.id)}
|
|
505
|
+
className="text-destructive focus:text-destructive"
|
|
506
|
+
>
|
|
507
|
+
<Trash2 className="h-4 w-4 mr-2" /> Delete View
|
|
508
|
+
</ContextMenuItem>
|
|
509
|
+
</>
|
|
510
|
+
)}
|
|
511
|
+
</ContextMenuContent>
|
|
512
|
+
</ContextMenu>
|
|
513
|
+
);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Build the tab with optional drag-reorder wrapper
|
|
517
|
+
if (reorderable && onReorderViews) {
|
|
518
|
+
return (
|
|
519
|
+
<React.Fragment key={view.id}>
|
|
520
|
+
{showSeparator && (
|
|
521
|
+
<div data-testid="view-tab-visibility-separator" className="w-px h-5 bg-border mx-1 self-center shrink-0" />
|
|
522
|
+
)}
|
|
523
|
+
<SortableTab id={view.id}>
|
|
524
|
+
{({ setNodeRef, style, listeners, attributes, isDragging }) => (
|
|
525
|
+
<div ref={setNodeRef} style={style} className={cn('flex', isDragging && 'z-10')}>
|
|
526
|
+
{wrapWithContextMenu(buildTabContent({ listeners, attributes }))}
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
529
|
+
</SortableTab>
|
|
530
|
+
</React.Fragment>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<React.Fragment key={view.id}>
|
|
536
|
+
{showSeparator && (
|
|
537
|
+
<div data-testid="view-tab-visibility-separator" className="w-px h-5 bg-border mx-1 self-center shrink-0" />
|
|
538
|
+
)}
|
|
539
|
+
{wrapWithContextMenu(buildTabContent())}
|
|
540
|
+
</React.Fragment>
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const tabList = (
|
|
545
|
+
<>
|
|
546
|
+
{visibleViews.map((view, index) => renderTab(view, index))}
|
|
547
|
+
</>
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<TooltipProvider>
|
|
552
|
+
<div
|
|
553
|
+
data-testid="view-tab-bar"
|
|
554
|
+
className={cn('flex items-center gap-0.5 -mb-px', className)}
|
|
555
|
+
>
|
|
556
|
+
{/* Visible tabs — optionally wrapped with DndContext for reorder */}
|
|
557
|
+
{reorderable && onReorderViews ? (
|
|
558
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
559
|
+
<SortableContext items={visibleViews.map(v => v.id)} strategy={horizontalListSortingStrategy}>
|
|
560
|
+
<div data-testid="view-tab-sortable-container" className="flex items-center gap-0.5">
|
|
561
|
+
{tabList}
|
|
562
|
+
</div>
|
|
563
|
+
</SortableContext>
|
|
564
|
+
</DndContext>
|
|
565
|
+
) : (
|
|
566
|
+
tabList
|
|
567
|
+
)}
|
|
568
|
+
|
|
569
|
+
{/* Overflow "More" dropdown */}
|
|
570
|
+
{overflowViews.length > 0 && (
|
|
571
|
+
<DropdownMenu>
|
|
572
|
+
<DropdownMenuTrigger asChild>
|
|
573
|
+
<button
|
|
574
|
+
data-testid="view-tab-overflow"
|
|
575
|
+
className="inline-flex items-center gap-1 px-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
576
|
+
>
|
|
577
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
578
|
+
<span className="text-xs">{overflowViews.length} more</span>
|
|
579
|
+
</button>
|
|
580
|
+
</DropdownMenuTrigger>
|
|
581
|
+
<DropdownMenuContent align="end">
|
|
582
|
+
{overflowViews.map((view) => {
|
|
583
|
+
const ViewIcon = viewTypeIcons[view.type] || DefaultIcon;
|
|
584
|
+
return (
|
|
585
|
+
<DropdownMenuItem
|
|
586
|
+
key={view.id}
|
|
587
|
+
data-testid={`view-tab-overflow-${view.id}`}
|
|
588
|
+
onClick={() => onViewChange(view.id)}
|
|
589
|
+
>
|
|
590
|
+
{view.isPinned && <Pin className="h-3 w-3 mr-1 text-primary shrink-0" />}
|
|
591
|
+
<ViewIcon className="h-4 w-4 mr-2" />
|
|
592
|
+
{view.label}
|
|
593
|
+
{showIndicators && (view.hasActiveFilters || view.hasActiveSort) && (
|
|
594
|
+
<span className="ml-auto inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-primary/15 text-[10px] font-medium text-primary px-1">
|
|
595
|
+
{[view.hasActiveFilters && 'F', view.hasActiveSort && 'S'].filter(Boolean).join('')}
|
|
596
|
+
</span>
|
|
597
|
+
)}
|
|
598
|
+
</DropdownMenuItem>
|
|
599
|
+
);
|
|
600
|
+
})}
|
|
601
|
+
</DropdownMenuContent>
|
|
602
|
+
</DropdownMenu>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
{/* Inline "+" Add View button */}
|
|
606
|
+
{showAddButton && onAddView && (
|
|
607
|
+
<Tooltip>
|
|
608
|
+
<TooltipTrigger asChild>
|
|
609
|
+
<button
|
|
610
|
+
data-testid="view-tab-add"
|
|
611
|
+
onClick={onAddView}
|
|
612
|
+
className="inline-flex items-center px-2 py-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
613
|
+
>
|
|
614
|
+
<Plus className="h-4 w-4" />
|
|
615
|
+
</button>
|
|
616
|
+
</TooltipTrigger>
|
|
617
|
+
<TooltipContent>Add View</TooltipContent>
|
|
618
|
+
</Tooltip>
|
|
619
|
+
)}
|
|
620
|
+
|
|
621
|
+
{/* "Save as View" indicator */}
|
|
622
|
+
{showSaveAsView && hasUnsavedChanges && (
|
|
623
|
+
<div
|
|
624
|
+
data-testid="view-tab-save-as"
|
|
625
|
+
className="flex items-center gap-1 ml-2 text-xs text-amber-600 dark:text-amber-400"
|
|
626
|
+
>
|
|
627
|
+
<Save className="h-3.5 w-3.5" />
|
|
628
|
+
<span className="hidden sm:inline">Unsaved changes</span>
|
|
629
|
+
{onSaveAsView && (
|
|
630
|
+
<Button
|
|
631
|
+
variant="ghost"
|
|
632
|
+
size="sm"
|
|
633
|
+
data-testid="view-tab-save-as-btn"
|
|
634
|
+
className="h-6 px-2 text-xs"
|
|
635
|
+
onClick={onSaveAsView}
|
|
636
|
+
>
|
|
637
|
+
Save as View
|
|
638
|
+
</Button>
|
|
639
|
+
)}
|
|
640
|
+
{onResetChanges && (
|
|
641
|
+
<Button
|
|
642
|
+
variant="ghost"
|
|
643
|
+
size="sm"
|
|
644
|
+
data-testid="view-tab-reset-btn"
|
|
645
|
+
className="h-6 px-2 text-xs"
|
|
646
|
+
onClick={onResetChanges}
|
|
647
|
+
>
|
|
648
|
+
Reset
|
|
649
|
+
</Button>
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
</TooltipProvider>
|
|
655
|
+
);
|
|
656
|
+
};
|