@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,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
+ };