@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +11 -0
- package/dist/index.js +4382 -830
- 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 +186 -22
- 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,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 ||
|
|
597
|
-
groupField: viewOptions.kanban?.groupField ||
|
|
598
|
-
titleField: viewOptions.kanban?.titleField ||
|
|
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 ||
|
|
607
|
-
endDateField: viewOptions.calendar?.endDateField ||
|
|
608
|
-
titleField: viewOptions.calendar?.titleField ||
|
|
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
|
|
616
|
-
titleField: viewOptions.gallery?.titleField ||
|
|
617
|
-
subtitleField: viewOptions.gallery?.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 ||
|
|
625
|
-
titleField: viewOptions.timeline?.titleField ||
|
|
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 ||
|
|
633
|
-
endDateField: viewOptions.gantt?.endDateField ||
|
|
634
|
-
progressField: viewOptions.gantt?.progressField ||
|
|
635
|
-
dependenciesField: viewOptions.gantt?.dependenciesField ||
|
|
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 ||
|
|
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
|
+
};
|