@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.
@@ -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) => {
@@ -521,7 +546,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
521
546
 
522
547
  const filterableFieldDefs = fieldEntries.map(([key, f]: [string, any]) => {
523
548
  const fieldType = f.type || 'text';
524
- let filterType: 'text' | 'number' | 'select' | 'date' | 'boolean' = 'text';
549
+ let filterType: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'boolean' = 'text';
525
550
  let options: Array<{ label: string; value: any }> | undefined;
526
551
 
527
552
  if (fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent') {
@@ -530,11 +555,18 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
530
555
  filterType = 'boolean';
531
556
  } else if (fieldType === 'date' || fieldType === 'datetime') {
532
557
  filterType = 'date';
533
- } else if (fieldType === 'select' || f.options) {
558
+ } else if (fieldType === 'select' || fieldType === 'status' || f.options) {
534
559
  filterType = 'select';
535
560
  options = (f.options || []).map((o: any) =>
536
561
  typeof o === 'string' ? { label: o, value: o } : { label: o.label, value: o.value },
537
562
  );
563
+ } else if (fieldType === 'lookup' || fieldType === 'master_detail' || fieldType === 'user' || fieldType === 'owner') {
564
+ if (f.options && f.options.length > 0) {
565
+ filterType = 'multi-select';
566
+ options = (f.options || []).map((o: any) =>
567
+ typeof o === 'string' ? { label: o, value: o } : { label: o.label, value: o.value },
568
+ );
569
+ }
538
570
  }
539
571
  return {
540
572
  field: key,
@@ -558,7 +590,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
558
590
  }, [schema.showFilters, schema.filterableFields, objectSchema, filterValues]);
559
591
 
560
592
  // --- SortUI schema ---
593
+ const showSort = (schema as ObjectViewSchema).showSort;
561
594
  const sortSchema: SortUISchema | null = useMemo(() => {
595
+ if (showSort === false) return null;
596
+
562
597
  const fields = (objectSchema as any)?.fields || {};
563
598
  const sortableFields = Object.entries(fields)
564
599
  .filter(([, f]: [string, any]) => !f.hidden)
@@ -574,7 +609,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
574
609
  fields: sortableFields,
575
610
  sort: sortConfig,
576
611
  };
577
- }, [objectSchema, sortConfig]);
612
+ }, [objectSchema, sortConfig, showSort]);
578
613
 
579
614
  // --- Generate view component schema for non-grid views ---
580
615
  const generateViewSchema = useCallback((viewType: string): any => {
@@ -582,7 +617,12 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
582
617
  objectName: schema.objectName,
583
618
  fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
584
619
  className: 'h-full w-full',
585
- showSearch: false,
620
+ showSearch: activeView?.showSearch ?? schema.showSearch ?? false,
621
+ showSort: activeView?.showSort ?? schema.showSort ?? false,
622
+ showFilters: activeView?.showFilters ?? schema.showFilters ?? false,
623
+ striped: activeView?.striped ?? false,
624
+ bordered: activeView?.bordered ?? false,
625
+ color: activeView?.color,
586
626
  };
587
627
 
588
628
  // Resolve type-specific options from current named view or active view
@@ -679,6 +719,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
679
719
  defaultSort: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort,
680
720
  pageSize: schema.table?.pageSize,
681
721
  selectable: schema.table?.selectable,
722
+ striped: activeView?.striped ?? schema.table?.striped,
723
+ bordered: activeView?.bordered ?? schema.table?.bordered,
682
724
  className: schema.table?.className,
683
725
  }), [schema, operations, currentNamedViewConfig, activeView]);
684
726
 
@@ -798,7 +840,54 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
798
840
  fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
799
841
  filters: mergedFilters,
800
842
  sort: mergedSort,
843
+ // Propagate appearance/view-config properties for live preview
844
+ rowHeight: activeView?.rowHeight,
845
+ densityMode: activeView?.densityMode,
846
+ groupBy: activeView?.groupBy,
801
847
  options: currentNamedViewConfig?.options || activeView,
