@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.
@@ -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,20 +610,41 @@ 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
622
+ // Per @objectstack/spec, type-specific config MUST be nested under the view type key
589
623
  const viewOptions = currentNamedViewConfig?.options || activeView || {};
590
624
 
625
+ // Dev-mode warning for flat property access violations
626
+ if (process.env.NODE_ENV === 'development') {
627
+ const flatKeys = ['startDateField', 'endDateField', 'dateField', 'groupBy', 'groupField',
628
+ 'locationField', 'imageField', 'dependenciesField', 'progressField', 'titleField',
629
+ 'subtitleField', 'latitudeField', 'longitudeField'];
630
+ const nestedConfig = viewOptions[viewType] || {};
631
+ const found = flatKeys.filter(k => k in viewOptions && !(k in nestedConfig));
632
+ if (found.length > 0) {
633
+ console.warn(
634
+ `[Spec Compliance] View options use flat properties ${JSON.stringify(found)}. ` +
635
+ `Move them under options.${viewType} per @objectstack/spec protocol.`
636
+ );
637
+ }
638
+ }
639
+
591
640
  switch (viewType) {
592
641
  case 'kanban':
593
642
  return {
594
643
  type: 'object-kanban',
595
644
  ...baseProps,
596
- groupBy: viewOptions.kanban?.groupField || viewOptions.groupBy || viewOptions.groupField || 'status',
597
- groupField: viewOptions.kanban?.groupField || viewOptions.groupField || 'status',
598
- titleField: viewOptions.kanban?.titleField || viewOptions.titleField || 'name',
645
+ groupBy: viewOptions.kanban?.groupField || 'status',
646
+ groupField: viewOptions.kanban?.groupField || 'status',
647
+ titleField: viewOptions.kanban?.titleField || 'name',
599
648
  cardFields: baseProps.fields || [],
600
649
  ...(viewOptions.kanban || {}),
601
650
  };
@@ -603,43 +652,43 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
603
652
  return {
604
653
  type: 'object-calendar',
605
654
  ...baseProps,
606
- startDateField: viewOptions.calendar?.startDateField || viewOptions.startDateField || 'start_date',
607
- endDateField: viewOptions.calendar?.endDateField || viewOptions.endDateField || 'end_date',
608
- titleField: viewOptions.calendar?.titleField || viewOptions.titleField || 'name',
655
+ startDateField: viewOptions.calendar?.startDateField || 'start_date',
656
+ endDateField: viewOptions.calendar?.endDateField || 'end_date',
657
+ titleField: viewOptions.calendar?.titleField || 'name',
609
658
  ...(viewOptions.calendar || {}),
610
659
  };
611
660
  case 'gallery':
612
661
  return {
613
662
  type: 'object-gallery',
614
663
  ...baseProps,
615
- imageField: viewOptions.gallery?.imageField || viewOptions.imageField,
616
- titleField: viewOptions.gallery?.titleField || viewOptions.titleField || 'name',
617
- subtitleField: viewOptions.gallery?.subtitleField || viewOptions.subtitleField,
664
+ imageField: viewOptions.gallery?.imageField,
665
+ titleField: viewOptions.gallery?.titleField || 'name',
666
+ subtitleField: viewOptions.gallery?.subtitleField,
618
667
  ...(viewOptions.gallery || {}),
619
668
  };
620
669
  case 'timeline':
621
670
  return {
622
671
  type: 'object-timeline',
623
672
  ...baseProps,
624
- dateField: viewOptions.timeline?.dateField || viewOptions.dateField || 'created_at',
625
- titleField: viewOptions.timeline?.titleField || viewOptions.titleField || 'name',
673
+ dateField: viewOptions.timeline?.dateField || 'created_at',
674
+ titleField: viewOptions.timeline?.titleField || 'name',
626
675
  ...(viewOptions.timeline || {}),
627
676
  };
628
677
  case 'gantt':
629
678
  return {
630
679
  type: 'object-gantt',
631
680
  ...baseProps,
632
- startDateField: viewOptions.gantt?.startDateField || viewOptions.startDateField || 'start_date',
633
- endDateField: viewOptions.gantt?.endDateField || viewOptions.endDateField || 'end_date',
634
- progressField: viewOptions.gantt?.progressField || viewOptions.progressField || 'progress',
635
- dependenciesField: viewOptions.gantt?.dependenciesField || viewOptions.dependenciesField || 'dependencies',
681
+ startDateField: viewOptions.gantt?.startDateField || 'start_date',
682
+ endDateField: viewOptions.gantt?.endDateField || 'end_date',
683
+ progressField: viewOptions.gantt?.progressField || 'progress',
684
+ dependenciesField: viewOptions.gantt?.dependenciesField || 'dependencies',
636
685
  ...(viewOptions.gantt || {}),
637
686
  };
638
687
  case 'map':
639
688
  return {
640
689
  type: 'object-map',
641
690
  ...baseProps,
642
- locationField: viewOptions.map?.locationField || viewOptions.locationField || 'location',
691
+ locationField: viewOptions.map?.locationField || 'location',
643
692
  ...(viewOptions.map || {}),
644
693
  };
645
694
  default:
@@ -663,6 +712,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
663
712
  defaultSort: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort,
664
713
  pageSize: schema.table?.pageSize,
665
714
  selectable: schema.table?.selectable,
715
+ striped: activeView?.striped ?? schema.table?.striped,
716
+ bordered: activeView?.bordered ?? schema.table?.bordered,
666
717
  className: schema.table?.className,
667
718
  }), [schema, operations, currentNamedViewConfig, activeView]);
668
719
 
@@ -782,7 +833,54 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
782
833
  fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
783
834
  filters: mergedFilters,
784
835
  sort: mergedSort,
836
+ // Propagate appearance/view-config properties for live preview
837
+ rowHeight: activeView?.rowHeight,
838
+ densityMode: activeView?.densityMode,
839
+ groupBy: activeView?.groupBy,
785
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,
786
884
  },
787
885
  dataSource,
788
886
  onEdit: handleEdit,
@@ -871,6 +969,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
871
969
  <ViewSwitcher
872
970
  schema={viewSwitcherSchema}
873
971
  onViewChange={handleViewTypeChange}
972
+ onCreateView={onCreateView}
973
+ onViewAction={onViewAction}
874
974
  className="overflow-x-auto"
875
975
  />
876
976
  )}
@@ -895,10 +995,59 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
895
995
  // Determine which form container to render
896
996
  const formLayout = navigationConfig?.mode === 'modal' ? 'modal'
897
997
  : navigationConfig?.mode === 'drawer' ? 'drawer'
998
+ : navigationConfig?.mode === 'split' ? 'split'
999
+ : navigationConfig?.mode === 'popover' ? 'popover'
898
1000
  : layout;
899
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
+
900
1049
  return (
901
- <div className={cn('flex flex-col h-full', className)}>
1050
+ <div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
902
1051
  {/* Title and description */}
903
1052
  {(schema.title || schema.description) && (
904
1053
  <div className="mb-4 shrink-0">
@@ -917,13 +1066,28 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
917
1066
  </div>
918
1067
 
919
1068
  {/* Content */}
920
- <div className="flex-1 min-h-0">
1069
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
921
1070
  {renderContent()}
922
1071
  </div>
923
1072
 
924
1073
  {/* Form (drawer or modal) */}
925
1074
  {formLayout === 'drawer' && renderDrawerForm()}
926
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
+ )}
927
1091
  </div>
928
1092
  );
929
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
+ };