@persona/layout-editor-remote-module 0.0.0-bootstrap.0 → 0.5.1

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.
@@ -0,0 +1,374 @@
1
+ ---
2
+ name: getting-started
3
+ description: >
4
+ Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register
5
+ personaLayoutModules in a typical app's vite.config.ts, author modules with defineLayoutModule
6
+ using a document (.html) render delivery, add a zod configSchema and an RJSF uiConfigSchema, and
7
+ talk to the host from the iframed document with LayoutModuleBridge from the bridge-frame entry
8
+ (layout data, host context such as layoutType / layoutObjectId / isReadOnly, resolveDataPath, and
9
+ height). Activate when adding a new layout module to an app, integrating this package for the
10
+ first time, wiring a module document to the dashboard host, or debugging a manifest that fails to
11
+ build or validate (duplicate key, missing defaultSize entry, render path not found) or a module
12
+ that never receives data from the host.
13
+ metadata:
14
+ type: core
15
+ library: '@persona/layout-editor-remote-module'
16
+ ---
17
+
18
+ # Getting started with @persona/layout-editor-remote-module
19
+
20
+ Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona
21
+ dashboard apps declare a layout module directly in a typical app's `vite.config.ts`, without editing
22
+ the host. The plugin builds each module and emits a
23
+ `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates.
24
+
25
+ This package is publicly available from the npm registry.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ yarn add @persona/layout-editor-remote-module
31
+ ```
32
+
33
+ `vite`, `esbuild`, and `zod` are peer dependencies; `react` / `react-dom` (`^17 || ^18 || ^19`) are
34
+ optional peers needed only by the `bridge-frame` entry. Add `esbuild` to your own `devDependencies`
35
+ if your package manager warns about an unmet peer — the plugin's dev-mode bundling calls esbuild
36
+ directly and should resolve to the same copy Vite already pulls in.
37
+
38
+ ## Setup (vite.config.ts)
39
+
40
+ ```ts
41
+ import { defineConfig } from 'vite';
42
+ import {
43
+ defineLayoutModule,
44
+ personaLayoutModules,
45
+ } from '@persona/layout-editor-remote-module/vite-plugin';
46
+
47
+ export default defineConfig({
48
+ plugins: [
49
+ personaLayoutModules({
50
+ modules: [
51
+ defineLayoutModule({
52
+ key: 'investigation_console',
53
+ displayName: 'Investigation Console',
54
+ descriptionShort: 'Full investigation workspace',
55
+ allowAddingToLayout: true,
56
+ layouts: ['case'],
57
+ defaultSize: { case: { width: 12, height: 40 } },
58
+ render: { entry: './index.html' },
59
+ }),
60
+ ],
61
+ }),
62
+ ],
63
+ });
64
+ ```
65
+
66
+ An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
67
+ specific layouts, `defaultSize` must provide an entry for each one.
68
+
69
+ Optional fields: `allowDynamicHeight` (expose the dynamic-height option in the module's
70
+ configuration), `badge` (`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
71
+ (`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
72
+ `build_your_own`), `icon` (an image path relative to the module's origin, such as
73
+ `/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is
74
+ rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to
75
+ narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below).
76
+
77
+ `entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) —
78
+ not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config.
79
+
80
+ ## Core Patterns
81
+
82
+ ### Document delivery — iframed page
83
+
84
+ A module's `render.entry` must end in `.html`: the host iframes it rather than importing anything.
85
+ Any other extension (`.ts`/`.tsx`/`.js`) builds as a `code` delivery, which the plugin and manifest
86
+ still accept but the host does not render yet — the module shows up in the library and renders
87
+ nothing. Use `path` for a client-routed SPA whose `index.html` already renders the whole app — it
88
+ names the logical route the SPA's own router should land on, not the file's build output path:
89
+
90
+ ```ts
91
+ defineLayoutModule({
92
+ key: 'investigation_console',
93
+ displayName: 'Investigation Console',
94
+ descriptionShort: 'Full investigation workspace',
95
+ allowAddingToLayout: true,
96
+ layouts: ['case'],
97
+ defaultSize: { case: { width: 12, height: 40 } },
98
+ render: { entry: './index.html', path: '/embed/investigations/console' },
99
+ });
100
+ ```
101
+
102
+ If the `.html` file is already one of the app's own build entries (its main `index.html`), the
103
+ plugin detects that and doesn't inject it again.
104
+
105
+ ### `configSchema` — validated module configuration
106
+
107
+ Author config as zod; the plugin converts it to JSON Schema (`z.toJSONSchema()`) for the host's
108
+ config panel:
109
+
110
+ ```ts
111
+ import { z } from 'zod';
112
+
113
+ defineLayoutModule({
114
+ // ...
115
+ configSchema: z.object({
116
+ threshold: z.number().min(0).max(1).default(0.7),
117
+ }),
118
+ });
119
+ ```
120
+
121
+ Keep `configSchema` to a plain data shape (objects, primitives, enums, arrays) — it crosses the wire
122
+ as static JSON Schema, not re-executed as zod on the consuming side.
123
+
124
+ ### `uiConfigSchema` — config panel layout
125
+
126
+ The host renders the config panel with RJSF. Pass an RJSF `uiSchema` (widget choice, field order,
127
+ labels) as `uiConfigSchema`; it is already plain data, so the plugin copies it into the manifest
128
+ unchanged:
129
+
130
+ ```ts
131
+ defineLayoutModule({
132
+ // ...
133
+ configSchema: z.object({
134
+ threshold: z.number().min(0).max(1).default(0.7),
135
+ title: z.string().optional(),
136
+ }),
137
+ uiConfigSchema: {
138
+ 'ui:order': ['title', 'threshold'],
139
+ threshold: { 'ui:widget': 'range' },
140
+ },
141
+ });
142
+ ```
143
+
144
+ ### `bridge-frame` — talking to the host from the iframed document
145
+
146
+ The host renders a document-delivered module in an iframe and speaks to it over `postMessage`. Wrap
147
+ the module's React root in `LayoutModuleBridge` from the `bridge-frame` entry; it owns the whole
148
+ handshake and hands the module a render prop:
149
+
150
+ ```tsx
151
+ import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';
152
+
153
+ export function App() {
154
+ return (
155
+ <LayoutModuleBridge fallback={<Spinner />}>
156
+ {({ data, context, resolveDataPath }) => (
157
+ <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
158
+ )}
159
+ </LayoutModuleBridge>
160
+ );
161
+ }
162
+ ```
163
+
164
+ - `children` is only invoked once the host has sent the initial layout data — it never sees
165
+ `data: undefined`. Until then `fallback` (default `null`) is rendered.
166
+ - `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an
167
+ update.
168
+ - `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
169
+ cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see
170
+ below).
171
+ - `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host owns the
172
+ implementation. It rejects if the host reports an error or does not answer within
173
+ `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
174
+ directly in a tab still renders.
175
+ - Height is reported automatically: the bridge wraps `children` in a `div` and observes it with a
176
+ `ResizeObserver` so the host can size the iframe (`observeHeight={false}` to opt out).
177
+ - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown.
178
+ - Mount exactly one `LayoutModuleBridge` per document.
179
+
180
+ Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
181
+ `resolveTimeoutMs?: number`, `allowedHostOrigins?` (to add host origins outside the built-in
182
+ allow-list). Built-in origins include `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`,
183
+ and `127.0.0.1`. Dev and preview dashboards must be added explicitly; compose the built-in predicate
184
+ with an app-specific check:
185
+
186
+ ```tsx
187
+ import {
188
+ LayoutModuleBridge,
189
+ personaDashboardHostOrigins,
190
+ } from '@persona/layout-editor-remote-module/bridge-frame';
191
+
192
+ <LayoutModuleBridge
193
+ allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
194
+ >
195
+ {({ data }) => <RiskSummary data={data} />}
196
+ </LayoutModuleBridge>;
197
+ ```
198
+
199
+ #### Host context
200
+
201
+ `RemoteModuleContext` (exported as a type from `bridge-frame`) has:
202
+
203
+ - `layoutType` — `'account' | 'case' | 'transaction'`, the layout the module is placed on.
204
+ - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
205
+ `case` layout), or `null` when there is none.
206
+ - `isReadOnly` — the host object is locked for edits; hide or disable mutating actions.
207
+ - `isInEditor` — rendered inside the layout editor rather than on a live record.
208
+ - `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network
209
+ calls or side effects keyed off the record.
210
+
211
+ `context` is `null` only when the document is not embedded (opened directly in a tab).
212
+
213
+ ```tsx
214
+ import {
215
+ LayoutModuleBridge,
216
+ type RemoteModuleContext,
217
+ } from '@persona/layout-editor-remote-module/bridge-frame';
218
+
219
+ function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
220
+ if (!context?.layoutObjectId) {
221
+ return <EmptyState />;
222
+ }
223
+ return <RiskScore caseId={context.layoutObjectId} readOnly={context.isReadOnly} />;
224
+ }
225
+ ```
226
+
227
+ #### Access token and environment id
228
+
229
+ The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app
230
+ hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
231
+ runs they have already been handled; the bridge neither reads nor strips them. Keep the host
232
+ iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
233
+ parameters cannot leak through subsequent requests.
234
+
235
+ ## Common Mistakes
236
+
237
+ ### [HIGH] Reading layout data outside `LayoutModuleBridge`
238
+
239
+ Wrong:
240
+
241
+ ```tsx
242
+ const data = await requestDataSomehow(); // no such API
243
+ const resolveDataPath = window.resolveDataPath; // no such API
244
+ ```
245
+
246
+ Correct:
247
+
248
+ ```tsx
249
+ function Module({
250
+ data,
251
+ resolveDataPath,
252
+ }: {
253
+ data: Json;
254
+ resolveDataPath: (
255
+ dataPath: string,
256
+ variableMap?: Record<string, string | number>,
257
+ ) => Promise<Json>;
258
+ }) {
259
+ // ...
260
+ }
261
+
262
+ <LayoutModuleBridge>
263
+ {({ data, resolveDataPath }) => <Module data={data} resolveDataPath={resolveDataPath} />}
264
+ </LayoutModuleBridge>;
265
+ ```
266
+
267
+ `data` and `resolveDataPath` only exist inside the render prop.
268
+
269
+ Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
270
+
271
+ ### [MEDIUM] Module renders forever in `fallback` when embedded
272
+
273
+ `children` never running means the handshake didn't complete. Check, in order: the document is
274
+ really cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects
275
+ unknown parents — pass your own only for a non-Persona dev host); the module is a `.html` document
276
+ delivery, not a script entry; the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
277
+ never send `init`). Opening the document directly in a tab is _not_ embedded — `data` and `context`
278
+ resolve to `null` and `children` renders, so a stuck fallback is always a host/origin problem.
279
+
280
+ Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`.
281
+
282
+ ### [HIGH] `key` uses the wrong charset or collides with another module
283
+
284
+ Wrong:
285
+
286
+ ```ts
287
+ defineLayoutModule({ key: 'risk-summary' /* ... */ }); // hyphens
288
+ defineLayoutModule({ key: 'Risk_Summary' /* ... */ }); // uppercase
289
+ ```
290
+
291
+ Correct:
292
+
293
+ ```ts
294
+ defineLayoutModule({ key: 'risk_summary' /* ... */ });
295
+ ```
296
+
297
+ `key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a
298
+ persisted module identity string and DOM ids. A bad charset fails zod validation at build time
299
+ (`closeBundle`); a duplicate throws
300
+ `duplicate module key "..." — keys must be unique within an app`. Never change a released module's
301
+ `key` — it's written into saved layouts.
302
+
303
+ Source: `src/schema.ts` (`moduleKeySchema`), `src/vite-plugin.ts` (`assertUniqueKeys`).
304
+
305
+ ### [HIGH] `defaultSize` missing an entry for a listed layout
306
+
307
+ Wrong:
308
+
309
+ ```ts
310
+ defineLayoutModule({
311
+ layouts: ['case', 'account'],
312
+ defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry
313
+ });
314
+ ```
315
+
316
+ Correct:
317
+
318
+ ```ts
319
+ defineLayoutModule({
320
+ layouts: ['case', 'account'],
321
+ defaultSize: {
322
+ case: { width: 4, height: 20 },
323
+ account: { width: 6, height: 24 },
324
+ },
325
+ });
326
+ ```
327
+
328
+ The manifest schema requires an entry in `defaultSize` for every layout named in `layouts`; a
329
+ missing one fails validation in `closeBundle` with
330
+ `defaultSize must have an entry for every layout in \`layouts\``. This error surfaces at build time,
331
+ not at the defineLayoutModule call site.
332
+
333
+ This requirement applies to each listed layout; an empty `layouts` imposes no `defaultSize`
334
+ requirement, and the host falls back to its own default size.
335
+
336
+ Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`).
337
+
338
+ ### [MEDIUM] `render.entry` doesn't resolve to a real file
339
+
340
+ `personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry
341
+ path doesn't exist on disk: `render "..." does not resolve to a file (...)`. This is nearly always a
342
+ relative-path mistake: `entry` resolves against the Vite **root**, not against `vite.config.ts`'s
343
+ directory, so a non-default `root` in the same config changes what a given relative `entry` points
344
+ at.
345
+
346
+ Source: `src/vite-plugin.ts` (`checkDeliveryExists`).
347
+
348
+ ### [MEDIUM] `manifestPath` set to a directory instead of a file
349
+
350
+ Wrong:
351
+
352
+ ```ts
353
+ personaLayoutModules({ manifestPath: '/manifests/', modules: [] });
354
+ ```
355
+
356
+ Correct:
357
+
358
+ ```ts
359
+ personaLayoutModules({ manifestPath: '/manifests/my-app.json', modules: [] });
360
+ ```
361
+
362
+ `manifestPath` must include the full JSON file name — it defaults to `DEFAULT_MANIFEST_PATH`
363
+ (`/_layout_modules_manifest.json`). A leading `/` is optional and stripped either way.
364
+
365
+ Code outside `vite.config.ts` that needs the default path (e.g. a subapp server adding CORS on the
366
+ manifest route) should import the constant from the runtime-safe `.` entry rather than hardcoding it
367
+ — this avoids pulling in vite/esbuild:
368
+
369
+ ```ts
370
+ import { DEFAULT_MANIFEST_PATH } from '@persona/layout-editor-remote-module';
371
+ ```
372
+
373
+ Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment),
374
+ `src/manifest-path.ts`.