@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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -0
  3. 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
+ }