@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,313 @@
|
|
|
1
|
+
// TODO: Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
|
|
2
|
+
import { type AgnosticRouteObject, createRouter } from '@remix-run/router';
|
|
3
|
+
import type { Observable } from 'rxjs';
|
|
4
|
+
import { filter, pairwise, shareReplay } from 'rxjs/operators';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BaseModuleProvider,
|
|
8
|
+
type BaseModuleProviderCtorArgs,
|
|
9
|
+
} from '@equinor/fusion-framework-module/provider';
|
|
10
|
+
|
|
11
|
+
import type { INavigationProvider } from './NavigationProvider.interface';
|
|
12
|
+
import type { INavigationConfigurator } from './NavigationConfigurator.interface';
|
|
13
|
+
import type { History, NavigateOptions, NavigationUpdate, Path, To } from './lib/types';
|
|
14
|
+
import {
|
|
15
|
+
TelemetryLevel,
|
|
16
|
+
TelemetryScope,
|
|
17
|
+
type ITelemetryProvider,
|
|
18
|
+
} from '@equinor/fusion-framework-module-telemetry';
|
|
19
|
+
import type { IEventModuleProvider } from '@equinor/fusion-framework-module-event';
|
|
20
|
+
import { NavigatedEvent } from './events';
|
|
21
|
+
import { pathToString } from './lib/utils';
|
|
22
|
+
import type { BaseHistory } from './lib';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes a pathname by:
|
|
26
|
+
* - Collapsing multiple consecutive slashes into a single slash
|
|
27
|
+
* - Removing trailing slashes
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* normalizePathname("/app//users///profile/") // returns "/app/users/profile"
|
|
31
|
+
* normalizePathname("///multiple///slashes///") // returns "/multiple/slashes"
|
|
32
|
+
*
|
|
33
|
+
* @param path - The pathname to normalize
|
|
34
|
+
* @returns The normalized pathname without consecutive or trailing slashes
|
|
35
|
+
*/
|
|
36
|
+
const normalizePathname = (path: string) => path.replace(/\/+/g, '/').replace(/\/$/, '');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Navigation provider implementation.
|
|
40
|
+
* Manages routing and navigation state with basename localization.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* This provider:
|
|
44
|
+
* - Wraps the Navigator to provide observable navigation state
|
|
45
|
+
* - Localizes paths by removing basename prefix
|
|
46
|
+
* - Creates routers compatible with industry-standard routers (Remix/React Router)
|
|
47
|
+
* - Handles navigation actions (push, replace, createHref, etc.)
|
|
48
|
+
*
|
|
49
|
+
* Path localization ensures that consumers receive paths relative to the basename,
|
|
50
|
+
* while internally we work with full paths including basename.
|
|
51
|
+
*/
|
|
52
|
+
export class NavigationProvider
|
|
53
|
+
extends BaseModuleProvider<INavigationConfigurator>
|
|
54
|
+
implements INavigationProvider
|
|
55
|
+
{
|
|
56
|
+
#history: History;
|
|
57
|
+
#basename?: string;
|
|
58
|
+
#state$: Observable<NavigationUpdate>;
|
|
59
|
+
|
|
60
|
+
#telemetry?: ITelemetryProvider;
|
|
61
|
+
#event?: IEventModuleProvider;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Observable stream of navigation state updates.
|
|
65
|
+
* Emits localized paths (with basename removed) for consumers.
|
|
66
|
+
*/
|
|
67
|
+
public get state$(): Observable<NavigationUpdate> {
|
|
68
|
+
return this.#state$;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Gets the basename.
|
|
73
|
+
*/
|
|
74
|
+
public get basename(): string {
|
|
75
|
+
return this.#basename ?? '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated Use `history` instead
|
|
80
|
+
*/
|
|
81
|
+
public get navigator(): History {
|
|
82
|
+
this.#telemetry?.trackException({
|
|
83
|
+
name: 'Navigation::navigator.deprecated',
|
|
84
|
+
exception: new Error('navigator is deprecated, use history instead'),
|
|
85
|
+
level: TelemetryLevel.Warning,
|
|
86
|
+
scope: ['navigation', TelemetryScope.Framework],
|
|
87
|
+
});
|
|
88
|
+
return this.#history;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets the history instance.
|
|
93
|
+
*/
|
|
94
|
+
public get history(): History {
|
|
95
|
+
return this.#history;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Gets the current localized path (basename removed).
|
|
100
|
+
*/
|
|
101
|
+
public get path(): Path {
|
|
102
|
+
return this._localizePath(this.#history.location);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param args - Configuration arguments containing module config
|
|
107
|
+
* @throws {Error} If no history is provided in the configuration
|
|
108
|
+
*/
|
|
109
|
+
constructor(args: BaseModuleProviderCtorArgs<INavigationConfigurator>) {
|
|
110
|
+
super(args);
|
|
111
|
+
|
|
112
|
+
// Extract configuration values
|
|
113
|
+
const { basename, history, telemetry, eventProvider } = args.config;
|
|
114
|
+
|
|
115
|
+
this.#basename = basename;
|
|
116
|
+
this.#event = eventProvider;
|
|
117
|
+
this.#telemetry = telemetry;
|
|
118
|
+
|
|
119
|
+
// History is required - validate and track error if missing
|
|
120
|
+
if (!history) {
|
|
121
|
+
this.#telemetry?.trackException({
|
|
122
|
+
name: 'Navigation::history.required',
|
|
123
|
+
exception: new Error('no history provided!'),
|
|
124
|
+
level: TelemetryLevel.Error,
|
|
125
|
+
scope: ['navigation', TelemetryScope.Application],
|
|
126
|
+
});
|
|
127
|
+
throw Error('no history provided!');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.#history = history;
|
|
131
|
+
|
|
132
|
+
// Create state$ observable that filters for paths within basename scope
|
|
133
|
+
// shareReplay ensures subscribers get the latest value and share the subscription
|
|
134
|
+
this.#state$ = this.#history.state$.pipe(
|
|
135
|
+
filter((update: NavigationUpdate) => this._isWithinBasenameScope(update.location.pathname)),
|
|
136
|
+
shareReplay({ bufferSize: 1, refCount: true }),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
this._initialize();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sets up navigation subscriptions for events and telemetry.
|
|
144
|
+
*
|
|
145
|
+
* Configures subscriptions for:
|
|
146
|
+
* - NavigatedEvent dispatching when navigation changes
|
|
147
|
+
* - Telemetry tracking for navigation actions and events
|
|
148
|
+
* - History disposal cleanup
|
|
149
|
+
*/
|
|
150
|
+
protected _initialize(): void {
|
|
151
|
+
// Dispatch NavigatedEvent and track telemetry when navigation changes
|
|
152
|
+
// pairwise() gives us [previous, current] pairs to compare navigation state
|
|
153
|
+
this._addTeardown(
|
|
154
|
+
this.#state$.pipe(pairwise()).subscribe(([previous, current]) => {
|
|
155
|
+
// emit NavigatedEvent for other modules to listen to when navigation changes
|
|
156
|
+
this.#event?.dispatchEvent(
|
|
157
|
+
new NavigatedEvent(
|
|
158
|
+
{
|
|
159
|
+
action: current.action,
|
|
160
|
+
current,
|
|
161
|
+
previous,
|
|
162
|
+
},
|
|
163
|
+
this,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
// track telemetry for navigation changes
|
|
167
|
+
this.#telemetry?.trackEvent({
|
|
168
|
+
name: 'Navigation::navigated',
|
|
169
|
+
level: TelemetryLevel.Information,
|
|
170
|
+
scope: ['navigation', TelemetryScope.Application],
|
|
171
|
+
properties: {
|
|
172
|
+
action: current.action,
|
|
173
|
+
location: pathToString(current.location),
|
|
174
|
+
previousLocation: pathToString(previous.location),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Track all navigation actions for debugging (if telemetry is enabled)
|
|
181
|
+
if (this.#telemetry) {
|
|
182
|
+
this._addTeardown(
|
|
183
|
+
this.#history.action$.subscribe((action) => {
|
|
184
|
+
this.#telemetry?.trackEvent({
|
|
185
|
+
name: `Navigation::action:${action.type}`,
|
|
186
|
+
level: TelemetryLevel.Debug,
|
|
187
|
+
scope: ['navigation', TelemetryScope.Framework],
|
|
188
|
+
properties: {
|
|
189
|
+
type: action.type,
|
|
190
|
+
action: action,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clean up history instance when provider is disposed
|
|
198
|
+
this._addTeardown(() => this.#history[Symbol.dispose]());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Creates a router instance from route configuration.
|
|
203
|
+
*
|
|
204
|
+
* @deprecated Use `@equinor/fusion-framework-react-router` instead
|
|
205
|
+
*
|
|
206
|
+
* @param routes - Route configuration objects compatible with industry-standard routers (Remix/React Router)
|
|
207
|
+
* @returns A configured and initialized router instance
|
|
208
|
+
*/
|
|
209
|
+
public createRouter(routes: AgnosticRouteObject[]) {
|
|
210
|
+
this.#telemetry?.trackEvent({
|
|
211
|
+
name: 'Navigation::createRouter',
|
|
212
|
+
level: TelemetryLevel.Warning,
|
|
213
|
+
scope: ['navigation', 'deprecated', TelemetryScope.Application],
|
|
214
|
+
});
|
|
215
|
+
const router = createRouter({
|
|
216
|
+
basename: this.#basename,
|
|
217
|
+
history: this.#history as unknown as import('@remix-run/router').History,
|
|
218
|
+
routes,
|
|
219
|
+
future: {
|
|
220
|
+
v7_prependBasename: true,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
router.initialize();
|
|
224
|
+
return router;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Creates a localized href string for navigation.
|
|
229
|
+
*
|
|
230
|
+
* @param to - Optional path or location (defaults to current path)
|
|
231
|
+
*/
|
|
232
|
+
public createHref(to?: To): string {
|
|
233
|
+
return this.#history.createHref(this._createToPath(to ?? this.path));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Creates a full URL object for navigation.
|
|
238
|
+
*
|
|
239
|
+
* @param to - Optional path or location (defaults to current path)
|
|
240
|
+
*/
|
|
241
|
+
public createURL(to?: To): URL {
|
|
242
|
+
return this.#history.createURL(this._createToPath(to ?? this.path));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Pushes a new navigation entry onto the history stack.
|
|
247
|
+
*
|
|
248
|
+
* @param to - Path or location to navigate to (relative to basename)
|
|
249
|
+
* @param state - Optional state to associate with the navigation
|
|
250
|
+
*/
|
|
251
|
+
public push(to: To, state?: unknown): void {
|
|
252
|
+
this.navigate(to, { state });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Replaces the current history entry with a new one.
|
|
257
|
+
*
|
|
258
|
+
* @param to - Path or location to navigate to (relative to basename)
|
|
259
|
+
* @param state - Optional state to associate with the navigation
|
|
260
|
+
*/
|
|
261
|
+
public replace(to: To, state?: unknown): void {
|
|
262
|
+
this.navigate(to, { replace: true, state });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Navigate to a location with explicit options.
|
|
267
|
+
*
|
|
268
|
+
* @param to - Optional path or location (defaults to current path)
|
|
269
|
+
* @param options - Optional navigation options
|
|
270
|
+
*/
|
|
271
|
+
public navigate(to?: To, options?: Partial<NavigateOptions>): void {
|
|
272
|
+
const { replace = false, state } = options ?? {};
|
|
273
|
+
this.#history.navigate(this._createToPath(to ?? this.path), { replace, state });
|
|
274
|
+
// we need to pop the history to update the notify history listeners
|
|
275
|
+
// Frameworks as react router have their internal state management,
|
|
276
|
+
// so we need to force a pop to notify the framework that the history has changed
|
|
277
|
+
(this.#history as BaseHistory).pop();
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Checks if a pathname is within the basename scope.
|
|
281
|
+
*/
|
|
282
|
+
protected _isWithinBasenameScope(pathname: string): boolean {
|
|
283
|
+
return this.#basename ? pathname.startsWith(this.#basename) : true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Localizes a path by removing the basename prefix.
|
|
288
|
+
*/
|
|
289
|
+
protected _localizePath(location: Path): Path {
|
|
290
|
+
const { pathname, search, hash } = location;
|
|
291
|
+
return {
|
|
292
|
+
pathname: normalizePathname(pathname.replace(this.#basename ?? '', '')),
|
|
293
|
+
search,
|
|
294
|
+
hash,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Creates a full path object from a target location, adding basename prefix.
|
|
300
|
+
*/
|
|
301
|
+
protected _createToPath(to: To): Partial<Path> {
|
|
302
|
+
// Parse the 'to' parameter into path components
|
|
303
|
+
const pathComponents = typeof to === 'string' ? { pathname: to } : to;
|
|
304
|
+
|
|
305
|
+
// Extract path parts, defaulting to current path values
|
|
306
|
+
const rawPathname = pathComponents.pathname ?? this.path.pathname;
|
|
307
|
+
const pathname = normalizePathname(`${this.#basename ?? ''}/${rawPathname}`);
|
|
308
|
+
const search = pathComponents.search ?? this.path.search;
|
|
309
|
+
const hash = pathComponents.hash ?? this.path.hash;
|
|
310
|
+
|
|
311
|
+
return { pathname, search, hash };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { firstValueFrom, skip } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import { NavigationProvider } from '../NavigationProvider';
|
|
5
|
+
import { createHistory } from '../lib/create-history';
|
|
6
|
+
import type { History } from '../lib/types';
|
|
7
|
+
|
|
8
|
+
describe('BrowserHistory', () => {
|
|
9
|
+
let provider: NavigationProvider;
|
|
10
|
+
let history: History;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
history = createHistory('browser');
|
|
14
|
+
provider = new NavigationProvider({
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
config: { history },
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
provider?.dispose();
|
|
22
|
+
history[Symbol.dispose]();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('push', () => {
|
|
26
|
+
it('should navigate when push is called', async () => {
|
|
27
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
28
|
+
provider.push('/about', { test: 'test' });
|
|
29
|
+
await updatePromise;
|
|
30
|
+
|
|
31
|
+
expect(provider.path.pathname).toBe(history.location.pathname);
|
|
32
|
+
|
|
33
|
+
expect(history.location.pathname).toBe('/about');
|
|
34
|
+
expect(history.action).toBe('PUSH');
|
|
35
|
+
expect(history.location.state).toEqual({ test: 'test' });
|
|
36
|
+
expect(window.location.pathname).toBe('/about');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should support browser back/forward navigation', async () => {
|
|
40
|
+
history.push('/page1', { page: 1 });
|
|
41
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
42
|
+
expect(history.location.pathname).toBe('/page1');
|
|
43
|
+
|
|
44
|
+
history.push('/page2', { page: 2 });
|
|
45
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
46
|
+
expect(history.location.pathname).toBe('/page2');
|
|
47
|
+
|
|
48
|
+
// Go back
|
|
49
|
+
const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
50
|
+
window.history.back();
|
|
51
|
+
await backPromise;
|
|
52
|
+
expect(history.location.pathname).toBe('/page1');
|
|
53
|
+
expect(history.action).toBe('POP');
|
|
54
|
+
expect(history.location.state).toEqual({ page: 1 });
|
|
55
|
+
|
|
56
|
+
// Go forward
|
|
57
|
+
const forwardPromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
58
|
+
window.history.forward();
|
|
59
|
+
await forwardPromise;
|
|
60
|
+
expect(history.location.pathname).toBe('/page2');
|
|
61
|
+
expect(history.action).toBe('POP');
|
|
62
|
+
expect(history.location.state).toEqual({ page: 2 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should support go() method', async () => {
|
|
66
|
+
history.push('/page1');
|
|
67
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
68
|
+
expect(history.location.pathname).toBe('/page1');
|
|
69
|
+
|
|
70
|
+
history.push('/page2');
|
|
71
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
72
|
+
expect(history.location.pathname).toBe('/page2');
|
|
73
|
+
|
|
74
|
+
history.push('/page3');
|
|
75
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
76
|
+
expect(history.location.pathname).toBe('/page3');
|
|
77
|
+
|
|
78
|
+
// Go back - wait for state update to /page2
|
|
79
|
+
const goBackPromise = firstValueFrom(
|
|
80
|
+
history.state$.pipe(
|
|
81
|
+
skip(1),
|
|
82
|
+
filter((update) => update.location.pathname === '/page2'),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
history.go(-1);
|
|
86
|
+
await goBackPromise;
|
|
87
|
+
expect(history.location.pathname).toBe('/page2');
|
|
88
|
+
expect(history.action).toBe('POP');
|
|
89
|
+
|
|
90
|
+
// Go forward - wait for state update to /page3
|
|
91
|
+
const goForwardPromise = firstValueFrom(
|
|
92
|
+
history.state$.pipe(
|
|
93
|
+
skip(1),
|
|
94
|
+
filter((update) => update.location.pathname === '/page3'),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
history.go(1);
|
|
98
|
+
await goForwardPromise;
|
|
99
|
+
expect(history.location.pathname).toBe('/page3');
|
|
100
|
+
expect(history.action).toBe('POP');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('replace', () => {
|
|
105
|
+
it('should navigate when replace is called', async () => {
|
|
106
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
107
|
+
provider.replace('/about', { test: 'test' });
|
|
108
|
+
await updatePromise;
|
|
109
|
+
|
|
110
|
+
expect(history.location.pathname).toBe('/about');
|
|
111
|
+
expect(history.action).toBe('REPLACE');
|
|
112
|
+
expect(history.location.state).toEqual({ test: 'test' });
|
|
113
|
+
expect(window.location.pathname).toBe('/about');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should replace current entry without adding to history stack', async () => {
|
|
117
|
+
// Start at root
|
|
118
|
+
expect(history.location.pathname).toBe('/');
|
|
119
|
+
|
|
120
|
+
// Push an entry
|
|
121
|
+
history.push('/page1', { page: 1 });
|
|
122
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
123
|
+
expect(history.location.pathname).toBe('/page1');
|
|
124
|
+
|
|
125
|
+
// Replace current entry
|
|
126
|
+
history.replace('/page2', { page: 2 });
|
|
127
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
128
|
+
expect(history.location.pathname).toBe('/page2');
|
|
129
|
+
expect(history.action).toBe('REPLACE');
|
|
130
|
+
|
|
131
|
+
// Go back - should go to root, not page1 (because page1 was replaced)
|
|
132
|
+
const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
133
|
+
window.history.back();
|
|
134
|
+
await backPromise;
|
|
135
|
+
expect(history.location.pathname).toBe('/');
|
|
136
|
+
expect(history.action).toBe('POP');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('state$ observable', () => {
|
|
141
|
+
it('should emit state updates on navigation', async () => {
|
|
142
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
143
|
+
history.push('/test', { data: 'test' });
|
|
144
|
+
const update = await updatePromise;
|
|
145
|
+
|
|
146
|
+
expect(update.action).toBe('PUSH');
|
|
147
|
+
expect(update.location.pathname).toBe('/test');
|
|
148
|
+
expect(update.location.state).toEqual({ data: 'test' });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { firstValueFrom, skip } from 'rxjs';
|
|
3
|
+
import { createHistory } from '../lib/create-history';
|
|
4
|
+
import type { History } from '../lib/types';
|
|
5
|
+
|
|
6
|
+
describe('HashHistory', () => {
|
|
7
|
+
let history: History;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
history = createHistory('hash');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
history[Symbol.dispose]();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('hash-based navigation', () => {
|
|
18
|
+
it('should update hash instead of pathname on push', async () => {
|
|
19
|
+
const initialPathname = window.location.pathname;
|
|
20
|
+
|
|
21
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
22
|
+
history.push('/about', { test: 'test' });
|
|
23
|
+
await updatePromise;
|
|
24
|
+
|
|
25
|
+
expect(history.location.pathname).toBe('/about');
|
|
26
|
+
expect(history.action).toBe('PUSH');
|
|
27
|
+
expect(history.location.state).toEqual({ test: 'test' });
|
|
28
|
+
// Pathname should remain unchanged
|
|
29
|
+
expect(window.location.pathname).toBe(initialPathname);
|
|
30
|
+
// Hash should contain the route
|
|
31
|
+
expect(window.location.hash).toBe('#/about');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should update hash instead of pathname on replace', async () => {
|
|
35
|
+
const initialPathname = window.location.pathname;
|
|
36
|
+
|
|
37
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
38
|
+
history.replace('/profile', { userId: 123 });
|
|
39
|
+
await updatePromise;
|
|
40
|
+
|
|
41
|
+
expect(history.location.pathname).toBe('/profile');
|
|
42
|
+
expect(history.action).toBe('REPLACE');
|
|
43
|
+
expect(history.location.state).toEqual({ userId: 123 });
|
|
44
|
+
// Pathname should remain unchanged
|
|
45
|
+
expect(window.location.pathname).toBe(initialPathname);
|
|
46
|
+
// Hash should contain the route
|
|
47
|
+
expect(window.location.hash).toBe('#/profile');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should resolve location from hash', async () => {
|
|
51
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
52
|
+
history.push('/test?query=1#section');
|
|
53
|
+
await updatePromise;
|
|
54
|
+
|
|
55
|
+
const location = history.location;
|
|
56
|
+
expect(location.pathname).toBe('/test');
|
|
57
|
+
expect(location.search).toBe('?query=1');
|
|
58
|
+
expect(location.hash).toBe('#section');
|
|
59
|
+
expect(window.location.hash).toBe('#/test?query=1#section');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle hashchange events for back/forward', async () => {
|
|
63
|
+
const initialPathname = window.location.pathname;
|
|
64
|
+
|
|
65
|
+
// Push multiple entries
|
|
66
|
+
history.push('/page1', { page: 1 });
|
|
67
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
68
|
+
expect(window.location.hash).toBe('#/page1');
|
|
69
|
+
|
|
70
|
+
history.push('/page2', { page: 2 });
|
|
71
|
+
await firstValueFrom(history.state$.pipe(skip(1)));
|
|
72
|
+
expect(window.location.hash).toBe('#/page2');
|
|
73
|
+
|
|
74
|
+
// Go back - should trigger hashchange event
|
|
75
|
+
const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
76
|
+
window.history.back();
|
|
77
|
+
await backPromise;
|
|
78
|
+
expect(history.location.pathname).toBe('/page1');
|
|
79
|
+
expect(history.action).toBe('POP');
|
|
80
|
+
expect(history.location.state).toEqual({ page: 1 });
|
|
81
|
+
expect(window.location.hash).toBe('#/page1');
|
|
82
|
+
expect(window.location.pathname).toBe(initialPathname);
|
|
83
|
+
|
|
84
|
+
// Go forward
|
|
85
|
+
const forwardPromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
86
|
+
window.history.forward();
|
|
87
|
+
await forwardPromise;
|
|
88
|
+
expect(history.location.pathname).toBe('/page2');
|
|
89
|
+
expect(history.action).toBe('POP');
|
|
90
|
+
expect(history.location.state).toEqual({ page: 2 });
|
|
91
|
+
expect(window.location.hash).toBe('#/page2');
|
|
92
|
+
expect(window.location.pathname).toBe(initialPathname);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should handle hash with search and hash fragments', async () => {
|
|
96
|
+
const initialPathname = window.location.pathname;
|
|
97
|
+
|
|
98
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
99
|
+
history.push('/users?filter=active#list', { filter: 'active' });
|
|
100
|
+
await updatePromise;
|
|
101
|
+
|
|
102
|
+
expect(history.location.pathname).toBe('/users');
|
|
103
|
+
expect(history.location.search).toBe('?filter=active');
|
|
104
|
+
expect(history.location.hash).toBe('#list');
|
|
105
|
+
// Hash should contain full path with search and hash
|
|
106
|
+
expect(window.location.hash).toBe('#/users?filter=active#list');
|
|
107
|
+
expect(window.location.pathname).toBe(initialPathname);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('state$ observable', () => {
|
|
112
|
+
it('should emit state updates on navigation', async () => {
|
|
113
|
+
const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
|
|
114
|
+
history.push('/test');
|
|
115
|
+
const update = await updatePromise;
|
|
116
|
+
|
|
117
|
+
expect(update.action).toBe('PUSH');
|
|
118
|
+
expect(update.location.pathname).toBe('/test');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|