@object-ui/plugin-view 3.0.3 → 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.
@@ -49,12 +49,14 @@ import {
49
49
  DrawerHeader,
50
50
  DrawerTitle,
51
51
  DrawerDescription,
52
+ NavigationOverlay,
52
53
  Button,
53
54
  Tabs,
54
55
  TabsList,
55
56
  TabsTrigger,
56
57
  } from '@object-ui/components';
57
58
  import { Plus } from 'lucide-react';
59
+ import { buildExpandFields } from '@object-ui/core';
58
60
  import { ViewSwitcher } from './ViewSwitcher';
59
61
 
60
62
  /**
@@ -139,6 +141,16 @@ export interface ObjectViewProps {
139
141
  * Toolbar addon: extra elements to render in the toolbar (e.g., MetadataToggle)
140
142
  */
141
143
  toolbarAddon?: React.ReactNode;
144
+
145
+ /**
146
+ * Callback when the "+" create view button is clicked in ViewSwitcher.
147
+ */
148
+ onCreateView?: () => void;
149
+
150
+ /**
151
+ * Callback when a per-view action is triggered in ViewSwitcher.
152
+ */
153
+ onViewAction?: (action: string, viewType: ViewType) => void;
142
154
  }
143
155
 
144
156
  type FormMode = 'create' | 'edit' | 'view';
@@ -203,6 +215,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
203
215
  onEdit: onEditProp,
204
216
  renderListView,
205
217
  toolbarAddon,
218
+ onCreateView,
219
+ onViewAction,
206
220
  }) => {
207
221
  const [objectSchema, setObjectSchema] = useState<Record<string, unknown> | null>(null);
208
222
  const [isFormOpen, setIsFormOpen] = useState(false);
@@ -308,10 +322,13 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
308
322
  ? sortConfig.map(s => ({ field: s.field, order: s.direction }))
309
323
  : (currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort || undefined);
310
324
 
325
+ // Auto-inject $expand for lookup/master_detail fields
326
+ const expand = buildExpandFields((objectSchema as any)?.fields);
311
327
  const results = await dataSource.find(schema.objectName, {
312
328
  $filter: finalFilter.length > 0 ? finalFilter : undefined,
313
329
  $orderby: sort,
314
330
  $top: 100,
331
+ ...(expand.length > 0 ? { $expand: expand } : {}),
315
332
  });
316
333
 
317
334
  let items: any[] = [];
@@ -336,7 +353,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
336
353
  fetchData();
337
354
  return () => { isMounted = false; };
338
355
  // eslint-disable-next-line react-hooks/exhaustive-deps
339
- }, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView]);
356
+ }, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView, objectSchema]);
340
357
 
341
358
  // Determine layout mode
342
359
  const layout = schema.layout || 'drawer';
@@ -425,6 +442,12 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
425
442
  }
426
443
  return;
427
444
  }
445
+ if (navigationConfig.mode === 'split' || navigationConfig.mode === 'popover') {
446
+ setFormMode('view');
447
+ setSelectedRecord(record);
448
+ setIsFormOpen(true);
449
+ return;
450
+ }
428
451
  }
429
452
 
430
453
  // Default behavior
@@ -490,8 +513,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
490
513
  icon: iconMap[v.type] || 'table',
491
514
  };
492
515
  }),
516
+ allowCreateView: schema.allowCreateView,
517
+ viewActions: schema.viewActions,
493
518
  };
494
- }, [hasMultiView, viewsPropResolved, activeView, schema.objectName]);
519
+ }, [hasMultiView, viewsPropResolved, activeView, schema.objectName, schema.allowCreateView, schema.viewActions]);
495
520
 
496
521
  // Handle view type change from ViewSwitcher → map back to view ID
497
522
  const handleViewTypeChange = useCallback((viewType: ViewType) => {
@@ -558,7 +583,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
558
583
  }, [schema.showFilters, schema.filterableFields, objectSchema, filterValues]);
559
584
 
560
585
  // --- SortUI schema ---
586
+ const showSort = (schema as ObjectViewSchema).showSort;
561
587
  const sortSchema: SortUISchema | null = useMemo(() => {
588
+ if (showSort === false) return null;
589
+
562
590
  const fields = (objectSchema as any)?.fields || {};
563
591
  const sortableFields = Object.entries(fields)
564
592
  .filter(([, f]: [string, any]) => !f.hidden)
@@ -574,7 +602,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
574
602
  fields: sortableFields,
575
603
  sort: sortConfig,
576
604
  };
577
- }, [objectSchema, sortConfig]);
605
+ }, [objectSchema, sortConfig, showSort]);
578
606
 