848
+ // Propagate toolbar toggle flags
849
+ showSearch: activeView?.showSearch ?? schema.showSearch,
850
+ showFilters: activeView?.showFilters ?? schema.showFilters,
851
+ showSort: activeView?.showSort ?? schema.showSort,
852
+ showHideFields: activeView?.showHideFields ?? (schema as any).showHideFields,
853
+ showGroup: activeView?.showGroup ?? (schema as any).showGroup,
854
+ showColor: activeView?.showColor ?? (schema as any).showColor,
855
+ showDensity: activeView?.showDensity ?? (schema as any).showDensity,
856
+ allowExport: activeView?.allowExport ?? (schema as any).allowExport,
857
+ // Propagate display properties
858
+ striped: activeView?.striped ?? (schema as any).striped,
859
+ bordered: activeView?.bordered ?? (schema as any).bordered,
860
+ color: activeView?.color ?? (schema as any).color,
861
+ // Propagate view-config properties (Bug 4 / items 14-22)
862
+ inlineEdit: activeView?.inlineEdit ?? (schema as any).inlineEdit,
863
+ wrapHeaders: activeView?.wrapHeaders ?? (schema as any).wrapHeaders,
864
+ clickIntoRecordDetails: activeView?.clickIntoRecordDetails ?? (schema as any).clickIntoRecordDetails,
865
+ addRecordViaForm: activeView?.addRecordViaForm ?? (schema as any).addRecordViaForm,
866
+ addDeleteRecordsInline: activeView?.addDeleteRecordsInline ?? (schema as any).addDeleteRecordsInline,
867
+ collapseAllByDefault: activeView?.collapseAllByDefault ?? (schema as any).collapseAllByDefault,
868
+ fieldTextColor: activeView?.fieldTextColor ?? (schema as any).fieldTextColor,
869
+ prefixField: activeView?.prefixField ?? (schema as any).prefixField,
870
+ showDescription: activeView?.showDescription ?? (schema as any).showDescription,
871
+ // Propagate new spec properties (P0/P1/P2)
872
+ navigation: activeView?.navigation ?? (schema as any).navigation,
873
+ selection: activeView?.selection ?? (schema as any).selection,
874
+ pagination: activeView?.pagination ?? (schema as any).pagination,
875
+ searchableFields: activeView?.searchableFields ?? (schema as any).searchableFields,
876
+ filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields,
877
+ resizable: activeView?.resizable ?? (schema as any).resizable,
878
+ hiddenFields: activeView?.hiddenFields ?? (schema as any).hiddenFields,
879
+ rowActions: activeView?.rowActions ?? (schema as any).rowActions,
880
+ bulkActions: activeView?.bulkActions ?? (schema as any).bulkActions,
881
+ sharing: activeView?.sharing ?? (schema as any).sharing,
882
+ addRecord: activeView?.addRecord ?? (schema as any).addRecord,
883
+ conditionalFormatting: activeView?.conditionalFormatting ?? (schema as any).conditionalFormatting,
884
+ quickFilters: activeView?.quickFilters ?? (schema as any).quickFilters,
885
+ showRecordCount: activeView?.showRecordCount ?? (schema as any).showRecordCount,
886
+ allowPrinting: activeView?.allowPrinting ?? (schema as any).allowPrinting,
887
+ virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll,
888
+ emptyState: activeView?.emptyState ?? (schema as any).emptyState,
889
+ aria: activeView?.aria ?? (schema as any).aria,
890
+ tabs: (schema as any).tabs,
802
891
  },
803
892
  dataSource,
804
893
  onEdit: handleEdit,
@@ -887,6 +976,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
887
976
  <ViewSwitcher
888
977
  schema={viewSwitcherSchema}
889
978
  onViewChange={handleViewTypeChange}
979
+ onCreateView={onCreateView}
980
+ onViewAction={onViewAction}
890
981
  className="overflow-x-auto"
891
982
  />
892
983
  )}
