vasuzex 2.3.13 → 2.3.14
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/CHANGELOG.md +88 -0
- package/framework/Database/Model.js +18 -5
- package/framework/Services/Media/MediaManager.js +213 -118
- package/frontend/react-ui/components/DataTable/ActionDefaults.jsx +116 -2
- package/frontend/react-ui/components/DataTable/DataTable.jsx +113 -19
- package/frontend/react-ui/components/DataTable/Filters.jsx +99 -56
- package/frontend/react-ui/components/DataTable/TableBody.jsx +85 -24
- package/frontend/react-ui/components/DataTable/TableState.jsx +42 -13
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Eye, Edit2, Trash2 } from "lucide-react";
|
|
1
|
+
import { Eye, Edit2, Trash2, RotateCcw, Flame } from "lucide-react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ActionDefaults - Production Ready
|
|
5
5
|
*
|
|
6
6
|
* Default configurations for common DataTable actions
|
|
7
|
-
* Provides sensible defaults for edit, view, delete, and switch actions
|
|
7
|
+
* Provides sensible defaults for edit, view, delete, hardDelete, restore and switch actions
|
|
8
8
|
* Uses lucide-react icons matching RowActionsCell design
|
|
9
9
|
*
|
|
10
10
|
* @module components/DataTable/ActionDefaults
|
|
@@ -35,6 +35,24 @@ export const ACTION_DEFAULTS = {
|
|
|
35
35
|
extraClass:
|
|
36
36
|
"p-1.5 text-gray-600 hover:text-rose-600 hover:bg-rose-50 rounded-md transition-colors dark:text-gray-400 dark:hover:text-rose-400 dark:hover:bg-rose-950/30",
|
|
37
37
|
},
|
|
38
|
+
/** Permanent / hard delete action — shown in trash-only mode */
|
|
39
|
+
hardDelete: {
|
|
40
|
+
type: "button",
|
|
41
|
+
label: "Permanently Delete",
|
|
42
|
+
icon: Flame,
|
|
43
|
+
title: "Permanently Delete",
|
|
44
|
+
extraClass:
|
|
45
|
+
"p-1.5 text-rose-500 hover:text-white hover:bg-rose-600 rounded-md transition-colors dark:text-rose-400 dark:hover:bg-rose-700",
|
|
46
|
+
},
|
|
47
|
+
/** Restore action — shown for trashed rows */
|
|
48
|
+
restore: {
|
|
49
|
+
type: "button",
|
|
50
|
+
label: "Restore",
|
|
51
|
+
icon: RotateCcw,
|
|
52
|
+
title: "Restore",
|
|
53
|
+
extraClass:
|
|
54
|
+
"p-1.5 text-blue-500 hover:text-white hover:bg-blue-600 rounded-md transition-colors dark:text-blue-400 dark:hover:bg-blue-700",
|
|
55
|
+
},
|
|
38
56
|
switch: {
|
|
39
57
|
type: "button",
|
|
40
58
|
name: "switch",
|
|
@@ -73,6 +91,11 @@ export function applyActionDefaults(
|
|
|
73
91
|
...action,
|
|
74
92
|
};
|
|
75
93
|
|
|
94
|
+
// For custom actions, use the user's label as tooltip title when no explicit title given
|
|
95
|
+
if (actionName === 'custom' && !action.title && action.label) {
|
|
96
|
+
mergedAction.title = action.label;
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
// Auto-generate getHref for edit action if resourceName is provided
|
|
77
100
|
if (actionName === "edit" && !mergedAction.getHref && resourceName) {
|
|
78
101
|
mergedAction.getHref = (row) => `/${resourceName}/${row[resourceIdField]}/edit`;
|
|
@@ -177,3 +200,94 @@ export function createDeleteClickHandler(
|
|
|
177
200
|
}
|
|
178
201
|
};
|
|
179
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a hard-delete (permanent) action onClick handler.
|
|
206
|
+
* Shows a severe "cannot be undone" confirmation before calling
|
|
207
|
+
* DELETE url?hardDelete=true.
|
|
208
|
+
*/
|
|
209
|
+
export function createHardDeleteClickHandler(
|
|
210
|
+
api,
|
|
211
|
+
deleteUrl,
|
|
212
|
+
confirmMessage,
|
|
213
|
+
resourceIdField = "id",
|
|
214
|
+
options = {}
|
|
215
|
+
) {
|
|
216
|
+
if (!api) {
|
|
217
|
+
throw new Error('createHardDeleteClickHandler requires "api" parameter');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return async (row) => {
|
|
221
|
+
try {
|
|
222
|
+
const Swal = window.Swal;
|
|
223
|
+
const defaultMsg = typeof confirmMessage === "function"
|
|
224
|
+
? confirmMessage(row)
|
|
225
|
+
: (confirmMessage || "This will permanently remove the record from the database. This action cannot be undone.");
|
|
226
|
+
|
|
227
|
+
if (Swal) {
|
|
228
|
+
const result = await Swal.fire({
|
|
229
|
+
title: options?.confirmTitle || "Permanently Delete?",
|
|
230
|
+
text: defaultMsg,
|
|
231
|
+
icon: "error",
|
|
232
|
+
showCancelButton: true,
|
|
233
|
+
confirmButtonColor: "#991b1b",
|
|
234
|
+
cancelButtonColor: "#6b7280",
|
|
235
|
+
confirmButtonText: options?.confirmButtonText || "Yes, permanently delete!",
|
|
236
|
+
});
|
|
237
|
+
if (!result.isConfirmed) return;
|
|
238
|
+
} else {
|
|
239
|
+
if (!window.confirm(`⚠️ PERMANENT DELETE ⚠️\n\n${defaultMsg}`)) return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Build URL and append hardDelete=true
|
|
243
|
+
const baseUrl = typeof deleteUrl === "function"
|
|
244
|
+
? deleteUrl(row)
|
|
245
|
+
: deleteUrl.replace(":id", row[resourceIdField]);
|
|
246
|
+
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
247
|
+
await api.delete(`${baseUrl}${separator}hardDelete=true`);
|
|
248
|
+
|
|
249
|
+
const toast = (await import("react-toastify")).toast;
|
|
250
|
+
const successMsg = options?.successMessage
|
|
251
|
+
? (typeof options.successMessage === "function" ? options.successMessage(row) : options.successMessage)
|
|
252
|
+
: "Permanently deleted";
|
|
253
|
+
toast.success(successMsg);
|
|
254
|
+
|
|
255
|
+
if (options?.onRefresh) options.onRefresh();
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const toast = (await import("react-toastify")).toast;
|
|
258
|
+
toast.error(error.message || "Failed to permanently delete");
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create a restore action onClick handler.
|
|
265
|
+
* Calls PATCH restoreUrl to restore a soft-deleted record.
|
|
266
|
+
*/
|
|
267
|
+
export function createRestoreClickHandler(
|
|
268
|
+
api,
|
|
269
|
+
restoreUrl,
|
|
270
|
+
resourceIdField = "id",
|
|
271
|
+
options = {}
|
|
272
|
+
) {
|
|
273
|
+
if (!api) {
|
|
274
|
+
throw new Error('createRestoreClickHandler requires "api" parameter');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return async (row) => {
|
|
278
|
+
try {
|
|
279
|
+
const url = typeof restoreUrl === "function"
|
|
280
|
+
? restoreUrl(row)
|
|
281
|
+
: restoreUrl.replace(":id", row[resourceIdField]);
|
|
282
|
+
await api.patch(url);
|
|
283
|
+
|
|
284
|
+
const toast = (await import("react-toastify")).toast;
|
|
285
|
+
toast.success(options?.successMessage || "Restored successfully");
|
|
286
|
+
|
|
287
|
+
if (options?.onRefresh) options.onRefresh();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const toast = (await import("react-toastify")).toast;
|
|
290
|
+
toast.error(error.message || "Failed to restore");
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -85,6 +85,9 @@ export function DataTable(props) {
|
|
|
85
85
|
onToggle,
|
|
86
86
|
api, // API client instance passed as prop
|
|
87
87
|
persistState = true, // Enable URL-based state persistence by default
|
|
88
|
+
trashable = false, // Enable trash/restore UI (Live / With Trash / Trash tabs)
|
|
89
|
+
restoreUrl, // URL template for restore, e.g. "/products/:id/restore"
|
|
90
|
+
initialTrashed = 'without', // Initial trash mode: 'without' | 'with' | 'only'
|
|
88
91
|
} = props;
|
|
89
92
|
|
|
90
93
|
// Validate that api client is provided
|
|
@@ -176,8 +179,11 @@ export function DataTable(props) {
|
|
|
176
179
|
// Abort controller for in-flight requests — cancelled whenever new fetch params arrive
|
|
177
180
|
const abortControllerRef = React.useRef(null);
|
|
178
181
|
|
|
182
|
+
const [trashed, setTrashed] = React.useState(initialTrashed);
|
|
183
|
+
|
|
179
184
|
const [data, setData] = React.useState([]);
|
|
180
|
-
|
|
185
|
+
// Start as true: skeleton shows immediately on first render before the initial fetch.
|
|
186
|
+
const [loading, setLoading] = React.useState(true);
|
|
181
187
|
const [totalPages, setTotalPages] = React.useState(1);
|
|
182
188
|
const [totalItems, setTotalItems] = React.useState(0);
|
|
183
189
|
|
|
@@ -325,6 +331,24 @@ export function DataTable(props) {
|
|
|
325
331
|
setPage(1);
|
|
326
332
|
}, [debouncedColumnSearch]);
|
|
327
333
|
|
|
334
|
+
// useLayoutEffect fires synchronously after DOM mutation, before paint.
|
|
335
|
+
// This guarantees the skeleton is committed to the DOM before ANY passive
|
|
336
|
+
// effect (useEffect) runs — which is where fetchData and the API call live.
|
|
337
|
+
// Without this, React 18 + createRoot batches setLoading(true) with the
|
|
338
|
+
// fast localhost API response into one render, so the skeleton never paints.
|
|
339
|
+
//
|
|
340
|
+
// Skip the very first run (initial mount) because loading already starts as
|
|
341
|
+
// true, and fetchData's own useEffect handles the first fetch.
|
|
342
|
+
const isFirstRender = React.useRef(true);
|
|
343
|
+
React.useLayoutEffect(() => {
|
|
344
|
+
if (isFirstRender.current) {
|
|
345
|
+
isFirstRender.current = false;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
setLoading(true);
|
|
349
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
350
|
+
}, [page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed, refreshKey, refreshSignal]);
|
|
351
|
+
|
|
328
352
|
const fetchData = React.useCallback(async () => {
|
|
329
353
|
// Cancel any in-flight request before starting a new one
|
|
330
354
|
if (abortControllerRef.current) {
|
|
@@ -333,7 +357,9 @@ export function DataTable(props) {
|
|
|
333
357
|
abortControllerRef.current = new AbortController();
|
|
334
358
|
const signal = abortControllerRef.current.signal;
|
|
335
359
|
|
|
336
|
-
|
|
360
|
+
// loading=true is already set synchronously by useLayoutEffect before this
|
|
361
|
+
// effect runs. Do not set it here — that caused React 18 batching issues.
|
|
362
|
+
let wasAborted = false;
|
|
337
363
|
try {
|
|
338
364
|
const params = new URLSearchParams({
|
|
339
365
|
page: page.toString(),
|
|
@@ -348,6 +374,9 @@ export function DataTable(props) {
|
|
|
348
374
|
if (value) params.append(`columnSearch[${field}]`, value);
|
|
349
375
|
});
|
|
350
376
|
|
|
377
|
+
// Append trashed param when trashable mode is active
|
|
378
|
+
if (trashable) params.append('trashed', trashed);
|
|
379
|
+
|
|
351
380
|
// Properly append params to apiUrl (check if apiUrl already has query params)
|
|
352
381
|
const separator = apiUrl.includes('?') ? '&' : '?';
|
|
353
382
|
const result = await api.get(`${apiUrl}${separator}${params}`, { signal });
|
|
@@ -362,35 +391,94 @@ export function DataTable(props) {
|
|
|
362
391
|
setTotalPages(pagination?.totalPages || 1);
|
|
363
392
|
setTotalItems(pagination?.total || 0);
|
|
364
393
|
} catch (err) {
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
if (err && err.code === 'ERR_CANCELED')
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
394
|
+
// Abort errors are expected when a newer request supersedes this one — do not
|
|
395
|
+
// reset loading state because the new request is already in flight with loading=true.
|
|
396
|
+
if (err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED')) {
|
|
397
|
+
wasAborted = true;
|
|
398
|
+
} else {
|
|
399
|
+
setData([]);
|
|
400
|
+
setTotalPages(1);
|
|
401
|
+
setTotalItems(0);
|
|
402
|
+
}
|
|
371
403
|
} finally {
|
|
372
|
-
|
|
404
|
+
// Only clear loading when this request completed normally (not aborted).
|
|
405
|
+
// An aborted request means a newer fetch is already running with loading=true.
|
|
406
|
+
if (!wasAborted) {
|
|
407
|
+
setLoading(false);
|
|
408
|
+
}
|
|
373
409
|
}
|
|
374
|
-
}, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch]);
|
|
375
|
-
|
|
376
|
-
//
|
|
410
|
+
}, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed]);
|
|
411
|
+
|
|
412
|
+
// Always-current ref to fetchData — lets refreshSignal and refreshKey effects
|
|
413
|
+
// call the latest fetchData without including fetchData in their dep arrays.
|
|
414
|
+
// This prevents double-fetches: if fetchData were in refreshSignal's deps, the
|
|
415
|
+
// effect would fire on EVERY sort/search (because fetchData recreates whenever
|
|
416
|
+
// its own deps change), causing two concurrent requests.
|
|
417
|
+
const fetchDataRef = React.useRef(fetchData);
|
|
418
|
+
fetchDataRef.current = fetchData;
|
|
419
|
+
|
|
420
|
+
// Trigger fetchData for main params.
|
|
421
|
+
//
|
|
422
|
+
// Why rAF + setTimeout(0):
|
|
423
|
+
// Browser frame sequence: macro-task → microtasks → rAF callbacks → layout+PAINT → next macro-task.
|
|
424
|
+
// useLayoutEffect commits loading=true (skeleton) synchronously during the commit phase.
|
|
425
|
+
// Passive effects (useEffect) run as a MessageChannel macro-task.
|
|
426
|
+
// Inside that macro-task we schedule rAF, which fires PRE-paint of the current frame.
|
|
427
|
+
// Inside rAF we schedule setTimeout(0), which queues as a MACRO-TASK — meaning it fires
|
|
428
|
+
// AFTER the browser completes layout+paint for the current frame.
|
|
429
|
+
// Result: skeleton is guaranteed to be painted to screen before fetchData() opens the XHR.
|
|
377
430
|
React.useEffect(() => {
|
|
378
|
-
|
|
431
|
+
let rafId;
|
|
432
|
+
let timerId;
|
|
433
|
+
rafId = requestAnimationFrame(() => {
|
|
434
|
+
timerId = setTimeout(() => {
|
|
435
|
+
fetchDataRef.current();
|
|
436
|
+
}, 0);
|
|
437
|
+
});
|
|
438
|
+
return () => {
|
|
439
|
+
cancelAnimationFrame(rafId);
|
|
440
|
+
clearTimeout(timerId);
|
|
441
|
+
};
|
|
379
442
|
}, [fetchData]);
|
|
380
443
|
|
|
381
|
-
// Trigger fetchData when refreshSignal changes
|
|
444
|
+
// Trigger fetchData when refreshSignal changes.
|
|
445
|
+
// fetchData is intentionally NOT in deps — we use fetchDataRef to avoid re-firing
|
|
446
|
+
// this effect on every sort/search (which would cause a double-fetch).
|
|
447
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
382
448
|
React.useEffect(() => {
|
|
383
449
|
if (typeof refreshSignal !== 'undefined') {
|
|
384
|
-
|
|
450
|
+
let rafId;
|
|
451
|
+
let timerId;
|
|
452
|
+
rafId = requestAnimationFrame(() => {
|
|
453
|
+
timerId = setTimeout(() => {
|
|
454
|
+
fetchDataRef.current();
|
|
455
|
+
}, 0);
|
|
456
|
+
});
|
|
457
|
+
return () => {
|
|
458
|
+
cancelAnimationFrame(rafId);
|
|
459
|
+
clearTimeout(timerId);
|
|
460
|
+
};
|
|
385
461
|
}
|
|
386
|
-
}, [refreshSignal
|
|
462
|
+
}, [refreshSignal]);
|
|
387
463
|
|
|
388
|
-
// Internal refresh after status toggle
|
|
464
|
+
// Internal refresh after status toggle.
|
|
465
|
+
// fetchData is intentionally NOT in deps — same reason as refreshSignal above.
|
|
466
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
389
467
|
React.useEffect(() => {
|
|
390
468
|
if (refreshKey > 0) {
|
|
391
|
-
|
|
469
|
+
let rafId;
|
|
470
|
+
let timerId;
|
|
471
|
+
rafId = requestAnimationFrame(() => {
|
|
472
|
+
timerId = setTimeout(() => {
|
|
473
|
+
fetchDataRef.current();
|
|
474
|
+
}, 0);
|
|
475
|
+
});
|
|
476
|
+
return () => {
|
|
477
|
+
cancelAnimationFrame(rafId);
|
|
478
|
+
clearTimeout(timerId);
|
|
479
|
+
};
|
|
392
480
|
}
|
|
393
|
-
}, [refreshKey
|
|
481
|
+
}, [refreshKey]);
|
|
394
482
|
|
|
395
483
|
// Abort any in-flight request when the component unmounts
|
|
396
484
|
React.useEffect(() => {
|
|
@@ -429,6 +517,10 @@ export function DataTable(props) {
|
|
|
429
517
|
setLimit={setLimit}
|
|
430
518
|
dataLength={data.length}
|
|
431
519
|
totalItems={totalItems}
|
|
520
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
521
|
+
trashable={trashable}
|
|
522
|
+
trashed={trashed}
|
|
523
|
+
setTrashed={(val) => { setTrashed(val); setPage(1); }}
|
|
432
524
|
/>
|
|
433
525
|
</div>
|
|
434
526
|
<table className="min-w-full w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
@@ -460,6 +552,8 @@ export function DataTable(props) {
|
|
|
460
552
|
resourceName={resourceName}
|
|
461
553
|
resourceIdField={resourceIdField}
|
|
462
554
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
555
|
+
trashMode={trashed}
|
|
556
|
+
restoreUrl={restoreUrl}
|
|
463
557
|
/>
|
|
464
558
|
)}
|
|
465
559
|
</tbody>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { RefreshCw, Trash2 } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Filters Component - Production Ready
|
|
5
|
-
*
|
|
6
|
-
* Status filters (All/Active/Inactive), rows per page selector,
|
|
7
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Status filters (All/Active/Inactive), rows per page selector, trash tabs,
|
|
8
|
+
* and a refresh button.
|
|
9
|
+
*
|
|
8
10
|
* @module components/DataTable/Filters
|
|
9
11
|
*/
|
|
10
12
|
export const Filters = ({
|
|
@@ -16,61 +18,102 @@ export const Filters = ({
|
|
|
16
18
|
setLimit,
|
|
17
19
|
dataLength,
|
|
18
20
|
totalItems,
|
|
21
|
+
onRefresh,
|
|
22
|
+
// Trash support
|
|
23
|
+
trashable,
|
|
24
|
+
trashed,
|
|
25
|
+
setTrashed,
|
|
19
26
|
}) => (
|
|
20
|
-
<div className="flex
|
|
21
|
-
{/*
|
|
22
|
-
<div className="flex
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
27
|
+
<div className="flex flex-col gap-2 px-2 py-2 text-xs text-gray-600 w-full">
|
|
28
|
+
{/* Top row: rows-per-page + showing info + trash tabs + refresh */}
|
|
29
|
+
<div className="flex items-start justify-between w-full gap-2">
|
|
30
|
+
{/* Left: Rows per page and Showing info */}
|
|
31
|
+
<div className="flex flex-col items-start gap-1">
|
|
32
|
+
<div className="flex items-center gap-1 whitespace-nowrap">
|
|
33
|
+
<label className="text-xs">Rows per page:</label>
|
|
34
|
+
<select
|
|
35
|
+
value={limit}
|
|
36
|
+
onChange={(e) => {
|
|
37
|
+
setLimit(Number(e.target.value));
|
|
38
|
+
setPage(1);
|
|
39
|
+
}}
|
|
40
|
+
className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
|
|
41
|
+
>
|
|
42
|
+
<option value="5">5</option>
|
|
43
|
+
<option value="10">10</option>
|
|
44
|
+
<option value="25">25</option>
|
|
45
|
+
<option value="50">50</option>
|
|
46
|
+
<option value="100">100</option>
|
|
47
|
+
</select>
|
|
48
|
+
</div>
|
|
49
|
+
<span className="whitespace-nowrap mt-1">
|
|
50
|
+
Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
|
|
51
|
+
{Math.min(page * limit, totalItems)} of {totalItems} items
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Right: Status filters + Trash tabs + Refresh */}
|
|
56
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
57
|
+
{/* Status filter buttons — hidden when viewing only-trash */}
|
|
58
|
+
{(!trashable || trashed !== 'only') && (
|
|
59
|
+
<div className="flex items-center gap-1">
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => { setStatusFilter("all"); setPage(1); }}
|
|
62
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "all" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
63
|
+
>
|
|
64
|
+
All
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => { setStatusFilter("true"); setPage(1); }}
|
|
68
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "true" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
69
|
+
>
|
|
70
|
+
Active
|
|
71
|
+
</button>
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => { setStatusFilter("false"); setPage(1); }}
|
|
74
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "false" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
75
|
+
>
|
|
76
|
+
Inactive
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{/* Trash tabs (only shown when trashable=true) */}
|
|
82
|
+
{trashable && (
|
|
83
|
+
<div className="flex items-center gap-1 border-l pl-2 border-gray-200 dark:border-gray-700">
|
|
84
|
+
<Trash2 className="h-3 w-3 text-gray-400 mr-0.5" />
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => { setTrashed('without'); setPage(1); }}
|
|
87
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'without' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
88
|
+
>
|
|
89
|
+
Live
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => { setTrashed('with'); setPage(1); }}
|
|
93
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'with' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
94
|
+
>
|
|
95
|
+
With Trash
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => { setTrashed('only'); setPage(1); }}
|
|
99
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'only' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
100
|
+
>
|
|
101
|
+
Trash
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{/* Refresh button — always shown */}
|
|
107
|
+
{onRefresh && (
|
|
108
|
+
<button
|
|
109
|
+
onClick={onRefresh}
|
|
110
|
+
title="Refresh"
|
|
111
|
+
className="p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
112
|
+
>
|
|
113
|
+
<RefreshCw className="h-4 w-4" />
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
39
116
|
</div>
|
|
40
|
-
<span className="whitespace-nowrap mt-1">
|
|
41
|
-
Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
|
|
42
|
-
{Math.min(page * limit, totalItems)} of {totalItems} items
|
|
43
|
-
</span>
|
|
44
|
-
</div>
|
|
45
|
-
{/* Right: Filter buttons */}
|
|
46
|
-
<div className="flex-shrink-0 flex gap-2">
|
|
47
|
-
<button
|
|
48
|
-
onClick={() => {
|
|
49
|
-
setStatusFilter("all");
|
|
50
|
-
setPage(1);
|
|
51
|
-
}}
|
|
52
|
-
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "all" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
53
|
-
>
|
|
54
|
-
All
|
|
55
|
-
</button>
|
|
56
|
-
<button
|
|
57
|
-
onClick={() => {
|
|
58
|
-
setStatusFilter("true");
|
|
59
|
-
setPage(1);
|
|
60
|
-
}}
|
|
61
|
-
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "true" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
62
|
-
>
|
|
63
|
-
Active
|
|
64
|
-
</button>
|
|
65
|
-
<button
|
|
66
|
-
onClick={() => {
|
|
67
|
-
setStatusFilter("false");
|
|
68
|
-
setPage(1);
|
|
69
|
-
}}
|
|
70
|
-
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${statusFilter === "false" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
71
|
-
>
|
|
72
|
-
Inactive
|
|
73
|
-
</button>
|
|
74
117
|
</div>
|
|
75
118
|
</div>
|
|
76
119
|
);
|
|
@@ -5,15 +5,19 @@ import {
|
|
|
5
5
|
applyActionDefaults,
|
|
6
6
|
createViewClickHandler,
|
|
7
7
|
createDeleteClickHandler,
|
|
8
|
+
createHardDeleteClickHandler,
|
|
9
|
+
createRestoreClickHandler,
|
|
10
|
+
ACTION_DEFAULTS,
|
|
8
11
|
} from "./ActionDefaults.jsx";
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* TableBody Component - Production Ready
|
|
12
|
-
*
|
|
13
|
-
* Table body with data rows, column rendering, and action buttons
|
|
14
|
-
* Handles Switch component for status toggle
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* Table body with data rows, column rendering, and action buttons.
|
|
17
|
+
* Handles Switch component for status toggle.
|
|
18
|
+
* Trash-aware: when trashMode='only', delete becomes hardDelete with warning;
|
|
19
|
+
* restore button shown automatically for trashed rows.
|
|
20
|
+
*
|
|
17
21
|
* @module components/DataTable/TableBody
|
|
18
22
|
*/
|
|
19
23
|
export function TableBody({
|
|
@@ -27,14 +31,26 @@ export function TableBody({
|
|
|
27
31
|
resourceName,
|
|
28
32
|
resourceIdField = "id",
|
|
29
33
|
onRefresh,
|
|
34
|
+
// Trash support: 'without' | 'with' | 'only'
|
|
35
|
+
trashMode,
|
|
36
|
+
// URL for the restore endpoint (e.g. "/products/:id/restore")
|
|
37
|
+
restoreUrl,
|
|
30
38
|
}) {
|
|
31
39
|
if (loading) {
|
|
40
|
+
const skeletonRows = Array.from({ length: 5 });
|
|
41
|
+
const colCount = columns.length + (actions ? 1 : 0);
|
|
32
42
|
return (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
<>
|
|
44
|
+
{skeletonRows.map((_, rowIdx) => (
|
|
45
|
+
<tr key={rowIdx} className="border-b border-gray-200 dark:border-gray-700 animate-pulse">
|
|
46
|
+
{Array.from({ length: colCount }).map((_, colIdx) => (
|
|
47
|
+
<td key={colIdx} className="px-6 py-4">
|
|
48
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
|
49
|
+
</td>
|
|
50
|
+
))}
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</>
|
|
38
54
|
);
|
|
39
55
|
}
|
|
40
56
|
|
|
@@ -83,6 +99,11 @@ export function TableBody({
|
|
|
83
99
|
return null;
|
|
84
100
|
}
|
|
85
101
|
|
|
102
|
+
// In trash-only mode: skip edit/switch/status-toggle actions
|
|
103
|
+
if (trashMode === 'only' && ['edit', 'switch'].includes(userAction.name)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
// Apply defaults based on action name
|
|
87
108
|
const action = applyActionDefaults(userAction, resourceName, resourceIdField);
|
|
88
109
|
|
|
@@ -114,22 +135,46 @@ export function TableBody({
|
|
|
114
135
|
}
|
|
115
136
|
}
|
|
116
137
|
|
|
117
|
-
// Handle delete action
|
|
138
|
+
// Handle delete action:
|
|
139
|
+
// — in trash-only mode, OR when row is already soft-deleted (has deleted_at) → hard-delete with permanent-delete confirmation
|
|
140
|
+
// — normal mode → soft-delete with standard confirmation
|
|
118
141
|
if (action.type === "button" && action.name === "delete") {
|
|
119
142
|
const deleteAction = action;
|
|
120
143
|
if (deleteAction.deleteUrl && !userAction.onClick) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
deleteAction.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
const isAlreadyTrashed = !!row.deleted_at;
|
|
145
|
+
if (trashMode === 'only' || isAlreadyTrashed) {
|
|
146
|
+
// Override to hardDelete
|
|
147
|
+
deleteAction.onClick = createHardDeleteClickHandler(
|
|
148
|
+
api,
|
|
149
|
+
deleteAction.deleteUrl,
|
|
150
|
+
deleteAction.confirmMessage || "This will permanently remove the record from the database. This action cannot be undone.",
|
|
151
|
+
resourceIdField,
|
|
152
|
+
{
|
|
153
|
+
confirmTitle: "Permanently Delete?",
|
|
154
|
+
confirmButtonText: "Yes, permanently delete!",
|
|
155
|
+
successMessage: deleteAction.successMessage || "Permanently deleted",
|
|
156
|
+
onRefresh,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
// Apply hardDelete styling
|
|
160
|
+
if (!userAction.className) {
|
|
161
|
+
deleteAction.className = ACTION_DEFAULTS.hardDelete.extraClass;
|
|
162
|
+
}
|
|
163
|
+
deleteAction.title = "Permanently Delete";
|
|
164
|
+
} else {
|
|
165
|
+
deleteAction.onClick = createDeleteClickHandler(
|
|
166
|
+
api,
|
|
167
|
+
deleteAction.deleteUrl,
|
|
168
|
+
deleteAction.confirmMessage || "Are you sure you want to delete this item?",
|
|
169
|
+
resourceIdField,
|
|
170
|
+
{
|
|
171
|
+
confirmTitle: deleteAction.confirmTitle,
|
|
172
|
+
confirmButtonText: deleteAction.confirmButtonText,
|
|
173
|
+
successMessage: deleteAction.successMessage,
|
|
174
|
+
onRefresh,
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
133
178
|
}
|
|
134
179
|
}
|
|
135
180
|
|
|
@@ -138,7 +183,7 @@ export function TableBody({
|
|
|
138
183
|
const className =
|
|
139
184
|
typeof action.className === "function"
|
|
140
185
|
? action.className(row)
|
|
141
|
-
: action.className || "";
|
|
186
|
+
: action.className || action.extraClass || "";
|
|
142
187
|
const title =
|
|
143
188
|
typeof action.title === "function"
|
|
144
189
|
? action.title(row)
|
|
@@ -183,6 +228,22 @@ export function TableBody({
|
|
|
183
228
|
|
|
184
229
|
return null;
|
|
185
230
|
})}
|
|
231
|
+
|
|
232
|
+
{/* Auto-inject Restore button when trashMode is active and row is trashed */}
|
|
233
|
+
{restoreUrl && (trashMode === 'only' || (trashMode === 'with' && row.deleted_at)) && (() => {
|
|
234
|
+
const handler = createRestoreClickHandler(api, restoreUrl, resourceIdField, { onRefresh });
|
|
235
|
+
const Icon = ACTION_DEFAULTS.restore.icon;
|
|
236
|
+
return (
|
|
237
|
+
<button
|
|
238
|
+
key="auto-restore"
|
|
239
|
+
onClick={() => handler(row)}
|
|
240
|
+
className={ACTION_DEFAULTS.restore.extraClass}
|
|
241
|
+
title="Restore"
|
|
242
|
+
>
|
|
243
|
+
<Icon className="h-4 w-4" />
|
|
244
|
+
</button>
|
|
245
|
+
);
|
|
246
|
+
})()}
|
|
186
247
|
</div>
|
|
187
248
|
</td>
|
|
188
249
|
)}
|