vasuzex 2.3.2 → 2.3.4
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.
|
@@ -139,6 +139,11 @@ export class BaseApp extends Application {
|
|
|
139
139
|
* Core middleware for parsing JSON and URL-encoded request bodies
|
|
140
140
|
*/
|
|
141
141
|
setupBodyParsing() {
|
|
142
|
+
// Configure query parser to support nested bracket notation (e.g. filters[name]=value)
|
|
143
|
+
// Express 5 defaults to 'simple' (Node's querystring) which does not parse brackets
|
|
144
|
+
// 'extended' uses qs.parse which correctly handles nested objects
|
|
145
|
+
this.express.set('query parser', 'extended');
|
|
146
|
+
|
|
142
147
|
// Parse JSON request bodies
|
|
143
148
|
this.express.use(express.json());
|
|
144
149
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Rows per page selector
|
|
11
11
|
* - Action buttons (edit/view/delete/switch)
|
|
12
12
|
* - Loading and empty states
|
|
13
|
-
* -
|
|
13
|
+
* - URL-based state persistence (page, sort, filters in query params)
|
|
14
14
|
*
|
|
15
15
|
* @module components/DataTable
|
|
16
16
|
*/
|
|
@@ -22,9 +22,27 @@ import { TableHeader } from "./TableHeader.jsx";
|
|
|
22
22
|
import { TableState } from "./TableState.jsx";
|
|
23
23
|
import { Pagination } from "./Pagination.jsx";
|
|
24
24
|
|
|
25
|
+
// Conditional import for React Router (optional dependency)
|
|
26
|
+
let useSearchParamsHook = null;
|
|
27
|
+
let useLocationHook = null;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const routerModule = require('react-router-dom');
|
|
31
|
+
useSearchParamsHook = routerModule.useSearchParams;
|
|
32
|
+
useLocationHook = routerModule.useLocation;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// React Router not available - will use plain window.history
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* Production-ready DataTable with complete server-side functionality
|
|
27
39
|
*
|
|
40
|
+
* State is persisted in URL query parameters, ensuring:
|
|
41
|
+
* - Each page has unique, isolated state
|
|
42
|
+
* - Browser back/forward buttons work correctly
|
|
43
|
+
* - Users can bookmark specific table states
|
|
44
|
+
* - No state bleeding between different DataTables
|
|
45
|
+
*
|
|
28
46
|
* @param {Object} props
|
|
29
47
|
* @param {Object} props.api - API client instance (required)
|
|
30
48
|
* @param {string} props.apiUrl - API endpoint for data fetching
|
|
@@ -34,13 +52,12 @@ import { Pagination } from "./Pagination.jsx";
|
|
|
34
52
|
* @param {string} props.resourceName - Resource name for route generation
|
|
35
53
|
* @param {string} props.resourceIdField - ID field name (default: "id")
|
|
36
54
|
* @param {number} props.refreshSignal - External refresh trigger
|
|
37
|
-
* @param {string} props.initialSortBy - Initial sort field
|
|
55
|
+
* @param {string} props.initialSortBy - Initial sort field (fallback if no URL param)
|
|
38
56
|
* @param {string} props.initialSortOrder - Initial sort order (asc/desc)
|
|
39
57
|
* @param {string} props.initialStatusFilter - Initial status filter (all/true/false)
|
|
40
58
|
* @param {number} props.initialLimit - Initial rows per page
|
|
41
59
|
* @param {string} props.emptyText - Text to show when no data
|
|
42
|
-
* @param {boolean} props.persistState - Enable state persistence (default: true)
|
|
43
|
-
* @param {string} props.stateKey - Custom key for state storage (default: derived from apiUrl)
|
|
60
|
+
* @param {boolean} props.persistState - Enable URL state persistence (default: true)
|
|
44
61
|
*/
|
|
45
62
|
export function DataTable(props) {
|
|
46
63
|
// Internal refresh key for self-refresh
|
|
@@ -63,8 +80,7 @@ export function DataTable(props) {
|
|
|
63
80
|
onDelete,
|
|
64
81
|
onToggle,
|
|
65
82
|
api, // API client instance passed as prop
|
|
66
|
-
persistState = true, // Enable state persistence by default
|
|
67
|
-
stateKey, // Optional custom state key
|
|
83
|
+
persistState = true, // Enable URL-based state persistence by default
|
|
68
84
|
} = props;
|
|
69
85
|
|
|
70
86
|
// Validate that api client is provided
|
|
@@ -72,56 +88,133 @@ export function DataTable(props) {
|
|
|
72
88
|
throw new Error('DataTable requires "api" prop - pass your API client instance');
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
// React Router hooks must be called unconditionally
|
|
92
|
+
// Call them if available, but handle errors gracefully
|
|
93
|
+
let searchParams = null;
|
|
94
|
+
let setSearchParams = null;
|
|
95
|
+
let location = null;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
if (useSearchParamsHook) {
|
|
99
|
+
[searchParams, setSearchParams] = useSearchParamsHook();
|
|
100
|
+
}
|
|
101
|
+
if (useLocationHook) {
|
|
102
|
+
location = useLocationHook();
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
|
|
106
|
+
// Hooks not available or failed - will use window.history
|
|
107
|
+
searchParams = null;
|
|
108
|
+
setSearchParams = null;
|
|
109
|
+
location = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hasReactRouter = !!(searchParams && setSearchParams);
|
|
113
|
+
|
|
114
|
+
|
|
82
115
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Load state from URL query parameters
|
|
118
|
+
* This provides natural isolation between different pages/DataTables
|
|
119
|
+
*/
|
|
120
|
+
const loadStateFromURL = React.useCallback(() => {
|
|
121
|
+
if (!persistState || typeof window === 'undefined') {
|
|
122
|
+
return {
|
|
123
|
+
page: initialPage,
|
|
124
|
+
sortBy: initialSortBy || (columns.find((c) => c.sortable)?.field) || "",
|
|
125
|
+
sortOrder: initialSortOrder,
|
|
126
|
+
search: initialSearch || "",
|
|
127
|
+
statusFilter: initialStatusFilter || "all",
|
|
128
|
+
limit: initialLimit || 10,
|
|
129
|
+
columnSearch: {},
|
|
130
|
+
};
|
|
91
131
|
}
|
|
92
|
-
}, [persistState, storageKey]);
|
|
93
132
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
// Use React Router searchParams or fallback to URLSearchParams
|
|
134
|
+
const params = hasReactRouter && searchParams
|
|
135
|
+
? searchParams
|
|
136
|
+
: new URLSearchParams(window.location.search);
|
|
137
|
+
|
|
138
|
+
// Parse column search from URL params (columnSearch[fieldName]=value format)
|
|
139
|
+
const columnSearch = {};
|
|
140
|
+
for (const [key, value] of params.entries()) {
|
|
141
|
+
const match = key.match(/^columnSearch\[(.+)\]$/);
|
|
142
|
+
if (match && value) {
|
|
143
|
+
columnSearch[match[1]] = value;
|
|
144
|
+
}
|
|
101
145
|
}
|
|
102
|
-
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
page: parseInt(params.get('page')) || initialPage,
|
|
149
|
+
sortBy: params.get('sortBy') || initialSortBy || (columns.find((c) => c.sortable)?.field) || "",
|
|
150
|
+
sortOrder: params.get('sortOrder') || initialSortOrder,
|
|
151
|
+
search: params.get('search') || initialSearch || "",
|
|
152
|
+
statusFilter: params.get('statusFilter') || initialStatusFilter || "all",
|
|
153
|
+
limit: parseInt(params.get('limit')) || initialLimit || 10,
|
|
154
|
+
columnSearch,
|
|
155
|
+
};
|
|
156
|
+
}, [persistState, initialPage, initialSortBy, initialSortOrder, initialSearch, initialStatusFilter, initialLimit, columns, hasReactRouter, searchParams]);
|
|
103
157
|
|
|
104
|
-
// Initialize state from
|
|
105
|
-
const
|
|
158
|
+
// Initialize state from URL
|
|
159
|
+
const urlState = loadStateFromURL();
|
|
160
|
+
|
|
161
|
+
const [page, setPage] = React.useState(urlState.page);
|
|
162
|
+
const [sortBy, setSortBy] = React.useState(urlState.sortBy);
|
|
163
|
+
const [sortOrder, setSortOrder] = React.useState(urlState.sortOrder);
|
|
164
|
+
const [search, setSearch] = React.useState(urlState.search);
|
|
165
|
+
const [statusFilter, setStatusFilter] = React.useState(urlState.statusFilter);
|
|
166
|
+
const [limit, setLimit] = React.useState(urlState.limit);
|
|
167
|
+
const [columnSearch, setColumnSearch] = React.useState(urlState.columnSearch);
|
|
106
168
|
|
|
107
|
-
const [page, setPage] = React.useState(persistedState?.page || initialPage);
|
|
108
|
-
const [sortBy, setSortBy] = React.useState(
|
|
109
|
-
persistedState?.sortBy || initialSortBy || (columns.find((c) => c.sortable)?.field) || "",
|
|
110
|
-
);
|
|
111
|
-
const [sortOrder, setSortOrder] = React.useState(persistedState?.sortOrder || initialSortOrder);
|
|
112
|
-
const [search, setSearch] = React.useState(persistedState?.search || initialSearch || "");
|
|
113
|
-
const [statusFilter, setStatusFilter] = React.useState(persistedState?.statusFilter || initialStatusFilter || "all");
|
|
114
|
-
const [limit, setLimit] = React.useState(persistedState?.limit || initialLimit || 10);
|
|
115
169
|
const [data, setData] = React.useState([]);
|
|
116
170
|
const [loading, setLoading] = React.useState(false);
|
|
117
171
|
const [totalPages, setTotalPages] = React.useState(1);
|
|
118
172
|
const [totalItems, setTotalItems] = React.useState(0);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Update URL with current state
|
|
176
|
+
* Uses replaceState to avoid polluting browser history with every filter change
|
|
177
|
+
* Only includes non-default values to keep URL clean
|
|
178
|
+
*/
|
|
179
|
+
const updateURL = React.useCallback((state) => {
|
|
180
|
+
if (!persistState || typeof window === 'undefined') return;
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
const params = new URLSearchParams();
|
|
185
|
+
|
|
186
|
+
// Only add non-default values to keep URL clean
|
|
187
|
+
if (state.page !== 1) params.set('page', state.page);
|
|
188
|
+
if (state.sortBy) params.set('sortBy', state.sortBy);
|
|
189
|
+
if (state.sortOrder !== initialSortOrder) params.set('sortOrder', state.sortOrder);
|
|
190
|
+
if (state.search) params.set('search', state.search);
|
|
191
|
+
if (state.statusFilter !== 'all') params.set('statusFilter', state.statusFilter);
|
|
192
|
+
if (state.limit !== (initialLimit || 10)) params.set('limit', state.limit);
|
|
193
|
+
|
|
194
|
+
// Add column search params
|
|
195
|
+
Object.entries(state.columnSearch || {}).forEach(([field, value]) => {
|
|
196
|
+
if (value) params.set(`columnSearch[${field}]`, value);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Use React Router if available, otherwise window.history
|
|
200
|
+
if (hasReactRouter && setSearchParams) {
|
|
201
|
+
|
|
202
|
+
setSearchParams(params, { replace: true });
|
|
203
|
+
} else if (typeof window !== 'undefined') {
|
|
204
|
+
const newURL = params.toString()
|
|
205
|
+
? `${window.location.pathname}?${params.toString()}`
|
|
206
|
+
: window.location.pathname;
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
window.history.replaceState({}, '', newURL);
|
|
210
|
+
}
|
|
211
|
+
}, [persistState, hasReactRouter, setSearchParams]);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sync URL whenever state changes
|
|
215
|
+
*/
|
|
123
216
|
React.useEffect(() => {
|
|
124
|
-
|
|
217
|
+
updateURL({
|
|
125
218
|
page,
|
|
126
219
|
sortBy,
|
|
127
220
|
sortOrder,
|
|
@@ -130,7 +223,57 @@ export function DataTable(props) {
|
|
|
130
223
|
limit,
|
|
131
224
|
columnSearch,
|
|
132
225
|
});
|
|
133
|
-
}, [page, sortBy, sortOrder, search, statusFilter, limit, columnSearch,
|
|
226
|
+
}, [page, sortBy, sortOrder, search, statusFilter, limit, columnSearch, updateURL]);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Handle browser back/forward buttons
|
|
230
|
+
* Reload state from URL when user navigates
|
|
231
|
+
*/
|
|
232
|
+
React.useEffect(() => {
|
|
233
|
+
if (!persistState || typeof window === 'undefined') return;
|
|
234
|
+
if (hasReactRouter) return; // React Router handles this automatically
|
|
235
|
+
|
|
236
|
+
const handlePopState = () => {
|
|
237
|
+
const newState = loadStateFromURL();
|
|
238
|
+
setPage(newState.page);
|
|
239
|
+
setSortBy(newState.sortBy);
|
|
240
|
+
setSortOrder(newState.sortOrder);
|
|
241
|
+
setSearch(newState.search);
|
|
242
|
+
setStatusFilter(newState.statusFilter);
|
|
243
|
+
setLimit(newState.limit);
|
|
244
|
+
setColumnSearch(newState.columnSearch);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
window.addEventListener('popstate', handlePopState);
|
|
248
|
+
return () => window.removeEventListener('popstate', handlePopState);
|
|
249
|
+
}, [persistState, loadStateFromURL, hasReactRouter]);
|
|
250
|
+
|
|
251
|
+
// For React Router: reload state when location.search changes
|
|
252
|
+
// Only when React Router and manual browser navigation (not programmatic updates)
|
|
253
|
+
React.useEffect(() => {
|
|
254
|
+
if (!hasReactRouter || !location) return;
|
|
255
|
+
|
|
256
|
+
// Avoid reacting to our own updates by checking if state already matches URL const urlState = loadStateFromURL();
|
|
257
|
+
const stateChanged = (
|
|
258
|
+
urlState.page !== page ||
|
|
259
|
+
urlState.sortBy !== sortBy ||
|
|
260
|
+
urlState.sortOrder !== sortOrder ||
|
|
261
|
+
urlState.search !== search ||
|
|
262
|
+
urlState.statusFilter !== statusFilter ||
|
|
263
|
+
urlState.limit !== limit ||
|
|
264
|
+
JSON.stringify(urlState.columnSearch) !== JSON.stringify(columnSearch)
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (stateChanged) {
|
|
268
|
+
setPage(urlState.page);
|
|
269
|
+
setSortBy(urlState.sortBy);
|
|
270
|
+
setSortOrder(urlState.sortOrder);
|
|
271
|
+
setSearch(urlState.search);
|
|
272
|
+
setStatusFilter(urlState.statusFilter);
|
|
273
|
+
setLimit(urlState.limit);
|
|
274
|
+
setColumnSearch(urlState.columnSearch);
|
|
275
|
+
}
|
|
276
|
+
}, [location?.search, hasReactRouter, loadStateFromURL]);
|
|
134
277
|
|
|
135
278
|
const handleStatusToggle = async (row) => {
|
|
136
279
|
if (!toggleLink) return;
|
|
@@ -14,3 +14,4 @@ export { useDebounce } from './useDebounce.js';
|
|
|
14
14
|
export { useFocusTrap } from './useFocusTrap.js';
|
|
15
15
|
export { useAnnouncer } from './useAnnouncer.js';
|
|
16
16
|
export { useKeyboardNavigation } from './useKeyboardNavigation.js';
|
|
17
|
+
export { useListNavigation } from './useListNavigation.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useCallback, useEffect } from 'react';
|
|
2
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook to preserve list page URL parameters when navigating to/from forms
|
|
6
|
+
*
|
|
7
|
+
* For LIST pages: Automatically stores the current URL (with query params) whenever it changes
|
|
8
|
+
* For FORM pages: Provides functions to navigate back to the stored URL
|
|
9
|
+
*
|
|
10
|
+
* @param {string} listPath - Base path of the list page (e.g., '/brands')
|
|
11
|
+
* @param {boolean} isListPage - Whether this is the list page (true) or form page (false)
|
|
12
|
+
* @returns {Object} - { navigateToList, getListUrl, storeCurrentUrl }
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // In a list component
|
|
16
|
+
* useListNavigation('/brands', true); // Auto-stores URL on changes
|
|
17
|
+
*
|
|
18
|
+
* // In a form component
|
|
19
|
+
* const { navigateToList, getListUrl } = useListNavigation('/brands', false);
|
|
20
|
+
*
|
|
21
|
+
* // On save success or cancel
|
|
22
|
+
* navigateToList();
|
|
23
|
+
*
|
|
24
|
+
* // In breadcrumb
|
|
25
|
+
* <BreadCrumb items={[
|
|
26
|
+
* { label: 'Brands', to: getListUrl() }
|
|
27
|
+
* ]} />
|
|
28
|
+
*/
|
|
29
|
+
export function useListNavigation(listPath, isListPage = false) {
|
|
30
|
+
const navigate = useNavigate();
|
|
31
|
+
const location = useLocation();
|
|
32
|
+
|
|
33
|
+
// Storage key for this list path
|
|
34
|
+
const storageKey = `listReturn_${listPath}`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Store current URL (called automatically on list pages)
|
|
38
|
+
*/
|
|
39
|
+
const storeCurrentUrl = useCallback(() => {
|
|
40
|
+
const searchParams = location.search || '';
|
|
41
|
+
sessionStorage.setItem(storageKey, searchParams);
|
|
42
|
+
}, [location.search, storageKey]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the stored list URL with query params
|
|
46
|
+
* @returns {string} - Full URL with query params, or base path if none stored
|
|
47
|
+
*/
|
|
48
|
+
const getListUrl = useCallback(() => {
|
|
49
|
+
const storedSearch = sessionStorage.getItem(storageKey);
|
|
50
|
+
return storedSearch ? `${listPath}${storedSearch}` : listPath;
|
|
51
|
+
}, [listPath, storageKey]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Navigate back to list page with preserved query params
|
|
55
|
+
*/
|
|
56
|
+
const navigateToList = useCallback(() => {
|
|
57
|
+
const fullUrl = getListUrl();
|
|
58
|
+
navigate(fullUrl);
|
|
59
|
+
// Clear after navigation to prevent stale data
|
|
60
|
+
sessionStorage.removeItem(storageKey);
|
|
61
|
+
}, [navigate, getListUrl, storageKey]);
|
|
62
|
+
|
|
63
|
+
// Auto-store URL on list pages whenever location changes
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (isListPage && location.pathname === listPath) {
|
|
66
|
+
storeCurrentUrl();
|
|
67
|
+
}
|
|
68
|
+
}, [isListPage, location.pathname, location.search, listPath, storeCurrentUrl]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
navigateToList,
|
|
72
|
+
getListUrl,
|
|
73
|
+
storeCurrentUrl,
|
|
74
|
+
};
|
|
75
|
+
}
|