@equinor/fusion-framework-module-navigation 6.0.1 → 7.0.0-next.2
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 +83 -5
- package/README.md +694 -0
- package/dist/esm/NavigationConfigurator.interface.js +2 -0
- package/dist/esm/NavigationConfigurator.interface.js.map +1 -0
- package/dist/esm/NavigationConfigurator.js +144 -0
- package/dist/esm/NavigationConfigurator.js.map +1 -0
- package/dist/esm/NavigationProvider.interface.js +2 -0
- package/dist/esm/NavigationProvider.interface.js.map +1 -0
- package/dist/esm/NavigationProvider.js +256 -0
- package/dist/esm/NavigationProvider.js.map +1 -0
- package/dist/esm/enable-navigation.js +52 -0
- package/dist/esm/enable-navigation.js.map +1 -0
- package/dist/esm/events.js +23 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.js +6 -5
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/lib/BaseHistory.js +189 -0
- package/dist/esm/lib/BaseHistory.js.map +1 -0
- package/dist/esm/lib/BrowserHistory.js +91 -0
- package/dist/esm/lib/BrowserHistory.js.map +1 -0
- package/dist/esm/lib/BrowserHistoryHashStack.js +53 -0
- package/dist/esm/lib/BrowserHistoryHashStack.js.map +1 -0
- package/dist/esm/lib/BrowserHistoryStack.js +77 -0
- package/dist/esm/lib/BrowserHistoryStack.js.map +1 -0
- package/dist/esm/lib/MemoryHistory.js +47 -0
- package/dist/esm/lib/MemoryHistory.js.map +1 -0
- package/dist/esm/lib/MemoryStack.js +98 -0
- package/dist/esm/lib/MemoryStack.js.map +1 -0
- package/dist/esm/lib/create-history.js +48 -0
- package/dist/esm/lib/create-history.js.map +1 -0
- package/dist/esm/lib/index.js +8 -1
- package/dist/esm/lib/index.js.map +1 -1
- package/dist/esm/lib/state/history.actions.js +71 -0
- package/dist/esm/lib/state/history.actions.js.map +1 -0
- package/dist/esm/lib/state/history.flows.js +212 -0
- package/dist/esm/lib/state/history.flows.js.map +1 -0
- package/dist/esm/lib/state/history.reducer.js +59 -0
- package/dist/esm/lib/state/history.reducer.js.map +1 -0
- package/dist/esm/lib/state/history.state.js +37 -0
- package/dist/esm/lib/state/history.state.js.map +1 -0
- package/dist/esm/lib/state/index.js +4 -0
- package/dist/esm/lib/state/index.js.map +1 -0
- package/dist/esm/lib/types.js +25 -0
- package/dist/esm/lib/types.js.map +1 -0
- package/dist/esm/lib/utils/encode-trailing-whitespace.js +19 -0
- package/dist/esm/lib/utils/encode-trailing-whitespace.js.map +1 -0
- package/dist/esm/lib/utils/has-protocol.js +22 -0
- package/dist/esm/lib/utils/has-protocol.js.map +1 -0
- package/dist/esm/lib/utils/index.js +6 -0
- package/dist/esm/lib/utils/index.js.map +1 -0
- package/dist/esm/lib/utils/path-to-string.js +23 -0
- package/dist/esm/lib/utils/path-to-string.js.map +1 -0
- package/dist/esm/lib/utils/path-to-url.js +47 -0
- package/dist/esm/lib/utils/path-to-url.js.map +1 -0
- package/dist/esm/lib/utils/resolve-browser-location.js +53 -0
- package/dist/esm/lib/utils/resolve-browser-location.js.map +1 -0
- package/dist/esm/lib/utils/resolve-path.js +21 -0
- package/dist/esm/lib/utils/resolve-path.js.map +1 -0
- package/dist/esm/module.js +34 -25
- package/dist/esm/module.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/NavigationConfigurator.d.ts +60 -0
- package/dist/types/NavigationConfigurator.interface.d.ts +17 -0
- package/dist/types/NavigationProvider.d.ts +112 -0
- package/dist/types/NavigationProvider.interface.d.ts +75 -0
- package/dist/types/enable-navigation.d.ts +40 -0
- package/dist/types/events.d.ts +48 -0
- package/dist/types/index.d.ts +13 -6
- package/dist/types/lib/BaseHistory.d.ts +122 -0
- package/dist/types/lib/BrowserHistory.d.ts +53 -0
- package/dist/types/lib/BrowserHistoryHashStack.d.ts +41 -0
- package/dist/types/lib/BrowserHistoryStack.d.ts +51 -0
- package/dist/types/lib/MemoryHistory.d.ts +27 -0
- package/dist/types/lib/MemoryStack.d.ts +57 -0
- package/dist/types/lib/create-history.d.ts +35 -0
- package/dist/types/lib/index.d.ts +7 -1
- package/dist/types/lib/state/history.actions.d.ts +61 -0
- package/dist/types/lib/state/history.flows.d.ts +102 -0
- package/dist/types/lib/state/history.reducer.d.ts +85 -0
- package/dist/types/lib/state/history.state.d.ts +27 -0
- package/dist/types/lib/state/index.d.ts +3 -0
- package/dist/types/lib/types.d.ts +145 -0
- package/dist/types/lib/utils/encode-trailing-whitespace.d.ts +16 -0
- package/dist/types/lib/utils/has-protocol.d.ts +19 -0
- package/dist/types/lib/utils/index.d.ts +5 -0
- package/dist/types/lib/utils/path-to-string.d.ts +17 -0
- package/dist/types/lib/utils/path-to-url.d.ts +32 -0
- package/dist/types/lib/utils/resolve-browser-location.d.ts +42 -0
- package/dist/types/lib/utils/resolve-path.d.ts +17 -0
- package/dist/types/module.d.ts +33 -5
- package/dist/types/version.d.ts +1 -1
- package/package.json +25 -7
- package/src/NavigationConfigurator.interface.ts +18 -0
- package/src/NavigationConfigurator.ts +184 -0
- package/src/NavigationProvider.interface.ts +86 -0
- package/src/NavigationProvider.ts +313 -0
- package/src/__tests__/BrowserHistory.test.ts +151 -0
- package/src/__tests__/HashHistory.test.ts +121 -0
- package/src/__tests__/MemoryHistory.test.ts +185 -0
- package/src/__tests__/NavigationProvider.test.ts +60 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/enable-navigation.ts +59 -0
- package/src/events.ts +65 -0
- package/src/index.ts +27 -6
- package/src/lib/BaseHistory.ts +229 -0
- package/src/lib/BrowserHistory.ts +123 -0
- package/src/lib/BrowserHistoryHashStack.ts +56 -0
- package/src/lib/BrowserHistoryStack.ts +82 -0
- package/src/lib/MemoryHistory.ts +66 -0
- package/src/lib/MemoryStack.ts +106 -0
- package/src/lib/create-history.ts +63 -0
- package/src/lib/index.ts +22 -1
- package/src/lib/state/history.actions.ts +108 -0
- package/src/lib/state/history.flows.ts +279 -0
- package/src/lib/state/history.reducer.ts +70 -0
- package/src/lib/state/history.state.ts +62 -0
- package/src/lib/state/index.ts +3 -0
- package/src/lib/types.ts +159 -0
- package/src/lib/utils/encode-trailing-whitespace.ts +18 -0
- package/src/lib/utils/has-protocol.ts +21 -0
- package/src/lib/utils/index.ts +5 -0
- package/src/lib/utils/path-to-string.ts +24 -0
- package/src/lib/utils/path-to-url.ts +52 -0
- package/src/lib/utils/resolve-browser-location.ts +60 -0
- package/src/lib/utils/resolve-path.ts +22 -0
- package/src/module.ts +53 -37
- package/src/version.ts +1 -1
- package/tsconfig.json +10 -1
- package/vitest.config.ts +14 -0
- package/dist/esm/configurator.js +0 -5
- package/dist/esm/configurator.js.map +0 -1
- package/dist/esm/createHistory.js +0 -18
- package/dist/esm/createHistory.js.map +0 -1
- package/dist/esm/lib/provider/INavigationProvider.js +0 -2
- package/dist/esm/lib/provider/INavigationProvider.js.map +0 -1
- package/dist/esm/lib/provider/NavigationProvider.js +0 -82
- package/dist/esm/lib/provider/NavigationProvider.js.map +0 -1
- package/dist/esm/lib/provider/index.js +0 -2
- package/dist/esm/lib/provider/index.js.map +0 -1
- package/dist/esm/navigator.js +0 -158
- package/dist/esm/navigator.js.map +0 -1
- package/dist/esm/types.js +0 -2
- package/dist/esm/types.js.map +0 -1
- package/dist/types/configurator.d.ts +0 -9
- package/dist/types/createHistory.d.ts +0 -4
- package/dist/types/lib/provider/INavigationProvider.d.ts +0 -38
- package/dist/types/lib/provider/NavigationProvider.d.ts +0 -22
- package/dist/types/lib/provider/index.d.ts +0 -2
- package/dist/types/navigator.d.ts +0 -105
- package/dist/types/types.d.ts +0 -3
- package/src/configurator.ts +0 -11
- package/src/createHistory.ts +0 -21
- package/src/lib/provider/INavigationProvider.ts +0 -46
- package/src/lib/provider/NavigationProvider.ts +0 -117
- package/src/lib/provider/index.ts +0 -2
- package/src/navigator.ts +0 -204
- package/src/types.ts +0 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Observable, of, merge } from 'rxjs';
|
|
2
|
+
import { concatMap, map, withLatestFrom } from 'rxjs/operators';
|
|
3
|
+
import type { Flow } from '@equinor/fusion-observable';
|
|
4
|
+
import { filterAction } from '@equinor/fusion-observable/operators';
|
|
5
|
+
import { actions, type Actions } from './history.actions';
|
|
6
|
+
import { resolvePath } from '../utils';
|
|
7
|
+
import { type Location, type LocationState, type HistoryStack, Action } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* History flow for processing navigation actions.
|
|
11
|
+
*/
|
|
12
|
+
export type HistoryFlow = Flow<Actions, LocationState>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Factory function that creates a history flow from a stack.
|
|
16
|
+
*/
|
|
17
|
+
export type HistoryFlowCreator = (stack: HistoryStack) => HistoryFlow;
|
|
18
|
+
|
|
19
|
+
const compareLocation = (a: Location, b: Location) => {
|
|
20
|
+
return (
|
|
21
|
+
a.hash === b.hash &&
|
|
22
|
+
a.search === b.search &&
|
|
23
|
+
a.pathname === b.pathname &&
|
|
24
|
+
JSON.stringify(a.state) === JSON.stringify(b.state)
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Flow creator for handling navigate actions.
|
|
30
|
+
*
|
|
31
|
+
* Execution flow:
|
|
32
|
+
* 1. Filters for navigate actions
|
|
33
|
+
* 2. Resolves the target path from the action payload
|
|
34
|
+
* 3. Creates a location object with the resolved path, key (from action meta), and optional state
|
|
35
|
+
* 4. Checks if the new location is the same as current to prevent navigation loops
|
|
36
|
+
* 5. Updates the stack based on replace option (replace updates current, otherwise pushes new entry)
|
|
37
|
+
* 6. Returns success action with the new location and appropriate action type (PUSH/REPLACE)
|
|
38
|
+
*/
|
|
39
|
+
export const navigate: HistoryFlowCreator =
|
|
40
|
+
(stack: HistoryStack): HistoryFlow =>
|
|
41
|
+
(action$: Observable<Actions>) => {
|
|
42
|
+
const { navigate } = actions;
|
|
43
|
+
return action$.pipe(
|
|
44
|
+
filterAction(navigate.type),
|
|
45
|
+
map((action) => {
|
|
46
|
+
const { payload, meta } = action;
|
|
47
|
+
const path = resolvePath(payload.to);
|
|
48
|
+
const key = meta.key;
|
|
49
|
+
const { replace, state } = payload.options;
|
|
50
|
+
const nextLocation = { ...path, key, state } satisfies Location;
|
|
51
|
+
// prevent death loop navigation
|
|
52
|
+
if (compareLocation(nextLocation, stack.current)) {
|
|
53
|
+
return actions.abortNavigate('Location is the same as the current location');
|
|
54
|
+
}
|
|
55
|
+
if (replace) {
|
|
56
|
+
stack.replace(nextLocation);
|
|
57
|
+
} else {
|
|
58
|
+
stack.push(nextLocation);
|
|
59
|
+
}
|
|
60
|
+
return navigate.success({
|
|
61
|
+
delta: 0,
|
|
62
|
+
action: replace ? Action.Replace : Action.Push,
|
|
63
|
+
location: nextLocation,
|
|
64
|
+
});
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Flow creator for handling go (back/forward) actions.
|
|
71
|
+
*
|
|
72
|
+
* Execution flow:
|
|
73
|
+
* 1. Filters for go actions
|
|
74
|
+
* 2. Combines with current state to get history entries
|
|
75
|
+
* 3. Moves the stack index by the specified delta (negative for back, positive for forward)
|
|
76
|
+
* 4. Returns success action with POP action type and the new current location
|
|
77
|
+
*/
|
|
78
|
+
export const go: HistoryFlowCreator =
|
|
79
|
+
(stack: HistoryStack): HistoryFlow =>
|
|
80
|
+
(action$: Observable<Actions>, state$: Observable<LocationState>) => {
|
|
81
|
+
const { go } = actions;
|
|
82
|
+
return action$.pipe(
|
|
83
|
+
filterAction(go.type),
|
|
84
|
+
withLatestFrom(state$),
|
|
85
|
+
map(([action, state]) => {
|
|
86
|
+
const { delta } = action.payload;
|
|
87
|
+
stack.go(delta, state);
|
|
88
|
+
return go.success({
|
|
89
|
+
delta: 0,
|
|
90
|
+
action: Action.Pop,
|
|
91
|
+
location: stack.current,
|
|
92
|
+
});
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Flow creator for handling pop (browser back/forward) actions.
|
|
99
|
+
*
|
|
100
|
+
* Execution flow:
|
|
101
|
+
* 1. Filters for pop actions
|
|
102
|
+
* 2. Uses the location from the action payload or falls back to stack.current
|
|
103
|
+
* 3. Emits a success action with POP action type and the current location
|
|
104
|
+
* 4. Completes the observable immediately (synchronous operation)
|
|
105
|
+
*/
|
|
106
|
+
export const pop: HistoryFlowCreator =
|
|
107
|
+
(stack: HistoryStack): HistoryFlow =>
|
|
108
|
+
(action$: Observable<Actions>) => {
|
|
109
|
+
const { pop } = actions;
|
|
110
|
+
return action$.pipe(
|
|
111
|
+
filterAction(pop.type),
|
|
112
|
+
concatMap(
|
|
113
|
+
(action) =>
|
|
114
|
+
new Observable<Actions>((subscriber) => {
|
|
115
|
+
const currentLocation = action.payload.update?.location ?? stack.current;
|
|
116
|
+
// todo - we might need to check if the current location is in the history stack
|
|
117
|
+
// for now we will just use the current location
|
|
118
|
+
subscriber.next(
|
|
119
|
+
pop.success({
|
|
120
|
+
delta: 0,
|
|
121
|
+
action: Action.Pop,
|
|
122
|
+
location: currentLocation,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
subscriber.complete();
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Flow creator for checking navigation blockers before processing actions.
|
|
133
|
+
*
|
|
134
|
+
* Execution flow:
|
|
135
|
+
* 1. Filters for navigation actions (navigate, go, pop, validateLocation)
|
|
136
|
+
* 2. Determines the action type (PUSH/REPLACE for navigate, POP for others)
|
|
137
|
+
* 3. Combines with current state to check for active blockers
|
|
138
|
+
* 4. If no blockers exist, passes the action through immediately
|
|
139
|
+
* 5. If blockers exist:
|
|
140
|
+
* - Creates a transition object with action, location, and retry callback
|
|
141
|
+
* - Calls each blocker asynchronously (wrapped in Promise to prevent blocking)
|
|
142
|
+
* - Blockers can call retry() to allow navigation or prevent it by not calling retry()
|
|
143
|
+
* - Waits for all blockers to complete before continuing
|
|
144
|
+
* 6. Returns the original action if navigation is allowed, or completes without emitting if blocked
|
|
145
|
+
*/
|
|
146
|
+
export const checkBlockers: HistoryFlowCreator =
|
|
147
|
+
(stack: HistoryStack): HistoryFlow =>
|
|
148
|
+
(action$: Observable<Actions>, state$: Observable<LocationState>) => {
|
|
149
|
+
const { pop, navigate, go, validateLocation } = actions;
|
|
150
|
+
return action$.pipe(
|
|
151
|
+
filterAction(navigate.type, pop.type, go.type, validateLocation.type),
|
|
152
|
+
map((action) => {
|
|
153
|
+
switch (action.type) {
|
|
154
|
+
case navigate.type:
|
|
155
|
+
return {
|
|
156
|
+
replace: action.payload.options.replace,
|
|
157
|
+
action: action,
|
|
158
|
+
};
|
|
159
|
+
case go.type:
|
|
160
|
+
case pop.type:
|
|
161
|
+
case validateLocation.type:
|
|
162
|
+
return {
|
|
163
|
+
type: 'POP' as Action,
|
|
164
|
+
action: action,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
withLatestFrom(state$),
|
|
169
|
+
concatMap(([{ action, replace }, state]) => {
|
|
170
|
+
if (!state.blockers.length) {
|
|
171
|
+
return of(action);
|
|
172
|
+
}
|
|
173
|
+
return new Observable<Actions>((subscriber) => {
|
|
174
|
+
const location = stack.current;
|
|
175
|
+
const blockers = state.blockers.map((blocker) =>
|
|
176
|
+
Promise.resolve(
|
|
177
|
+
blocker({
|
|
178
|
+
delta: 0,
|
|
179
|
+
action: replace ? Action.Replace : Action.Push,
|
|
180
|
+
location,
|
|
181
|
+
retry: () => {
|
|
182
|
+
subscriber.next(action);
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
Promise.allSettled(blockers).then(() => {
|
|
188
|
+
subscriber.complete();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Flow creator for validating the current location against history state.
|
|
197
|
+
*
|
|
198
|
+
* Execution flow:
|
|
199
|
+
* 1. Filters for validateLocation actions
|
|
200
|
+
* 2. Combines with current state to access history entries
|
|
201
|
+
* 3. Gets the current location from the stack
|
|
202
|
+
* 4. Searches history for an entry matching the current location's key
|
|
203
|
+
* 5. Validates that:
|
|
204
|
+
* - The location key exists in history (returns failure if not found)
|
|
205
|
+
* - The location state matches the history entry state (returns failure if mismatch)
|
|
206
|
+
* 6. Returns success action with current location if validation passes, failure otherwise
|
|
207
|
+
*/
|
|
208
|
+
export const validateCurrentLocation: HistoryFlowCreator =
|
|
209
|
+
(stack: HistoryStack): HistoryFlow =>
|
|
210
|
+
(action$: Observable<Actions>, state$: Observable<LocationState>) => {
|
|
211
|
+
const { validateLocation } = actions;
|
|
212
|
+
return action$.pipe(
|
|
213
|
+
filterAction(validateLocation.type),
|
|
214
|
+
withLatestFrom(state$),
|
|
215
|
+
map(([, state]) => {
|
|
216
|
+
const currentLocation = stack.current;
|
|
217
|
+
const record = state.history.find(({ location }) => location?.key === currentLocation?.key);
|
|
218
|
+
if (!record) {
|
|
219
|
+
return validateLocation.failure(new Error('Stack state not found'));
|
|
220
|
+
}
|
|
221
|
+
if (record?.location?.state !== currentLocation?.state) {
|
|
222
|
+
return validateLocation.failure(new Error('Stack state mismatch'));
|
|
223
|
+
}
|
|
224
|
+
return validateLocation.success({
|
|
225
|
+
delta: 0,
|
|
226
|
+
action: Action.Pop,
|
|
227
|
+
location: currentLocation,
|
|
228
|
+
});
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Creates a combined history flow from multiple flow creators.
|
|
235
|
+
*
|
|
236
|
+
* Execution flow:
|
|
237
|
+
* 1. Creates a preprocessing step that applies blocker checking (unless skipped)
|
|
238
|
+
* 2. Initializes each flow creator with the stack to create individual flows
|
|
239
|
+
* 3. Merges all flows so they process actions in parallel
|
|
240
|
+
* 4. Returns a combined flow that:
|
|
241
|
+
* - First applies preprocessing (blocker checking)
|
|
242
|
+
* - Then processes actions through all merged flows
|
|
243
|
+
* - Emits results from any flow that matches the action
|
|
244
|
+
*
|
|
245
|
+
* @param flowCreators - Array of flow creators to combine
|
|
246
|
+
* @param options - Optional configuration options
|
|
247
|
+
* @param options.skipBlockCheck - If true, skip blocker checking
|
|
248
|
+
* @returns A combined history flow creator
|
|
249
|
+
*/
|
|
250
|
+
export const createFlow = (
|
|
251
|
+
flowCreators: HistoryFlowCreator[],
|
|
252
|
+
options?: { skipBlockCheck?: boolean },
|
|
253
|
+
): HistoryFlowCreator => {
|
|
254
|
+
return (stack: HistoryStack): HistoryFlow => {
|
|
255
|
+
const preProcessActions = options?.skipBlockCheck
|
|
256
|
+
? (action$: Observable<Actions>) => action$
|
|
257
|
+
: checkBlockers(stack);
|
|
258
|
+
|
|
259
|
+
const flows: HistoryFlow = (
|
|
260
|
+
action$: Observable<Actions>,
|
|
261
|
+
state$: Observable<LocationState>,
|
|
262
|
+
) => {
|
|
263
|
+
return merge(
|
|
264
|
+
...flowCreators
|
|
265
|
+
.map((initializer) => initializer(stack))
|
|
266
|
+
.map((flow) => flow(action$, state$)),
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return (action$: Observable<Actions>, state$: Observable<LocationState>) => {
|
|
271
|
+
return preProcessActions(action$, state$).pipe((source$) => flows(source$, state$));
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Collection of flow creators for history state management.
|
|
278
|
+
*/
|
|
279
|
+
export const flowCreators = { navigate, go, pop, validateCurrentLocation };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createReducer } from '@equinor/fusion-observable';
|
|
2
|
+
import type { LocationState, NavigationUpdate } from '../types';
|
|
3
|
+
import { actions, type Actions } from './history.actions';
|
|
4
|
+
import { isSuccessAction } from '@equinor/fusion-observable/actions';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finds the index of the current location in the history array.
|
|
8
|
+
* Used to determine where to insert/replace entries for REPLACE actions.
|
|
9
|
+
*/
|
|
10
|
+
const findIndex = (state: LocationState) => {
|
|
11
|
+
return state.history.findIndex((update) => update.location.key === state.current.location.key);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a reducer for history state management.
|
|
16
|
+
*
|
|
17
|
+
* @param initial - Initial navigation update or function that returns initial state
|
|
18
|
+
* @param options - Optional configuration options
|
|
19
|
+
* @param options.maxHistory - Maximum number of history entries to keep (default: 100)
|
|
20
|
+
* @returns A reducer for history state
|
|
21
|
+
*/
|
|
22
|
+
export const createHistoryReducer = (
|
|
23
|
+
initial: NavigationUpdate | (() => LocationState),
|
|
24
|
+
options?: { maxHistory?: number },
|
|
25
|
+
) => {
|
|
26
|
+
const maxHistory = options?.maxHistory ?? 100;
|
|
27
|
+
|
|
28
|
+
const initialState: LocationState =
|
|
29
|
+
initial instanceof Function
|
|
30
|
+
? initial()
|
|
31
|
+
: ({
|
|
32
|
+
current: initial,
|
|
33
|
+
history: [initial],
|
|
34
|
+
blockers: [],
|
|
35
|
+
} satisfies LocationState);
|
|
36
|
+
|
|
37
|
+
return createReducer<LocationState, Actions>(initialState, (builder) => {
|
|
38
|
+
// Handle navigate.success actions to update history array
|
|
39
|
+
builder.addCase(actions.navigate.success, (state, action) => {
|
|
40
|
+
const { update } = action.payload;
|
|
41
|
+
// For REPLACE actions, remove all entries after the current location (the tail)
|
|
42
|
+
// This ensures REPLACE doesn't add to history length when at the end,
|
|
43
|
+
// and removes any forward history when not at the end
|
|
44
|
+
if (update.action === 'REPLACE') {
|
|
45
|
+
const currentIndex = findIndex(state);
|
|
46
|
+
if (currentIndex !== -1) {
|
|
47
|
+
// Remove all entries from current position onwards
|
|
48
|
+
state.history.splice(currentIndex, state.history.length - currentIndex);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
state.history.push(update);
|
|
52
|
+
});
|
|
53
|
+
// Update current location for all successful actions (navigate, go, pop, etc.)
|
|
54
|
+
builder.addMatcher(isSuccessAction, (state, action) => {
|
|
55
|
+
const { update } = action.payload;
|
|
56
|
+
state.current = update;
|
|
57
|
+
});
|
|
58
|
+
// Limit history size to prevent unbounded growth
|
|
59
|
+
// This matcher always runs (returns true) to check history length after every action
|
|
60
|
+
builder.addMatcher(
|
|
61
|
+
() => true,
|
|
62
|
+
(state) => {
|
|
63
|
+
if (state.history.length > maxHistory) {
|
|
64
|
+
// Keep only the most recent entries, removing oldest ones
|
|
65
|
+
state.history = state.history.slice(-maxHistory);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createState,
|
|
3
|
+
type FlowState,
|
|
4
|
+
type ReducerWithInitialState,
|
|
5
|
+
} from '@equinor/fusion-observable';
|
|
6
|
+
import { actions, type Actions } from './history.actions';
|
|
7
|
+
import { flowCreators, createFlow, type HistoryFlowCreator } from './history.flows';
|
|
8
|
+
import type { HistoryStack, LocationState } from '../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* History state containing flow state and stack.
|
|
12
|
+
*/
|
|
13
|
+
export type HistoryState = FlowState<LocationState, typeof actions> & {
|
|
14
|
+
stack: HistoryStack;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default flows for history state management.
|
|
19
|
+
*/
|
|
20
|
+
export const defaultFlows = [flowCreators.navigate, flowCreators.go, flowCreators.pop];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a history store with the specified stack and reducer.
|
|
24
|
+
*
|
|
25
|
+
* @param stack - The history stack implementation
|
|
26
|
+
* @param reducer - The reducer for history state
|
|
27
|
+
* @param options - Optional configuration options
|
|
28
|
+
* @returns A HistoryState instance
|
|
29
|
+
*/
|
|
30
|
+
export const createStore = (
|
|
31
|
+
stack: HistoryStack,
|
|
32
|
+
reducer: ReducerWithInitialState<LocationState, Actions>,
|
|
33
|
+
options?: {
|
|
34
|
+
flows?: HistoryFlowCreator[];
|
|
35
|
+
skipBlockCheck?: boolean;
|
|
36
|
+
validateCurrentLocation?: boolean;
|
|
37
|
+
},
|
|
38
|
+
): HistoryState => {
|
|
39
|
+
// Create base state with actions and reducer, then add stack property
|
|
40
|
+
// The stack is stored alongside the flow state for use in flows
|
|
41
|
+
const state = Object.assign(createState(actions, reducer), { stack }) as HistoryState;
|
|
42
|
+
|
|
43
|
+
// Build set of flows to use (default flows or custom flows)
|
|
44
|
+
// Use Set to ensure no duplicates if validateCurrentLocation is added
|
|
45
|
+
const flows = new Set<HistoryFlowCreator>(options?.flows ?? defaultFlows);
|
|
46
|
+
// Optionally add validation flow to check location consistency
|
|
47
|
+
if (options?.validateCurrentLocation) {
|
|
48
|
+
flows.add(flowCreators.validateCurrentLocation);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create combined flow from all flow creators
|
|
52
|
+
// This merges all flows so they process actions in parallel
|
|
53
|
+
const flow = createFlow([...flows], {
|
|
54
|
+
skipBlockCheck: options?.skipBlockCheck,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Initialize the flow with the stack and add it to the state subject
|
|
58
|
+
// The flow processes actions and updates state through the reducer
|
|
59
|
+
state.subject.addFlow(flow(stack));
|
|
60
|
+
|
|
61
|
+
return state;
|
|
62
|
+
};
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Observable } from 'rxjs';
|
|
2
|
+
import type { Actions } from './state/history.actions';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Actions represent the type of change to a location value.
|
|
6
|
+
*/
|
|
7
|
+
export enum Action {
|
|
8
|
+
/**
|
|
9
|
+
* A POP indicates a change to an arbitrary index in the history stack, such
|
|
10
|
+
* as a back or forward navigation. It does not describe the direction of the
|
|
11
|
+
* navigation, only that the current index changed.
|
|
12
|
+
*
|
|
13
|
+
* Note: This is the default action for newly created history objects.
|
|
14
|
+
*/
|
|
15
|
+
Pop = 'POP',
|
|
16
|
+
/**
|
|
17
|
+
* A PUSH indicates a new entry being added to the history stack, such as when
|
|
18
|
+
* a link is clicked and a new page loads.
|
|
19
|
+
*/
|
|
20
|
+
Push = 'PUSH',
|
|
21
|
+
/**
|
|
22
|
+
* A REPLACE indicates the entry at the current index in the history stack
|
|
23
|
+
* being replaced by a new one.
|
|
24
|
+
*/
|
|
25
|
+
Replace = 'REPLACE',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Target path for navigation operations.
|
|
30
|
+
* Can be a string path or a Path object.
|
|
31
|
+
*/
|
|
32
|
+
export type To = string | Partial<Path>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Path object containing pathname, optional search, and optional hash.
|
|
36
|
+
*/
|
|
37
|
+
export type Path = {
|
|
38
|
+
pathname: string;
|
|
39
|
+
search: string;
|
|
40
|
+
hash: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Location object representing a navigation entry.
|
|
45
|
+
* Extends Path with state and a unique key.
|
|
46
|
+
*/
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: necessary
|
|
48
|
+
export type Location<T = any> = Path & {
|
|
49
|
+
state: T;
|
|
50
|
+
key: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Internal state for history management.
|
|
55
|
+
*/
|
|
56
|
+
export type LocationState = {
|
|
57
|
+
current: NavigationUpdate;
|
|
58
|
+
history: NavigationUpdate[];
|
|
59
|
+
blockers: NavigationBlocker[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Callback function for listening to navigation changes.
|
|
64
|
+
*/
|
|
65
|
+
export type NavigationListener = (update: Readonly<NavigationUpdate>) => void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Navigation update event containing action and location.
|
|
69
|
+
*/
|
|
70
|
+
export type NavigationUpdate<A extends string = Action, T = unknown> = {
|
|
71
|
+
delta: number;
|
|
72
|
+
action: Readonly<A>;
|
|
73
|
+
location: Readonly<Location<T>>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Navigation transition that was blocked.
|
|
78
|
+
* Provides a retry method to allow the navigation to proceed.
|
|
79
|
+
*/
|
|
80
|
+
export interface NavigationTransition extends NavigationUpdate {
|
|
81
|
+
/**
|
|
82
|
+
* Retries the navigation that was blocked.
|
|
83
|
+
*/
|
|
84
|
+
retry(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Callback function for blocking navigation attempts.
|
|
89
|
+
*/
|
|
90
|
+
export type NavigationBlocker = (transition: NavigationTransition) => void;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Options for controlling navigation behavior.
|
|
94
|
+
*
|
|
95
|
+
* @interface NavigateOptions
|
|
96
|
+
*
|
|
97
|
+
* @property {boolean} [replace] - Determines the navigation action:
|
|
98
|
+
* - `false` (default): Pushes a new entry onto the history stack
|
|
99
|
+
* - `true`: Replaces the current entry in the history stack
|
|
100
|
+
*
|
|
101
|
+
* @property {unknown} [state] - Optional state object to associate with the navigation entry.
|
|
102
|
+
* This state can be accessed later when navigating back to this entry.
|
|
103
|
+
*/
|
|
104
|
+
export interface NavigateOptions {
|
|
105
|
+
replace?: boolean;
|
|
106
|
+
state?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* History interface for managing navigation state.
|
|
111
|
+
* Compatible with industry-standard routers (Remix/React Router) and provides observable state management.
|
|
112
|
+
*/
|
|
113
|
+
export interface History extends Disposable {
|
|
114
|
+
/** Observable stream of navigation state updates. */
|
|
115
|
+
readonly state$: Observable<NavigationUpdate>;
|
|
116
|
+
/** Observable stream of navigation actions. */
|
|
117
|
+
readonly action$: Observable<Actions>;
|
|
118
|
+
/** Current navigation action. */
|
|
119
|
+
readonly action: Action;
|
|
120
|
+
/** Current location in the history stack. */
|
|
121
|
+
readonly location: Location;
|
|
122
|
+
|
|
123
|
+
/** Creates a valid href string for a given path. */
|
|
124
|
+
createHref(to: To): string;
|
|
125
|
+
/** Creates a URL object for a given path. */
|
|
126
|
+
createURL(to: To): URL;
|
|
127
|
+
/** Encodes a location by properly URL-encoding the pathname. */
|
|
128
|
+
encodeLocation(to: To): Path;
|
|
129
|
+
/** Pushes a new navigation entry onto the history stack. */
|
|
130
|
+
push(to: To, state?: unknown): void;
|
|
131
|
+
/** Replaces the current history entry with a new one. */
|
|
132
|
+
replace(to: To, state?: unknown): void;
|
|
133
|
+
/** Navigate to a location with explicit options. */
|
|
134
|
+
navigate(to: To, options?: NavigateOptions): void;
|
|
135
|
+
/** Navigates backward or forward in the history stack. */
|
|
136
|
+
go(delta: number): void;
|
|
137
|
+
/** Sets up a listener for navigation changes. */
|
|
138
|
+
listen(listener: NavigationListener): () => void;
|
|
139
|
+
/** Registers a blocker to intercept navigation attempts. */
|
|
140
|
+
block(blocker: NavigationBlocker): VoidFunction;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* History stack interface for managing navigation entries.
|
|
145
|
+
*/
|
|
146
|
+
export interface HistoryStack {
|
|
147
|
+
/** Origin URL for the history stack. */
|
|
148
|
+
readonly origin: string;
|
|
149
|
+
/** Current location in the stack. */
|
|
150
|
+
readonly current: Location;
|
|
151
|
+
/** Pushes a new entry onto the history stack. */
|
|
152
|
+
push(location: Location): void;
|
|
153
|
+
/** Replaces the current entry in the history stack. */
|
|
154
|
+
replace(location: Location): void;
|
|
155
|
+
/** Navigates backward or forward in the history stack. */
|
|
156
|
+
go(delta: number, state: Readonly<LocationState>): void;
|
|
157
|
+
/** Creates a URL object for a given path. */
|
|
158
|
+
createURL(to: To): URL;
|
|
159
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encodes trailing whitespace in a string by replacing it with '%20'.
|
|
3
|
+
*
|
|
4
|
+
* @param input - The string to encode
|
|
5
|
+
* @returns The string with trailing whitespace encoded
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* encodeTrailingWhitespace('hello ')
|
|
10
|
+
* // 'hello%20'
|
|
11
|
+
*
|
|
12
|
+
* encodeTrailingWhitespace('hello')
|
|
13
|
+
* // 'hello'
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export const encodeTrailingWhitespace = (input: string): string => {
|
|
17
|
+
return input.replace(/ $/, '%20');
|
|
18
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a string has a protocol prefix.
|
|
3
|
+
*
|
|
4
|
+
* @param input - The string to check
|
|
5
|
+
* @returns True if the string starts with a protocol (e.g., 'http:', 'https:', 'file:')
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* hasProtocol('https://example.com')
|
|
10
|
+
* // true
|
|
11
|
+
*
|
|
12
|
+
* hasProtocol('/users')
|
|
13
|
+
* // false
|
|
14
|
+
*
|
|
15
|
+
* hasProtocol('file:///path/to/file')
|
|
16
|
+
* // true
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const hasProtocol = (input: string): boolean => {
|
|
20
|
+
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input);
|
|
21
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { pathToString } from './path-to-string';
|
|
2
|
+
export { pathToUrl } from './path-to-url';
|
|
3
|
+
export { resolvePath } from './resolve-path';
|
|
4
|
+
export { resolveWindowLocation } from './resolve-browser-location';
|
|
5
|
+
export { resolveHashLocation } from './resolve-browser-location';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Path } from '../types';
|
|
2
|
+
import { encodeTrailingWhitespace } from './encode-trailing-whitespace';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Converts a Partial<Path> object to a path string.
|
|
6
|
+
*
|
|
7
|
+
* @param to - The path object with optional pathname, search, and hash
|
|
8
|
+
* @returns A path string with properly formatted search and hash
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* pathToString({ pathname: '/users', search: '?id=1', hash: '#section' })
|
|
13
|
+
* // '/users?id=1#section'
|
|
14
|
+
*
|
|
15
|
+
* pathToString({ pathname: '/users', search: 'id=1', hash: 'section' })
|
|
16
|
+
* // '/users?id=1#section'
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const pathToString = (to: Partial<Path>): string => {
|
|
20
|
+
const pathname = encodeTrailingWhitespace(to.pathname ?? '/');
|
|
21
|
+
const search = encodeTrailingWhitespace(to.search?.replace(/^\?/, '') ?? '');
|
|
22
|
+
const hash = encodeTrailingWhitespace(to.hash?.replace(/^#/, '') ?? '');
|
|
23
|
+
return `${pathname}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`;
|
|
24
|
+
};
|