579
607
  // --- Generate view component schema for non-grid views ---
580
608
  const generateViewSchema = useCallback((viewType: string): any => {
@@ -582,7 +610,12 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
582
610
  objectName: schema.objectName,
583
611
  fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
584
612
  className: 'h-full w-full',
585
- showSearch: false,
613
+ showSearch: activeView?.showSearch ?? schema.showSearch ?? false,
614
+ showSort: activeView?.showSort ?? schema.showSort ?? false,
615
+ showFilters: activeView?.showFilters ?? schema.showFilters ?? false,
616
+ striped: activeView?.striped ?? false,
617
+ bordered: activeView?.bordered ?? false,
618
+ color: activeView?.color,
586
619
  };
587
620
 
588
621
  // Resolve type-specific options from current named view or active view
@@ -679,6 +712,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
679
712
  defaultSort: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort,
680
713
  pageSize: schema.table?.pageSize,
681
714
  selectable: schema.table?.selectable,
715
+ striped: activeView?.striped ?? schema.table?.striped,
716
+ bordered: activeView?.bordered ?? schema.table?.bordered,
682
717
  className: schema.table?.className,
683
718
  }), [schema, operations, currentNamedViewConfig, activeView]);
684
719
 
@@ -798,7 +833,54 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
798
833
  fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
799
834
  filters: mergedFilters,
800
835
  sort: mergedSort,
836
+ // Propagate appearance/view-config properties for live preview
837
+ rowHeight: activeView?.rowHeight,
838
+ densityMode: activeView?.densityMode,
839
+ groupBy: activeView?.groupBy,
801
840
  options: currentNamedViewConfig?.options || activeView,
841
+ // Propagate toolbar toggle flags
842
+ showSearch: activeView?.showSearch ?? schema.showSearch,
843
+ showFilters: activeView?.showFilters ?? schema.showFilters,
844
+ showSort: activeView?.showSort ?? schema.showSort,
845
+ showHideFields: activeView?.showHideFields ?? (schema as any).showHideFields,
846
+ showGroup: activeView?.showGroup ?? (schema as any).showGroup,
847
+ showColor: activeView?.showColor ?? (schema as any).showColor,
848
+ showDensity: activeView?.showDensity ?? (schema as any).showDensity,
849
+ allowExport: activeView?.allowExport ?? (schema as any).allowExport,
850
+ // Propagate display properties
851
+ striped: activeView?.striped ?? (schema as any).striped,
852
+ bordered: activeView?.bordered ?? (schema as any).bordered,
853
+ color: activeView?.color ?? (schema as any).color,
854
+ // Propagate view-config properties (Bug 4 / items 14-22)
855
+ inlineEdit: activeView?.inlineEdit ?? (schema as any).inlineEdit,
856
+ wrapHeaders: activeView?.wrapHeaders ?? (schema as any).wrapHeaders,
857
+ clickIntoRecordDetails: activeView?.clickIntoRecordDetails ?? (schema as any).clickIntoRecordDetails,
858
+ addRecordViaForm: activeView?.addRecordViaForm ?? (schema as any).addRecordViaForm,
859
+ addDeleteRecordsInline: activeView?.addDeleteRecordsInline ?? (schema as any).addDeleteRecordsInline,
860
+ collapseAllByDefault: activeView?.collapseAllByDefault ?? (schema as any).collapseAllByDefault,
861
+ fieldTextColor: activeView?.fieldTextColor ?? (schema as any).fieldTextColor,
862
+ prefixField: activeView?.prefixField ?? (schema as any).prefixField,
863
+ showDescription: activeView?.showDescription ?? (schema as any).showDescription,
864
+ // Propagate new spec properties (P0/P1/P2)
865
+ navigation: activeView?.navigation ?? (schema as any).navigation,
866
+ selection: activeView?.selection ?? (schema as any).selection,
867
+ pagination: activeView?.pagination ?? (schema as any).pagination,
868
+ searchableFields: activeView?.searchableFields ?? (schema as any).searchableFields,
869
+ filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields,
870
+ resizable: activeView?.resizable ?? (schema as any).resizable,
871
+ hiddenFields: activeView?.hiddenFields ?? (schema as any).hiddenFields,
872
+ rowActions: activeView?.rowActions ?? (schema as any).rowActions,
873
+ bulkActions: activeView?.bulkActions ?? (schema as any).bulkActions,
874
+ sharing: activeView?.sharing ?? (schema as any).sharing,
875
+ addRecord: activeView?.addRecord ?? (schema as any).addRecord,
876
+ conditionalFormatting: activeView?.conditionalFormatting ?? (schema as any).conditionalFormatting,
877
+ quickFilters: activeView?.quickFilters ?? (schema as any).quickFilters,
878
+ showRecordCount: activeView?.showRecordCount ?? (schema as any).showRecordCount,
879
+ allowPrinting: activeView?.allowPrinting ?? (schema as any).allowPrinting,
880
+ virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll,
881
+ emptyState: activeView?.emptyState ?? (schema as any).emptyState,
882
+ aria: activeView?.aria ?? (schema as any).aria,
883
+ tabs: (schema as any).tabs,
802
884
  },
