@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +12 -0
- package/dist/index.js +4399 -836
- 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/FilterUI.tsx +33 -0
- package/src/ObjectView.tsx +163 -8
- package/src/SharedViewLink.tsx +199 -0
- package/src/ViewSwitcher.tsx +69 -1
- package/src/ViewTabBar.tsx +656 -0
- package/src/__tests__/FilterUI.test.tsx +97 -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) => {
|
|
@@ -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
|
+
};
|
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
|
|