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
- * - State persistence (restores page/filters when navigating back)
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
- // Generate unique storage key based on apiUrl or custom stateKey
76
- const storageKey = React.useMemo(() => {
77
- if (stateKey) return `datatable_${stateKey}`;
78
- // Use apiUrl as key (remove query params for consistency)
79
- const cleanUrl = apiUrl.split('?')[0];
80
- return `datatable_${cleanUrl.replace(/[^a-zA-Z0-9]/g, '_')}`;
81
- }, [apiUrl, stateKey]);
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
- // Helper to load persisted state
84
- const loadPersistedState = React.useCallback(() => {
85
- if (!persistState) return null;
86
- try {
87
- const stored = sessionStorage.getItem(storageKey);
88
- return stored ? JSON.parse(stored) : null;
89
- } catch (error) {
90
- return null;
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
- // Helper to save state
95
- const saveState = React.useCallback((state) => {
96
- if (!persistState) return;
97
- try {
98
- sessionStorage.setItem(storageKey, JSON.stringify(state));
99
- } catch (error) {
100
- // Silently fail if sessionStorage is not available
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
- }, [persistState, storageKey]);
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 persisted data or props
105
- const persistedState = loadPersistedState();
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
- // Column search state
120
- const [columnSearch, setColumnSearch] = React.useState(persistedState?.columnSearch || {});
121
-
122
- // Save state whenever it changes
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
- saveState({
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, saveState]);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vasuzex",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "description": "Laravel-inspired framework for Node.js monorepos - V2 with optimized dependencies",
5
5
  "type": "module",
6
6
  "main": "./framework/index.js",