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.
@@ -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
- const [loading, setLoading] = React.useState(false);
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
- setLoading(true);
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
- // Ignore abort errors — they are expected when a newer request supersedes this one
366
- if (err && err.name === 'AbortError') return;
367
- if (err && err.code === 'ERR_CANCELED') return;
368
- setData([]);
369
- setTotalPages(1);
370
- setTotalItems(0);
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
- setLoading(false);
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
- // Trigger fetchData for main params
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
- fetchData();
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
- fetchData();
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, fetchData]);
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
- fetchData();
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, fetchData]);
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, and showing info
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 items-start justify-between px-2 py-2 text-xs text-gray-600 w-full">
21
- {/* Left: Rows per page and Showing info */}
22
- <div className="flex flex-col items-start gap-1">
23
- <div className="flex items-center gap-1 whitespace-nowrap">
24
- <label className="text-xs">Rows per page:</label>
25
- <select
26
- value={limit}
27
- onChange={(e) => {
28
- setLimit(Number(e.target.value));
29
- setPage(1);
30
- }}
31
- className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
32
- >
33
- <option value="5">5</option>
34
- <option value="10">10</option>
35
- <option value="25">25</option>
36
- <option value="50">50</option>
37
- <option value="100">100</option>
38
- </select>
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
- * Auto-configures edit/view/delete actions
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
- <tr>
34
- <td colSpan={columns.length + (actions ? 1 : 0)} className="text-center py-8">
35
- Loading data...
36
- </td>
37
- </tr>
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 with automatic confirmation
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
- deleteAction.onClick = createDeleteClickHandler(
122
- api,
123
- deleteAction.deleteUrl,
124
- deleteAction.confirmMessage || "Are you sure you want to delete this item?",
125
- resourceIdField,
126
- {
127
- confirmTitle: deleteAction.confirmTitle,
128
- confirmButtonText: deleteAction.confirmButtonText,
129
- successMessage: deleteAction.successMessage,
130
- onRefresh,
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
  )}