vasuzex 2.3.13 → 2.3.15
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 +129 -0
- package/framework/Database/Model.js +18 -5
- package/framework/Services/Media/MediaManager.js +213 -118
- package/frontend/react-ui/components/BreadCrumb/BreadCrumb.jsx +3 -3
- package/frontend/react-ui/components/DataTable/ActionDefaults.jsx +116 -2
- package/frontend/react-ui/components/DataTable/CellComponents/RowActionsCell.jsx +26 -5
- package/frontend/react-ui/components/DataTable/DataTable.jsx +168 -26
- package/frontend/react-ui/components/DataTable/Filters.jsx +80 -41
- package/frontend/react-ui/components/DataTable/MobileCardList.jsx +226 -0
- package/frontend/react-ui/components/DataTable/Pagination.jsx +120 -57
- package/frontend/react-ui/components/DataTable/TableBody.jsx +85 -24
- package/frontend/react-ui/components/DataTable/TableState.jsx +42 -13
- package/frontend/react-ui/hooks/index.js +1 -0
- package/frontend/react-ui/hooks/useMobileDetect.js +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
applyActionDefaults,
|
|
5
|
+
createViewClickHandler,
|
|
6
|
+
createDeleteClickHandler,
|
|
7
|
+
createHardDeleteClickHandler,
|
|
8
|
+
createRestoreClickHandler,
|
|
9
|
+
} from './ActionDefaults.jsx';
|
|
10
|
+
|
|
11
|
+
// ─── MobileCardList ──────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Column priority system:
|
|
14
|
+
// primary — shown full-width, no label prefix (first = bold title)
|
|
15
|
+
// secondary — compact label:value pairs below a divider
|
|
16
|
+
// hidden-mobile — never shown
|
|
17
|
+
//
|
|
18
|
+
// If no column has priority set:
|
|
19
|
+
// first 3 columns → primary | rest → secondary
|
|
20
|
+
|
|
21
|
+
export function MobileCardList({
|
|
22
|
+
api,
|
|
23
|
+
data,
|
|
24
|
+
columns,
|
|
25
|
+
actions,
|
|
26
|
+
loading,
|
|
27
|
+
emptyText,
|
|
28
|
+
resourceName,
|
|
29
|
+
resourceIdField = 'id',
|
|
30
|
+
onRefresh,
|
|
31
|
+
trashMode,
|
|
32
|
+
restoreUrl,
|
|
33
|
+
}) {
|
|
34
|
+
const hasPriority = columns.some((c) => c.priority);
|
|
35
|
+
|
|
36
|
+
// Detect column-based actions (render fn inside field:'actions' column)
|
|
37
|
+
const actionsColumn = columns.find((c) => c.field === 'actions');
|
|
38
|
+
|
|
39
|
+
let primaryColumns, secondaryColumns;
|
|
40
|
+
if (hasPriority) {
|
|
41
|
+
primaryColumns = columns.filter((c) => c.priority === 'primary' && c.field !== 'actions');
|
|
42
|
+
secondaryColumns = columns.filter((c) => c.priority === 'secondary' && c.field !== 'actions');
|
|
43
|
+
} else {
|
|
44
|
+
// No priority set — treat first 3 visible columns as primary, rest as secondary
|
|
45
|
+
const visible = columns.filter((c) => c.field !== 'actions');
|
|
46
|
+
primaryColumns = visible.slice(0, 3);
|
|
47
|
+
secondaryColumns = visible.slice(3);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const renderCell = (col, row) => {
|
|
51
|
+
if (col.render) return col.render(row);
|
|
52
|
+
const val = row[col.field];
|
|
53
|
+
return (val === undefined || val === null) ? '—' : String(val);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Skeleton loading ───────────────────────────────────────────────────────
|
|
57
|
+
if (loading) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="divide-y divide-gray-100">
|
|
60
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
61
|
+
<div key={i} className="p-4 animate-pulse">
|
|
62
|
+
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
|
|
63
|
+
<div className="h-3 bg-gray-100 rounded w-3/4 mb-1.5" />
|
|
64
|
+
<div className="h-3 bg-gray-100 rounded w-2/3 mb-3" />
|
|
65
|
+
<div className="flex gap-2 mt-3">
|
|
66
|
+
<div className="h-7 bg-gray-100 rounded w-16" />
|
|
67
|
+
<div className="h-7 bg-gray-100 rounded w-16" />
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Empty state ────────────────────────────────────────────────────────────
|
|
76
|
+
if (!data || data.length === 0) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
79
|
+
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
|
|
80
|
+
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
81
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
82
|
+
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
|
83
|
+
</svg>
|
|
84
|
+
</div>
|
|
85
|
+
<p className="text-sm text-gray-500">{emptyText || 'No data found'}</p>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Card list ──────────────────────────────────────────────────────────────
|
|
91
|
+
return (
|
|
92
|
+
<div className="divide-y divide-gray-100">
|
|
93
|
+
{data.map((row, idx) => {
|
|
94
|
+
const rowId = row[resourceIdField] ?? row.id ?? row._id ?? idx;
|
|
95
|
+
const isTrashed = !!row.deleted_at;
|
|
96
|
+
const renderedColumnActions = actionsColumn && actionsColumn.render
|
|
97
|
+
? actionsColumn.render(row)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div key={rowId} className={`p-4 ${isTrashed ? 'bg-red-50/40' : ''}`}>
|
|
102
|
+
|
|
103
|
+
{/* ── Primary columns: title + body rows, no label prefix ── */}
|
|
104
|
+
<div className="space-y-1.5 mb-3">
|
|
105
|
+
{primaryColumns.map((col, colIdx) => {
|
|
106
|
+
const content = renderCell(col, row);
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
key={col.field}
|
|
110
|
+
className={colIdx === 0
|
|
111
|
+
? 'text-sm font-semibold text-gray-900 leading-snug'
|
|
112
|
+
: 'text-sm text-gray-700 leading-snug'
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{content}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* ── Secondary columns: compact label:value pairs ── */}
|
|
122
|
+
{secondaryColumns.length > 0 && (
|
|
123
|
+
<div className="border-t border-gray-100 pt-2 mb-3 space-y-1">
|
|
124
|
+
{secondaryColumns.map((col) => (
|
|
125
|
+
<div key={col.field} className="flex items-start gap-2">
|
|
126
|
+
<span className="text-xs text-gray-400 w-20 shrink-0 pt-0.5">{col.label}</span>
|
|
127
|
+
<div className="text-xs text-gray-600 flex-1 min-w-0 break-words">{renderCell(col, row)}</div>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* ── Trashed indicator ── */}
|
|
134
|
+
{isTrashed && (
|
|
135
|
+
<div className="mb-2">
|
|
136
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
|
137
|
+
Deleted
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* ── Actions ── */}
|
|
143
|
+
{/* Column-based actions (e.g. RowActionsCellExtended) take precedence */}
|
|
144
|
+
{renderedColumnActions ? (
|
|
145
|
+
<div className="mt-2 pt-2 border-t border-gray-100">
|
|
146
|
+
{renderedColumnActions}
|
|
147
|
+
</div>
|
|
148
|
+
) : actions && actions.length > 0 && (
|
|
149
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
150
|
+
{actions.map((actionDef) => {
|
|
151
|
+
const action = applyActionDefaults(actionDef, resourceName, resourceIdField);
|
|
152
|
+
const Icon = action.icon;
|
|
153
|
+
|
|
154
|
+
// Switch toggle — skip in mobile card
|
|
155
|
+
if (action.name === 'switch') return null;
|
|
156
|
+
|
|
157
|
+
// Trash-mode visibility rules
|
|
158
|
+
const isDeleteAction = action.name === 'delete';
|
|
159
|
+
const isHardDelete = action.name === 'hardDelete';
|
|
160
|
+
const isRestore = action.name === 'restore';
|
|
161
|
+
if (trashMode === 'only' && isDeleteAction) return null;
|
|
162
|
+
if (trashMode !== 'only' && (isHardDelete || isRestore)) return null;
|
|
163
|
+
|
|
164
|
+
// Resolve onClick
|
|
165
|
+
let onClick = action.onClick ? () => action.onClick(row) : null;
|
|
166
|
+
if (!onClick) {
|
|
167
|
+
if (isHardDelete && action.deleteUrl) {
|
|
168
|
+
const handler = createHardDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
|
|
169
|
+
onClick = () => handler(row);
|
|
170
|
+
} else if (isRestore && restoreUrl) {
|
|
171
|
+
const handler = createRestoreClickHandler(api, restoreUrl, resourceIdField, { onRefresh });
|
|
172
|
+
onClick = () => handler(row);
|
|
173
|
+
} else if (isDeleteAction && action.deleteUrl) {
|
|
174
|
+
const handler = createDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
|
|
175
|
+
onClick = () => handler(row);
|
|
176
|
+
} else if (action.name === 'view' && action.apiUrl) {
|
|
177
|
+
const handler = createViewClickHandler(api, action.apiUrl, action.modalEvent, resourceIdField);
|
|
178
|
+
onClick = () => handler(row);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const btnBase = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors';
|
|
183
|
+
const btnColor = (isDeleteAction || isHardDelete)
|
|
184
|
+
? 'border-red-200 bg-white text-red-600 hover:bg-red-50'
|
|
185
|
+
: isRestore
|
|
186
|
+
? 'border-green-200 bg-white text-green-700 hover:bg-green-50'
|
|
187
|
+
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50';
|
|
188
|
+
|
|
189
|
+
if (action.type === 'link' && action.getHref) {
|
|
190
|
+
return (
|
|
191
|
+
<Link
|
|
192
|
+
key={action.name || action.label}
|
|
193
|
+
to={action.getHref(row)}
|
|
194
|
+
title={action.title || action.label}
|
|
195
|
+
className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
|
|
196
|
+
>
|
|
197
|
+
{Icon && <Icon size={12} />}
|
|
198
|
+
<span>{action.label || action.title}</span>
|
|
199
|
+
</Link>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
key={action.name || action.label}
|
|
206
|
+
type="button"
|
|
207
|
+
title={action.title || action.label}
|
|
208
|
+
onClick={onClick}
|
|
209
|
+
className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
|
|
210
|
+
>
|
|
211
|
+
{Icon && <Icon size={12} />}
|
|
212
|
+
<span>{action.label || action.title}</span>
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default MobileCardList;
|
|
226
|
+
|
|
@@ -1,72 +1,135 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Pagination Component - Production Ready
|
|
5
5
|
*
|
|
6
6
|
* Pagination controls with page numbers, previous/next buttons
|
|
7
|
+
* Mobile: compact Prev / Page X of Y (jump input) / Next layout
|
|
8
|
+
* Desktop: full numbered page buttons
|
|
7
9
|
*
|
|
8
10
|
* @module components/DataTable/Pagination
|
|
9
11
|
*/
|
|
10
|
-
export const Pagination = ({ page, totalPages, onPageChange }) =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
12
|
+
export const Pagination = ({ page, totalPages, onPageChange }) => {
|
|
13
|
+
const [jumpValue, setJumpValue] = useState('');
|
|
14
|
+
|
|
15
|
+
const handleJump = (e) => {
|
|
16
|
+
if (e.key === 'Enter' || e.type === 'blur') {
|
|
17
|
+
const val = parseInt(jumpValue, 10);
|
|
18
|
+
if (!isNaN(val) && val >= 1 && val <= totalPages) {
|
|
19
|
+
onPageChange(val);
|
|
20
|
+
}
|
|
21
|
+
setJumpValue('');
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
|
27
|
+
|
|
28
|
+
{/* ── Mobile: compact layout (hidden on sm+) ── */}
|
|
29
|
+
<div className="sm:hidden px-3 py-3 space-y-2">
|
|
30
|
+
<div className="text-center text-xs text-gray-600 dark:text-gray-400">
|
|
31
|
+
Page {page} of {totalPages}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="grid grid-cols-2 gap-2">
|
|
35
|
+
<button
|
|
36
|
+
onClick={() => onPageChange(page - 1)}
|
|
37
|
+
disabled={page === 1}
|
|
38
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
39
|
+
>
|
|
40
|
+
Previous
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => onPageChange(page + 1)}
|
|
45
|
+
disabled={page === totalPages}
|
|
46
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
47
|
+
>
|
|
48
|
+
Next
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="flex items-center justify-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
53
|
+
<span>Go to</span>
|
|
54
|
+
<input
|
|
55
|
+
type="number"
|
|
56
|
+
min={1}
|
|
57
|
+
max={totalPages}
|
|
58
|
+
value={jumpValue}
|
|
59
|
+
onChange={(e) => setJumpValue(e.target.value)}
|
|
60
|
+
onKeyDown={handleJump}
|
|
61
|
+
onBlur={handleJump}
|
|
62
|
+
placeholder="page"
|
|
63
|
+
className="w-16 rounded border border-gray-300 px-2 py-0.5 text-xs text-center outline-none focus:border-brand-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* ── Desktop: full numbered layout (hidden below sm) ── */}
|
|
69
|
+
<div className="hidden sm:flex items-center justify-between px-6 py-4">
|
|
70
|
+
<div className="text-sm text-gray-700 dark:text-gray-400">
|
|
71
|
+
Page {page} of {totalPages}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex gap-2 items-center">
|
|
44
74
|
<button
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
className=
|
|
75
|
+
onClick={() => onPageChange(page - 1)}
|
|
76
|
+
disabled={page === 1}
|
|
77
|
+
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
48
78
|
>
|
|
49
|
-
|
|
79
|
+
Previous
|
|
50
80
|
</button>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
{page > 3 && (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => onPageChange(1)}
|
|
85
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === 1 ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
86
|
+
>
|
|
87
|
+
1
|
|
88
|
+
</button>
|
|
89
|
+
<span className="text-gray-400">...</span>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
93
|
+
.filter(
|
|
94
|
+
(p) =>
|
|
95
|
+
p === page ||
|
|
96
|
+
p === page - 1 ||
|
|
97
|
+
p === page + 1 ||
|
|
98
|
+
(page <= 3 && p <= 4) ||
|
|
99
|
+
(page >= totalPages - 2 && p >= totalPages - 3),
|
|
100
|
+
)
|
|
101
|
+
.map((p) => (
|
|
102
|
+
<button
|
|
103
|
+
key={p}
|
|
104
|
+
onClick={() => onPageChange(p)}
|
|
105
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${p === page ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
106
|
+
>
|
|
107
|
+
{p}
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
{page < totalPages - 2 && (
|
|
111
|
+
<>
|
|
112
|
+
<span className="text-gray-400">...</span>
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => onPageChange(totalPages)}
|
|
115
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === totalPages ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
116
|
+
>
|
|
117
|
+
{totalPages}
|
|
118
|
+
</button>
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
55
121
|
<button
|
|
56
|
-
onClick={() => onPageChange(
|
|
57
|
-
|
|
122
|
+
onClick={() => onPageChange(page + 1)}
|
|
123
|
+
disabled={page === totalPages}
|
|
124
|
+
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
58
125
|
>
|
|
59
|
-
|
|
126
|
+
Next
|
|
60
127
|
</button>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
onClick={() => onPageChange(page + 1)}
|
|
65
|
-
disabled={page === totalPages}
|
|
66
|
-
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
67
|
-
>
|
|
68
|
-
Next
|
|
69
|
-
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
70
131
|
</div>
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
|
|
@@ -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
|
)}
|
|
@@ -1,23 +1,52 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Skeleton pulse block – light-grey bar that animates.
|
|
5
|
+
* Width can be "full", "3/4", "1/2", "1/3" or a fixed px value.
|
|
6
|
+
*/
|
|
7
|
+
const SkeletonCell = ({ width = "3/4", height = "h-4" }) => {
|
|
8
|
+
const widthClass =
|
|
9
|
+
width === "full" ? "w-full" :
|
|
10
|
+
width === "3/4" ? "w-3/4" :
|
|
11
|
+
width === "1/2" ? "w-1/2" :
|
|
12
|
+
width === "1/3" ? "w-1/3" :
|
|
13
|
+
width === "1/4" ? "w-1/4" : width;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={`${height} ${widthClass} rounded bg-gray-200 dark:bg-gray-700 animate-pulse`}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single skeleton row matching the number of visible columns + optional action column.
|
|
24
|
+
* Alternates bar widths so the shimmer does not look monotonous.
|
|
25
|
+
*/
|
|
26
|
+
const WIDTHS = ["3/4", "1/2", "full", "1/3", "3/4", "1/2"];
|
|
27
|
+
|
|
28
|
+
const SkeletonRow = ({ colSpan }) => (
|
|
29
|
+
<tr className="border-b border-gray-200 dark:border-gray-700">
|
|
30
|
+
{Array.from({ length: colSpan }).map((_, i) => (
|
|
31
|
+
<td key={i} className="px-6 py-4">
|
|
32
|
+
<SkeletonCell width={WIDTHS[i % WIDTHS.length]} />
|
|
33
|
+
</td>
|
|
34
|
+
))}
|
|
35
|
+
</tr>
|
|
36
|
+
);
|
|
37
|
+
|
|
3
38
|
/**
|
|
4
39
|
* TableState Component
|
|
5
|
-
* Handles loading and empty states for DataTable
|
|
40
|
+
* Handles loading (skeleton rows) and empty states for DataTable
|
|
6
41
|
*/
|
|
7
|
-
export const TableState = ({ loading, empty, colSpan, emptyText }) => {
|
|
42
|
+
export const TableState = ({ loading, empty, colSpan, emptyText, skeletonRows = 6 }) => {
|
|
8
43
|
if (loading) {
|
|
9
44
|
return (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
16
|
-
</svg>
|
|
17
|
-
<span>Loading data...</span>
|
|
18
|
-
</div>
|
|
19
|
-
</td>
|
|
20
|
-
</tr>
|
|
45
|
+
<>
|
|
46
|
+
{Array.from({ length: skeletonRows }).map((_, i) => (
|
|
47
|
+
<SkeletonRow key={i} colSpan={colSpan} />
|
|
48
|
+
))}
|
|
49
|
+
</>
|
|
21
50
|
);
|
|
22
51
|
}
|
|
23
52
|
if (empty) {
|
|
@@ -15,3 +15,4 @@ export { useFocusTrap } from './useFocusTrap.js';
|
|
|
15
15
|
export { useAnnouncer } from './useAnnouncer.js';
|
|
16
16
|
export { useKeyboardNavigation } from './useKeyboardNavigation.js';
|
|
17
17
|
export { useListNavigation } from './useListNavigation.js';
|
|
18
|
+
export { useMobileDetect } from './useMobileDetect.js';
|