803
885
  dataSource,
804
886
  onEdit: handleEdit,
@@ -887,6 +969,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
887
969
  <ViewSwitcher
888
970
  schema={viewSwitcherSchema}
889
971
  onViewChange={handleViewTypeChange}
972
+ onCreateView={onCreateView}
973
+ onViewAction={onViewAction}
890
974
  className="overflow-x-auto"
891
975
  />
892
976
  )}
@@ -911,10 +995,59 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
911
995
  // Determine which form container to render
912
996
  const formLayout = navigationConfig?.mode === 'modal' ? 'modal'
913
997
  : navigationConfig?.mode === 'drawer' ? 'drawer'
998
+ : navigationConfig?.mode === 'split' ? 'split'
999
+ : navigationConfig?.mode === 'popover' ? 'popover'
914
1000
  : layout;
915
1001
 
1002
+ // Build the record detail content for NavigationOverlay (split/popover modes)
1003
+ const renderOverlayDetail = (_record: Record<string, unknown>) => (
1004
+ <div className="space-y-3">
1005
+ <ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
1006
+ </div>
1007
+ );
1008
+
1009
+ // Shared handler for NavigationOverlay onOpenChange — close form when overlay is dismissed
1010
+ const handleOverlayOpenChange = useCallback((open: boolean) => {
1011
+ if (!open) handleFormCancel();
1012
+ }, [handleFormCancel]);
1013
+
1014
+ // For split mode, wrap content inside NavigationOverlay with mainContent
1015
+ if (formLayout === 'split') {
1016
+ const objectLabel = (objectSchema?.label as string) || schema.objectName;
1017
+ return (
1018
+ <div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
1019
+ {(schema.title || schema.description) && (
1020
+ <div className="mb-4 shrink-0">
1021
+ {schema.title && <h2 className="text-2xl font-bold tracking-tight">{schema.title}</h2>}
1022
+ {schema.description && <p className="text-muted-foreground mt-1">{schema.description}</p>}
1023
+ </div>
1024
+ )}
1025
+ <div className="mb-4 shrink-0">{renderToolbar()}</div>
1026
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
1027
+ {isFormOpen && selectedRecord ? (
1028
+ <NavigationOverlay
1029
+ isOpen={isFormOpen}
1030
+ selectedRecord={selectedRecord}
1031
+ mode="split"
1032
+ close={handleFormCancel}
1033
+ setIsOpen={handleOverlayOpenChange}
1034
+ width={navigationConfig?.width}
1035
+ isOverlay={true}
1036
+ title={`${objectLabel} Detail`}
1037
+ mainContent={<div className="h-full overflow-auto">{renderContent()}</div>}
1038
+ >
1039
+ {renderOverlayDetail}
1040
+ </NavigationOverlay>
1041
+ ) : (
1042
+ renderContent()
1043
+ )}
1044
+ </div>
1045
+ </div>
1046
+ );
1047
+ }
1048
+
916
1049
  return (
917
- <div className={cn('flex flex-col h-full', className)}>
1050
+ <div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
918
1051
  {/* Title and description */}
919
1052
  {(schema.title || schema.description) && (
920
1053
  <div className="mb-4 shrink-0">
@@ -933,13 +1066,28 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
933
1066
  </div>
934
1067
 
935
1068
  {/* Content */}
936
- <div className="flex-1 min-h-0">
1069
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
937
1070
  {renderContent()}
938
1071
  </div>
939
1072
 
940
1073
  {/* Form (drawer or modal) */}
941
1074
  {formLayout === 'drawer' && renderDrawerForm()}
942
1075
  {formLayout === 'modal' && renderModalForm()}
1076
+ {/* Popover mode — uses NavigationOverlay Dialog fallback (no popoverTrigger) */}
1077
+ {formLayout === 'popover' && isFormOpen && selectedRecord && (
1078
+ <NavigationOverlay
1079
+ isOpen={isFormOpen}
1080
+ selectedRecord={selectedRecord}
1081
+ mode="popover"
1082
+ close={handleFormCancel}
1083
+ setIsOpen={handleOverlayOpenChange}
1084
+ width={navigationConfig?.width}
1085
+ isOverlay={true}
1086
+ title={getFormTitle()}
1087
+ >
1088
+ {renderOverlayDetail}
1089
+ </NavigationOverlay>
1090
+ )}
943
1091
  </div>
944
1092
  );
945
1093
  };
@@ -0,0 +1,199 @@
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 * as React from 'react';
10
+ import {
11
+ cn,
12
+ Button,
13
+ Badge,
14
+ Input,
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from '@object-ui/components';
19
+ import { Share2, Copy, Check, Lock, Calendar } from 'lucide-react';
20
+
21
+ export interface SharedViewLinkProps {
22
+ /** The object name used in the share URL path */
23
+ objectName: string;
24
+ /** Optional view identifier; defaults to "default" */
25
+ viewId?: string;
26
+ /** Base URL for the shareable link (defaults to window.location.origin) */
27
+ baseUrl?: string;
28
+ /** Callback fired after a share URL is generated */
29
+ onShare?: (shareUrl: string, options?: { password?: string; expiresAt?: string }) => void;
30
+ className?: string;
31
+ }
32
+
33
+ function generateToken(): string {
34
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
35
+ return crypto.randomUUID();
36
+ }
37
+ // Fallback for environments without crypto.randomUUID
38
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
39
+ const bytes = new Uint8Array(16);
40
+ crypto.getRandomValues(bytes);
41
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
42
+ }
43
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
44
+ }
45
+
46
+ function buildShareUrl(baseUrl: string, objectName: string, viewId: string, token: string): string {
47
+ return `${baseUrl}/share/${objectName}/${viewId}?mode=readonly&token=${token}`;
48
+ }
49
+
50
+ export const SharedViewLink: React.FC<SharedViewLinkProps> = ({
51
+ objectName,
52
+ viewId = 'default',
53
+ baseUrl,
54
+ onShare,
55
+ className,
56
+ }) => {
57
+ const [shareUrl, setShareUrl] = React.useState<string | null>(null);
58
+ const [copied, setCopied] = React.useState(false);
59
+ const [open, setOpen] = React.useState(false);
60
+ const [password, setPassword] = React.useState('');
61
+ const [expiresIn, setExpiresIn] = React.useState('');
62
+
63
+ const resolvedBaseUrl = baseUrl ?? (typeof window !== 'undefined' ? window.location.origin : '');
64
+
65
+ const handleGenerateLink = React.useCallback(() => {
66
+ const token = generateToken();
67
+ const url = buildShareUrl(resolvedBaseUrl, objectName, viewId, token);
68
+ setShareUrl(url);
69
+ setCopied(false);
70
+ const expiresAt = expiresIn ? new Date(Date.now() + parseInt(expiresIn, 10) * 86400000).toISOString() : undefined;
71
+ onShare?.(url, { password: password || undefined, expiresAt });
72
+ }, [resolvedBaseUrl, objectName, viewId, onShare, password, expiresIn]);
73
+
74
+ const handleCopy = React.useCallback(async () => {
75
+ if (!shareUrl) return;
76
+ try {
77
+ await navigator.clipboard.writeText(shareUrl);
78
+ setCopied(true);
79
+ setTimeout(() => setCopied(false), 2000);
80
+ } catch {
81
+ // Fallback for environments without clipboard API
82
+ const textarea = document.createElement('textarea');
83
+ textarea.value = shareUrl;
84
+ document.body.appendChild(textarea);
85
+ textarea.select();
86
+ document.execCommand('copy');
87
+ document.body.removeChild(textarea);
88
+ setCopied(true);
89
+ setTimeout(() => setCopied(false), 2000);
90
+ }
91
+ }, [shareUrl]);
92
+
93
+ return (
94
+ <Popover open={open} onOpenChange={setOpen}>
95
+ <PopoverTrigger asChild>
96
+ <Button variant="outline" size="sm" className={cn('gap-2', className)}>
97
+ <Share2 className="h-4 w-4" />
98
+ Share
99
+ </Button>
100
+ </PopoverTrigger>
101
+ <PopoverContent className="w-96 space-y-4" align="end">
102
+ <div className="space-y-2">
103
+ <div className="flex items-center justify-between">
104
+ <h4 className="text-sm font-medium">Share View</h4>
105
+ <Badge variant="secondary" className="text-xs">
106
+ Read-only
107
+ </Badge>
108
+ </div>
109
+ <p className="text-xs text-muted-foreground">
110
+ Generate a public link to share this view. Recipients can view data without logging in.
111
+ </p>
112
+ </div>
113
+
114
+ {!shareUrl ? (
115
+ <div className="space-y-3">
116
+ {/* Password protection */}
117
+ <div className="space-y-1.5">
118
+ <label className="flex items-center gap-1.5 text-xs font-medium text-foreground">
119
+ <Lock className="h-3.5 w-3.5" />
120
+ Password protection (optional)
121
+ </label>
122
+ <Input
123
+ type="password"
124
+ value={password}
125
+ onChange={(e) => setPassword(e.target.value)}
126
+ placeholder="Enter password..."
127
+ className="h-8 text-xs"
128
+ />
129
+ </div>
130
+
131
+ {/* Expiration */}
132
+ <div className="space-y-1.5">
133
+ <label className="flex items-center gap-1.5 text-xs font-medium text-foreground">
134
+ <Calendar className="h-3.5 w-3.5" />
135
+ Expires after (optional)
136
+ </label>
137
+ <select
138
+ value={expiresIn}
139
+ onChange={(e) => setExpiresIn(e.target.value)}
140
+ className="w-full h-8 text-xs rounded-md border border-input bg-background px-3"
141
+ >
142
+ <option value="">Never</option>
143
+ <option value="1">1 day</option>
144
+ <option value="7">7 days</option>
145
+ <option value="30">30 days</option>
146
+ <option value="90">90 days</option>
147
+ </select>
148
+ </div>
149
+
150
+ <Button onClick={handleGenerateLink} className="w-full gap-2" size="sm">
151
+ <Share2 className="h-4 w-4" />
152
+ Generate Link
153
+ </Button>
154
+ </div>
155
+ ) : (
156
+ <>
157
+ <div className="flex items-center gap-2">
158
+ <Input
159
+ value={shareUrl}
160
+ readOnly
161
+ className="h-8 text-xs"
162
+ onClick={(e) => (e.target as HTMLInputElement).select()}
163
+ />
164
+ <Button
165
+ variant="outline"
166
+ size="sm"
167
+ onClick={handleCopy}
168
+ className="shrink-0 gap-1"
169
+ >
170
+ {copied ? (
171
+ <Check className="h-4 w-4 text-green-500" />
172
+ ) : (
173
+ <Copy className="h-4 w-4" />
174
+ )}
175
+ </Button>
176
+ </div>
177
+ {/* Share options indicators */}
178
+ {(password || expiresIn) && (
179
+ <div className="flex items-center gap-2 flex-wrap">
180
+ {password && (
181
+ <Badge variant="outline" className="text-xs gap-1">
182
+ <Lock className="h-3 w-3" />
183
+ Password protected
184
+ </Badge>
185
+ )}
186
+ {expiresIn && (
187
+ <Badge variant="outline" className="text-xs gap-1">
188
+ <Calendar className="h-3 w-3" />
189
+ Expires in {expiresIn} day{expiresIn !== '1' ? 's' : ''}
190
+ </Badge>
191
+ )}
192
+ </div>
193
+ )}
194
+ </>
195
+ )}
196
+ </PopoverContent>
197
+ </Popover>
198
+ );
199
+ };
@@ -26,10 +26,17 @@ import {
26
26
  Activity,
27
27
  Calendar,
28
28
  FileText,
29
+ GanttChartSquare,
29
30
  Grid,
31
+ Images,
30
32
  LayoutGrid,
31
33
  List,
32
34
  Map,
35
+ Plus,
36
+ Share2,
37
+ Settings,
38
+ Copy,
39
+ Trash2,
33
40
  icons,
34
41
  type LucideIcon,
35
42
  } from 'lucide-react';
