@equinor/fusion-framework-plugin-context-navigation 0.1.0
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/LICENSE +21 -0
- package/README.md +293 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Equinor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Context Navigation Plugin (`@equinor/fusion-framework-plugin-context-navigation`)
|
|
2
|
+
|
|
3
|
+
Event-driven context-to-URL reconciler plugin for Fusion Framework portals. It keeps the active context synchronized with the browser URL by reacting to context changes and app switches, encoding context into the URL, and optionally guarding against manual URL edits that drop context.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this plugin when your **portal** needs to:
|
|
8
|
+
|
|
9
|
+
- Reflect the active context (project, facility, etc.) in the browser URL automatically
|
|
10
|
+
- Support multiple URL encoding strategies (path segment, query parameter, or app-defined custom encoding)
|
|
11
|
+
- Preserve context across app switches and page reloads
|
|
12
|
+
- Prevent users from accidentally losing context by editing the URL manually
|
|
13
|
+
|
|
14
|
+
> [!NOTE]
|
|
15
|
+
> This plugin is intended for **portal hosts**, not individual applications.
|
|
16
|
+
> Applications declare their preferred routing strategy via the app manifest's
|
|
17
|
+
> `build.options.contextRouting` field — the portal's context-navigation plugin
|
|
18
|
+
> picks up that declaration and applies the correct URL encoding automatically.
|
|
19
|
+
|
|
20
|
+
## For App Developers
|
|
21
|
+
|
|
22
|
+
You don't need to install or configure this plugin. The portal handles it.
|
|
23
|
+
Your only responsibility is to **declare how context should appear in your URL**.
|
|
24
|
+
|
|
25
|
+
### Declare a routing strategy
|
|
26
|
+
|
|
27
|
+
Set `contextRouting` in your app manifest's `build.options`:
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
// app.manifest.config.ts
|
|
31
|
+
export default defineAppManifest((env) => ({
|
|
32
|
+
// ...
|
|
33
|
+
build: {
|
|
34
|
+
options: {
|
|
35
|
+
contextRouting: 'query', // or 'path', or omit for default path behavior
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Value | URL Shape | When to Use |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `'path'` (or omitted) | `/apps/{appKey}/{contextId}/sub-route` | Default — simple apps without complex routing |
|
|
44
|
+
| `'query'` | `/apps/{appKey}/route?$contextId={id}` | Apps with path-based sub-routes that conflict with a context segment |
|
|
45
|
+
|
|
46
|
+
Apps with custom URL shapes should omit `contextRouting` and register custom hooks instead (see below).
|
|
47
|
+
|
|
48
|
+
> [!NOTE]
|
|
49
|
+
> When `contextRouting` is not set (or set to `null`), the portal defaults to the
|
|
50
|
+
> **path adapter** which encodes context as a path segment after the app key.
|
|
51
|
+
> If the app registers custom hooks (`setContextPathExtractor` /
|
|
52
|
+
> `setContextPathGenerator`), the custom adapter takes priority over the path
|
|
53
|
+
> adapter regardless of the `contextRouting` value.
|
|
54
|
+
|
|
55
|
+
### Custom URL shapes
|
|
56
|
+
|
|
57
|
+
If your app encodes context in a non-standard position (e.g. `/route-a/{contextId}`
|
|
58
|
+
instead of `/{contextId}/route-a`), register custom hooks in your app's context
|
|
59
|
+
configuration:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// app config
|
|
63
|
+
builder.setContextPathExtractor((pathname) => {
|
|
64
|
+
// Extract context id from your custom URL position
|
|
65
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
66
|
+
return segments[1]; // e.g. /route-a/{contextId} → contextId
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
builder.setContextPathGenerator((context, pathname) => {
|
|
70
|
+
// Generate a URL with context in your custom position
|
|
71
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
72
|
+
const route = segments[0] ?? '';
|
|
73
|
+
return `/${route}/${context.id}`;
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
When these hooks are registered, the plugin's custom adapter picks them up
|
|
78
|
+
automatically — no manifest `contextRouting` declaration needed.
|
|
79
|
+
|
|
80
|
+
## For Portal Developers
|
|
81
|
+
|
|
82
|
+
The rest of this README covers portal-level setup and configuration.
|
|
83
|
+
|
|
84
|
+
## Key Concepts
|
|
85
|
+
|
|
86
|
+
| Concept | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| **Adapter** | A self-selecting URL encoder/decoder. Each adapter declares `canHandle()` and provides `encode()` / `decode()` methods. |
|
|
89
|
+
| **Reconciler** | The reactive loop that watches context + app changes and triggers navigation when the URL is out of sync. |
|
|
90
|
+
| **URL Guard** | An optional secondary subscription that re-applies context encoding if an external navigation drops the context from the URL. |
|
|
91
|
+
| **Source** | The observable factory that drives the reconciler — determines whether app switches or context changes take priority. |
|
|
92
|
+
|
|
93
|
+
### How It Works
|
|
94
|
+
|
|
95
|
+
1. **Observe** — The reconciler watches the current app and current context via a configurable source factory.
|
|
96
|
+
2. **Resolve adapter** — For each app, the first adapter whose `canHandle()` returns `true` is selected.
|
|
97
|
+
3. **Encode** — The selected adapter encodes the context into a target URL.
|
|
98
|
+
4. **Compare** — If the target URL differs from the current URL, navigation proceeds.
|
|
99
|
+
5. **Dispatch** — A cancelable `onContextNavigationNavigate` event fires. Listeners can call `preventDefault()` to abort.
|
|
100
|
+
6. **Navigate** — The framework navigation module performs the URL update.
|
|
101
|
+
7. **Confirm** — An `onContextNavigationNavigated` event fires after navigation completes.
|
|
102
|
+
|
|
103
|
+
## Installation
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
pnpm add @equinor/fusion-framework-plugin-context-navigation
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Quick Start
|
|
110
|
+
|
|
111
|
+
### App-portal (app switches lead)
|
|
112
|
+
|
|
113
|
+
An app-portal shows one app at a time. When the user switches apps, the reconciler
|
|
114
|
+
picks up the new app's context and encodes it into the URL. This is the default behavior.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { enableContextNavigation } from '@equinor/fusion-framework-plugin-context-navigation';
|
|
118
|
+
|
|
119
|
+
export const configure = (configurator) => {
|
|
120
|
+
enableContextNavigation(configurator, (builder) => {
|
|
121
|
+
builder.setPortalName('app-portal');
|
|
122
|
+
builder.setDebug(true);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The default source factory (`createAppFirstSource`) watches `app.current$` and
|
|
128
|
+
resolves the app's context modules before emitting to the reconciler. When an app
|
|
129
|
+
switch happens, the new app's active context drives the URL update.
|
|
130
|
+
|
|
131
|
+
### Context-portal (context changes lead)
|
|
132
|
+
|
|
133
|
+
A context-portal is organized around a shared context (e.g. a project). When the
|
|
134
|
+
user selects a context, navigation updates the URL immediately — regardless of
|
|
135
|
+
which app is active. Clearing context navigates back to the portal root.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { enableContextNavigation } from '@equinor/fusion-framework-plugin-context-navigation';
|
|
139
|
+
import { createContextFirstSource } from '@equinor/fusion-framework-plugin-context-navigation/sources';
|
|
140
|
+
|
|
141
|
+
export const configure = (configurator) => {
|
|
142
|
+
enableContextNavigation(configurator, (builder) => {
|
|
143
|
+
builder.setPortalName('context-portal');
|
|
144
|
+
builder.setSourceFactory(createContextFirstSource());
|
|
145
|
+
builder.setNullContextUrl('/');
|
|
146
|
+
builder.setDebug(true);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Key differences from app-portal:
|
|
152
|
+
- **`createContextFirstSource()`** — context changes are the primary trigger; app modules are resolved as a dependency.
|
|
153
|
+
- **`setNullContextUrl('/')`** — when context is cleared, navigate to the portal landing page instead of delegating to the adapter.
|
|
154
|
+
|
|
155
|
+
## Built-in Adapters
|
|
156
|
+
|
|
157
|
+
The plugin ships with three adapters evaluated in priority order:
|
|
158
|
+
|
|
159
|
+
| Adapter | URL Shape | When Selected |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| **custom** | App-defined (via `generatePathFromContext` / `extractContextIdFromPath` hooks) | App provides `generatePathFromContext` and/or `extractContextIdFromPath` hooks on its context provider |
|
|
162
|
+
| **query** | `/apps/{appKey}/route?$contextId={id}` | App manifest declares `build.options.contextRouting: 'query'` |
|
|
163
|
+
| **path** | `/apps/{appKey}/{contextId}/sub-route` | Default fallback — matches when `contextRouting` is `'path'`, `null`, or omitted |
|
|
164
|
+
|
|
165
|
+
When no custom adapters are registered, all three built-in adapters are available. The first whose `canHandle()` returns `true` for the current app wins.
|
|
166
|
+
|
|
167
|
+
### Registering a Custom Adapter
|
|
168
|
+
|
|
169
|
+
You can register your own adapters for URL shapes not covered by the built-in set.
|
|
170
|
+
The `canHandle` predicate controls when your adapter is selected — use any signal
|
|
171
|
+
available in `AdapterResolutionContext` (app key, URL, context provider, routing strategy).
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import type { ContextNavigationAdapter } from '@equinor/fusion-framework-plugin-context-navigation/adapters';
|
|
175
|
+
|
|
176
|
+
const hashAdapter: ContextNavigationAdapter = {
|
|
177
|
+
id: 'hash',
|
|
178
|
+
// Select this adapter for a specific app that uses hash-based context
|
|
179
|
+
canHandle: ({ appKey }) => appKey === 'my-hash-app',
|
|
180
|
+
encode: ({ context, currentURL }) => {
|
|
181
|
+
const url = new URL(currentURL.href);
|
|
182
|
+
url.hash = context ? `#ctx=${context.id}` : '';
|
|
183
|
+
return url;
|
|
184
|
+
},
|
|
185
|
+
decode: (url) => {
|
|
186
|
+
const match = url.hash.match(/^#ctx=(.+)$/);
|
|
187
|
+
return match?.[1] ?? null;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
enableContextNavigation(configurator, (builder) => {
|
|
192
|
+
builder.registerAdapter(hashAdapter);
|
|
193
|
+
// NOTE: registering any adapter disables built-in defaults.
|
|
194
|
+
// Register all adapters you need explicitly.
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
| Builder Method | Default | Description |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `registerAdapter(adapter)` | Built-in set | Register a navigation adapter (object or factory). First match wins. |
|
|
203
|
+
| `setPortalName(name)` | `'Portal'` | Name used in debug log output. |
|
|
204
|
+
| `setOrigin(origin)` | `window.location.origin` | Origin for constructing absolute URLs. |
|
|
205
|
+
| `setUrlGuard(enabled)` | `true` | Re-sync context if an external navigation drops it from the URL. With `replace: false`, back/forward navigations update context from the URL instead of overwriting it. |
|
|
206
|
+
| `setDebug(enabled)` | `false` | Enable verbose `console.debug` output. |
|
|
207
|
+
| `setNullContextUrl(urlOrFn)` | — | Function (or static string) that returns the URL to navigate to when context is cleared. Receives `{ appKey, currentURL }`. |
|
|
208
|
+
| `setNavigationOptions(options)` | `{ replace: true }` | Options passed to `navigation.navigate()` during URL updates. Set `{ replace: false }` to push history entries — back/forward will then sync context from the URL rather than re-asserting the active context. |
|
|
209
|
+
| `setOnTransition(fn)` | — | Side-effect hook called after each successful navigation. |
|
|
210
|
+
| `setSourceFactory(factory)` | `createAppFirstSource()` | Observable source factory that drives the reconciler. |
|
|
211
|
+
|
|
212
|
+
## Events
|
|
213
|
+
|
|
214
|
+
The plugin dispatches events through the Fusion Framework event system. Subscribe via `framework.event.addEventListener()`.
|
|
215
|
+
|
|
216
|
+
| Event | When | Cancelable |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `onContextNavigationNavigate` | Before navigation — adapter resolved, target URL computed | **Yes** |
|
|
219
|
+
| `onContextNavigationNavigated` | After navigation completes | No |
|
|
220
|
+
| `onContextNavigationAdapterResolved` | When an adapter is selected for an app | No |
|
|
221
|
+
| `onContextNavigationSkipped` | When reconciliation decides NOT to navigate | No |
|
|
222
|
+
|
|
223
|
+
### Skip Reasons
|
|
224
|
+
|
|
225
|
+
The `onContextNavigationSkipped` event includes a `reason` field:
|
|
226
|
+
|
|
227
|
+
| Reason | Meaning |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `'url-matches'` | Target URL already matches the current URL |
|
|
230
|
+
| `'no-context'` | Context is `undefined` (still initializing) |
|
|
231
|
+
| `'no-adapter'` | No adapter could handle the current app |
|
|
232
|
+
| `'encode-returned-null'` | Adapter's `encode()` returned `null` |
|
|
233
|
+
| `'canceled'` | A listener called `preventDefault()` on the navigate event |
|
|
234
|
+
|
|
235
|
+
### Intercepting Navigation
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
framework.event.addEventListener('onContextNavigationNavigate', (event) => {
|
|
239
|
+
console.log('About to navigate:', event.detail.targetURL.pathname);
|
|
240
|
+
|
|
241
|
+
// Cancel navigation conditionally
|
|
242
|
+
if (shouldBlock(event.detail)) {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Adapter Interface
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
interface ContextNavigationAdapter {
|
|
252
|
+
/** Unique identifier for logging and diagnostics. */
|
|
253
|
+
id: string;
|
|
254
|
+
/** Return `true` if this adapter handles the given app/URL combination. */
|
|
255
|
+
canHandle(ctx: AdapterResolutionContext): boolean;
|
|
256
|
+
/** Encode context into a URL. Return `null` to skip navigation. */
|
|
257
|
+
encode(args: { context: ContextItem | null; currentURL: URL }): URL | null;
|
|
258
|
+
/** Decode a context ID from a URL. Return `null` if not present. */
|
|
259
|
+
decode(url: URL): string | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface AdapterResolutionContext {
|
|
263
|
+
appKey: string;
|
|
264
|
+
appContext: IContextProvider;
|
|
265
|
+
routingStrategy?: 'path' | 'query' | null;
|
|
266
|
+
currentURL: URL;
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Exports
|
|
271
|
+
|
|
272
|
+
| Specifier | Description |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `@equinor/fusion-framework-plugin-context-navigation` | Plugin runtime, configurator, enable helper, types, events |
|
|
275
|
+
| `@equinor/fusion-framework-plugin-context-navigation/adapters` | Built-in adapter factories (`createPathAdapter`, `createQueryAdapter`, `createCustomAdapter`) |
|
|
276
|
+
| `@equinor/fusion-framework-plugin-context-navigation/sources` | Source factories (`createAppFirstSource`, `createContextFirstSource`) and reconciler types |
|
|
277
|
+
| `@equinor/fusion-framework-plugin-context-navigation/utils` | URL utility functions |
|
|
278
|
+
|
|
279
|
+
## Peer Dependencies
|
|
280
|
+
|
|
281
|
+
| Package | Purpose |
|
|
282
|
+
|---|---|
|
|
283
|
+
| `@equinor/fusion-framework-module` | Base module contract |
|
|
284
|
+
| `@equinor/fusion-framework-module-app` | App switching and instance loading |
|
|
285
|
+
| `@equinor/fusion-framework-module-context` | Context state management |
|
|
286
|
+
| `@equinor/fusion-framework-module-navigation` | URL navigation |
|
|
287
|
+
| `@equinor/fusion-framework-module-event` | Event dispatch |
|
|
288
|
+
| `rxjs` | Reactive streams |
|
|
289
|
+
|
|
290
|
+
## See Also
|
|
291
|
+
|
|
292
|
+
- [`@equinor/fusion-framework-module-context`](../../modules/context/) — context module for query, validation, and resolution
|
|
293
|
+
- [`@equinor/fusion-framework-module-app`](../../modules/app/) — app module defining `FrameworkOptions.contextRouting` in build manifest
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@equinor/fusion-framework-plugin-context-navigation",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Adapter-based context-to-URL reconciler for Fusion Framework portals",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/esm/index.js",
|
|
11
|
+
"types": "./dist/types/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./adapters": {
|
|
14
|
+
"import": "./dist/esm/adapters/index.js",
|
|
15
|
+
"types": "./dist/types/adapters/index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./sources": {
|
|
18
|
+
"import": "./dist/esm/sources/index.js",
|
|
19
|
+
"types": "./dist/types/sources/index.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./utils": {
|
|
22
|
+
"import": "./dist/esm/utils/url/index.js",
|
|
23
|
+
"types": "./dist/types/utils/url/index.d.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"rxjs": "^7.8.1",
|
|
28
|
+
"@equinor/fusion-framework-module": "^6.1.2",
|
|
29
|
+
"@equinor/fusion-framework-module-app": "^8.0.4",
|
|
30
|
+
"@equinor/fusion-framework-module-context": "^8.0.2",
|
|
31
|
+
"@equinor/fusion-framework-module-event": "^6.0.1",
|
|
32
|
+
"@equinor/fusion-framework-module-navigation": "^7.0.7"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"rxjs": "^7.8.1",
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"vitest": "^4.1.10",
|
|
38
|
+
"@equinor/fusion-framework-module": "^6.1.2",
|
|
39
|
+
"@equinor/fusion-framework-module-app": "^8.0.4",
|
|
40
|
+
"@equinor/fusion-framework-module-context": "^8.0.2",
|
|
41
|
+
"@equinor/fusion-framework-module-event": "^6.0.1",
|
|
42
|
+
"@equinor/fusion-framework-module-navigation": "^7.0.7"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -b",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
}
|
|
54
|
+
}
|