@@ -911,10 +1002,59 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
911
1002
  // Determine which form container to render
912
1003
  const formLayout = navigationConfig?.mode === 'modal' ? 'modal'
913
1004
  : navigationConfig?.mode === 'drawer' ? 'drawer'
1005
+ : navigationConfig?.mode === 'split' ? 'split'
1006
+ : navigationConfig?.mode === 'popover' ? 'popover'
914
1007
  : layout;
915
1008
 
1009
+ // Build the record detail content for NavigationOverlay (split/popover modes)
1010
+ const renderOverlayDetail = (_record: Record<string, unknown>) => (
1011
+ <div className="space-y-3">
1012
+ <ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
1013
+ </div>
1014
+ );
1015
+
1016
+ // Shared handler for NavigationOverlay onOpenChange — close form when overlay is dismissed
1017
+ const handleOverlayOpenChange = useCallback((open: boolean) => {
1018
+ if (!open) handleFormCancel();
1019
+ }, [handleFormCancel]);
1020
+
1021
+ // For split mode, wrap content inside NavigationOverlay with mainContent
1022
+ if (formLayout === 'split') {
1023
+ const objectLabel = (objectSchema?.label as string) || schema.objectName;
1024
+ return (
1025
+ <div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
1026
+ {(schema.title || schema.description) && (
1027
+ <div className="mb-4 shrink-0">
1028
+ {schema.title && <h2 className="text-2xl font-bold tracking-tight">{schema.title}</h2>}
1029
+ {schema.description && <p className="text-muted-foreground mt-1">{schema.description}</p>}
1030
+ </div>
1031
+ )}
1032
+ <div className="mb-4 shrink-0">{renderToolbar()}</div>
1033
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
1034
+ {isFormOpen && selectedRecord ? (
1035
+ <NavigationOverlay
1036
+ isOpen={isFormOpen}
1037
+ selectedRecord={selectedRecord}
1038
+ mode="split"
1039
+ close={handleFormCancel}
1040
+ setIsOpen={handleOverlayOpenChange}
1041
+ width={navigationConfig?.width}
1042
+ isOverlay={true}
1043
+ title={`${objectLabel} Detail`}
1044
+ mainContent={<div className="h-full overflow-auto">{renderContent()}</div>}
1045
+ >
1046
+ {renderOverlayDetail}
1047
+ </NavigationOverlay>
1048
+ ) : (
1049
+ renderContent()
1050
+ )}
1051
+ </div>
1052
+ </div>
1053
+ );
1054
+ }
1055
+
916
1056
  return (
917
- <div className={cn('flex flex-col h-full', className)}>
1057
+ <div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
918
1058
  {/* Title and description */}
919
1059
  {(schema.title || schema.description) && (
920
1060
  <div className="mb-4 shrink-0">
@@ -933,13 +1073,28 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
933
1073
  </div>
934
1074
 
935
1075
  {/* Content */}
936
- <div className="flex-1 min-h-0">
1076
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
937
1077
  {renderContent()}
938
1078
  </div>
939
1079
 
940
1080
  {/* Form (drawer or modal) */}
941
1081
  {formLayout === 'drawer' && renderDrawerForm()}
942
1082
  {formLayout === 'modal' && renderModalForm()}
1083
+ {/* Popover mode — uses NavigationOverlay Dialog fallback (no popoverTrigger) */}
1084
+ {formLayout === 'popover' && isFormOpen && selectedRecord && (
1085
+ <NavigationOverlay
1086
+ isOpen={isFormOpen}
1087
+ selectedRecord={selectedRecord}
1088
+ mode="popover"
1089
+ close={handleFormCancel}
1090
+ setIsOpen={handleOverlayOpenChange}
1091
+ width={navigationConfig?.width}
1092
+ isOverlay={true}
1093
+ title={getFormTitle()}
1094
+ >
1095
+ {renderOverlayDetail}
1096
+ </NavigationOverlay>
1097
+ )}
943
1098
  </div>
944
1099
  );
945
1100
  };
@@ -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