@@ -40,6 +47,9 @@ export type ViewSwitcherProps = {
40
47
  schema: ViewSwitcherSchema;
41
48
  className?: string;
42
49
  onViewChange?: (view: ViewType) => void;
50
+ onCreateView?: () => void;
51
+ onViewAction?: (action: string, view: ViewType) => void;
52
+ createViewLabel?: string;
43
53
  [key: string]: any;
44
54
  };
45
55
 
@@ -51,6 +61,8 @@ const DEFAULT_VIEW_LABELS: Record<ViewType, string> = {
51
61
  calendar: 'Calendar',
52
62
  timeline: 'Timeline',
53
63
  map: 'Map',
64
+ gallery: 'Gallery',
65
+ gantt: 'Gantt',
54
66
  };
55
67
 
56
68
  const DEFAULT_VIEW_ICONS: Record<ViewType, LucideIcon> = {
@@ -61,6 +73,8 @@ const DEFAULT_VIEW_ICONS: Record<ViewType, LucideIcon> = {
61
73
  calendar: Calendar,
62
74
  timeline: Activity,
63
75
  map: Map,
76
+ gallery: Images,
77
+ gantt: GanttChartSquare,
64
78
  };
65
79
 
66
80
  const viewSwitcherLayout = cva('flex gap-4', {
@@ -149,10 +163,27 @@ function getInitialView(schema: ViewSwitcherSchema): ViewType | undefined {
149
163
  return schema.views?.[0]?.type;
150
164
  }
151
165
 
166
+ const DEFAULT_VIEW_ACTION_ICONS: Record<string, LucideIcon> = {
167
+ share: Share2,
168
+ settings: Settings,
169
+ duplicate: Copy,
170
+ delete: Trash2,
171
+ };
172
+
173
+ const DEFAULT_VIEW_ACTION_LABELS: Record<string, string> = {
174
+ share: 'Share',
175
+ settings: 'Settings',
176
+ duplicate: 'Duplicate',
177
+ delete: 'Delete',
178
+ };
179
+
152
180
  export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
153
181
  schema,
154
182
  className,
155
183
  onViewChange,
184
+ onCreateView,
185
+ onViewAction,
186
+ createViewLabel = 'Create view',
156
187
  ...props
157
188
  }) => {
158
189
  const storageKey = React.useMemo(() => {
@@ -219,8 +250,42 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
219
250
  const isVertical = position === 'left' || position === 'right';
220
251
  const orientation = isVertical ? 'vertical' : 'horizontal';
221
252
 
253
+ const viewActionButtons = schema.viewActions && schema.viewActions.length > 0 ? (
254
+ <div className="flex items-center gap-1">
255
+ {schema.viewActions.map((action, idx) => {
256
+ const ActionIcon = action.icon
257
+ ? resolveIcon(action.icon) || DEFAULT_VIEW_ACTION_ICONS[action.type]
258
+ : DEFAULT_VIEW_ACTION_ICONS[action.type];
259
+ return (
260
+ <Button
261
+ key={`action-${action.type}-${idx}`}
262
+ type="button"
263
+ variant="ghost"
264
+ size="icon-sm"
265
+ onClick={() => onViewAction?.(action.type, currentView!)}
266
+ title={DEFAULT_VIEW_ACTION_LABELS[action.type] || action.type}
267
+ >
268
+ {ActionIcon ? <ActionIcon className="h-3.5 w-3.5" /> : null}
269
+ </Button>
270
+ );
271
+ })}
272
+ </div>
273
+ ) : null;
274
+
275
+ const createViewButton = schema.allowCreateView ? (
276
+ <Button
277
+ type="button"
278
+ variant="ghost"
279
+ size="icon-sm"
280
+ onClick={() => onCreateView?.()}
281
+ title={createViewLabel}
282
+ >
283
+ <Plus className="h-3.5 w-3.5" />
284
+ </Button>
285
+ ) : null;
286
+
222
287
  const switcher = (
223
- <div className={cn(viewSwitcherWidth({ orientation }))}>
288
+ <div className={cn(viewSwitcherWidth({ orientation }), 'flex items-center gap-1')}>
224
289
  {variant === 'dropdown' && (
225
290
  <Select value={currentViewValue} onValueChange={(value) => handleViewChange(value as ViewType)}>
226
291
  <SelectTrigger className={cn('w-full', isVertical ? 'h-10' : 'h-9')}>
@@ -278,6 +343,9 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
278
343
  </TabsList>
279
344
  </Tabs>
280
345
  )}
346
+
347
+ {viewActionButtons}
348
+ {createViewButton}
281
349
  </div>
282
350
  );
283
351