Skip to content

Commit bb20be0

Browse files
authored
Merge pull request #85 from TeskaLabs/feature/create-resuable-backlink-navigation
Feature/create reusable backlink navigation
2 parents 00ccec4 + 0c2a86c commit bb20be0

6 files changed

Lines changed: 346 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG for ASAB WebUI Components
22

3+
## 27.5.5
4+
5+
- Implement a reusable `backlink` utility to "remember" navigation of a previous screen (#85)
6+
37
## 27.5.4
48

59
- `AsabReactJson` - Define `14px` as a default fontsize (#84)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Possible imports:
4242
- removeFileExtension
4343
- deepMerge
4444
- problemMarkers
45+
- setBacklink, setBacklinkFromLocation, getBacklink, getBacklinkData, clearBacklink, consumeBacklink, consumeBacklinkData, createBacklinkHandlers
4546
- AppStoreProvider, useAppStore, useAppSelector, createAppStore, registerReducer
4647

4748
## Documentation

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "asab_webui_components",
3-
"version": "27.5.4",
3+
"version": "27.5.5",
44
"license": "BSD-3-Clause",
55
"description": "TeskaLabs ASAB WebUI Components Library",
66
"contributors": [

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export { PubSubProvider, usePubSub } from './components/Context/PubSubContext';
3737
export { AppStoreProvider, useAppStore, useAppSelector, createAppStore } from './components/Context/store/AppStore.jsx';
3838
export { registerReducer } from './components/Context/store/reducer/reducerRegistry.jsx';
3939
export { CopyableInput } from './components/CopyableInput/CopyableInput';
40+
export { setBacklink, setBacklinkFromLocation, getBacklink, getBacklinkData, clearBacklink, consumeBacklink, consumeBacklinkData, createBacklinkHandlers } from './utils/backlink/backlink.jsx';
4041

4142
// OBSOLETED COMPONENTS (Aug 2023) - don't use this in a new designs
4243
export { default as Pagination } from './components/DataTable/Pagination';

src/utils/backlink/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# ASAB WebUI Backlink Utility
2+
3+
Store and retrieve navigation "back" URLs using sessionStorage.
4+
5+
## API
6+
7+
### `setBacklink(key, pathname, search?, options?)`
8+
Store a URL to return to later.
9+
10+
### `setBacklinkFromLocation(key, location, searchParams?, options?)`
11+
Same, but using React Router's location object.
12+
13+
### `consumeBacklink(key)` / `consumeBacklinkData(key)`
14+
Get stored URL and remove it from session storage (one-time read, React Strict Mode safe).
15+
16+
### `getBacklink(key)` / `getBacklinkData(key)`
17+
Get stored URL without removing it from session storage.
18+
19+
### `clearBacklink(key)`
20+
Manually remove a stored backlink.
21+
22+
### `createBacklinkHandlers(key)`
23+
Factory for pre-bound handlers.
24+
25+
## Usage
26+
27+
**Store before navigating away:**
28+
```jsx
29+
import { setBacklink } from 'asab_webui_components';
30+
31+
const handleClick = () => {
32+
setBacklink('SomeScreen', location.pathname, location.search);
33+
navigate('/somescreen/edit');
34+
};
35+
```
36+
37+
**Consume on mount:**
38+
```jsx
39+
import { consumeBacklink } from 'asab_webui_components';
40+
41+
useEffect(() => {
42+
const backUrl = consumeBacklink('SomeScreen');
43+
if (backUrl) setReturnTo(backUrl);
44+
}, []);
45+
```
46+
47+
**With React Router location:**
48+
```jsx
49+
import { setBacklinkFromLocation } from 'asab_webui_components';
50+
51+
setBacklinkFromLocation('SomeScreen', location);
52+
```
53+
54+
**Using the factory (avoids repeating the key):**
55+
```jsx
56+
import { createBacklinkHandlers } from 'asab_webui_components';
57+
58+
// Create once, use multiple times without repeating 'SomeScreen'
59+
const backlink = createBacklinkHandlers('SomeScreen');
60+
61+
backlink.setFromLocation(location); // stores under 'SomeScreen'
62+
const url = backlink.consume(); // retrieves from 'SomeScreen'
63+
backlink.clear(); // removes 'SomeScreen'
64+
```

src/utils/backlink/backlink.jsx

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/*
2+
Backlink utility for storing and retrieving navigation back URLs
3+
Uses sessionStorage to persist backlink data across page navigations
4+
5+
Example:
6+
7+
Storing a backlink before navigation
8+
```setBacklink('SomeScreen', location.pathname, searchParams.toString());```
9+
10+
Or using the location helper (searchParams is optional)
11+
```setBacklinkFromLocation('SomeScreen', location, searchParams);```
12+
13+
Or store pathname only:
14+
```setBacklinkFromLocation('SomeScreen', location);```
15+
16+
Consuming a backlink on component mount (one-time read, React Strict Mode safe)
17+
```
18+
useEffect(() => {
19+
const url = consumeBacklink('SomeScreen');
20+
if (url) setBacklinkState(url);
21+
}, []);
22+
```
23+
*/
24+
25+
const STORAGE_PREFIX = 'backlink:';
26+
27+
/*
28+
Validates that a URL is safe for internal navigation
29+
Rejects external URLs and protocol-relative URLs to prevent open redirect attacks
30+
@param {string} url - The URL to validate
31+
@returns {boolean} True if the URL is a safe internal path
32+
*/
33+
function isSafeInternalPath(url) {
34+
return typeof url === 'string' &&
35+
url.startsWith('/') &&
36+
!url.startsWith('//');
37+
}
38+
39+
/*
40+
Normalizes a search/query string by stripping leading '?' characters
41+
@param {string} search - The search string to normalize
42+
@returns {string} Normalized search string without leading '?'
43+
*/
44+
function normalizeSearch(search = '') {
45+
if (!search) return '';
46+
return search.replace(/^\?+/, '');
47+
}
48+
49+
/*
50+
Safely executes a sessionStorage operation with error handling
51+
@param {Function} operation - The sessionStorage operation to perform
52+
@param {string} context - Description of the operation for error logging
53+
*/
54+
function safeStorageOperation(operation, context) {
55+
try {
56+
return operation();
57+
} catch (error) {
58+
// Fail silently in private mode or when storage is disabled
59+
if (error.name === 'QuotaExceededError' || error.name === 'SecurityError') {
60+
console.warn(`backlink: ${context} failed - storage may be disabled or quota exceeded`, error);
61+
} else {
62+
console.error(`backlink: ${context} error`, error);
63+
}
64+
}
65+
}
66+
67+
/*
68+
Creates a backlink by storing the current location in sessionStorage
69+
@param {string} key - Unique identifier for this backlink context (e.g., 'SomeScreen')
70+
@param {string} pathname - The pathname to store (typically location.pathname)
71+
@param {string} search - The search/query string to store (typically location.search or searchParams.toString())
72+
@param {Object} options - Optional metadata to store with the backlink (cannot override url or timestamp)
73+
*/
74+
export function setBacklink(key, pathname, search = '', options = {}) {
75+
if (!key) {
76+
console.warn('backlink: setBacklink requires a key');
77+
return;
78+
}
79+
if (!pathname || typeof pathname !== 'string') {
80+
console.warn('backlink: setBacklink requires a pathname string');
81+
return;
82+
}
83+
84+
// If 3rd arg is URLSearchParams, normalize it to a string
85+
if (search instanceof URLSearchParams) {
86+
search = search.toString();
87+
} else if (search != undefined && typeof search === 'object') {
88+
// 3rd arg is a plain object (not string/null/undefined/URLSearchParams), treat it as options
89+
options = search;
90+
search = '';
91+
}
92+
93+
const storageKey = STORAGE_PREFIX + key;
94+
const normalizedSearch = normalizeSearch(search);
95+
const url = pathname + (normalizedSearch ? '?' + normalizedSearch : '');
96+
97+
// Defense in depth: validate URL on write as well as read
98+
if (!isSafeInternalPath(url)) {
99+
console.warn(`backlink: refusing to store unsafe URL for key "${key}":`, url);
100+
return;
101+
}
102+
103+
// options are spread first to prevent overwriting core fields
104+
const value = JSON.stringify({
105+
...options,
106+
url,
107+
timestamp: Date.now(),
108+
});
109+
110+
safeStorageOperation(() => sessionStorage.setItem(storageKey, value), 'setBacklink');
111+
}
112+
113+
/*
114+
Convenience helper for React Router locations
115+
@param {string} key - Unique identifier for this backlink context
116+
@param {Object} location - React Router location object with pathname and optional search property
117+
@param {URLSearchParams|string} searchParams - Query parameters as URLSearchParams or string (optional, defaults to location.search)
118+
@param {Object} options - Optional metadata to store with the backlink
119+
*/
120+
export function setBacklinkFromLocation(key, location, searchParams, options = {}) {
121+
if (!location || typeof location.pathname !== 'string') {
122+
console.warn('backlink: setBacklinkFromLocation requires a location object with pathname');
123+
return;
124+
}
125+
126+
// Handle overload: if there are only 3 args and the 3rd is a plain object (not URLSearchParams/string/null), treat it as options
127+
if (arguments.length === 3 && searchParams != undefined && typeof searchParams === 'object' && !(searchParams instanceof URLSearchParams)) {
128+
options = searchParams;
129+
searchParams = undefined;
130+
}
131+
132+
const search = searchParams === undefined
133+
? (location.search || '')
134+
: searchParams === null
135+
? ''
136+
: typeof searchParams === 'string'
137+
? searchParams
138+
: searchParams instanceof URLSearchParams
139+
? searchParams.toString()
140+
: String(searchParams);
141+
142+
setBacklink(key, location.pathname, search, options);
143+
}
144+
145+
/*
146+
Retrieves a stored backlink URL
147+
Returns null if the URL is not found or is not a safe internal path.
148+
@param {string} key - The unique identifier for this backlink context
149+
@returns {string|null} The stored backlink URL, or null if not found/invalid
150+
*/
151+
export function getBacklink(key) {
152+
if (!key) {
153+
console.warn('backlink: getBacklink requires a key');
154+
return null;
155+
}
156+
157+
const storageKey = STORAGE_PREFIX + key;
158+
const value = safeStorageOperation(() => sessionStorage.getItem(storageKey), 'getBacklink');
159+
if (!value) return null;
160+
161+
let url;
162+
try {
163+
const parsed = JSON.parse(value);
164+
url = parsed.url;
165+
} catch {
166+
// Fallback for legacy string-only storage
167+
url = value;
168+
}
169+
170+
// Validate URL to prevent open redirect attacks
171+
if (!isSafeInternalPath(url)) {
172+
console.warn(`backlink: ignoring unsafe URL for key "${key}":`, url);
173+
return null;
174+
}
175+
176+
return url;
177+
}
178+
179+
/*
180+
Retrieves full backlink data including metadata
181+
Returns null if the URL is not found or is not a safe internal path
182+
@param {string} key - The unique identifier for this backlink context
183+
@returns {Object|null} The stored backlink data with url, timestamp, and any custom options
184+
*/
185+
export function getBacklinkData(key) {
186+
if (!key) {
187+
console.warn('backlink: getBacklinkData requires a key');
188+
return null;
189+
}
190+
191+
const storageKey = STORAGE_PREFIX + key;
192+
const value = safeStorageOperation(() => sessionStorage.getItem(storageKey), 'getBacklinkData');
193+
if (!value) return null;
194+
195+
let data;
196+
try {
197+
data = JSON.parse(value);
198+
} catch {
199+
// Fallback for legacy string-only storage
200+
data = { url: value, timestamp: null };
201+
}
202+
203+
// Validate URL to prevent open redirect attacks
204+
if (!isSafeInternalPath(data?.url)) {
205+
console.warn(`backlink: ignoring unsafe URL for key "${key}":`, data?.url);
206+
return null;
207+
}
208+
209+
return data;
210+
}
211+
212+
/*
213+
Removes a backlink from sessionStorage
214+
@param {string} key - The unique identifier for this backlink context
215+
*/
216+
export function clearBacklink(key) {
217+
if (!key) {
218+
console.warn('backlink: clearBacklink requires a key');
219+
return;
220+
}
221+
222+
const storageKey = STORAGE_PREFIX + key;
223+
safeStorageOperation(() => sessionStorage.removeItem(storageKey), 'clearBacklink');
224+
}
225+
226+
/*
227+
Consumes a backlink - retrieves it and immediately removes it from storage
228+
This is the most common pattern for "one-time" back navigation
229+
Returns null if the URL is not found or is not a safe internal path
230+
@param {string} key - The unique identifier for this backlink context
231+
@returns {string|null} The stored backlink URL, or null if not found/invalid
232+
*/
233+
export function consumeBacklink(key) {
234+
const url = getBacklink(key);
235+
if (url) {
236+
clearBacklink(key);
237+
}
238+
return url;
239+
}
240+
241+
/*
242+
Consumes a backlink with full data - retrieves data and immediately removes it from storage
243+
Returns null if the URL is not found or is not a safe internal path
244+
@param {string} key - The unique identifier for this backlink context
245+
@returns {Object|null} The stored backlink data, or null if not found/invalid
246+
*/
247+
export function consumeBacklinkData(key) {
248+
const data = getBacklinkData(key);
249+
if (data) {
250+
clearBacklink(key);
251+
}
252+
return data;
253+
}
254+
255+
/*
256+
Factory for creating backlink handlers with a fixed key
257+
Useful when multiple components share the same backlink context
258+
@param {string} key - The unique identifier for this backlink context
259+
@returns {Object} Object with set, setFromLocation, get, getData, clear, consume, and consumeData methods bound to the key
260+
*/
261+
export function createBacklinkHandlers(key) {
262+
if (!key) {
263+
throw new Error('backlink: createBacklinkHandlers requires a key');
264+
}
265+
return {
266+
set: (pathname, search, options) => setBacklink(key, pathname, search, options),
267+
setFromLocation: (location, searchParams, options) => setBacklinkFromLocation(key, location, searchParams, options),
268+
get: () => getBacklink(key),
269+
getData: () => getBacklinkData(key),
270+
clear: () => clearBacklink(key),
271+
consume: () => consumeBacklink(key),
272+
consumeData: () => consumeBacklinkData(key),
273+
key,
274+
};
275+
}

0 commit comments

Comments
 (0)