@netsapiens/horizon-sdk 0.1.5 → 0.1.6
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 +282 -288
- package/dist/index.cjs +1 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -102
- package/dist/index.d.ts +1 -102
- package/dist/index.js +2 -38
- 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
|
|
|
@@ -75,21 +235,6 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
75
235
|
|
|
76
236
|
---
|
|
77
237
|
|
|
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
238
|
## Registration APIs
|
|
94
239
|
|
|
95
240
|
### Routes — full pages added to Horizon
|
|
@@ -119,7 +264,7 @@ const MySettingsRoute = useMemo(
|
|
|
119
264
|
|
|
120
265
|
sdk.registerRoute({
|
|
121
266
|
id: "my-app.settings",
|
|
122
|
-
parentPath: "/home", // attach under
|
|
267
|
+
parentPath: "/home", // attach under the My Account menu
|
|
123
268
|
path: "my-settings", // → /home/my-settings
|
|
124
269
|
label: "My Settings",
|
|
125
270
|
icon: "mdi:cog",
|
|
@@ -128,39 +273,14 @@ sdk.registerRoute({
|
|
|
128
273
|
});
|
|
129
274
|
```
|
|
130
275
|
|
|
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:
|
|
276
|
+
Inside the page component, read context with `useHorizonContext()`:
|
|
157
277
|
|
|
158
278
|
```tsx
|
|
159
279
|
// src/pages/MySettingsPage.tsx
|
|
160
280
|
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
161
281
|
|
|
162
282
|
export default function MySettingsPage() {
|
|
163
|
-
const { ui, user, navigate
|
|
283
|
+
const { ui, user, navigate } = useHorizonContext();
|
|
164
284
|
const { PageTemplate } = ui.templates;
|
|
165
285
|
const { Button, Stack, Typography } = ui;
|
|
166
286
|
|
|
@@ -186,7 +306,7 @@ The `useRoute` hook handles register + unregister automatically:
|
|
|
186
306
|
useRoute(horizonContext.eventBus, 'my-app', { id: '...', /* ... */, component: MySettingsRoute });
|
|
187
307
|
```
|
|
188
308
|
|
|
189
|
-
If your route component
|
|
309
|
+
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
310
|
|
|
191
311
|
```tsx
|
|
192
312
|
const { loading, error } = useRouteFromModule(
|
|
@@ -198,10 +318,21 @@ const { loading, error } = useRouteFromModule(
|
|
|
198
318
|
path: "my-settings",
|
|
199
319
|
label: "My Settings",
|
|
200
320
|
},
|
|
201
|
-
{ scope: "myApp", module: "./SettingsPage" },
|
|
321
|
+
{ scope: "myApp", module: "./SettingsPage" }, // an exposed module in YOUR webpack config
|
|
202
322
|
);
|
|
203
323
|
```
|
|
204
324
|
|
|
325
|
+
#### Menu placement
|
|
326
|
+
|
|
327
|
+
Use `placement` to control where your route appears in the menu, relative to existing menu items:
|
|
328
|
+
|
|
329
|
+
- `{ after: 'anchor-id' }` — Place immediately after the specified item
|
|
330
|
+
- `{ before: 'anchor-id' }` — Place immediately before the specified item
|
|
331
|
+
- `{ first: true }` — Force to the start of the menu
|
|
332
|
+
- `{ last: true }` — Force to the end of the menu (default if no placement specified)
|
|
333
|
+
|
|
334
|
+
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.
|
|
335
|
+
|
|
205
336
|
### Dynamic extensions — inject UI into host pages
|
|
206
337
|
|
|
207
338
|
Extensions render at named **zones** on routes that match a **pattern**. You don't need the host page to opt in.
|
|
@@ -218,23 +349,22 @@ sdk.registerDynamicExtension({
|
|
|
218
349
|
});
|
|
219
350
|
```
|
|
220
351
|
|
|
221
|
-
**Available zones
|
|
352
|
+
**Available zones** (the zones the host mounts today):
|
|
222
353
|
|
|
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 |
|
|
354
|
+
| Zone | Where it renders |
|
|
355
|
+
| ----------------------- | ---------------------------------------------------------------------------------- |
|
|
356
|
+
| `page-header-actions` | Right side of any page header |
|
|
357
|
+
| `page-header-secondary` | Subtitle row of the page header (badges, status) |
|
|
358
|
+
| `page-content-after` | Below the main page content |
|
|
359
|
+
| `topbar-actions` | Global top app bar (after comms buttons, before utilities) |
|
|
360
|
+
| `table-toolbar` | Toolbar above any DataGrid |
|
|
361
|
+
| `table-filter-bar` | Filter chips inline with the search bar; pass a row predicate via `onFilterChange` |
|
|
362
|
+
| `table-row-actions` | Per-row action column |
|
|
363
|
+
| `form-section-before` | Above a form's field sections (`SidePanel`/`FormPanel`) |
|
|
364
|
+
| `form-section-after` | Below a form's fields, before the action buttons |
|
|
365
|
+
| `inbound-call-content` | Incoming-call notification body |
|
|
236
366
|
|
|
237
|
-
> **Note**: Check Platform → UI SDK Management → SDK Settings for the
|
|
367
|
+
> **Note**: Check Platform → UI SDK Management → SDK Settings for the live list of zones (a platform admin can disable individual zones).
|
|
238
368
|
|
|
239
369
|
Custom zones (any string) work for pages that explicitly render `<DynamicExtensionRenderer zone="my-zone" />`.
|
|
240
370
|
|
|
@@ -260,11 +390,7 @@ useDynamicExtension(horizonContext.eventBus, "my-app", {
|
|
|
260
390
|
|
|
261
391
|
#### table-filter-bar zone
|
|
262
392
|
|
|
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.
|
|
393
|
+
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
394
|
|
|
269
395
|
```tsx
|
|
270
396
|
import { useState } from "react";
|
|
@@ -282,10 +408,9 @@ export function VipFilter({ context }: ExtensionComponentProps) {
|
|
|
282
408
|
const next = value === "vip";
|
|
283
409
|
setActive(next);
|
|
284
410
|
if (next) {
|
|
285
|
-
filterCtx?.onFilterChange(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
});
|
|
411
|
+
filterCtx?.onFilterChange(
|
|
412
|
+
(row) => (row as Record<string, unknown>)["customer-tier"] === "vip",
|
|
413
|
+
);
|
|
289
414
|
} else {
|
|
290
415
|
filterCtx?.onFilterChange(null); // clear — show all rows
|
|
291
416
|
}
|
|
@@ -302,14 +427,6 @@ export function VipFilter({ context }: ExtensionComponentProps) {
|
|
|
302
427
|
/>
|
|
303
428
|
);
|
|
304
429
|
}
|
|
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
430
|
```
|
|
314
431
|
|
|
315
432
|
`TableFilterBarContext` fields:
|
|
@@ -318,21 +435,16 @@ sdk.registerDynamicExtension({
|
|
|
318
435
|
| ---------------- | --------------------------------------------------------- | ----------------------------------------------------- |
|
|
319
436
|
| `onFilterChange` | `(filterFn: ((row: unknown) => boolean) \| null) => void` | Pass a predicate to filter rows; pass `null` to clear |
|
|
320
437
|
|
|
321
|
-
**Important:** track active/inactive state locally with `useState`
|
|
322
|
-
`TableFilterBarContext` does not expose the current filter state back to you.
|
|
438
|
+
**Important:** track active/inactive state locally with `useState` — `TableFilterBarContext` does not expose the current filter state back to you.
|
|
323
439
|
|
|
324
440
|
> **React `useState` gotcha** — never pass a filter function directly to `setState`:
|
|
325
441
|
>
|
|
326
442
|
> ```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);
|
|
443
|
+
> setMyFilter(filterFn); // ❌ React treats functions as lazy initializers, calling fn(prev)
|
|
444
|
+
> setMyFilter(() => filterFn); // ✅ Wrap in an arrow so React stores the function itself
|
|
332
445
|
> ```
|
|
333
446
|
>
|
|
334
|
-
> This only affects you if you
|
|
335
|
-
> The host DataTable handles this correctly internally.
|
|
447
|
+
> This only affects you if you store the filter function in your own component state. The host DataTable handles this correctly internally.
|
|
336
448
|
|
|
337
449
|
### Dynamic table columns
|
|
338
450
|
|
|
@@ -357,149 +469,7 @@ sdk.registerDynamicColumn({
|
|
|
357
469
|
|
|
358
470
|
Hook: `useDynamicColumn(eventBus, appId, config)`.
|
|
359
471
|
|
|
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
|
-
```
|
|
472
|
+
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
473
|
|
|
504
474
|
---
|
|
505
475
|
|
|
@@ -507,7 +477,7 @@ gates forward navigation (return `boolean | Promise<boolean>`):
|
|
|
507
477
|
|
|
508
478
|
Dark mode is handled automatically — **you write no theme-switching code for MUI components**.
|
|
509
479
|
|
|
510
|
-
MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's
|
|
480
|
+
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
481
|
|
|
512
482
|
```tsx
|
|
513
483
|
import { useTheme } from "@netsapiens/horizon-sdk";
|
|
@@ -522,19 +492,10 @@ import { useTheme } from "@netsapiens/horizon-sdk";
|
|
|
522
492
|
|
|
523
493
|
```tsx
|
|
524
494
|
// 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
|
-
}
|
|
495
|
+
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
|
|
531
496
|
|
|
532
497
|
// 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
|
-
}
|
|
498
|
+
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
|
|
538
499
|
```
|
|
539
500
|
|
|
540
501
|
> Do not subscribe to `theme:changed` on the event bus manually. `useTheme()` handles the subscription and cleanup internally.
|
|
@@ -546,9 +507,9 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
546
507
|
```tsx
|
|
547
508
|
const { api, user } = horizonContext;
|
|
548
509
|
const devices = await api.get(
|
|
549
|
-
`/domains/${user.domain}/users/${user.
|
|
510
|
+
`/domains/${user.domain}/users/${user.extension}/devices`,
|
|
550
511
|
);
|
|
551
|
-
await api.post(`/domains/${user.domain}/users/${user.
|
|
512
|
+
await api.post(`/domains/${user.domain}/users/${user.extension}/contacts`, {
|
|
552
513
|
"name-first-name": "John",
|
|
553
514
|
});
|
|
554
515
|
```
|
|
@@ -557,21 +518,54 @@ All calls run through Horizon's audited proxy — credentials never reach the re
|
|
|
557
518
|
|
|
558
519
|
---
|
|
559
520
|
|
|
521
|
+
## Remote authentication
|
|
522
|
+
|
|
523
|
+
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.
|
|
524
|
+
|
|
525
|
+
```tsx
|
|
526
|
+
const { auth } = horizonContext;
|
|
527
|
+
|
|
528
|
+
const token = await auth.requestRemoteAuth({
|
|
529
|
+
vendorId: "my-backend", // your system/vendor id
|
|
530
|
+
callbackUrl: "https://api.example.com/horizon/callback", // your backend webhook
|
|
531
|
+
scopes: ["contacts:read"], // optional
|
|
532
|
+
});
|
|
533
|
+
// token: { vendorId, accessToken, tokenType?, expiresAt?, refreshToken?, metadata? }
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Flow:**
|
|
537
|
+
|
|
538
|
+
1. Your app calls `auth.requestRemoteAuth(...)`.
|
|
539
|
+
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`.
|
|
540
|
+
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.
|
|
541
|
+
4. The promise resolves with a `RemoteAuthResponse` (your token), cached for the session; it rejects with a `RemoteAuthError` on failure or timeout.
|
|
542
|
+
|
|
543
|
+
Retrieve or clear a cached token later:
|
|
544
|
+
|
|
545
|
+
```tsx
|
|
546
|
+
const cached = auth.getRemoteAuthToken("my-backend"); // RemoteAuthResponse | null
|
|
547
|
+
auth.clearRemoteAuthToken("my-backend"); // sign out of the vendor
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**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`.
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
560
554
|
## Event bus
|
|
561
555
|
|
|
562
556
|
For cross-app messages and host events:
|
|
563
557
|
|
|
564
558
|
```tsx
|
|
565
559
|
useEffect(() => {
|
|
566
|
-
const handler = (data: unknown) => console.log("
|
|
567
|
-
horizonContext.eventBus.on("
|
|
568
|
-
return () => horizonContext.eventBus.off("
|
|
560
|
+
const handler = (data: unknown) => console.log("call event", data);
|
|
561
|
+
horizonContext.eventBus.on("call-event", handler);
|
|
562
|
+
return () => horizonContext.eventBus.off("call-event", handler);
|
|
569
563
|
}, []);
|
|
570
564
|
```
|
|
571
565
|
|
|
572
566
|
Common host-emitted events: `theme:changed`, `call-event`, `routes:updated`. Apps can emit any custom event under their own namespace (`my-app:something`).
|
|
573
567
|
|
|
574
|
-
> **
|
|
568
|
+
> 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
569
|
|
|
576
570
|
---
|
|
577
571
|
|
|
@@ -586,7 +580,7 @@ Your app is delivered as a static bundle served over HTTPS. Before you can regis
|
|
|
586
580
|
**CORS** — The Horizon host makes a cross-origin request to fetch your `remoteEntry.js`. Your server must respond with:
|
|
587
581
|
|
|
588
582
|
```
|
|
589
|
-
Access-Control-Allow-Origin: https://your-horizon-instance.
|
|
583
|
+
Access-Control-Allow-Origin: https://your-horizon-instance.fqdn
|
|
590
584
|
```
|
|
591
585
|
|
|
592
586
|
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 +616,7 @@ module.exports = (_env, argv) => ({
|
|
|
622
616
|
loglevel: { singleton: true, requiredVersion: "^1.9.0" },
|
|
623
617
|
"@netsapiens/horizon-sdk": {
|
|
624
618
|
singleton: true,
|
|
625
|
-
requiredVersion: "^1.0
|
|
619
|
+
requiredVersion: "^0.1.0", // match the @netsapiens/horizon-sdk version you installed
|
|
626
620
|
},
|
|
627
621
|
|
|
628
622
|
// Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
|
|
@@ -630,7 +624,7 @@ module.exports = (_env, argv) => ({
|
|
|
630
624
|
// so declaring it as a singleton causes an "Unsatisfied version" crash.
|
|
631
625
|
//
|
|
632
626
|
// Instead, consume all MUI components via horizonContext.ui — this is
|
|
633
|
-
// what gives your components the
|
|
627
|
+
// what gives your components the theme and dark mode automatically.
|
|
634
628
|
},
|
|
635
629
|
}),
|
|
636
630
|
],
|
|
@@ -655,7 +649,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
|
|
|
655
649
|
"description": "Short description shown in the app list",
|
|
656
650
|
"version": "1.0.0",
|
|
657
651
|
"remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
|
|
658
|
-
"
|
|
652
|
+
"webpack_module": "myApp",
|
|
659
653
|
"author": "Acme Corp",
|
|
660
654
|
"enabled": true
|
|
661
655
|
}'
|
|
@@ -675,7 +669,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
|
|
|
675
669
|
|
|
676
670
|
### Extension App ID
|
|
677
671
|
|
|
678
|
-
The **Extension App ID** (
|
|
672
|
+
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
673
|
|
|
680
674
|
```js
|
|
681
675
|
// webpack.config.js
|
|
@@ -687,14 +681,14 @@ new ModuleFederationPlugin({
|
|
|
687
681
|
|
|
688
682
|
```json
|
|
689
683
|
// Registration
|
|
690
|
-
{ "
|
|
684
|
+
{ "webpack_module": "myUcaasApp" }
|
|
691
685
|
```
|
|
692
686
|
|
|
693
687
|
**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
688
|
|
|
695
689
|
**Rules:**
|
|
696
690
|
|
|
697
|
-
- Must be unique
|
|
691
|
+
- 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`).
|
|
698
692
|
- Use camelCase (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`)
|
|
699
693
|
- No spaces or special characters
|
|
700
694
|
|
|
@@ -713,15 +707,11 @@ If the domain is not approved, your app will silently fail to load. Check the br
|
|
|
713
707
|
|
|
714
708
|
After registering and refreshing the browser:
|
|
715
709
|
|
|
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
|
-
|
|
710
|
+
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
|
|
711
|
+
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
712
|
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
713
|
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
|
|
714
|
+
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
715
|
|
|
726
716
|
If nothing appears after a full page refresh:
|
|
727
717
|
|
|
@@ -729,6 +719,17 @@ If nothing appears after a full page refresh:
|
|
|
729
719
|
- Confirm your CDN domain is approved (see above)
|
|
730
720
|
- Check for JavaScript errors in the console — a crash in your `App.tsx` before `sdk.registerRoute` is called means nothing gets registered
|
|
731
721
|
|
|
722
|
+
**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:
|
|
723
|
+
|
|
724
|
+
```js
|
|
725
|
+
window.__horizonSDKDebug__.help(); // list all commands
|
|
726
|
+
window.__horizonSDKDebug__.disableExtensionApps(); // reload with ALL remote apps off (this session only)
|
|
727
|
+
window.__horizonSDKDebug__.enableExtensionApps(); // re-enable and reload
|
|
728
|
+
window.__horizonSDKDebug__.enable(); // turn on verbose SDK logging
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
The disable state is session-local (persisted in `localStorage`), so it only affects your own browser.
|
|
732
|
+
|
|
732
733
|
---
|
|
733
734
|
|
|
734
735
|
## After registration
|
|
@@ -737,7 +738,7 @@ If nothing appears after a full page refresh:
|
|
|
737
738
|
|
|
738
739
|
**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
740
|
|
|
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
|
|
741
|
+
**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
742
|
|
|
742
743
|
---
|
|
743
744
|
|
|
@@ -751,6 +752,16 @@ If you use version-specific URLs (e.g. `cdn.example.com/my-app/v1.2.0/remoteEntr
|
|
|
751
752
|
|
|
752
753
|
Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.
|
|
753
754
|
|
|
755
|
+
> **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.
|
|
756
|
+
>
|
|
757
|
+
> Generate the hash from your **build** so it always matches the emitted bundle, e.g.:
|
|
758
|
+
>
|
|
759
|
+
> ```bash
|
|
760
|
+
> echo "sha384-$(openssl dgst -sha384 -binary dist/remoteEntry.js | openssl base64 -A)"
|
|
761
|
+
> ```
|
|
762
|
+
>
|
|
763
|
+
> 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.
|
|
764
|
+
|
|
754
765
|
### Disable vs. Delete
|
|
755
766
|
|
|
756
767
|
| Action | Effect | Reversible? |
|
|
@@ -788,32 +799,15 @@ Codes: `PERMISSION_DENIED`, `RATE_LIMIT_EXCEEDED`, `API_ERROR`, `NETWORK_ERROR`,
|
|
|
788
799
|
|
|
789
800
|
---
|
|
790
801
|
|
|
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:
|
|
802
|
+
## Migration
|
|
813
803
|
|
|
814
|
-
|
|
804
|
+
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
805
|
|
|
816
|
-
|
|
806
|
+
- **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).
|
|
807
|
+
- **`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.
|
|
808
|
+
- **React 19 only.** The `react` / `react-dom` peer dependency is now `^19` (was `^18 || ^19`). These are greenfield apps — target React 19.
|
|
809
|
+
- **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).
|
|
810
|
+
- **`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
811
|
|
|
818
812
|
---
|
|
819
813
|
|