@superdangerous/app-framework 4.9.2 → 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/README.md +8 -2
- package/dist/api/logsRouter.d.ts +4 -1
- package/dist/api/logsRouter.d.ts.map +1 -1
- package/dist/api/logsRouter.js +100 -118
- package/dist/api/logsRouter.js.map +1 -1
- 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 +48 -43
- package/dist/middleware/validation.d.ts.map +1 -1
- package/dist/middleware/validation.js +48 -43
- package/dist/middleware/validation.js.map +1 -1
- 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/dist/services/websocketServer.d.ts +7 -4
- package/dist/services/websocketServer.d.ts.map +1 -1
- package/dist/services/websocketServer.js +22 -16
- package/dist/services/websocketServer.js.map +1 -1
- package/dist/types/index.d.ts +7 -8
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/api/logsRouter.ts +119 -138
- package/src/index.ts +14 -0
- package/src/middleware/validation.ts +82 -90
- package/src/services/emailService.ts +812 -0
- package/src/services/index.ts +14 -0
- package/src/services/websocketServer.ts +37 -23
- package/src/types/index.ts +7 -8
- 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
package/src/services/index.ts
CHANGED
|
@@ -46,3 +46,17 @@ export type {
|
|
|
46
46
|
// Queue Service
|
|
47
47
|
export { default as QueueService } from "./queueService.js";
|
|
48
48
|
export type { QueueJob, QueueConfig, JobHandler } from "./queueService.js";
|
|
49
|
+
|
|
50
|
+
// Email Service
|
|
51
|
+
export {
|
|
52
|
+
EmailService,
|
|
53
|
+
getEmailService,
|
|
54
|
+
createEmailService,
|
|
55
|
+
} from "./emailService.js";
|
|
56
|
+
export type {
|
|
57
|
+
EmailConfig,
|
|
58
|
+
EmailOptions,
|
|
59
|
+
EmailServiceStatus,
|
|
60
|
+
NotificationEvent,
|
|
61
|
+
NotificationEventType,
|
|
62
|
+
} from "./emailService.js";
|
|
@@ -13,9 +13,10 @@ import {
|
|
|
13
13
|
DataUpdateMessage,
|
|
14
14
|
Simulator,
|
|
15
15
|
Template,
|
|
16
|
+
Logger,
|
|
16
17
|
} from "../types/index.js";
|
|
17
18
|
|
|
18
|
-
let logger:
|
|
19
|
+
let logger: Logger | null = null;
|
|
19
20
|
|
|
20
21
|
interface ClientInfo {
|
|
21
22
|
id: string;
|
|
@@ -26,7 +27,10 @@ interface ClientInfo {
|
|
|
26
27
|
interface BroadcastData {
|
|
27
28
|
simulatorId?: string;
|
|
28
29
|
templateId?: string;
|
|
29
|
-
|
|
30
|
+
address?: string;
|
|
31
|
+
value?: unknown;
|
|
32
|
+
values?: Record<string, unknown>;
|
|
33
|
+
[key: string]: unknown;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
class WebSocketServer {
|
|
@@ -34,7 +38,7 @@ class WebSocketServer {
|
|
|
34
38
|
private httpServer: HTTPServer;
|
|
35
39
|
private clients: Map<string, ClientInfo>;
|
|
36
40
|
private simulatorSubscriptions: Map<string, Set<string>>; // simulatorId -> Set of socket IDs
|
|
37
|
-
private broadcastHook?: (event: string, data:
|
|
41
|
+
private broadcastHook?: (event: string, data: BroadcastData) => void;
|
|
38
42
|
|
|
39
43
|
constructor(httpServer: HTTPServer) {
|
|
40
44
|
// Initialize logger if not already done
|
|
@@ -48,12 +52,12 @@ class WebSocketServer {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
initialize(opts?: {
|
|
51
|
-
broadcastHook?: (event: string, data:
|
|
55
|
+
broadcastHook?: (event: string, data: BroadcastData) => void;
|
|
52
56
|
}): void {
|
|
53
57
|
if (this.io) {
|
|
54
58
|
if (opts?.broadcastHook) {
|
|
55
59
|
this.broadcastHook = opts.broadcastHook;
|
|
56
|
-
logger
|
|
60
|
+
logger?.debug("WebSocket broadcast hook updated");
|
|
57
61
|
}
|
|
58
62
|
return;
|
|
59
63
|
}
|
|
@@ -71,7 +75,7 @@ class WebSocketServer {
|
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
this.setupEventHandlers();
|
|
74
|
-
logger
|
|
78
|
+
logger?.info("WebSocket server initialized");
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/**
|
|
@@ -87,7 +91,7 @@ class WebSocketServer {
|
|
|
87
91
|
this.io.on("connection", (socket: Socket) => {
|
|
88
92
|
// Only log in debug mode to avoid spam
|
|
89
93
|
if (process.env.LOG_LEVEL?.toLowerCase() === "debug") {
|
|
90
|
-
logger
|
|
94
|
+
logger?.debug(`Client connected: ${socket.id}`);
|
|
91
95
|
}
|
|
92
96
|
this.clients.set(socket.id, {
|
|
93
97
|
id: socket.id,
|
|
@@ -114,14 +118,14 @@ class WebSocketServer {
|
|
|
114
118
|
socket.on("disconnect", () => {
|
|
115
119
|
// Only log in debug mode to avoid spam
|
|
116
120
|
if (process.env.LOG_LEVEL?.toLowerCase() === "debug") {
|
|
117
|
-
logger
|
|
121
|
+
logger?.debug(`Client disconnected: ${socket.id}`);
|
|
118
122
|
}
|
|
119
123
|
this.handleDisconnect(socket);
|
|
120
124
|
});
|
|
121
125
|
|
|
122
126
|
// Handle errors
|
|
123
|
-
socket.on("error", (
|
|
124
|
-
logger
|
|
127
|
+
socket.on("error", (error: Error) => {
|
|
128
|
+
logger?.error(`Socket error for ${socket.id}:`, error);
|
|
125
129
|
});
|
|
126
130
|
});
|
|
127
131
|
}
|
|
@@ -145,7 +149,7 @@ class WebSocketServer {
|
|
|
145
149
|
// Join socket.io room for efficient broadcasting
|
|
146
150
|
socket.join(`simulator:${simulatorId}`);
|
|
147
151
|
|
|
148
|
-
logger
|
|
152
|
+
logger?.debug(`Client ${socket.id} subscribed to simulator ${simulatorId}`);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
private unsubscribeFromSimulator(socket: Socket, simulatorId: string): void {
|
|
@@ -167,7 +171,7 @@ class WebSocketServer {
|
|
|
167
171
|
// Leave socket.io room
|
|
168
172
|
socket.leave(`simulator:${simulatorId}`);
|
|
169
173
|
|
|
170
|
-
logger
|
|
174
|
+
logger?.debug(
|
|
171
175
|
`Client ${socket.id} unsubscribed from simulator ${simulatorId}`,
|
|
172
176
|
);
|
|
173
177
|
}
|
|
@@ -240,7 +244,9 @@ class WebSocketServer {
|
|
|
240
244
|
type: "data:update",
|
|
241
245
|
data: {
|
|
242
246
|
simulatorId: data.simulatorId!,
|
|
243
|
-
values: data.values
|
|
247
|
+
values: (data.values as Record<string, unknown>) || {
|
|
248
|
+
[data.address as string]: data.value,
|
|
249
|
+
},
|
|
244
250
|
},
|
|
245
251
|
} as DataUpdateMessage;
|
|
246
252
|
break;
|
|
@@ -252,21 +258,29 @@ class WebSocketServer {
|
|
|
252
258
|
} as WebSocketMessage;
|
|
253
259
|
}
|
|
254
260
|
|
|
255
|
-
// Broadcast to
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// If simulator-specific, also send to room
|
|
261
|
+
// Broadcast: if simulator-specific, send only to room; otherwise broadcast globally
|
|
262
|
+
// This prevents duplicate events for subscribed clients
|
|
259
263
|
if (data.simulatorId) {
|
|
260
264
|
this.io.to(`simulator:${data.simulatorId}`).emit(event, message.data);
|
|
265
|
+
const subscriberCount =
|
|
266
|
+
this.simulatorSubscriptions.get(data.simulatorId)?.size || 0;
|
|
267
|
+
logger?.debug(
|
|
268
|
+
`Broadcast ${event} to ${subscriberCount} subscribers of simulator ${data.simulatorId}`,
|
|
269
|
+
);
|
|
270
|
+
} else {
|
|
271
|
+
this.io.emit(event, message.data);
|
|
272
|
+
logger?.debug(`Broadcast ${event} to ${this.clients.size} clients`);
|
|
261
273
|
}
|
|
262
|
-
|
|
263
|
-
logger.debug(`Broadcast ${event} to ${this.clients.size} clients`);
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
/**
|
|
267
277
|
* Send event to specific simulator subscribers
|
|
268
278
|
*/
|
|
269
|
-
broadcastToSimulator(
|
|
279
|
+
broadcastToSimulator(
|
|
280
|
+
simulatorId: string,
|
|
281
|
+
event: string,
|
|
282
|
+
data: Record<string, unknown>,
|
|
283
|
+
): void {
|
|
270
284
|
if (!this.io) return;
|
|
271
285
|
|
|
272
286
|
const message: DataUpdateMessage = {
|
|
@@ -281,7 +295,7 @@ class WebSocketServer {
|
|
|
281
295
|
|
|
282
296
|
const subscriberCount =
|
|
283
297
|
this.simulatorSubscriptions.get(simulatorId)?.size || 0;
|
|
284
|
-
logger
|
|
298
|
+
logger?.debug(
|
|
285
299
|
`Broadcast ${event} to ${subscriberCount} subscribers of simulator ${simulatorId}`,
|
|
286
300
|
);
|
|
287
301
|
}
|
|
@@ -338,7 +352,7 @@ class WebSocketServer {
|
|
|
338
352
|
// Close the server
|
|
339
353
|
await new Promise<void>((resolve) => {
|
|
340
354
|
this.io!.close(() => {
|
|
341
|
-
logger
|
|
355
|
+
logger?.info("WebSocket server shut down");
|
|
342
356
|
resolve();
|
|
343
357
|
});
|
|
344
358
|
});
|
|
@@ -362,7 +376,7 @@ export function createWebSocketServer(
|
|
|
362
376
|
httpServer: HTTPServer,
|
|
363
377
|
opts?: {
|
|
364
378
|
reset?: boolean;
|
|
365
|
-
broadcastHook?: (event: string, data:
|
|
379
|
+
broadcastHook?: (event: string, data: BroadcastData) => void;
|
|
366
380
|
},
|
|
367
381
|
): WebSocketServer {
|
|
368
382
|
// Initialize logger if not already done
|
package/src/types/index.ts
CHANGED
|
@@ -92,7 +92,6 @@ export interface UpdateCheckResult {
|
|
|
92
92
|
export interface ClientInfo {
|
|
93
93
|
id: string;
|
|
94
94
|
connectedAt: Date;
|
|
95
|
-
lastActivity: Date;
|
|
96
95
|
subscriptions: Set<string>;
|
|
97
96
|
}
|
|
98
97
|
|
|
@@ -109,9 +108,9 @@ export interface Logger {
|
|
|
109
108
|
/**
|
|
110
109
|
* WebSocket message types
|
|
111
110
|
*/
|
|
112
|
-
export interface WebSocketMessage {
|
|
111
|
+
export interface WebSocketMessage<T = unknown> {
|
|
113
112
|
type: string;
|
|
114
|
-
data:
|
|
113
|
+
data: T;
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
export interface SimulatorUpdateMessage extends WebSocketMessage {
|
|
@@ -128,7 +127,7 @@ export interface DataUpdateMessage extends WebSocketMessage {
|
|
|
128
127
|
type: "data:update";
|
|
129
128
|
data: {
|
|
130
129
|
simulatorId: string;
|
|
131
|
-
values: Record<string,
|
|
130
|
+
values: Record<string, unknown>;
|
|
132
131
|
};
|
|
133
132
|
}
|
|
134
133
|
|
|
@@ -136,19 +135,19 @@ export interface Simulator {
|
|
|
136
135
|
id: string;
|
|
137
136
|
name: string;
|
|
138
137
|
status: string;
|
|
139
|
-
[key: string]:
|
|
138
|
+
[key: string]: unknown;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
export interface Template {
|
|
143
142
|
id: string;
|
|
144
143
|
name: string;
|
|
145
|
-
[key: string]:
|
|
144
|
+
[key: string]: unknown;
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
export interface AppConfig {
|
|
149
|
-
[key: string]:
|
|
148
|
+
[key: string]: unknown;
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
export interface ValidationError extends Error {
|
|
153
|
-
details?:
|
|
152
|
+
details?: unknown[];
|
|
154
153
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { Card } from '../../components/base/card';
|
|
4
|
+
import { Button } from '../../components/base/button';
|
|
5
|
+
import { cn } from '../../src/utils/cn';
|
|
6
|
+
|
|
7
|
+
export interface BatchActionsBarProps {
|
|
8
|
+
/** Number of selected items */
|
|
9
|
+
selectedCount: number;
|
|
10
|
+
/** Callback to clear selection */
|
|
11
|
+
onClear: () => void;
|
|
12
|
+
/** Action buttons to display on the right side */
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** Label for the selected items (default: "item"/"items") */
|
|
15
|
+
itemLabel?: string;
|
|
16
|
+
/** Additional CSS classes */
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A horizontal bar that appears when items are selected,
|
|
22
|
+
* showing the count and providing batch action buttons.
|
|
23
|
+
*/
|
|
24
|
+
export function BatchActionsBar({
|
|
25
|
+
selectedCount,
|
|
26
|
+
onClear,
|
|
27
|
+
children,
|
|
28
|
+
itemLabel,
|
|
29
|
+
className,
|
|
30
|
+
}: BatchActionsBarProps) {
|
|
31
|
+
if (selectedCount === 0) return null;
|
|
32
|
+
|
|
33
|
+
const label = itemLabel ?? (selectedCount === 1 ? 'item' : 'items');
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Card className={cn("p-3 bg-primary/5 border-primary/20", className)}>
|
|
37
|
+
<div className="flex items-center justify-between">
|
|
38
|
+
<div className="flex items-center gap-3">
|
|
39
|
+
<span className="font-medium text-sm" role="status" aria-live="polite">
|
|
40
|
+
{selectedCount} {label} selected
|
|
41
|
+
</span>
|
|
42
|
+
<Button variant="ghost" size="sm" onClick={onClear} aria-label="Clear selection">
|
|
43
|
+
<X className="h-4 w-4 mr-1" aria-hidden="true" />
|
|
44
|
+
Clear
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center gap-2" role="group" aria-label="Batch actions">
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</Card>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Columns3, Check, Eye, EyeOff } from 'lucide-react';
|
|
2
|
+
import { Button } from '../../components/base/button';
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuLabel,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from '../../components/base/dropdown-menu';
|
|
11
|
+
import { cn } from '../../src/utils/cn';
|
|
12
|
+
import type { ColumnConfig } from '../hooks/useColumnVisibility';
|
|
13
|
+
|
|
14
|
+
interface ColumnVisibilityProps {
|
|
15
|
+
columns: ColumnConfig[];
|
|
16
|
+
isColumnVisible: (columnId: string) => boolean;
|
|
17
|
+
toggleColumn: (columnId: string) => void;
|
|
18
|
+
showAllColumns: () => void;
|
|
19
|
+
hideAllColumns: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ColumnVisibility({
|
|
23
|
+
columns,
|
|
24
|
+
isColumnVisible,
|
|
25
|
+
toggleColumn,
|
|
26
|
+
showAllColumns,
|
|
27
|
+
hideAllColumns,
|
|
28
|
+
}: ColumnVisibilityProps) {
|
|
29
|
+
const visibleCount = columns.filter(c => isColumnVisible(c.id)).length;
|
|
30
|
+
const toggleableColumns = columns.filter(c => !c.locked);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<DropdownMenu>
|
|
34
|
+
<DropdownMenuTrigger asChild>
|
|
35
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
36
|
+
<Columns3 className="h-4 w-4" />
|
|
37
|
+
Columns
|
|
38
|
+
<span className="text-muted-foreground text-xs">
|
|
39
|
+
({visibleCount}/{columns.length})
|
|
40
|
+
</span>
|
|
41
|
+
</Button>
|
|
42
|
+
</DropdownMenuTrigger>
|
|
43
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
44
|
+
<DropdownMenuLabel className="font-normal text-xs text-muted-foreground">
|
|
45
|
+
Toggle columns
|
|
46
|
+
</DropdownMenuLabel>
|
|
47
|
+
<DropdownMenuSeparator />
|
|
48
|
+
|
|
49
|
+
{columns.map(column => {
|
|
50
|
+
const visible = isColumnVisible(column.id);
|
|
51
|
+
const isLocked = column.locked === true;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<DropdownMenuItem
|
|
55
|
+
key={column.id}
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (!isLocked) {
|
|
59
|
+
toggleColumn(column.id);
|
|
60
|
+
}
|
|
61
|
+
}}
|
|
62
|
+
className={cn(
|
|
63
|
+
'gap-2 cursor-pointer',
|
|
64
|
+
isLocked && 'opacity-50 cursor-not-allowed'
|
|
65
|
+
)}
|
|
66
|
+
disabled={isLocked}
|
|
67
|
+
>
|
|
68
|
+
<div className="w-4 h-4 flex items-center justify-center">
|
|
69
|
+
{visible ? (
|
|
70
|
+
<Check className="h-3.5 w-3.5 text-primary" />
|
|
71
|
+
) : (
|
|
72
|
+
<div className="h-3.5 w-3.5" />
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
<span className="flex-1">{column.label}</span>
|
|
76
|
+
{isLocked && (
|
|
77
|
+
<span className="text-xs text-muted-foreground">Required</span>
|
|
78
|
+
)}
|
|
79
|
+
</DropdownMenuItem>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
|
|
83
|
+
{toggleableColumns.length > 1 && (
|
|
84
|
+
<>
|
|
85
|
+
<DropdownMenuSeparator />
|
|
86
|
+
<DropdownMenuItem
|
|
87
|
+
onClick={(e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
showAllColumns();
|
|
90
|
+
}}
|
|
91
|
+
className="gap-2 cursor-pointer"
|
|
92
|
+
>
|
|
93
|
+
<Eye className="h-4 w-4" />
|
|
94
|
+
Show All
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
<DropdownMenuItem
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
hideAllColumns();
|
|
100
|
+
}}
|
|
101
|
+
className="gap-2 cursor-pointer"
|
|
102
|
+
>
|
|
103
|
+
<EyeOff className="h-4 w-4" />
|
|
104
|
+
Hide Optional
|
|
105
|
+
</DropdownMenuItem>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</DropdownMenuContent>
|
|
109
|
+
</DropdownMenu>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTablePage - Full-height data table layout with integrated header
|
|
3
|
+
*
|
|
4
|
+
* This component provides a desktop-app style layout where:
|
|
5
|
+
* - The header contains search, filters, action buttons, AND pagination controls
|
|
6
|
+
* - The data table fills the available vertical space between header and footer
|
|
7
|
+
* - Pagination controls appear inline in the header (right side)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <DataTablePage
|
|
12
|
+
* title="Issues"
|
|
13
|
+
* description="Review and manage detected code issues"
|
|
14
|
+
* search={searchTerm}
|
|
15
|
+
* onSearchChange={setSearchTerm}
|
|
16
|
+
* searchPlaceholder="Search issues..."
|
|
17
|
+
* filters={filterOptions}
|
|
18
|
+
* activeFilterCount={countActiveFilters}
|
|
19
|
+
* onClearFilters={clearFilters}
|
|
20
|
+
* pagination={pagination}
|
|
21
|
+
* actions={<>
|
|
22
|
+
* <Button>Refresh</Button>
|
|
23
|
+
* <Button>Export</Button>
|
|
24
|
+
* </>}
|
|
25
|
+
* >
|
|
26
|
+
* <DataTable ... hidePagination />
|
|
27
|
+
* </DataTablePage>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { Search, Filter, X } from 'lucide-react';
|
|
33
|
+
import { Input } from '../../components/base/input';
|
|
34
|
+
import { Button } from '../../components/base/button';
|
|
35
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../components/base/popover';
|
|
36
|
+
import { cn } from '../../src/utils/cn';
|
|
37
|
+
import { PaginationControls, type PaginationControlsProps } from './PaginationControls';
|
|
38
|
+
|
|
39
|
+
export interface FilterOption {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
render: () => React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DataTablePageProps {
|
|
46
|
+
/** Page title */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Page description */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Search term */
|
|
51
|
+
search?: string;
|
|
52
|
+
/** Search change handler */
|
|
53
|
+
onSearchChange?: (value: string) => void;
|
|
54
|
+
/** Search placeholder text */
|
|
55
|
+
searchPlaceholder?: string;
|
|
56
|
+
/** Filter options for popover */
|
|
57
|
+
filters?: FilterOption[];
|
|
58
|
+
/** Number of active filters */
|
|
59
|
+
activeFilterCount?: number;
|
|
60
|
+
/** Clear all filters handler */
|
|
61
|
+
onClearFilters?: () => void;
|
|
62
|
+
/** Pagination props from usePagination hook */
|
|
63
|
+
pagination?: PaginationControlsProps;
|
|
64
|
+
/** Action buttons to show in the header */
|
|
65
|
+
actions?: React.ReactNode;
|
|
66
|
+
/** Content before the table (e.g., BatchActionsBar) */
|
|
67
|
+
beforeTable?: React.ReactNode;
|
|
68
|
+
/** The DataTable component */
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
/** Additional class for the container */
|
|
71
|
+
className?: string;
|
|
72
|
+
/** Whether to show a loading state */
|
|
73
|
+
loading?: boolean;
|
|
74
|
+
/** Loading component to show */
|
|
75
|
+
loadingComponent?: React.ReactNode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function DataTablePage({
|
|
79
|
+
title,
|
|
80
|
+
description,
|
|
81
|
+
search,
|
|
82
|
+
onSearchChange,
|
|
83
|
+
searchPlaceholder = 'Search...',
|
|
84
|
+
filters,
|
|
85
|
+
activeFilterCount = 0,
|
|
86
|
+
onClearFilters,
|
|
87
|
+
pagination,
|
|
88
|
+
actions,
|
|
89
|
+
beforeTable,
|
|
90
|
+
children,
|
|
91
|
+
className,
|
|
92
|
+
loading,
|
|
93
|
+
loadingComponent,
|
|
94
|
+
}: DataTablePageProps) {
|
|
95
|
+
// Always show pagination controls when pagination is provided (for row count selector)
|
|
96
|
+
const showPagination = pagination && pagination.totalItems > 0;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={cn('flex flex-col h-full', className)}>
|
|
100
|
+
{/* Page Header - has horizontal padding */}
|
|
101
|
+
<div className="data-table-page-header flex-shrink-0 space-y-4 pb-4">
|
|
102
|
+
{/* Title */}
|
|
103
|
+
<div>
|
|
104
|
+
<h1 className="text-3xl font-bold">{title}</h1>
|
|
105
|
+
{description && (
|
|
106
|
+
<p className="text-muted-foreground">{description}</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Controls Row: Search | Filters | Pagination | Spacer | Actions */}
|
|
111
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
112
|
+
{/* Search Input - responsive width */}
|
|
113
|
+
{onSearchChange !== undefined && (
|
|
114
|
+
<div className="relative w-full sm:w-auto sm:min-w-[200px] sm:max-w-xs">
|
|
115
|
+
<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" />
|
|
116
|
+
<Input
|
|
117
|
+
placeholder={searchPlaceholder}
|
|
118
|
+
value={search || ''}
|
|
119
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
120
|
+
className="pl-9 h-9"
|
|
121
|
+
/>
|
|
122
|
+
{search && (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => onSearchChange('')}
|
|
126
|
+
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-sm hover:bg-muted"
|
|
127
|
+
>
|
|
128
|
+
<X className="h-3 w-3 text-muted-foreground" />
|
|
129
|
+
</button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Filters Popover */}
|
|
135
|
+
{filters && filters.length > 0 && (
|
|
136
|
+
<Popover>
|
|
137
|
+
<PopoverTrigger asChild>
|
|
138
|
+
<Button variant="outline" size="sm" className="gap-2 h-9">
|
|
139
|
+
<Filter className="h-4 w-4" />
|
|
140
|
+
<span className="hidden sm:inline">Filters</span>
|
|
141
|
+
{activeFilterCount > 0 && (
|
|
142
|
+
<span className="rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-xs font-medium">
|
|
143
|
+
{activeFilterCount}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</Button>
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
<PopoverContent
|
|
149
|
+
className="w-80 overflow-y-auto"
|
|
150
|
+
align="start"
|
|
151
|
+
collisionPadding={16}
|
|
152
|
+
style={{ maxHeight: 'var(--radix-popover-content-available-height)' }}
|
|
153
|
+
>
|
|
154
|
+
<div className="space-y-4">
|
|
155
|
+
<div className="flex items-center justify-between">
|
|
156
|
+
<h4 className="font-medium text-sm">Filters</h4>
|
|
157
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
158
|
+
<Button
|
|
159
|
+
variant="ghost"
|
|
160
|
+
size="sm"
|
|
161
|
+
onClick={onClearFilters}
|
|
162
|
+
className="h-auto p-0 text-xs text-destructive hover:text-destructive"
|
|
163
|
+
>
|
|
164
|
+
Clear all
|
|
165
|
+
</Button>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="space-y-3">
|
|
170
|
+
{filters.map((filter) => (
|
|
171
|
+
<div key={filter.id} className="space-y-1.5">
|
|
172
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
173
|
+
{filter.label}
|
|
174
|
+
</label>
|
|
175
|
+
{filter.render()}
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</PopoverContent>
|
|
181
|
+
</Popover>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Clear filters button (visible when filters are active) */}
|
|
185
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
186
|
+
<Button
|
|
187
|
+
variant="ghost"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={onClearFilters}
|
|
190
|
+
className="h-9 gap-1.5 text-muted-foreground hover:text-foreground"
|
|
191
|
+
title="Clear filters"
|
|
192
|
+
>
|
|
193
|
+
<X className="h-4 w-4" />
|
|
194
|
+
<span className="hidden sm:inline">Clear</span>
|
|
195
|
+
</Button>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{/* Pagination Controls (after filters) */}
|
|
199
|
+
{showPagination && (
|
|
200
|
+
<PaginationControls {...pagination} />
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Spacer */}
|
|
204
|
+
<div className="flex-1" />
|
|
205
|
+
|
|
206
|
+
{/* Action buttons (right side) - never wrap */}
|
|
207
|
+
{actions && (
|
|
208
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
209
|
+
{actions}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* Before Table Content (e.g., BatchActionsBar) - with padding */}
|
|
216
|
+
{beforeTable && (
|
|
217
|
+
<div className="px-6 pb-2">
|
|
218
|
+
{beforeTable}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* Table Container - edge to edge, scrolls both directions */}
|
|
223
|
+
<div className="relative flex-1 min-h-0">
|
|
224
|
+
<div className="data-table-scroll-container h-full">
|
|
225
|
+
{loading ? (
|
|
226
|
+
loadingComponent || (
|
|
227
|
+
<div className="flex items-center justify-center h-full">
|
|
228
|
+
<div className="text-muted-foreground">Loading...</div>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
) : (
|
|
232
|
+
children
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|