@superdangerous/app-framework 4.14.0 → 4.15.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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/validation.d.ts +12 -12
- package/dist/services/emailService.d.ts +146 -0
- package/dist/services/emailService.d.ts.map +1 -0
- package/dist/services/emailService.js +649 -0
- package/dist/services/emailService.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/package.json +9 -1
- package/src/index.ts +14 -0
- package/src/services/emailService.ts +812 -0
- package/src/services/index.ts +14 -0
- package/ui/data-table/components/BatchActionsBar.tsx +53 -0
- package/ui/data-table/components/ColumnVisibility.tsx +111 -0
- package/ui/data-table/components/DataTablePage.tsx +238 -0
- package/ui/data-table/components/Pagination.tsx +203 -0
- package/ui/data-table/components/PaginationControls.tsx +122 -0
- package/ui/data-table/components/TableFilters.tsx +139 -0
- package/ui/data-table/components/index.ts +27 -0
- package/ui/data-table/hooks/index.ts +17 -0
- package/ui/data-table/hooks/useColumnOrder.ts +233 -0
- package/ui/data-table/hooks/useColumnVisibility.ts +128 -0
- package/ui/data-table/hooks/usePagination.ts +160 -0
- package/ui/data-table/hooks/useResizableColumns.ts +280 -0
- package/ui/data-table/index.ts +74 -0
- package/ui/dist/index.d.mts +207 -5
- package/ui/dist/index.d.ts +207 -5
- package/ui/dist/index.js +36 -43
- package/ui/dist/index.js.map +1 -1
- package/ui/dist/index.mjs +36 -43
- package/ui/dist/index.mjs.map +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PaginationControls - Compact inline pagination for table headers
|
|
3
|
+
*
|
|
4
|
+
* A condensed version of pagination controls designed to sit alongside
|
|
5
|
+
* search/filter controls in a table header bar.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ChevronLeft,
|
|
10
|
+
ChevronRight,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import { Button } from '../../components/base/button';
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from '../../components/base/select';
|
|
20
|
+
import { cn } from '../../src/utils/cn';
|
|
21
|
+
|
|
22
|
+
export interface PaginationControlsProps {
|
|
23
|
+
page: number;
|
|
24
|
+
pageSize: number;
|
|
25
|
+
totalPages: number;
|
|
26
|
+
totalItems: number;
|
|
27
|
+
startIndex: number;
|
|
28
|
+
endIndex: number;
|
|
29
|
+
canGoNext: boolean;
|
|
30
|
+
canGoPrev: boolean;
|
|
31
|
+
pageSizeOptions: number[];
|
|
32
|
+
setPage: (page: number) => void;
|
|
33
|
+
setPageSize: (size: number) => void;
|
|
34
|
+
nextPage: () => void;
|
|
35
|
+
prevPage: () => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PaginationControls({
|
|
40
|
+
page,
|
|
41
|
+
pageSize,
|
|
42
|
+
totalPages,
|
|
43
|
+
totalItems,
|
|
44
|
+
startIndex,
|
|
45
|
+
endIndex,
|
|
46
|
+
canGoNext,
|
|
47
|
+
canGoPrev,
|
|
48
|
+
pageSizeOptions,
|
|
49
|
+
setPage: _setPage,
|
|
50
|
+
setPageSize,
|
|
51
|
+
nextPage,
|
|
52
|
+
prevPage,
|
|
53
|
+
className,
|
|
54
|
+
}: PaginationControlsProps) {
|
|
55
|
+
// Note: setPage is included for API compatibility with usePagination but not used
|
|
56
|
+
// as this component only provides prev/next navigation
|
|
57
|
+
void _setPage;
|
|
58
|
+
if (totalItems === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn('flex items-center gap-3 text-sm', className)}>
|
|
64
|
+
{/* Page size selector - hidden on small screens */}
|
|
65
|
+
<div className="hidden md:flex items-center gap-1.5">
|
|
66
|
+
<span className="text-muted-foreground hidden lg:inline">Rows:</span>
|
|
67
|
+
<Select
|
|
68
|
+
value={String(pageSize)}
|
|
69
|
+
onValueChange={(v) => setPageSize(Number(v))}
|
|
70
|
+
>
|
|
71
|
+
<SelectTrigger className="w-16 h-8 text-xs">
|
|
72
|
+
<SelectValue />
|
|
73
|
+
</SelectTrigger>
|
|
74
|
+
<SelectContent>
|
|
75
|
+
{pageSizeOptions.map((size) => (
|
|
76
|
+
<SelectItem key={size} value={String(size)}>
|
|
77
|
+
{size}
|
|
78
|
+
</SelectItem>
|
|
79
|
+
))}
|
|
80
|
+
</SelectContent>
|
|
81
|
+
</Select>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Page navigation - always visible when there are multiple pages */}
|
|
85
|
+
{totalPages > 1 && (
|
|
86
|
+
<div className="flex items-center gap-1">
|
|
87
|
+
<Button
|
|
88
|
+
variant="outline"
|
|
89
|
+
size="icon"
|
|
90
|
+
className="h-8 w-8"
|
|
91
|
+
onClick={prevPage}
|
|
92
|
+
disabled={!canGoPrev}
|
|
93
|
+
title="Previous page"
|
|
94
|
+
>
|
|
95
|
+
<ChevronLeft className="h-4 w-4" />
|
|
96
|
+
</Button>
|
|
97
|
+
|
|
98
|
+
{/* Page indicator - hidden on very small screens */}
|
|
99
|
+
<span className="hidden sm:inline px-2 text-muted-foreground tabular-nums min-w-[60px] text-center">
|
|
100
|
+
{page} / {totalPages}
|
|
101
|
+
</span>
|
|
102
|
+
|
|
103
|
+
<Button
|
|
104
|
+
variant="outline"
|
|
105
|
+
size="icon"
|
|
106
|
+
className="h-8 w-8"
|
|
107
|
+
onClick={nextPage}
|
|
108
|
+
disabled={!canGoNext}
|
|
109
|
+
title="Next page"
|
|
110
|
+
>
|
|
111
|
+
<ChevronRight className="h-4 w-4" />
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Items info - hidden on smaller screens */}
|
|
117
|
+
<span className="text-muted-foreground whitespace-nowrap hidden lg:inline">
|
|
118
|
+
Showing {startIndex}–{endIndex} of {totalItems}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Search, Filter, X } from 'lucide-react';
|
|
3
|
+
import { Input } from '../../components/base/input';
|
|
4
|
+
import { Button } from '../../components/base/button';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../components/base/popover';
|
|
6
|
+
import { cn } from '../../src/utils/cn';
|
|
7
|
+
|
|
8
|
+
export interface FilterOption {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
render: () => React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TableFiltersProps {
|
|
15
|
+
search?: string;
|
|
16
|
+
onSearchChange?: (value: string) => void;
|
|
17
|
+
searchPlaceholder?: string;
|
|
18
|
+
filters?: FilterOption[];
|
|
19
|
+
activeFilterCount?: number;
|
|
20
|
+
onClearFilters?: () => void;
|
|
21
|
+
className?: string;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* TableFilters - Compact filter controls for data tables
|
|
27
|
+
*
|
|
28
|
+
* Features:
|
|
29
|
+
* - Search input
|
|
30
|
+
* - Popover filter menu with badge showing active count
|
|
31
|
+
* - Clear all filters button
|
|
32
|
+
* - Custom filter components via render prop
|
|
33
|
+
* - Children slot for additional action buttons
|
|
34
|
+
*/
|
|
35
|
+
export function TableFilters({
|
|
36
|
+
search,
|
|
37
|
+
onSearchChange,
|
|
38
|
+
searchPlaceholder = 'Search...',
|
|
39
|
+
filters,
|
|
40
|
+
activeFilterCount = 0,
|
|
41
|
+
onClearFilters,
|
|
42
|
+
className,
|
|
43
|
+
children,
|
|
44
|
+
}: TableFiltersProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('flex items-center gap-3 flex-wrap', className)}>
|
|
47
|
+
{/* Search Input */}
|
|
48
|
+
{onSearchChange !== undefined && (
|
|
49
|
+
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
|
50
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-10" />
|
|
51
|
+
<Input
|
|
52
|
+
placeholder={searchPlaceholder}
|
|
53
|
+
value={search || ''}
|
|
54
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
55
|
+
className="pl-9 h-9"
|
|
56
|
+
/>
|
|
57
|
+
{search && (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={() => onSearchChange('')}
|
|
61
|
+
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-sm hover:bg-muted"
|
|
62
|
+
>
|
|
63
|
+
<X className="h-3 w-3 text-muted-foreground" />
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{/* Filters Popover */}
|
|
70
|
+
{filters && filters.length > 0 && (
|
|
71
|
+
<Popover>
|
|
72
|
+
<PopoverTrigger asChild>
|
|
73
|
+
<Button variant="outline" size="sm" className="gap-2 h-9">
|
|
74
|
+
<Filter className="h-4 w-4" />
|
|
75
|
+
Filters
|
|
76
|
+
{activeFilterCount > 0 && (
|
|
77
|
+
<span className="ml-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-xs font-medium">
|
|
78
|
+
{activeFilterCount}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</Button>
|
|
82
|
+
</PopoverTrigger>
|
|
83
|
+
<PopoverContent
|
|
84
|
+
className="w-80 overflow-y-auto"
|
|
85
|
+
align="start"
|
|
86
|
+
collisionPadding={16}
|
|
87
|
+
style={{ maxHeight: 'var(--radix-popover-content-available-height)' }}
|
|
88
|
+
>
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
<div className="flex items-center justify-between">
|
|
91
|
+
<h4 className="font-medium text-sm">Filters</h4>
|
|
92
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
93
|
+
<Button
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="sm"
|
|
96
|
+
onClick={onClearFilters}
|
|
97
|
+
className="h-auto p-0 text-xs text-destructive hover:text-destructive"
|
|
98
|
+
>
|
|
99
|
+
Clear all
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="space-y-3">
|
|
105
|
+
{filters.map((filter) => (
|
|
106
|
+
<div key={filter.id} className="space-y-1.5">
|
|
107
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
108
|
+
{filter.label}
|
|
109
|
+
</label>
|
|
110
|
+
{filter.render()}
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</PopoverContent>
|
|
116
|
+
</Popover>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Clear filters button (visible when filters are active, outside popover) */}
|
|
120
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="sm"
|
|
124
|
+
onClick={onClearFilters}
|
|
125
|
+
className="h-9 gap-1.5 text-muted-foreground hover:text-foreground"
|
|
126
|
+
>
|
|
127
|
+
<X className="h-4 w-4" />
|
|
128
|
+
Clear
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* Spacer to push children to the right */}
|
|
133
|
+
<div className="flex-1" />
|
|
134
|
+
|
|
135
|
+
{/* Custom children (e.g., export button, create button, etc.) */}
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Table Components
|
|
3
|
+
*
|
|
4
|
+
* Reusable components for building data tables:
|
|
5
|
+
* - DataTablePage: Full-page layout with header controls
|
|
6
|
+
* - PaginationControls: Compact inline pagination
|
|
7
|
+
* - Pagination: Full pagination with page numbers
|
|
8
|
+
* - BatchActionsBar: Multi-select action bar
|
|
9
|
+
* - ColumnVisibility: Column toggle dropdown
|
|
10
|
+
* - TableFilters: Search and filter controls
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { DataTablePage } from './DataTablePage';
|
|
14
|
+
export type { DataTablePageProps, FilterOption } from './DataTablePage';
|
|
15
|
+
|
|
16
|
+
export { PaginationControls } from './PaginationControls';
|
|
17
|
+
export type { PaginationControlsProps } from './PaginationControls';
|
|
18
|
+
|
|
19
|
+
export { Pagination } from './Pagination';
|
|
20
|
+
|
|
21
|
+
export { BatchActionsBar } from './BatchActionsBar';
|
|
22
|
+
export type { BatchActionsBarProps } from './BatchActionsBar';
|
|
23
|
+
|
|
24
|
+
export { ColumnVisibility } from './ColumnVisibility';
|
|
25
|
+
|
|
26
|
+
export { TableFilters } from './TableFilters';
|
|
27
|
+
export type { TableFiltersProps, FilterOption as TableFilterOption } from './TableFilters';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Table Hooks
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive hooks for building data tables with:
|
|
5
|
+
* - Pagination with localStorage persistence
|
|
6
|
+
* - Column visibility toggling
|
|
7
|
+
* - Column resizing with drag handles
|
|
8
|
+
* - Column reordering with drag-and-drop
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { usePagination } from './usePagination';
|
|
12
|
+
export { useColumnVisibility } from './useColumnVisibility';
|
|
13
|
+
export type { ColumnConfig, ColumnVisibilityState } from './useColumnVisibility';
|
|
14
|
+
export { useResizableColumns } from './useResizableColumns';
|
|
15
|
+
export type { ResizableColumnResult } from './useResizableColumns';
|
|
16
|
+
export { useColumnOrder, useColumnDragDrop } from './useColumnOrder';
|
|
17
|
+
export type { ColumnOrderConfig, DragState } from './useColumnOrder';
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ColumnOrderConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
locked?: boolean; // Locked columns can't be moved
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface UseColumnOrderOptions {
|
|
10
|
+
storageKey: string;
|
|
11
|
+
defaultOrder: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseColumnOrderReturn {
|
|
15
|
+
columnOrder: string[];
|
|
16
|
+
moveColumn: (fromIndex: number, toIndex: number) => void;
|
|
17
|
+
moveColumnById: (columnId: string, direction: 'left' | 'right') => void;
|
|
18
|
+
resetOrder: () => void;
|
|
19
|
+
getOrderedColumns: <T extends { id: string }>(columns: T[]) => T[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook for managing column order with localStorage persistence
|
|
24
|
+
*/
|
|
25
|
+
export function useColumnOrder({
|
|
26
|
+
storageKey,
|
|
27
|
+
defaultOrder,
|
|
28
|
+
}: UseColumnOrderOptions): UseColumnOrderReturn {
|
|
29
|
+
const [columnOrder, setColumnOrder] = useState<string[]>(() => {
|
|
30
|
+
try {
|
|
31
|
+
const stored = localStorage.getItem(storageKey);
|
|
32
|
+
if (stored) {
|
|
33
|
+
const parsed = JSON.parse(stored);
|
|
34
|
+
const storedSet = new Set(parsed);
|
|
35
|
+
const defaultSet = new Set(defaultOrder);
|
|
36
|
+
|
|
37
|
+
// Remove old columns that aren't in default anymore
|
|
38
|
+
const validStored = parsed.filter((col: string) => defaultSet.has(col));
|
|
39
|
+
|
|
40
|
+
// Find missing columns and insert them at their default positions
|
|
41
|
+
const missingColumns = defaultOrder.filter(col => !storedSet.has(col));
|
|
42
|
+
|
|
43
|
+
if (missingColumns.length > 0) {
|
|
44
|
+
// Build new order by inserting missing columns at their default positions
|
|
45
|
+
const result = [...validStored];
|
|
46
|
+
for (const missing of missingColumns) {
|
|
47
|
+
const defaultIndex = defaultOrder.indexOf(missing);
|
|
48
|
+
// Find the best insertion point based on surrounding columns in default order
|
|
49
|
+
let insertAt = result.length;
|
|
50
|
+
for (let i = 0; i < result.length; i++) {
|
|
51
|
+
const currentDefaultIndex = defaultOrder.indexOf(result[i]);
|
|
52
|
+
if (currentDefaultIndex > defaultIndex) {
|
|
53
|
+
insertAt = i;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
result.splice(insertAt, 0, missing);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return validStored.length > 0 ? validStored : defaultOrder;
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.warn('Failed to load column order from localStorage:', e);
|
|
66
|
+
}
|
|
67
|
+
return defaultOrder;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Persist to localStorage
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
try {
|
|
73
|
+
localStorage.setItem(storageKey, JSON.stringify(columnOrder));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn('Failed to save column order to localStorage:', e);
|
|
76
|
+
}
|
|
77
|
+
}, [storageKey, columnOrder]);
|
|
78
|
+
|
|
79
|
+
const moveColumn = useCallback((fromIndex: number, toIndex: number) => {
|
|
80
|
+
if (fromIndex === toIndex) return;
|
|
81
|
+
|
|
82
|
+
setColumnOrder(prev => {
|
|
83
|
+
const newOrder = [...prev];
|
|
84
|
+
const [removed] = newOrder.splice(fromIndex, 1);
|
|
85
|
+
newOrder.splice(toIndex, 0, removed);
|
|
86
|
+
return newOrder;
|
|
87
|
+
});
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const moveColumnById = useCallback((columnId: string, direction: 'left' | 'right') => {
|
|
91
|
+
setColumnOrder(prev => {
|
|
92
|
+
const currentIndex = prev.indexOf(columnId);
|
|
93
|
+
if (currentIndex === -1) return prev;
|
|
94
|
+
|
|
95
|
+
const newIndex = direction === 'left'
|
|
96
|
+
? Math.max(0, currentIndex - 1)
|
|
97
|
+
: Math.min(prev.length - 1, currentIndex + 1);
|
|
98
|
+
|
|
99
|
+
if (currentIndex === newIndex) return prev;
|
|
100
|
+
|
|
101
|
+
const newOrder = [...prev];
|
|
102
|
+
const [removed] = newOrder.splice(currentIndex, 1);
|
|
103
|
+
newOrder.splice(newIndex, 0, removed);
|
|
104
|
+
return newOrder;
|
|
105
|
+
});
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const resetOrder = useCallback(() => {
|
|
109
|
+
setColumnOrder(defaultOrder);
|
|
110
|
+
}, [defaultOrder]);
|
|
111
|
+
|
|
112
|
+
const getOrderedColumns = useCallback(<T extends { id: string }>(columns: T[]): T[] => {
|
|
113
|
+
const columnMap = new Map(columns.map(col => [col.id, col]));
|
|
114
|
+
return columnOrder
|
|
115
|
+
.map(id => columnMap.get(id))
|
|
116
|
+
.filter((col): col is T => col !== undefined);
|
|
117
|
+
}, [columnOrder]);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
columnOrder,
|
|
121
|
+
moveColumn,
|
|
122
|
+
moveColumnById,
|
|
123
|
+
resetOrder,
|
|
124
|
+
getOrderedColumns,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Drag and drop helpers for column reordering
|
|
130
|
+
*/
|
|
131
|
+
export interface DragState {
|
|
132
|
+
isDragging: boolean;
|
|
133
|
+
draggedId: string | null;
|
|
134
|
+
dropIndex: number | null; // Index where the column will be inserted
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function useColumnDragDrop(
|
|
138
|
+
columnOrder: string[],
|
|
139
|
+
moveColumn: (from: number, to: number) => void,
|
|
140
|
+
lockedColumns: string[] = []
|
|
141
|
+
) {
|
|
142
|
+
const [dragState, setDragState] = useState<DragState>({
|
|
143
|
+
isDragging: false,
|
|
144
|
+
draggedId: null,
|
|
145
|
+
dropIndex: null,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const handleDragStart = useCallback((columnId: string) => {
|
|
149
|
+
if (lockedColumns.includes(columnId)) return;
|
|
150
|
+
setDragState({
|
|
151
|
+
isDragging: true,
|
|
152
|
+
draggedId: columnId,
|
|
153
|
+
dropIndex: null,
|
|
154
|
+
});
|
|
155
|
+
}, [lockedColumns]);
|
|
156
|
+
|
|
157
|
+
const handleDragOver = useCallback((columnId: string, e: React.DragEvent) => {
|
|
158
|
+
if (lockedColumns.includes(columnId)) return;
|
|
159
|
+
|
|
160
|
+
const targetIndex = columnOrder.indexOf(columnId);
|
|
161
|
+
if (targetIndex === -1) return;
|
|
162
|
+
|
|
163
|
+
// Determine drop index based on mouse position relative to column center
|
|
164
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
165
|
+
const midpoint = rect.left + rect.width / 2;
|
|
166
|
+
const dropIndex = e.clientX < midpoint ? targetIndex : targetIndex + 1;
|
|
167
|
+
|
|
168
|
+
setDragState(prev => ({
|
|
169
|
+
...prev,
|
|
170
|
+
dropIndex,
|
|
171
|
+
}));
|
|
172
|
+
}, [lockedColumns, columnOrder]);
|
|
173
|
+
|
|
174
|
+
const handleDrop = useCallback(() => {
|
|
175
|
+
if (!dragState.draggedId || dragState.dropIndex === null) {
|
|
176
|
+
setDragState({ isDragging: false, draggedId: null, dropIndex: null });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fromIndex = columnOrder.indexOf(dragState.draggedId);
|
|
181
|
+
let toIndex = dragState.dropIndex;
|
|
182
|
+
|
|
183
|
+
// Adjust if moving from before the drop position
|
|
184
|
+
if (fromIndex < toIndex) {
|
|
185
|
+
toIndex = toIndex - 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
189
|
+
moveColumn(fromIndex, toIndex);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setDragState({ isDragging: false, draggedId: null, dropIndex: null });
|
|
193
|
+
}, [dragState.draggedId, dragState.dropIndex, columnOrder, moveColumn]);
|
|
194
|
+
|
|
195
|
+
const handleDragEnd = useCallback(() => {
|
|
196
|
+
setDragState({ isDragging: false, draggedId: null, dropIndex: null });
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
const getDragHandleProps = useCallback((columnId: string) => ({
|
|
200
|
+
draggable: !lockedColumns.includes(columnId),
|
|
201
|
+
onDragStart: (e: React.DragEvent) => {
|
|
202
|
+
if (lockedColumns.includes(columnId)) {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
207
|
+
handleDragStart(columnId);
|
|
208
|
+
},
|
|
209
|
+
onDragOver: (e: React.DragEvent) => {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.dataTransfer.dropEffect = 'move';
|
|
212
|
+
handleDragOver(columnId, e);
|
|
213
|
+
},
|
|
214
|
+
onDrop: (e: React.DragEvent) => {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
handleDrop();
|
|
217
|
+
},
|
|
218
|
+
onDragEnd: handleDragEnd,
|
|
219
|
+
}), [lockedColumns, handleDragStart, handleDragOver, handleDrop, handleDragEnd]);
|
|
220
|
+
|
|
221
|
+
// Helper to check if drop indicator should show on the left of a column
|
|
222
|
+
const showDropIndicator = useCallback((columnId: string) => {
|
|
223
|
+
if (!dragState.isDragging || dragState.dropIndex === null) return false;
|
|
224
|
+
const columnIndex = columnOrder.indexOf(columnId);
|
|
225
|
+
return columnIndex === dragState.dropIndex;
|
|
226
|
+
}, [dragState.isDragging, dragState.dropIndex, columnOrder]);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
dragState,
|
|
230
|
+
getDragHandleProps,
|
|
231
|
+
showDropIndicator,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ColumnConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
defaultVisible?: boolean;
|
|
7
|
+
locked?: boolean; // If true, column cannot be hidden
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ColumnVisibilityState {
|
|
11
|
+
[key: string]: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseColumnVisibilityOptions {
|
|
15
|
+
columns: ColumnConfig[];
|
|
16
|
+
storageKey: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UseColumnVisibilityReturn {
|
|
20
|
+
visibleColumns: ColumnVisibilityState;
|
|
21
|
+
isColumnVisible: (columnId: string) => boolean;
|
|
22
|
+
toggleColumn: (columnId: string) => void;
|
|
23
|
+
showAllColumns: () => void;
|
|
24
|
+
hideAllColumns: () => void;
|
|
25
|
+
columns: ColumnConfig[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useColumnVisibility({
|
|
29
|
+
columns,
|
|
30
|
+
storageKey,
|
|
31
|
+
}: UseColumnVisibilityOptions): UseColumnVisibilityReturn {
|
|
32
|
+
const fullStorageKey = `column-visibility-${storageKey}`;
|
|
33
|
+
|
|
34
|
+
// Initialize state from localStorage or defaults (with SSR guard)
|
|
35
|
+
const [visibleColumns, setVisibleColumns] = useState<ColumnVisibilityState>(() => {
|
|
36
|
+
// Default state
|
|
37
|
+
const defaults: ColumnVisibilityState = {};
|
|
38
|
+
columns.forEach(col => {
|
|
39
|
+
defaults[col.id] = col.defaultVisible !== false;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// SSR guard - localStorage is not available on server
|
|
43
|
+
if (typeof window === 'undefined') {
|
|
44
|
+
return defaults;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const stored = localStorage.getItem(fullStorageKey);
|
|
49
|
+
if (stored) {
|
|
50
|
+
const parsed = JSON.parse(stored);
|
|
51
|
+
// Merge with defaults to handle new columns
|
|
52
|
+
const merged: ColumnVisibilityState = {};
|
|
53
|
+
columns.forEach(col => {
|
|
54
|
+
if (col.locked) {
|
|
55
|
+
merged[col.id] = true;
|
|
56
|
+
} else if (parsed[col.id] !== undefined) {
|
|
57
|
+
merged[col.id] = parsed[col.id];
|
|
58
|
+
} else {
|
|
59
|
+
merged[col.id] = col.defaultVisible !== false;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return merged;
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error('Error loading column visibility state:', e);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return defaults;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Persist to localStorage (with SSR guard)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (typeof window === 'undefined') return;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(fullStorageKey, JSON.stringify(visibleColumns));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('Error saving column visibility state:', e);
|
|
79
|
+
}
|
|
80
|
+
}, [visibleColumns, fullStorageKey]);
|
|
81
|
+
|
|
82
|
+
const isColumnVisible = useCallback(
|
|
83
|
+
(columnId: string): boolean => {
|
|
84
|
+
const col = columns.find(c => c.id === columnId);
|
|
85
|
+
if (col?.locked) return true;
|
|
86
|
+
return visibleColumns[columnId] !== false;
|
|
87
|
+
},
|
|
88
|
+
[visibleColumns, columns]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const toggleColumn = useCallback(
|
|
92
|
+
(columnId: string) => {
|
|
93
|
+
const col = columns.find(c => c.id === columnId);
|
|
94
|
+
if (col?.locked) return; // Cannot toggle locked columns
|
|
95
|
+
|
|
96
|
+
setVisibleColumns(prev => ({
|
|
97
|
+
...prev,
|
|
98
|
+
[columnId]: !prev[columnId],
|
|
99
|
+
}));
|
|
100
|
+
},
|
|
101
|
+
[columns]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const showAllColumns = useCallback(() => {
|
|
105
|
+
const allVisible: ColumnVisibilityState = {};
|
|
106
|
+
columns.forEach(col => {
|
|
107
|
+
allVisible[col.id] = true;
|
|
108
|
+
});
|
|
109
|
+
setVisibleColumns(allVisible);
|
|
110
|
+
}, [columns]);
|
|
111
|
+
|
|
112
|
+
const hideAllColumns = useCallback(() => {
|
|
113
|
+
const onlyLocked: ColumnVisibilityState = {};
|
|
114
|
+
columns.forEach(col => {
|
|
115
|
+
onlyLocked[col.id] = col.locked === true;
|
|
116
|
+
});
|
|
117
|
+
setVisibleColumns(onlyLocked);
|
|
118
|
+
}, [columns]);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
visibleColumns,
|
|
122
|
+
isColumnVisible,
|
|
123
|
+
toggleColumn,
|
|
124
|
+
showAllColumns,
|
|
125
|
+
hideAllColumns,
|
|
126
|
+
columns,
|
|
127
|
+
};
|
|
128
|
+
}
|