@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.
- package/.turbo/turbo-build.log +6 -6
- package/dist/index.js +4352 -820
- package/dist/index.umd.cjs +6 -2
- package/dist/plugin-view/src/ObjectView.d.ts +8 -0
- package/dist/plugin-view/src/SharedViewLink.d.ts +23 -0
- package/dist/plugin-view/src/ViewSwitcher.d.ts +3 -0
- package/dist/plugin-view/src/ViewTabBar.d.ts +75 -0
- package/dist/plugin-view/src/index.d.ts +5 -1
- package/package.json +11 -8
- package/src/ObjectView.tsx +154 -6
- package/src/SharedViewLink.tsx +199 -0
- package/src/ViewSwitcher.tsx +69 -1
- package/src/ViewTabBar.tsx +656 -0
- package/src/__tests__/ObjectView.test.tsx +290 -0
- package/src/__tests__/SharedViewLinkPassword.test.tsx +172 -0
- package/src/__tests__/ViewTabBar.test.tsx +710 -0
- package/src/__tests__/config-sync-integration.test.tsx +588 -0
- package/src/index.tsx +21 -1
package/src/ObjectView.tsx
CHANGED
|
@@ -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
|
+
};
|
package/src/ViewSwitcher.tsx
CHANGED
|
@@ -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
|
|