@netsapiens/horizon-sdk 0.1.5 → 0.1.7
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/README.md +292 -294
- package/dist/index.cjs +31 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -111
- package/dist/index.d.ts +43 -111
- package/dist/index.js +31 -57
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -19,9 +19,169 @@ This package is for **third-party developers**. It provides:
|
|
|
19
19
|
npm install @netsapiens/horizon-sdk
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Peer deps: `react ^
|
|
22
|
+
Peer deps: `react ^19`, `react-dom ^19`, `loglevel ^1.9`.
|
|
23
23
|
|
|
24
|
-
> Do **not** add `@mui/material`, `@emotion/*`, or `@mui/x-data-grid-pro` to your `package.json` or your webpack `shared` config. The host's federation loader does not expose them as shared modules — declaring them as singletons causes a version-mismatch crash at load time. Use all MUI components via `horizonContext.ui` instead; that is how
|
|
24
|
+
> Do **not** add `@mui/material`, `@emotion/*`, or `@mui/x-data-grid-pro` to your `package.json` or your webpack `shared` config. The host's federation loader does not expose them as shared modules — declaring them as singletons causes a version-mismatch crash at load time. Use all MUI components via `horizonContext.ui` instead; that is how theme and dark mode reach your app.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## What's in `horizonContext`
|
|
29
|
+
|
|
30
|
+
Your `App` component receives this object as props; page components read it with `useHorizonContext()`.
|
|
31
|
+
|
|
32
|
+
| Field | Notes |
|
|
33
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| `user` | `displayName`, `domain`, `email?`, `extension?`, `scope?`, `department?`, `site?` (+ extras) |
|
|
35
|
+
| `auth` | `isAuthenticated()`, plus the remote-auth methods (see [Remote authentication](#remote-authentication)) |
|
|
36
|
+
| `api` | Authenticated NetSapiens v2 client (`get`/`post`/`put`/`delete`/`getBaseUrl`) |
|
|
37
|
+
| `theme` | `'light'` \| `'dark'` (reactive) |
|
|
38
|
+
| `locale` | e.g. `'en-US'` (reactive) |
|
|
39
|
+
| `t(key, opts?)` | Host i18next translation function — all host strings are available, no setup needed |
|
|
40
|
+
| `navigate(path)` | Pushes a route in the host router |
|
|
41
|
+
| `eventBus` | `emit`/`on`/`off` for cross-app messages |
|
|
42
|
+
| `ui` | Pre-themed components and templates (see below) |
|
|
43
|
+
| `breadcrumbs` | Current breadcrumb trail (optional) |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Using `horizonContext.ui`
|
|
48
|
+
|
|
49
|
+
Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's MUI theme, including automatic dark/light mode switching.
|
|
50
|
+
|
|
51
|
+
> **Styling is handled by the SDK UI components.** They inherit the host theme (dark mode included) and sensible layout defaults, so you rarely set styling yourself.
|
|
52
|
+
|
|
53
|
+
> **Renders are isolated.** Each extension and side panel you render is wrapped in an error boundary by the host — a render error in your component shows a fallback instead of crashing the host page.
|
|
54
|
+
|
|
55
|
+
Page components read the context with `useHorizonContext()`; extension components receive it as a `context` prop:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// Page component — read the context with the hook
|
|
59
|
+
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
60
|
+
|
|
61
|
+
export default function MyPage() {
|
|
62
|
+
const { ui, user } = useHorizonContext();
|
|
63
|
+
const { PageTemplate, DatagridTemplate } = ui.templates;
|
|
64
|
+
const { Button, Stack, Typography } = ui;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<PageTemplate
|
|
68
|
+
title="My Page"
|
|
69
|
+
breadcrumbs={[{ label: "Apps", url: "/apps" }]}
|
|
70
|
+
>
|
|
71
|
+
<Stack spacing={2}>
|
|
72
|
+
<Typography variant="h5">Hello, {user.displayName}</Typography>
|
|
73
|
+
<Button variant="contained">Action</Button>
|
|
74
|
+
</Stack>
|
|
75
|
+
</PageTemplate>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// Extension component — the host passes `context`
|
|
82
|
+
function MyButton({ context }: ExtensionComponentProps) {
|
|
83
|
+
const { Button } = context.ui ?? {};
|
|
84
|
+
return <Button variant="contained">Click</Button>;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
|
|
89
|
+
|
|
90
|
+
- `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SidePanel`, `FormPanel`, `DatagridTemplate`
|
|
91
|
+
|
|
92
|
+
> **`DatagridTemplate` and the MUI X Pro license** — `DatagridTemplate` wraps MUI's `DataGridPro` internally, which is a paid licensed component. The license is held by the Horizon host; **you do not need an MUI X Pro license** and must not import `@mui/x-data-grid-pro` directly in your remote app. Always use `DatagridTemplate` from the SDK.
|
|
93
|
+
|
|
94
|
+
- Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`, `FormControlLabel`
|
|
95
|
+
- Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
|
|
96
|
+
- Layout: `Stack`, `Paper`
|
|
97
|
+
- Feedback: `Alert`
|
|
98
|
+
- `theme` (`'light' | 'dark'`, reactive via `useHorizonContext()`) and `styles` for derived tokens
|
|
99
|
+
|
|
100
|
+
#### IconButton — shorthand-only API
|
|
101
|
+
|
|
102
|
+
`IconButton` does **not** accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
// ✅ Correct — pass the Iconify name via the `icon` prop
|
|
106
|
+
<IconButton icon="mdi:account" aria-label="Account" />
|
|
107
|
+
<IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />
|
|
108
|
+
|
|
109
|
+
// ❌ Wrong — children are dropped, the button renders empty at 0×0
|
|
110
|
+
<IconButton aria-label="Account">
|
|
111
|
+
<Icon icon="mdi:account" />
|
|
112
|
+
</IconButton>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The TypeScript types reject the children pattern at compile time. If you ever see a 0×0 button in the DOM, this is the first thing to check.
|
|
116
|
+
|
|
117
|
+
Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
|
|
118
|
+
|
|
119
|
+
#### SidePanel & FormPanel — right-side drawers
|
|
120
|
+
|
|
121
|
+
`SidePanel` and `FormPanel` are the shared right-drawer components Horizon uses for its own add/edit forms and detail panels. Reach for these when building a right-side drawer so it matches the host exactly and picks up the `form-section-*` extension zones automatically.
|
|
122
|
+
|
|
123
|
+
**`SidePanel`** — the drawer shell (sticky header, scrollable body, optional sticky footer). Use for detail/view/settings panels:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
const { SidePanel } = ui.templates;
|
|
127
|
+
|
|
128
|
+
<SidePanel
|
|
129
|
+
open={open}
|
|
130
|
+
onClose={() => setOpen(false)}
|
|
131
|
+
title="Details"
|
|
132
|
+
subtitle="Read-only"
|
|
133
|
+
width="lg" // 'sm' | 'md' | 'lg' | 'xl'
|
|
134
|
+
footer={<Button onClick={() => setOpen(false)}>Close</Button>}
|
|
135
|
+
>
|
|
136
|
+
{/* body */}
|
|
137
|
+
</SidePanel>;
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**`FormPanel`** — `SidePanel` + a `<form>`, a Submit/Cancel footer, and the `form-section-before` / `form-section-after` extension zones built in:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
const { FormPanel } = ui.templates;
|
|
144
|
+
|
|
145
|
+
<FormPanel
|
|
146
|
+
open={open}
|
|
147
|
+
onClose={() => setOpen(false)}
|
|
148
|
+
title="Add widget"
|
|
149
|
+
formType="widget" // identifies the form to extensions
|
|
150
|
+
mode="add" // 'add' | 'edit'
|
|
151
|
+
formData={values} // live values handed to extensions
|
|
152
|
+
onSubmit={(e) => {
|
|
153
|
+
e?.preventDefault();
|
|
154
|
+
save(values);
|
|
155
|
+
}}
|
|
156
|
+
submitLabel="Add"
|
|
157
|
+
isSubmitting={pending}
|
|
158
|
+
error={error}
|
|
159
|
+
>
|
|
160
|
+
{/* field sections */}
|
|
161
|
+
</FormPanel>;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Multi-step wizard** — pass `steps` instead of `children`; FormPanel renders the stepper header and a Back/Next/Submit footer. Each step's optional `validate` gates forward navigation (return `boolean | Promise<boolean>`):
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
<FormPanel
|
|
168
|
+
open={open}
|
|
169
|
+
onClose={() => setOpen(false)}
|
|
170
|
+
title="Setup"
|
|
171
|
+
formType="setup"
|
|
172
|
+
mode="add"
|
|
173
|
+
onSubmit={handleSubmit(onValid)}
|
|
174
|
+
isComplete={isValid} // offer Submit before the last step
|
|
175
|
+
steps={[
|
|
176
|
+
{
|
|
177
|
+
label: "Basics",
|
|
178
|
+
content: <BasicsStep />,
|
|
179
|
+
validate: () => trigger(["name"]),
|
|
180
|
+
},
|
|
181
|
+
{ label: "Options", content: <OptionsStep /> },
|
|
182
|
+
]}
|
|
183
|
+
/>
|
|
184
|
+
```
|
|
25
185
|
|
|
26
186
|
---
|
|
27
187
|
|
|
@@ -34,7 +194,9 @@ import { type HorizonContext, useRemoteApp } from "@netsapiens/horizon-sdk";
|
|
|
34
194
|
import MyButton from "./extensions/MyButton";
|
|
35
195
|
|
|
36
196
|
export default function App(horizonContext: HorizonContext) {
|
|
37
|
-
|
|
197
|
+
// "myApp" === the name in your ModuleFederationPlugin config (webpack_module).
|
|
198
|
+
// The SDK derives the kebab app id ("my-app") used for registry attribution.
|
|
199
|
+
const { sdk, user, theme } = useRemoteApp(horizonContext, "myApp");
|
|
38
200
|
|
|
39
201
|
useEffect(() => {
|
|
40
202
|
sdk.registerDynamicExtension({
|
|
@@ -75,21 +237,6 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
75
237
|
|
|
76
238
|
---
|
|
77
239
|
|
|
78
|
-
## What's in `horizonContext`
|
|
79
|
-
|
|
80
|
-
| Field | Notes |
|
|
81
|
-
| ---------------- | ----------------------------------------------------------------------------- |
|
|
82
|
-
| `user` | `displayName`, `domain`, `email?`, plus host-defined extras |
|
|
83
|
-
| `auth` | `isAuthenticated()` |
|
|
84
|
-
| `api` | Authenticated NetSapiens v2 client (`get`/`post`/`put`/`delete`/`getBaseUrl`) |
|
|
85
|
-
| `theme` | `'light'` \| `'dark'` |
|
|
86
|
-
| `locale` | e.g. `'en-US'` |
|
|
87
|
-
| `navigate(path)` | Pushes a route in the host router |
|
|
88
|
-
| `eventBus` | `emit`/`on`/`off` for cross-app messages |
|
|
89
|
-
| `ui` | Pre-themed components and templates (see below) |
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
240
|
## Registration APIs
|
|
94
241
|
|
|
95
242
|
### Routes — full pages added to Horizon
|
|
@@ -119,7 +266,7 @@ const MySettingsRoute = useMemo(
|
|
|
119
266
|
|
|
120
267
|
sdk.registerRoute({
|
|
121
268
|
id: "my-app.settings",
|
|
122
|
-
parentPath: "/home", // attach under
|
|
269
|
+
parentPath: "/home", // attach under the My Account menu
|
|
123
270
|
path: "my-settings", // → /home/my-settings
|
|
124
271
|
label: "My Settings",
|
|
125
272
|
icon: "mdi:cog",
|
|
@@ -128,39 +275,14 @@ sdk.registerRoute({
|
|
|
128
275
|
});
|
|
129
276
|
```
|
|
130
277
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Use `placement` to control where your route appears in the menu. You can position relative to existing menu items using semantic anchors:
|
|
134
|
-
|
|
135
|
-
```tsx
|
|
136
|
-
sdk.registerRoute({
|
|
137
|
-
id: "my-app.settings",
|
|
138
|
-
parentPath: "/home",
|
|
139
|
-
path: "my-settings",
|
|
140
|
-
label: "My Settings",
|
|
141
|
-
icon: "mdi:cog",
|
|
142
|
-
placement: { after: "settings" }, // After the Settings item
|
|
143
|
-
component: MySettingsRoute,
|
|
144
|
-
});
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**Placement options:**
|
|
148
|
-
|
|
149
|
-
- `{ after: 'anchor-id' }` — Place immediately after the specified item
|
|
150
|
-
- `{ before: 'anchor-id' }` — Place immediately before the specified item
|
|
151
|
-
- `{ first: true }` — Force to the start of the menu
|
|
152
|
-
- `{ last: true }` — Force to the end of the menu (default if no placement specified)
|
|
153
|
-
|
|
154
|
-
The system uses fuzzy matching with a 0.8 similarity threshold, so minor typos (e.g., `contats` instead of `contacts`) will still work. If the anchor isn't found, your item is placed at the end of the menu with a console warning.
|
|
155
|
-
|
|
156
|
-
Inside the page component, read context with `useHorizonContext()` instead of accepting it as props:
|
|
278
|
+
Inside the page component, read context with `useHorizonContext()`:
|
|
157
279
|
|
|
158
280
|
```tsx
|
|
159
281
|
// src/pages/MySettingsPage.tsx
|
|
160
282
|
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
161
283
|
|
|
162
284
|
export default function MySettingsPage() {
|
|
163
|
-
const { ui, user, navigate
|
|
285
|
+
const { ui, user, navigate } = useHorizonContext();
|
|
164
286
|
const { PageTemplate } = ui.templates;
|
|
165
287
|
const { Button, Stack, Typography } = ui;
|
|
166
288
|
|
|
@@ -183,25 +305,36 @@ export default function MySettingsPage() {
|
|
|
183
305
|
The `useRoute` hook handles register + unregister automatically:
|
|
184
306
|
|
|
185
307
|
```tsx
|
|
186
|
-
useRoute(horizonContext.eventBus, '
|
|
308
|
+
useRoute(horizonContext.eventBus, 'myApp', { id: '...', /* ... */, component: MySettingsRoute });
|
|
187
309
|
```
|
|
188
310
|
|
|
189
|
-
If your route component
|
|
311
|
+
If your route's page component is **exposed as a separate Module Federation module** in your own bundle (rather than imported into your entry `App`), use `useRouteFromModule` — it loads that exposed module from your container and registers it as a route. (This is for splitting up your _own_ app's code; it does not pull a component from another app.)
|
|
190
312
|
|
|
191
313
|
```tsx
|
|
192
314
|
const { loading, error } = useRouteFromModule(
|
|
193
315
|
horizonContext.eventBus,
|
|
194
|
-
"
|
|
316
|
+
"myApp",
|
|
195
317
|
{
|
|
196
318
|
id: "my-app.settings",
|
|
197
319
|
parentPath: "/home",
|
|
198
320
|
path: "my-settings",
|
|
199
321
|
label: "My Settings",
|
|
200
322
|
},
|
|
201
|
-
{ scope: "myApp", module: "./SettingsPage" },
|
|
323
|
+
{ scope: "myApp", module: "./SettingsPage" }, // an exposed module in YOUR webpack config
|
|
202
324
|
);
|
|
203
325
|
```
|
|
204
326
|
|
|
327
|
+
#### Menu placement
|
|
328
|
+
|
|
329
|
+
Use `placement` to control where your route appears in the menu, relative to existing menu items:
|
|
330
|
+
|
|
331
|
+
- `{ after: 'anchor-id' }` — Place immediately after the specified item
|
|
332
|
+
- `{ before: 'anchor-id' }` — Place immediately before the specified item
|
|
333
|
+
- `{ first: true }` — Force to the start of the menu
|
|
334
|
+
- `{ last: true }` — Force to the end of the menu (default if no placement specified)
|
|
335
|
+
|
|
336
|
+
The anchor is the human-readable id/label of an existing item (e.g. `'contacts'`, `'devices'`, `'settings'`). The host resolves it with **fuzzy matching**, so minor differences and typos (e.g. `contats`) still match. If no anchor matches, your item is placed at the end of the menu with a console warning.
|
|
337
|
+
|
|
205
338
|
### Dynamic extensions — inject UI into host pages
|
|
206
339
|
|
|
207
340
|
Extensions render at named **zones** on routes that match a **pattern**. You don't need the host page to opt in.
|
|
@@ -218,23 +351,22 @@ sdk.registerDynamicExtension({
|
|
|
218
351
|
});
|
|
219
352
|
```
|
|
220
353
|
|
|
221
|
-
**Available zones
|
|
354
|
+
**Available zones** (the zones the host mounts today):
|
|
222
355
|
|
|
223
|
-
| Zone | Where it renders
|
|
224
|
-
| ----------------------- |
|
|
225
|
-
| `page-header-actions` | Right side of any page header
|
|
226
|
-
| `page-header-secondary` | Subtitle row of page header (badges, status) |
|
|
227
|
-
| `page-content-after` | Below main page content |
|
|
228
|
-
| `
|
|
229
|
-
| `table-toolbar` |
|
|
230
|
-
| `table-filter-bar` | Filter chips inline with search bar; pass a row predicate via `onFilterChange` |
|
|
231
|
-
| `table-row-actions` | Per-row action column
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `inbound-call-content` | Incoming-call notification body |
|
|
356
|
+
| Zone | Where it renders |
|
|
357
|
+
| ----------------------- | ---------------------------------------------------------------------------------- |
|
|
358
|
+
| `page-header-actions` | Right side of any page header |
|
|
359
|
+
| `page-header-secondary` | Subtitle row of the page header (badges, status) |
|
|
360
|
+
| `page-content-after` | Below the main page content |
|
|
361
|
+
| `topbar-actions` | Global top app bar (after comms buttons, before utilities) |
|
|
362
|
+
| `table-toolbar` | Toolbar above any DataGrid |
|
|
363
|
+
| `table-filter-bar` | Filter chips inline with the search bar; pass a row predicate via `onFilterChange` |
|
|
364
|
+
| `table-row-actions` | Per-row action column |
|
|
365
|
+
| `form-section-before` | Above a form's field sections (`SidePanel`/`FormPanel`) |
|
|
366
|
+
| `form-section-after` | Below a form's fields, before the action buttons |
|
|
367
|
+
| `inbound-call-content` | Incoming-call notification body |
|
|
236
368
|
|
|
237
|
-
> **Note**: Check Platform → UI SDK Management → SDK Settings for the
|
|
369
|
+
> **Note**: Check Platform → UI SDK Management → SDK Settings for the live list of zones (a platform admin can disable individual zones).
|
|
238
370
|
|
|
239
371
|
Custom zones (any string) work for pages that explicitly render `<DynamicExtensionRenderer zone="my-zone" />`.
|
|
240
372
|
|
|
@@ -250,7 +382,7 @@ Custom zones (any string) work for pages that explicitly render `<DynamicExtensi
|
|
|
250
382
|
Hook form:
|
|
251
383
|
|
|
252
384
|
```tsx
|
|
253
|
-
useDynamicExtension(horizonContext.eventBus, "
|
|
385
|
+
useDynamicExtension(horizonContext.eventBus, "myApp", {
|
|
254
386
|
id: "my-app.banner",
|
|
255
387
|
zone: "page-content-after",
|
|
256
388
|
routes: [{ pattern: "/manage/:domain/users" }],
|
|
@@ -260,11 +392,7 @@ useDynamicExtension(horizonContext.eventBus, "my-app", {
|
|
|
260
392
|
|
|
261
393
|
#### table-filter-bar zone
|
|
262
394
|
|
|
263
|
-
Pages that render a filter bar
|
|
264
|
-
`TableFilterBarContext` via `context.pageContext`. Extensions in this zone render
|
|
265
|
-
alongside the host's built-in filter chips and can drive row-level filtering by
|
|
266
|
-
passing a **predicate function** to `onFilterChange`. The host applies the function
|
|
267
|
-
generically — your extension owns the filtering logic, not the host.
|
|
395
|
+
Pages that render a filter bar expose a `TableFilterBarContext` via `context.pageContext`. Extensions in this zone render alongside the host's built-in filter chips and can drive row-level filtering by passing a **predicate function** to `onFilterChange`. The host applies the function generically — your extension owns the filtering logic, not the host.
|
|
268
396
|
|
|
269
397
|
```tsx
|
|
270
398
|
import { useState } from "react";
|
|
@@ -282,10 +410,9 @@ export function VipFilter({ context }: ExtensionComponentProps) {
|
|
|
282
410
|
const next = value === "vip";
|
|
283
411
|
setActive(next);
|
|
284
412
|
if (next) {
|
|
285
|
-
filterCtx?.onFilterChange(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
});
|
|
413
|
+
filterCtx?.onFilterChange(
|
|
414
|
+
(row) => (row as Record<string, unknown>)["customer-tier"] === "vip",
|
|
415
|
+
);
|
|
289
416
|
} else {
|
|
290
417
|
filterCtx?.onFilterChange(null); // clear — show all rows
|
|
291
418
|
}
|
|
@@ -302,14 +429,6 @@ export function VipFilter({ context }: ExtensionComponentProps) {
|
|
|
302
429
|
/>
|
|
303
430
|
);
|
|
304
431
|
}
|
|
305
|
-
|
|
306
|
-
// Register against any page that exposes a filter bar
|
|
307
|
-
sdk.registerDynamicExtension({
|
|
308
|
-
id: "my-app.vip-filter",
|
|
309
|
-
zone: "table-filter-bar",
|
|
310
|
-
routes: [{ pattern: "/manage/:domain/call-logs" }],
|
|
311
|
-
component: VipFilter,
|
|
312
|
-
});
|
|
313
432
|
```
|
|
314
433
|
|
|
315
434
|
`TableFilterBarContext` fields:
|
|
@@ -318,21 +437,16 @@ sdk.registerDynamicExtension({
|
|
|
318
437
|
| ---------------- | --------------------------------------------------------- | ----------------------------------------------------- |
|
|
319
438
|
| `onFilterChange` | `(filterFn: ((row: unknown) => boolean) \| null) => void` | Pass a predicate to filter rows; pass `null` to clear |
|
|
320
439
|
|
|
321
|
-
**Important:** track active/inactive state locally with `useState`
|
|
322
|
-
`TableFilterBarContext` does not expose the current filter state back to you.
|
|
440
|
+
**Important:** track active/inactive state locally with `useState` — `TableFilterBarContext` does not expose the current filter state back to you.
|
|
323
441
|
|
|
324
442
|
> **React `useState` gotcha** — never pass a filter function directly to `setState`:
|
|
325
443
|
>
|
|
326
444
|
> ```ts
|
|
327
|
-
> // ❌ React treats functions as lazy initializers, calling fn(
|
|
328
|
-
> setMyFilter(filterFn);
|
|
329
|
-
>
|
|
330
|
-
> // ✅ Wrap in an arrow function so React stores the function, not its return value
|
|
331
|
-
> setMyFilter(() => filterFn);
|
|
445
|
+
> setMyFilter(filterFn); // ❌ React treats functions as lazy initializers, calling fn(prev)
|
|
446
|
+
> setMyFilter(() => filterFn); // ✅ Wrap in an arrow so React stores the function itself
|
|
332
447
|
> ```
|
|
333
448
|
>
|
|
334
|
-
> This only affects you if you
|
|
335
|
-
> The host DataTable handles this correctly internally.
|
|
449
|
+
> This only affects you if you store the filter function in your own component state. The host DataTable handles this correctly internally.
|
|
336
450
|
|
|
337
451
|
### Dynamic table columns
|
|
338
452
|
|
|
@@ -357,149 +471,7 @@ sdk.registerDynamicColumn({
|
|
|
357
471
|
|
|
358
472
|
Hook: `useDynamicColumn(eventBus, appId, config)`.
|
|
359
473
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
## Using `horizonContext.ui`
|
|
363
|
-
|
|
364
|
-
Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's Aurora MUI theme, including automatic dark/light mode switching.
|
|
365
|
-
|
|
366
|
-
The recommended pattern uses `useHorizonContext()` (see Routes above). The old pattern of accepting `horizonContext` as props still works for extension components:
|
|
367
|
-
|
|
368
|
-
```tsx
|
|
369
|
-
// Recommended — page component using the hook (fully reactive theme)
|
|
370
|
-
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
371
|
-
|
|
372
|
-
export default function MyPage() {
|
|
373
|
-
const { ui, user } = useHorizonContext();
|
|
374
|
-
const { PageTemplate, DatagridTemplate } = ui.templates;
|
|
375
|
-
const { Button, Stack, Typography } = ui;
|
|
376
|
-
|
|
377
|
-
return (
|
|
378
|
-
<PageTemplate
|
|
379
|
-
title="My Page"
|
|
380
|
-
breadcrumbs={[{ label: "Apps", url: "/apps" }]}
|
|
381
|
-
>
|
|
382
|
-
<Stack spacing={2}>
|
|
383
|
-
<Typography variant="h5">Hello, {user.displayName}</Typography>
|
|
384
|
-
<Button variant="contained">Action</Button>
|
|
385
|
-
</Stack>
|
|
386
|
-
</PageTemplate>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
```tsx
|
|
392
|
-
// Legacy — still works for extension components (not full pages)
|
|
393
|
-
function MyButton({ context }: ExtensionComponentProps) {
|
|
394
|
-
const { Button } = context.ui ?? {};
|
|
395
|
-
return <Button variant="contained">Click</Button>;
|
|
396
|
-
}
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
|
|
400
|
-
|
|
401
|
-
- `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SidePanel`, `FormPanel`, `DatagridTemplate`
|
|
402
|
-
|
|
403
|
-
> **`DatagridTemplate` and the MUI X Pro license** — `DatagridTemplate` wraps MUI's `DataGridPro` internally, which is a paid licensed component. The license is held by the Horizon host; **you do not need an MUI X Pro license** and must not import `@mui/x-data-grid-pro` directly in your remote app. Always use `DatagridTemplate` from the SDK.
|
|
404
|
-
|
|
405
|
-
- Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`, `FormControlLabel`
|
|
406
|
-
- Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
|
|
407
|
-
- Layout: `Stack`, `Paper`
|
|
408
|
-
- Feedback: `Alert`
|
|
409
|
-
- `theme` (`'light' | 'dark'`, reactive via `useHorizonContext()`) and `styles` for derived tokens
|
|
410
|
-
|
|
411
|
-
#### IconButton — shorthand-only API
|
|
412
|
-
|
|
413
|
-
`IconButton` does **not** accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:
|
|
414
|
-
|
|
415
|
-
```tsx
|
|
416
|
-
// ✅ Correct — pass the Iconify name via the `icon` prop
|
|
417
|
-
<IconButton icon="mdi:account" aria-label="Account" />
|
|
418
|
-
<IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />
|
|
419
|
-
|
|
420
|
-
// ❌ Wrong — children are dropped, the button renders empty at 0×0
|
|
421
|
-
<IconButton aria-label="Account">
|
|
422
|
-
<Icon icon="mdi:account" />
|
|
423
|
-
</IconButton>
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
The TypeScript types reject the children pattern at compile time. If you ever see a 0×0 button in the DOM, this is the first thing to check.
|
|
427
|
-
|
|
428
|
-
Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
|
|
429
|
-
|
|
430
|
-
#### SidePanel & FormPanel — right-side drawers
|
|
431
|
-
|
|
432
|
-
`SidePanel` and `FormPanel` are the shared right-drawer components Horizon uses
|
|
433
|
-
for its own add/edit forms and detail panels. Reach for these when building a
|
|
434
|
-
right-side drawer so it matches the host exactly and picks up the
|
|
435
|
-
`form-section-*` extension zones automatically.
|
|
436
|
-
|
|
437
|
-
**`SidePanel`** — the drawer shell (sticky header, scrollable body, optional
|
|
438
|
-
sticky footer). Use for detail/view/settings panels:
|
|
439
|
-
|
|
440
|
-
```tsx
|
|
441
|
-
const { SidePanel } = ui.templates;
|
|
442
|
-
|
|
443
|
-
<SidePanel
|
|
444
|
-
open={open}
|
|
445
|
-
onClose={() => setOpen(false)}
|
|
446
|
-
title="Details"
|
|
447
|
-
subtitle="Read-only"
|
|
448
|
-
width="lg" // 'sm' | 'md' | 'lg' | 'xl'
|
|
449
|
-
footer={<Button onClick={() => setOpen(false)}>Close</Button>}
|
|
450
|
-
>
|
|
451
|
-
{/* body */}
|
|
452
|
-
</SidePanel>;
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
**`FormPanel`** — `SidePanel` + a `<form>`, a Submit/Cancel footer, and the
|
|
456
|
-
`form-section-before` / `form-section-after` extension zones built in:
|
|
457
|
-
|
|
458
|
-
```tsx
|
|
459
|
-
const { FormPanel } = ui.templates;
|
|
460
|
-
|
|
461
|
-
<FormPanel
|
|
462
|
-
open={open}
|
|
463
|
-
onClose={() => setOpen(false)}
|
|
464
|
-
title="Add widget"
|
|
465
|
-
formType="widget" // identifies the form to extensions
|
|
466
|
-
mode="add" // 'add' | 'edit'
|
|
467
|
-
formData={values} // live values handed to extensions
|
|
468
|
-
onSubmit={(e) => {
|
|
469
|
-
e?.preventDefault();
|
|
470
|
-
save(values);
|
|
471
|
-
}}
|
|
472
|
-
submitLabel="Add"
|
|
473
|
-
isSubmitting={pending}
|
|
474
|
-
error={error}
|
|
475
|
-
>
|
|
476
|
-
{/* field sections */}
|
|
477
|
-
</FormPanel>;
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
**Multi-step wizard** — pass `steps` instead of `children`; FormPanel renders the
|
|
481
|
-
stepper header and a Back/Next/Submit footer. Each step's optional `validate`
|
|
482
|
-
gates forward navigation (return `boolean | Promise<boolean>`):
|
|
483
|
-
|
|
484
|
-
```tsx
|
|
485
|
-
<FormPanel
|
|
486
|
-
open={open}
|
|
487
|
-
onClose={() => setOpen(false)}
|
|
488
|
-
title="Setup"
|
|
489
|
-
formType="setup"
|
|
490
|
-
mode="add"
|
|
491
|
-
onSubmit={handleSubmit(onValid)}
|
|
492
|
-
isComplete={isValid} // offer Submit before the last step
|
|
493
|
-
steps={[
|
|
494
|
-
{
|
|
495
|
-
label: "Basics",
|
|
496
|
-
content: <BasicsStep />,
|
|
497
|
-
validate: () => trigger(["name"]),
|
|
498
|
-
},
|
|
499
|
-
{ label: "Options", content: <OptionsStep /> },
|
|
500
|
-
]}
|
|
501
|
-
/>
|
|
502
|
-
```
|
|
474
|
+
Registered columns **right-align by default** (header and cell) to match Horizon's native data-table columns — you don't need to set `align`. Override per column with `align`/`headerAlign: 'left' | 'center'`.
|
|
503
475
|
|
|
504
476
|
---
|
|
505
477
|
|
|
@@ -507,7 +479,7 @@ gates forward navigation (return `boolean | Promise<boolean>`):
|
|
|
507
479
|
|
|
508
480
|
Dark mode is handled automatically — **you write no theme-switching code for MUI components**.
|
|
509
481
|
|
|
510
|
-
MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's
|
|
482
|
+
MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's ThemeProvider via the shared module singleton and switch the moment the user toggles the theme. For any conditional logic that reads the theme string (custom colors, conditional icons, etc.), use `useTheme()`:
|
|
511
483
|
|
|
512
484
|
```tsx
|
|
513
485
|
import { useTheme } from "@netsapiens/horizon-sdk";
|
|
@@ -522,19 +494,10 @@ import { useTheme } from "@netsapiens/horizon-sdk";
|
|
|
522
494
|
|
|
523
495
|
```tsx
|
|
524
496
|
// Page component
|
|
525
|
-
|
|
526
|
-
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
|
|
527
|
-
return (
|
|
528
|
-
<div style={{ background: theme === "dark" ? "#1B2124" : "#fff" }}>...</div>
|
|
529
|
-
);
|
|
530
|
-
}
|
|
497
|
+
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
|
|
531
498
|
|
|
532
499
|
// Extension component
|
|
533
|
-
|
|
534
|
-
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
|
|
535
|
-
const { Button } = context.ui ?? {};
|
|
536
|
-
return <Button>{theme === "dark" ? "🌙" : "☀️"} Export</Button>;
|
|
537
|
-
}
|
|
500
|
+
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
|
|
538
501
|
```
|
|
539
502
|
|
|
540
503
|
> Do not subscribe to `theme:changed` on the event bus manually. `useTheme()` handles the subscription and cleanup internally.
|
|
@@ -546,9 +509,9 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
546
509
|
```tsx
|
|
547
510
|
const { api, user } = horizonContext;
|
|
548
511
|
const devices = await api.get(
|
|
549
|
-
`/domains/${user.domain}/users/${user.
|
|
512
|
+
`/domains/${user.domain}/users/${user.extension}/devices`,
|
|
550
513
|
);
|
|
551
|
-
await api.post(`/domains/${user.domain}/users/${user.
|
|
514
|
+
await api.post(`/domains/${user.domain}/users/${user.extension}/contacts`, {
|
|
552
515
|
"name-first-name": "John",
|
|
553
516
|
});
|
|
554
517
|
```
|
|
@@ -557,21 +520,54 @@ All calls run through Horizon's audited proxy — credentials never reach the re
|
|
|
557
520
|
|
|
558
521
|
---
|
|
559
522
|
|
|
523
|
+
## Remote authentication
|
|
524
|
+
|
|
525
|
+
When your app needs to call **your own backend** (or a third-party vendor) on behalf of the signed-in user, have the host broker a trusted identity handshake. This lets your server verify that a request genuinely represents the current Horizon user — without your app ever handling Horizon credentials.
|
|
526
|
+
|
|
527
|
+
```tsx
|
|
528
|
+
const { auth } = horizonContext;
|
|
529
|
+
|
|
530
|
+
const token = await auth.requestRemoteAuth({
|
|
531
|
+
vendorId: "my-backend", // your system/vendor id
|
|
532
|
+
callbackUrl: "https://api.example.com/horizon/callback", // your backend webhook
|
|
533
|
+
scopes: ["contacts:read"], // optional
|
|
534
|
+
});
|
|
535
|
+
// token: { vendorId, accessToken, tokenType?, expiresAt?, refreshToken?, metadata? }
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
**Flow:**
|
|
539
|
+
|
|
540
|
+
1. Your app calls `auth.requestRemoteAuth(...)`.
|
|
541
|
+
2. The host validates the request — the app must have remote auth enabled and the `callbackUrl` host must be on the app's allowed-hostnames list — then brokers the handshake and delivers a signed auth code to your `callbackUrl`.
|
|
542
|
+
3. **Your backend verifies authenticity** by checking the HMAC signature header (`X-NS-Signature`) against your app's shared callback secret. A valid signature proves the request came from Horizon, so you can trust the asserted user identity, then issue your own token.
|
|
543
|
+
4. The promise resolves with a `RemoteAuthResponse` (your token), cached for the session; it rejects with a `RemoteAuthError` on failure or timeout.
|
|
544
|
+
|
|
545
|
+
Retrieve or clear a cached token later:
|
|
546
|
+
|
|
547
|
+
```tsx
|
|
548
|
+
const cached = auth.getRemoteAuthToken("my-backend"); // RemoteAuthResponse | null
|
|
549
|
+
auth.clearRemoteAuthToken("my-backend"); // sign out of the vendor
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
**Admin setup (per app, in Registered Apps):** enable remote auth, list the allowed callback hostname(s), and set the callback signing secret your backend uses to verify the `X-NS-Signature`.
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
560
556
|
## Event bus
|
|
561
557
|
|
|
562
558
|
For cross-app messages and host events:
|
|
563
559
|
|
|
564
560
|
```tsx
|
|
565
561
|
useEffect(() => {
|
|
566
|
-
const handler = (data: unknown) => console.log("
|
|
567
|
-
horizonContext.eventBus.on("
|
|
568
|
-
return () => horizonContext.eventBus.off("
|
|
562
|
+
const handler = (data: unknown) => console.log("call event", data);
|
|
563
|
+
horizonContext.eventBus.on("call-event", handler);
|
|
564
|
+
return () => horizonContext.eventBus.off("call-event", handler);
|
|
569
565
|
}, []);
|
|
570
566
|
```
|
|
571
567
|
|
|
572
568
|
Common host-emitted events: `theme:changed`, `call-event`, `routes:updated`. Apps can emit any custom event under their own namespace (`my-app:something`).
|
|
573
569
|
|
|
574
|
-
> **
|
|
570
|
+
> There is **no generic `useEvent` hook** — subscribe directly with `context.eventBus.on(...)` / `.off(...)` and clean up in your effect. The SDK only wraps the specific events it manages for you: `useTheme()` (theme changes), `useLocale()` (locale changes), and `useSidePanel()`. In particular, **do not** subscribe to `theme:changed` yourself — use `useTheme()`.
|
|
575
571
|
|
|
576
572
|
---
|
|
577
573
|
|
|
@@ -586,7 +582,7 @@ Your app is delivered as a static bundle served over HTTPS. Before you can regis
|
|
|
586
582
|
**CORS** — The Horizon host makes a cross-origin request to fetch your `remoteEntry.js`. Your server must respond with:
|
|
587
583
|
|
|
588
584
|
```
|
|
589
|
-
Access-Control-Allow-Origin: https://your-horizon-instance.
|
|
585
|
+
Access-Control-Allow-Origin: https://your-horizon-instance.fqdn
|
|
590
586
|
```
|
|
591
587
|
|
|
592
588
|
During local development the webpack dev server sets `Access-Control-Allow-Origin: *` (any origin) so you can test against any Horizon instance. Tighten this to the specific Horizon domain before deploying to production — `*` in production means any website could load your bundle.
|
|
@@ -622,7 +618,7 @@ module.exports = (_env, argv) => ({
|
|
|
622
618
|
loglevel: { singleton: true, requiredVersion: "^1.9.0" },
|
|
623
619
|
"@netsapiens/horizon-sdk": {
|
|
624
620
|
singleton: true,
|
|
625
|
-
requiredVersion: "^1.0
|
|
621
|
+
requiredVersion: "^0.1.0", // match the @netsapiens/horizon-sdk version you installed
|
|
626
622
|
},
|
|
627
623
|
|
|
628
624
|
// Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
|
|
@@ -630,7 +626,7 @@ module.exports = (_env, argv) => ({
|
|
|
630
626
|
// so declaring it as a singleton causes an "Unsatisfied version" crash.
|
|
631
627
|
//
|
|
632
628
|
// Instead, consume all MUI components via horizonContext.ui — this is
|
|
633
|
-
// what gives your components the
|
|
629
|
+
// what gives your components the theme and dark mode automatically.
|
|
634
630
|
},
|
|
635
631
|
}),
|
|
636
632
|
],
|
|
@@ -655,7 +651,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
|
|
|
655
651
|
"description": "Short description shown in the app list",
|
|
656
652
|
"version": "1.0.0",
|
|
657
653
|
"remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
|
|
658
|
-
"
|
|
654
|
+
"webpack_module": "myApp",
|
|
659
655
|
"author": "Acme Corp",
|
|
660
656
|
"enabled": true
|
|
661
657
|
}'
|
|
@@ -675,7 +671,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
|
|
|
675
671
|
|
|
676
672
|
### Extension App ID
|
|
677
673
|
|
|
678
|
-
The **Extension App ID** (
|
|
674
|
+
The **Extension App ID** (the API field `webpack_module`) is the single most important field to get right. It must **exactly match** the `name` value in your webpack `ModuleFederationPlugin` config:
|
|
679
675
|
|
|
680
676
|
```js
|
|
681
677
|
// webpack.config.js
|
|
@@ -687,16 +683,18 @@ new ModuleFederationPlugin({
|
|
|
687
683
|
|
|
688
684
|
```json
|
|
689
685
|
// Registration
|
|
690
|
-
{ "
|
|
686
|
+
{ "webpack_module": "myUcaasApp" }
|
|
691
687
|
```
|
|
692
688
|
|
|
693
689
|
**Why it matters:** At runtime, the platform loads your bundle and calls `window['myUcaasApp'].init(...)` to initialise the federation container. If the name doesn't match exactly — including case — the container won't be found and your app silently fails to load with no visible error.
|
|
694
690
|
|
|
691
|
+
**One identifier, everywhere.** `webpack_module` is the only id you maintain. You pass the same value to `useRemoteApp(horizonContext, "myUcaasApp")`, and the SDK, host, and API all derive the kebab-case registry `id` (`my-ucaas-app`) from it the same way — that derived id is the registration's primary key, the `/ui-extensions/{id}` path, and the attribution id for your extensions. (Need it explicitly? `import { deriveAppId } from "@netsapiens/horizon-sdk"`.)
|
|
692
|
+
|
|
695
693
|
**Rules:**
|
|
696
694
|
|
|
697
|
-
- Must be
|
|
698
|
-
-
|
|
699
|
-
-
|
|
695
|
+
- Must be a **valid JavaScript identifier** — letters, digits, `_`, `$`; no dashes or spaces, can't start with a digit. webpack's default container library declares it as a `var`, so a dash (e.g. `my-app`) fails the build with _"name … must be a valid identifier."_ camelCase is the convention (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`).
|
|
696
|
+
- Must be **unique platform-wide**. The API enforces this with a database unique constraint and returns **HTTP `409 Conflict`** if the `webpack_module` is already taken — on both register and update. Pick a distinctive, namespaced value (e.g. `acmeCrmSync`, not `crm`).
|
|
697
|
+
- Immutable once registered — it's baked into your built `remoteEntry.js` as the container global, so changing it means rebuilding and re-registering.
|
|
700
698
|
|
|
701
699
|
### Domain whitelist
|
|
702
700
|
|
|
@@ -713,15 +711,11 @@ If the domain is not approved, your app will silently fail to load. Check the br
|
|
|
713
711
|
|
|
714
712
|
After registering and refreshing the browser:
|
|
715
713
|
|
|
716
|
-
1. **Open DevTools → Console** and look for log lines
|
|
717
|
-
|
|
718
|
-
2. **Check the network tab** — filter by your domain and look for `remoteEntry.js`. A `200` response confirms the bundle was fetched. A `CORS error` or `net::ERR_*` means the domain isn't reachable or CORS headers are missing
|
|
719
|
-
|
|
714
|
+
1. **Open DevTools → Console** and look for log lines from your app — your `App.tsx` runs on page load and any `console.log` calls appear here
|
|
715
|
+
2. **Check the network tab** — filter by your domain and look for `remoteEntry.js`. A `200` confirms the bundle was fetched; a `CORS error` or `net::ERR_*` means the domain isn't reachable or CORS headers are missing
|
|
720
716
|
3. **Look for your registered routes** — if you called `sdk.registerRoute`, your page should appear in the navigation sidebar immediately after the app loads
|
|
721
|
-
|
|
722
717
|
4. **Look for your extensions** — visit the route pattern your extension targets (e.g. `/manage/call-logs`) and confirm the injected component appears
|
|
723
|
-
|
|
724
|
-
5. **Check the Registered Apps page** (Platform → UI SDK Management → Registered Apps) — the **Extension Zones** column will show which zones your app has active extensions registered in once it has loaded
|
|
718
|
+
5. **Check the Registered Apps page** (Platform → UI SDK Management → Registered Apps) — the **Extension Zones** column shows which zones your app has active extensions in once it has loaded
|
|
725
719
|
|
|
726
720
|
If nothing appears after a full page refresh:
|
|
727
721
|
|
|
@@ -729,6 +723,17 @@ If nothing appears after a full page refresh:
|
|
|
729
723
|
- Confirm your CDN domain is approved (see above)
|
|
730
724
|
- Check for JavaScript errors in the console — a crash in your `App.tsx` before `sdk.registerRoute` is called means nothing gets registered
|
|
731
725
|
|
|
726
|
+
**Live QA toggle:** from the browser console, `window.__horizonSDKDebug__` exposes session-local debug helpers — handy for isolating whether an issue comes from a remote app:
|
|
727
|
+
|
|
728
|
+
```js
|
|
729
|
+
window.__horizonSDKDebug__.help(); // list all commands
|
|
730
|
+
window.__horizonSDKDebug__.disableExtensionApps(); // reload with ALL remote apps off (this session only)
|
|
731
|
+
window.__horizonSDKDebug__.enableExtensionApps(); // re-enable and reload
|
|
732
|
+
window.__horizonSDKDebug__.enable(); // turn on verbose SDK logging
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
The disable state is session-local (persisted in `localStorage`), so it only affects your own browser.
|
|
736
|
+
|
|
732
737
|
---
|
|
733
738
|
|
|
734
739
|
## After registration
|
|
@@ -737,7 +742,7 @@ If nothing appears after a full page refresh:
|
|
|
737
742
|
|
|
738
743
|
**Per-session load:** Your app's `App.tsx` mounts once per browser session inside a hidden container. It runs for the lifetime of the session, keeping all registrations active. Navigating between pages does not reload the app.
|
|
739
744
|
|
|
740
|
-
**Enabling and disabling:** Toggling the **Enabled** switch takes effect immediately for new sessions — the running app list is refreshed in the background. Users in active sessions
|
|
745
|
+
**Enabling and disabling:** Toggling the **Enabled** switch takes effect immediately for new sessions — the running app list is refreshed in the background. Users in active sessions see the change on their next page load.
|
|
741
746
|
|
|
742
747
|
---
|
|
743
748
|
|
|
@@ -751,6 +756,16 @@ If you use version-specific URLs (e.g. `cdn.example.com/my-app/v1.2.0/remoteEntr
|
|
|
751
756
|
|
|
752
757
|
Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.
|
|
753
758
|
|
|
759
|
+
> **Integrity (SRI) checking — opt-in.** You may register an `integrity_hash` (Subresource Integrity hash of your `remoteEntry.js`, encoded as `sha384-<base64>`). When present, the host verifies the fetched bundle against it and refuses to load on mismatch; when absent, the check is skipped. It is opt-in today and not yet enforced by the platform.
|
|
760
|
+
>
|
|
761
|
+
> Generate the hash from your **build** so it always matches the emitted bundle, e.g.:
|
|
762
|
+
>
|
|
763
|
+
> ```bash
|
|
764
|
+
> echo "sha384-$(openssl dgst -sha384 -binary dist/remoteEntry.js | openssl base64 -A)"
|
|
765
|
+
> ```
|
|
766
|
+
>
|
|
767
|
+
> Submit that value as `integrity_hash` on register/update. When you redeploy, **regenerate it for the new bundle and update the registration** — a stale hash makes the app fail the integrity check and refuse to load.
|
|
768
|
+
|
|
754
769
|
### Disable vs. Delete
|
|
755
770
|
|
|
756
771
|
| Action | Effect | Reversible? |
|
|
@@ -788,32 +803,15 @@ Codes: `PERMISSION_DENIED`, `RATE_LIMIT_EXCEEDED`, `API_ERROR`, `NETWORK_ERROR`,
|
|
|
788
803
|
|
|
789
804
|
---
|
|
790
805
|
|
|
791
|
-
##
|
|
792
|
-
|
|
793
|
-
Run the slash command:
|
|
794
|
-
|
|
795
|
-
```
|
|
796
|
-
/create-horizon-app
|
|
797
|
-
```
|
|
798
|
-
|
|
799
|
-
(see `.claude/skills/create-horizon-app.md` for the full prompt.)
|
|
800
|
-
|
|
801
|
-
Or paste this into a Claude Code session:
|
|
802
|
-
|
|
803
|
-
> Scaffold a new remote app for NetSapiens Horizon called `<app-name>` with Extension App ID `<extensionAppId>` on port `<port>`. It should:
|
|
804
|
-
>
|
|
805
|
-
> - Use webpack Module Federation, expose `./App`, and share react/react-dom/loglevel/@netsapiens/horizon-sdk as singletons
|
|
806
|
-
> - Have an `App.tsx` that uses `useRemoteApp(horizonContext, '<app-id>')` and registers one route at `/home/<app-id>` plus one dynamic extension on `page-header-actions` for `/manage/*/users`
|
|
807
|
-
> - Use `HorizonContextProvider` + `useHorizonContext()` for all page components so dark mode works automatically
|
|
808
|
-
> - Do NOT share `@mui/material`, `@mui/x-data-grid-pro`, `@emotion/*` in webpack — these are not provided by the host's federation loader
|
|
809
|
-
> - Render all UI through `horizonContext.ui` to get themed components
|
|
810
|
-
> - Include a README with run + register instructions
|
|
811
|
-
|
|
812
|
-
For one-off tasks inside an existing remote app, prompts like these work well:
|
|
806
|
+
## Migration
|
|
813
807
|
|
|
814
|
-
|
|
808
|
+
The SDK is pre-release (`0.x`) and not yet published, so no released version is affected — but if you built against an earlier working copy, note these breaking changes:
|
|
815
809
|
|
|
816
|
-
|
|
810
|
+
- **Registration field renamed: `module_scope` → `webpack_module`.** The `/ui-extensions` API request and response now use `webpack_module`. Re-register or update your app with the new field name; the value itself (your `ModuleFederationPlugin` `name`) is unchanged. See [Extension App ID](#extension-app-id).
|
|
811
|
+
- **`webpack_module` must be unique platform-wide.** Registration now fails with **HTTP `409 Conflict`** if the value is already taken (register and update). Choose a distinctive, namespaced id.
|
|
812
|
+
- **React 19 only.** The `react` / `react-dom` peer dependency is now `^19` (was `^18 || ^19`). These are greenfield apps — target React 19.
|
|
813
|
+
- **Anchor constants removed.** `MANAGE_ANCHORS` / `PLATFORM_ANCHORS` / `APPS_ANCHORS` / `MY_ACCOUNT_ANCHORS` / `ANCHORS` and the `AnchorId` type are gone. Pass placement targets as plain strings (e.g. `placement: { after: "contacts" }`) — the host resolves them with fuzzy matching. See [Menu placement](#menu-placement).
|
|
814
|
+
- **`integrity_hash` (opt-in, additive).** You may now submit an SRI hash (`sha384-<base64>`) on registration. See [Updating a deployed app](#updating-a-deployed-app).
|
|
817
815
|
|
|
818
816
|
---
|
|
819
817
|
|