@netsapiens/horizon-sdk 0.1.1 → 0.1.3
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 +263 -84
- package/dist/index.cjs +47 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +170 -26
- package/dist/index.d.ts +170 -26
- package/dist/index.js +47 -16
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -29,18 +29,18 @@ Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`, `loglevel ^1.9`.
|
|
|
29
29
|
|
|
30
30
|
```tsx
|
|
31
31
|
// src/App.tsx
|
|
32
|
-
import { useEffect } from
|
|
33
|
-
import { type HorizonContext, useRemoteApp } from
|
|
34
|
-
import MyButton from
|
|
32
|
+
import { useEffect } from "react";
|
|
33
|
+
import { type HorizonContext, useRemoteApp } from "@netsapiens/horizon-sdk";
|
|
34
|
+
import MyButton from "./extensions/MyButton";
|
|
35
35
|
|
|
36
36
|
export default function App(horizonContext: HorizonContext) {
|
|
37
|
-
const { sdk, user, theme } = useRemoteApp(horizonContext,
|
|
37
|
+
const { sdk, user, theme } = useRemoteApp(horizonContext, "my-app");
|
|
38
38
|
|
|
39
39
|
useEffect(() => {
|
|
40
40
|
sdk.registerDynamicExtension({
|
|
41
|
-
id:
|
|
42
|
-
zone:
|
|
43
|
-
routes: [{ pattern:
|
|
41
|
+
id: "my-app.export-button",
|
|
42
|
+
zone: "page-header-actions",
|
|
43
|
+
routes: [{ pattern: "/manage/*/call-logs" }],
|
|
44
44
|
component: MyButton,
|
|
45
45
|
});
|
|
46
46
|
}, [sdk]);
|
|
@@ -51,14 +51,20 @@ export default function App(horizonContext: HorizonContext) {
|
|
|
51
51
|
|
|
52
52
|
```tsx
|
|
53
53
|
// src/extensions/MyButton.tsx
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
type ExtensionComponentProps,
|
|
56
|
+
useTheme,
|
|
57
|
+
} from "@netsapiens/horizon-sdk";
|
|
55
58
|
|
|
56
59
|
export default function MyButton({ context }: ExtensionComponentProps) {
|
|
57
60
|
const { theme } = useTheme(context.eventBus); // reactive — no manual event wiring
|
|
58
61
|
const { Button, Icon } = context.ui ?? {};
|
|
59
62
|
if (!Button || !Icon) return null;
|
|
60
63
|
return (
|
|
61
|
-
<Button
|
|
64
|
+
<Button
|
|
65
|
+
startIcon={<Icon icon="mdi:download" />}
|
|
66
|
+
onClick={() => alert("Exported!")}
|
|
67
|
+
>
|
|
62
68
|
Export
|
|
63
69
|
</Button>
|
|
64
70
|
);
|
|
@@ -91,8 +97,11 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
91
97
|
Register a full page route using the SDK. Wrap your page component in `HorizonContextProvider` so it gets a live, reactive context — including automatic dark mode — without any extra wiring.
|
|
92
98
|
|
|
93
99
|
```tsx
|
|
94
|
-
import {
|
|
95
|
-
|
|
100
|
+
import {
|
|
101
|
+
HorizonContextProvider,
|
|
102
|
+
useHorizonContext,
|
|
103
|
+
} from "@netsapiens/horizon-sdk";
|
|
104
|
+
import MySettingsPage from "./pages/MySettingsPage";
|
|
96
105
|
|
|
97
106
|
// In your App component:
|
|
98
107
|
const MySettingsRoute = useMemo(
|
|
@@ -109,12 +118,12 @@ const MySettingsRoute = useMemo(
|
|
|
109
118
|
);
|
|
110
119
|
|
|
111
120
|
sdk.registerRoute({
|
|
112
|
-
id:
|
|
113
|
-
parentPath:
|
|
114
|
-
path:
|
|
115
|
-
label:
|
|
116
|
-
icon:
|
|
117
|
-
placement: { after:
|
|
121
|
+
id: "my-app.settings",
|
|
122
|
+
parentPath: "/home", // attach under Apps menu
|
|
123
|
+
path: "my-settings", // → /home/my-settings
|
|
124
|
+
label: "My Settings",
|
|
125
|
+
icon: "mdi:cog",
|
|
126
|
+
placement: { after: "settings" }, // position after the Settings item
|
|
118
127
|
component: MySettingsRoute,
|
|
119
128
|
});
|
|
120
129
|
```
|
|
@@ -125,12 +134,12 @@ Use `placement` to control where your route appears in the menu. You can positio
|
|
|
125
134
|
|
|
126
135
|
```tsx
|
|
127
136
|
sdk.registerRoute({
|
|
128
|
-
id:
|
|
129
|
-
parentPath:
|
|
130
|
-
path:
|
|
131
|
-
label:
|
|
132
|
-
icon:
|
|
133
|
-
placement: { after:
|
|
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
|
|
134
143
|
component: MySettingsRoute,
|
|
135
144
|
});
|
|
136
145
|
```
|
|
@@ -148,7 +157,7 @@ Inside the page component, read context with `useHorizonContext()` instead of ac
|
|
|
148
157
|
|
|
149
158
|
```tsx
|
|
150
159
|
// src/pages/MySettingsPage.tsx
|
|
151
|
-
import { useHorizonContext } from
|
|
160
|
+
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
152
161
|
|
|
153
162
|
export default function MySettingsPage() {
|
|
154
163
|
const { ui, user, navigate, theme } = useHorizonContext();
|
|
@@ -156,10 +165,13 @@ export default function MySettingsPage() {
|
|
|
156
165
|
const { Button, Stack, Typography } = ui;
|
|
157
166
|
|
|
158
167
|
return (
|
|
159
|
-
<PageTemplate
|
|
168
|
+
<PageTemplate
|
|
169
|
+
title="Settings"
|
|
170
|
+
breadcrumbs={[{ label: "Apps", url: "/apps" }]}
|
|
171
|
+
>
|
|
160
172
|
<Stack spacing={2}>
|
|
161
173
|
<Typography variant="body1">Hello, {user.displayName}.</Typography>
|
|
162
|
-
<Button variant="contained" onClick={() => navigate(
|
|
174
|
+
<Button variant="contained" onClick={() => navigate("/apps")}>
|
|
163
175
|
Back
|
|
164
176
|
</Button>
|
|
165
177
|
</Stack>
|
|
@@ -179,9 +191,14 @@ If your route component lives in a separate exposed module, use `useRouteFromMod
|
|
|
179
191
|
```tsx
|
|
180
192
|
const { loading, error } = useRouteFromModule(
|
|
181
193
|
horizonContext.eventBus,
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
|
|
194
|
+
"my-app",
|
|
195
|
+
{
|
|
196
|
+
id: "my-app.settings",
|
|
197
|
+
parentPath: "/home",
|
|
198
|
+
path: "my-settings",
|
|
199
|
+
label: "My Settings",
|
|
200
|
+
},
|
|
201
|
+
{ scope: "myApp", module: "./SettingsPage" },
|
|
185
202
|
);
|
|
186
203
|
```
|
|
187
204
|
|
|
@@ -191,31 +208,31 @@ Extensions render at named **zones** on routes that match a **pattern**. You don
|
|
|
191
208
|
|
|
192
209
|
```tsx
|
|
193
210
|
sdk.registerDynamicExtension({
|
|
194
|
-
id:
|
|
195
|
-
zone:
|
|
196
|
-
routes: [{ pattern:
|
|
197
|
-
priority: 10, // higher = first
|
|
198
|
-
requiredPermissions: [
|
|
199
|
-
condition: (ctx) => ctx.user.domain ===
|
|
211
|
+
id: "my-app.row-action",
|
|
212
|
+
zone: "table-row-actions",
|
|
213
|
+
routes: [{ pattern: "/manage/*/call-logs" }],
|
|
214
|
+
priority: 10, // higher = first
|
|
215
|
+
requiredPermissions: ["calls:read"],
|
|
216
|
+
condition: (ctx) => ctx.user.domain === "special.com",
|
|
200
217
|
component: RowAction,
|
|
201
218
|
});
|
|
202
219
|
```
|
|
203
220
|
|
|
204
221
|
**Available zones:**
|
|
205
222
|
|
|
206
|
-
| Zone
|
|
207
|
-
|
|
|
208
|
-
| `page-header-actions`
|
|
209
|
-
| `page-header-secondary`
|
|
210
|
-
| `page-content-
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `table-
|
|
214
|
-
| `table-row-actions`
|
|
215
|
-
| `detail-panel-tabs`
|
|
216
|
-
| `detail-panel-actions`
|
|
217
|
-
| `topbar-actions`
|
|
218
|
-
| `inbound-call-content`
|
|
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
|
+
| `sidetray` | Side tray panel (open/close via host toggle) |
|
|
229
|
+
| `table-toolbar` | Above any DataGrid |
|
|
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
|
+
| `detail-panel-tabs` | Detail-panel tab strip |
|
|
233
|
+
| `detail-panel-actions` | Detail-panel action area |
|
|
234
|
+
| `topbar-actions` | Global top app bar (after comms, before utilities) |
|
|
235
|
+
| `inbound-call-content` | Incoming-call notification body |
|
|
219
236
|
|
|
220
237
|
> **Note**: Check Platform → UI SDK Management → SDK Settings for the current list of available zones and their descriptions.
|
|
221
238
|
|
|
@@ -233,26 +250,102 @@ Custom zones (any string) work for pages that explicitly render `<DynamicExtensi
|
|
|
233
250
|
Hook form:
|
|
234
251
|
|
|
235
252
|
```tsx
|
|
236
|
-
useDynamicExtension(horizonContext.eventBus,
|
|
237
|
-
id:
|
|
238
|
-
zone:
|
|
239
|
-
routes: [{ pattern:
|
|
253
|
+
useDynamicExtension(horizonContext.eventBus, "my-app", {
|
|
254
|
+
id: "my-app.banner",
|
|
255
|
+
zone: "page-content-after",
|
|
256
|
+
routes: [{ pattern: "/manage/:domain/users" }],
|
|
240
257
|
component: Banner,
|
|
241
258
|
});
|
|
242
259
|
```
|
|
243
260
|
|
|
261
|
+
#### table-filter-bar zone
|
|
262
|
+
|
|
263
|
+
Pages that render a filter bar (e.g. active calls, hardphone devices) expose a
|
|
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.
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
import { useState } from "react";
|
|
271
|
+
import type {
|
|
272
|
+
ExtensionComponentProps,
|
|
273
|
+
TableFilterBarContext,
|
|
274
|
+
} from "@netsapiens/horizon-sdk";
|
|
275
|
+
|
|
276
|
+
export function VipFilter({ context }: ExtensionComponentProps) {
|
|
277
|
+
const filterCtx = context.pageContext as TableFilterBarContext | undefined;
|
|
278
|
+
const { ToggleButtonGroup } = context.ui ?? {};
|
|
279
|
+
const [active, setActive] = useState(false);
|
|
280
|
+
|
|
281
|
+
function handleChange(_e: React.SyntheticEvent, value: string | null) {
|
|
282
|
+
const next = value === "vip";
|
|
283
|
+
setActive(next);
|
|
284
|
+
if (next) {
|
|
285
|
+
filterCtx?.onFilterChange((row) => {
|
|
286
|
+
const r = row as Record<string, unknown>;
|
|
287
|
+
return r["customer-tier"] === "vip";
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
filterCtx?.onFilterChange(null); // clear — show all rows
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!ToggleButtonGroup) return null;
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<ToggleButtonGroup
|
|
298
|
+
value={active ? "vip" : null}
|
|
299
|
+
exclusive
|
|
300
|
+
onChange={handleChange}
|
|
301
|
+
options={[{ value: "vip", label: "VIP Only" }]}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
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
|
+
```
|
|
314
|
+
|
|
315
|
+
`TableFilterBarContext` fields:
|
|
316
|
+
|
|
317
|
+
| Field | Type | Description |
|
|
318
|
+
| ---------------- | --------------------------------------------------------- | ----------------------------------------------------- |
|
|
319
|
+
| `onFilterChange` | `(filterFn: ((row: unknown) => boolean) \| null) => void` | Pass a predicate to filter rows; pass `null` to clear |
|
|
320
|
+
|
|
321
|
+
**Important:** track active/inactive state locally with `useState` in your component —
|
|
322
|
+
`TableFilterBarContext` does not expose the current filter state back to you.
|
|
323
|
+
|
|
324
|
+
> **React `useState` gotcha** — never pass a filter function directly to `setState`:
|
|
325
|
+
>
|
|
326
|
+
> ```ts
|
|
327
|
+
> // ❌ React treats functions as lazy initializers, calling fn(prevState)
|
|
328
|
+
> setMyFilter(filterFn);
|
|
329
|
+
>
|
|
330
|
+
> // ✅ Wrap in an arrow function so React stores the function, not its return value
|
|
331
|
+
> setMyFilter(() => filterFn);
|
|
332
|
+
> ```
|
|
333
|
+
>
|
|
334
|
+
> This only affects you if you're storing the filter function in your own component state.
|
|
335
|
+
> The host DataTable handles this correctly internally.
|
|
336
|
+
|
|
244
337
|
### Dynamic table columns
|
|
245
338
|
|
|
246
339
|
Add columns to host DataGrids that opt in (zone names like `call-logs-columns`, `users-columns`, etc.):
|
|
247
340
|
|
|
248
341
|
```tsx
|
|
249
342
|
sdk.registerDynamicColumn({
|
|
250
|
-
id:
|
|
251
|
-
zone:
|
|
252
|
-
routes: [{ pattern:
|
|
343
|
+
id: "my-app.priority",
|
|
344
|
+
zone: "call-logs-columns",
|
|
345
|
+
routes: [{ pattern: "/manage/*/call-logs" }],
|
|
253
346
|
column: {
|
|
254
|
-
field:
|
|
255
|
-
headerName:
|
|
347
|
+
field: "priority",
|
|
348
|
+
headerName: "Priority",
|
|
256
349
|
width: 120,
|
|
257
350
|
sortable: true,
|
|
258
351
|
filterable: true,
|
|
@@ -274,7 +367,7 @@ The recommended pattern uses `useHorizonContext()` (see Routes above). The old p
|
|
|
274
367
|
|
|
275
368
|
```tsx
|
|
276
369
|
// Recommended — page component using the hook (fully reactive theme)
|
|
277
|
-
import { useHorizonContext } from
|
|
370
|
+
import { useHorizonContext } from "@netsapiens/horizon-sdk";
|
|
278
371
|
|
|
279
372
|
export default function MyPage() {
|
|
280
373
|
const { ui, user } = useHorizonContext();
|
|
@@ -282,7 +375,10 @@ export default function MyPage() {
|
|
|
282
375
|
const { Button, Stack, Typography } = ui;
|
|
283
376
|
|
|
284
377
|
return (
|
|
285
|
-
<PageTemplate
|
|
378
|
+
<PageTemplate
|
|
379
|
+
title="My Page"
|
|
380
|
+
breadcrumbs={[{ label: "Apps", url: "/apps" }]}
|
|
381
|
+
>
|
|
286
382
|
<Stack spacing={2}>
|
|
287
383
|
<Typography variant="h5">Hello, {user.displayName}</Typography>
|
|
288
384
|
<Button variant="contained">Action</Button>
|
|
@@ -302,11 +398,11 @@ function MyButton({ context }: ExtensionComponentProps) {
|
|
|
302
398
|
|
|
303
399
|
Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
|
|
304
400
|
|
|
305
|
-
- `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `
|
|
401
|
+
- `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SidePanel`, `FormPanel`, `DatagridTemplate`
|
|
306
402
|
|
|
307
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.
|
|
308
404
|
|
|
309
|
-
- Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`
|
|
405
|
+
- Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`, `FormControlLabel`
|
|
310
406
|
- Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
|
|
311
407
|
- Layout: `Stack`, `Paper`
|
|
312
408
|
- Feedback: `Alert`
|
|
@@ -331,6 +427,80 @@ The TypeScript types reject the children pattern at compile time. If you ever se
|
|
|
331
427
|
|
|
332
428
|
Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
|
|
333
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
|
+
```
|
|
503
|
+
|
|
334
504
|
---
|
|
335
505
|
|
|
336
506
|
## Dark mode
|
|
@@ -340,7 +510,7 @@ Dark mode is handled automatically — **you write no theme-switching code for M
|
|
|
340
510
|
MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's Aurora 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()`:
|
|
341
511
|
|
|
342
512
|
```tsx
|
|
343
|
-
import { useTheme } from
|
|
513
|
+
import { useTheme } from "@netsapiens/horizon-sdk";
|
|
344
514
|
```
|
|
345
515
|
|
|
346
516
|
`useTheme()` is a single hook that works identically in both contexts:
|
|
@@ -354,14 +524,16 @@ import { useTheme } from '@netsapiens/horizon-sdk';
|
|
|
354
524
|
// Page component
|
|
355
525
|
export default function MyPage() {
|
|
356
526
|
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
|
|
357
|
-
return
|
|
527
|
+
return (
|
|
528
|
+
<div style={{ background: theme === "dark" ? "#1B2124" : "#fff" }}>...</div>
|
|
529
|
+
);
|
|
358
530
|
}
|
|
359
531
|
|
|
360
532
|
// Extension component
|
|
361
533
|
export default function MyButton({ context }: ExtensionComponentProps) {
|
|
362
534
|
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
|
|
363
535
|
const { Button } = context.ui ?? {};
|
|
364
|
-
return <Button>{theme ===
|
|
536
|
+
return <Button>{theme === "dark" ? "🌙" : "☀️"} Export</Button>;
|
|
365
537
|
}
|
|
366
538
|
```
|
|
367
539
|
|
|
@@ -373,9 +545,11 @@ export default function MyButton({ context }: ExtensionComponentProps) {
|
|
|
373
545
|
|
|
374
546
|
```tsx
|
|
375
547
|
const { api, user } = horizonContext;
|
|
376
|
-
const devices = await api.get(
|
|
548
|
+
const devices = await api.get(
|
|
549
|
+
`/domains/${user.domain}/users/${user.displayName}/devices`,
|
|
550
|
+
);
|
|
377
551
|
await api.post(`/domains/${user.domain}/users/${user.displayName}/contacts`, {
|
|
378
|
-
|
|
552
|
+
"name-first-name": "John",
|
|
379
553
|
});
|
|
380
554
|
```
|
|
381
555
|
|
|
@@ -389,9 +563,9 @@ For cross-app messages and host events:
|
|
|
389
563
|
|
|
390
564
|
```tsx
|
|
391
565
|
useEffect(() => {
|
|
392
|
-
const handler = (data: unknown) => console.log(
|
|
393
|
-
horizonContext.eventBus.on(
|
|
394
|
-
return () => horizonContext.eventBus.off(
|
|
566
|
+
const handler = (data: unknown) => console.log("theme changed", data);
|
|
567
|
+
horizonContext.eventBus.on("theme:changed", handler);
|
|
568
|
+
return () => horizonContext.eventBus.off("theme:changed", handler);
|
|
395
569
|
}, []);
|
|
396
570
|
```
|
|
397
571
|
|
|
@@ -426,25 +600,30 @@ During local development the webpack dev server sets `Access-Control-Allow-Origi
|
|
|
426
600
|
Use webpack Module Federation. Minimal `webpack.config.js`:
|
|
427
601
|
|
|
428
602
|
```js
|
|
429
|
-
const ModuleFederationPlugin = require(
|
|
603
|
+
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
|
|
430
604
|
|
|
431
605
|
module.exports = (_env, argv) => ({
|
|
432
|
-
entry:
|
|
433
|
-
output: { publicPath:
|
|
434
|
-
resolve: { extensions: [
|
|
606
|
+
entry: "./src/App.tsx",
|
|
607
|
+
output: { publicPath: "auto", filename: "remoteEntry.js", clean: true },
|
|
608
|
+
resolve: { extensions: [".tsx", ".ts", ".js"] },
|
|
435
609
|
module: {
|
|
436
|
-
rules: [
|
|
610
|
+
rules: [
|
|
611
|
+
{ test: /\.(tsx?|jsx?)$/, exclude: /node_modules/, use: "babel-loader" },
|
|
612
|
+
],
|
|
437
613
|
},
|
|
438
614
|
plugins: [
|
|
439
615
|
new ModuleFederationPlugin({
|
|
440
|
-
name:
|
|
441
|
-
filename:
|
|
442
|
-
exposes: {
|
|
616
|
+
name: "myApp", // your Extension App ID — must match exactly what you enter during registration
|
|
617
|
+
filename: "remoteEntry.js",
|
|
618
|
+
exposes: { "./App": "./src/App" },
|
|
443
619
|
shared: {
|
|
444
|
-
react: { singleton: true, requiredVersion:
|
|
445
|
-
|
|
446
|
-
loglevel: { singleton: true, requiredVersion:
|
|
447
|
-
|
|
620
|
+
react: { singleton: true, requiredVersion: "^19.0.0" },
|
|
621
|
+
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
|
|
622
|
+
loglevel: { singleton: true, requiredVersion: "^1.9.0" },
|
|
623
|
+
"@netsapiens/horizon-sdk": {
|
|
624
|
+
singleton: true,
|
|
625
|
+
requiredVersion: "^1.0.0",
|
|
626
|
+
},
|
|
448
627
|
|
|
449
628
|
// Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
|
|
450
629
|
// The host's federation loader does not register MUI as a shared module,
|
|
@@ -455,7 +634,7 @@ module.exports = (_env, argv) => ({
|
|
|
455
634
|
},
|
|
456
635
|
}),
|
|
457
636
|
],
|
|
458
|
-
devServer: { port: 5005, headers: {
|
|
637
|
+
devServer: { port: 5005, headers: { "Access-Control-Allow-Origin": "*" } },
|
|
459
638
|
});
|
|
460
639
|
```
|
|
461
640
|
|
|
@@ -594,10 +773,10 @@ Use **Disable** when you need to take an app offline temporarily (maintenance, i
|
|
|
594
773
|
Throw and catch typed errors with codes:
|
|
595
774
|
|
|
596
775
|
```tsx
|
|
597
|
-
import { HorizonSDKError, apiError } from
|
|
776
|
+
import { HorizonSDKError, apiError } from "@netsapiens/horizon-sdk";
|
|
598
777
|
|
|
599
778
|
try {
|
|
600
|
-
await api.get(
|
|
779
|
+
await api.get("/...");
|
|
601
780
|
} catch (err) {
|
|
602
781
|
if (HorizonSDKError.isHorizonSDKError(err)) {
|
|
603
782
|
console.error(err.code, err.getUserMessage());
|