UNPKG

@persona/layout-editor-remote-module

Version:

Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.

497 lines (400 loc) • 22.8 kB
--- name: getting-started description: > Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using an .html document entry, add a zod configSchema and an RJSF uiConfigSchema (labels, field order, DataSourceField pickers), and talk to the host from the iframe with LayoutModuleBridge (layout data, host context such as layoutType / layoutObjectId / isReadOnly / dynamicHeight, resolveDataPath, height, loaded signal). Activate when adding a layout module, integrating this package for the first time, wiring a module document to the dashboard host, or debugging a manifest that fails to build or validate (duplicate key, missing defaultSize entry, render path not found), a module that never receives data from the host, a host loading card that never clears, or an iframe whose height collapses or keeps growing under dynamic height. metadata: type: core library: '@persona/layout-editor-remote-module' --- # Getting started with @persona/layout-editor-remote-module Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona dashboard apps declare a layout module directly in a typical app's `vite.config.ts`, without editing the host. The plugin ensures each module's HTML document is built and emits a `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates. This package is publicly available from the npm registry. ## Install ```sh yarn add @persona/layout-editor-remote-module ``` `vite` (`^6 || ^7 || ^8`) and `zod` are peer dependencies. Vite 5 is unsupported because the plugin relies on the Vite 6 Environment API; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional peers needed only by the `bridge-frame` entry. Build the iframed document against `@persona/layout-editor-remote-module` 0.5.3 or later. Older frames reject any host message that carries a key they don't know, so they stop working once the host sends new context fields such as `dynamicHeight`. ## Setup (vite.config.ts) ```ts import { defineConfig } from 'vite'; import { defineLayoutModule, personaLayoutModules, } from '@persona/layout-editor-remote-module/vite-plugin'; export default defineConfig({ plugins: [ personaLayoutModules({ modules: [ defineLayoutModule({ key: 'investigation_console', displayName: 'Investigation Console', descriptionShort: 'Full investigation workspace', allowAddingToLayout: true, layouts: ['case'], defaultSize: { case: { width: 12, height: 40 } }, render: { entry: './index.html' }, }), ], }), ], }); ``` An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists specific layouts, `defaultSize` must provide an entry for each one. Optional fields: `allowDynamicHeight` (exposes the option only; the host sizes the iframe to the module's reported height only when the user turns "dynamic height" on for that placement, and the module learns this through [`context.dynamicHeight`](#host-context)), `badge` (`'beta' | 'deprecated' | 'improved' | 'new'`), `category` (`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to `build_your_own`), `categoryCustom` (a free-form module-library category label, up to 80 characters; the host renders it verbatim — it is not translated — and groups modules that share the same trimmed, case-sensitive string, even across different module sources. When both are set, `categoryCustom` overrides `category`, and a label equal to one of the host's built-in category labels lands in "Other"; keep setting `category` as a fallback for hosts that predate `categoryCustom`), `icon` (an image path relative to the module's origin, such as `/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below). `entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) — not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config. In a multi-environment build (e.g. with `@cloudflare/vite-plugin`), the plugin adds module documents only to the `client` environment's build inputs and writes the manifest only into the client environment's `build.outDir`. Non-client environments (workers/SSR) never see the `.html` entries. Source: `src/vite-plugin.ts` (`configEnvironment`, `closeBundle`). ## Core Patterns ### Document delivery — iframed page A module's `render.entry` must end in `.html`: the host iframes the page. Any other extension fails during Vite config resolution with a clear error. Use `path` for a client-routed SPA whose `index.html` already renders the whole app — it names the logical route the SPA's own router should land on, not the file's build output path: ```ts defineLayoutModule({ key: 'investigation_console', displayName: 'Investigation Console', descriptionShort: 'Full investigation workspace', allowAddingToLayout: true, layouts: ['case'], defaultSize: { case: { width: 12, height: 40 } }, render: { entry: './index.html', path: '/embed/investigations/console' }, }); ``` If the `.html` file is already one of the app's own build entries (its main `index.html`), the plugin detects that and doesn't inject it again. ### `configSchema` — validated module configuration Author config as zod; the plugin converts it to JSON Schema for the host's config panel: ```ts import { z } from 'zod'; defineLayoutModule({ layouts: ['case', 'account'], configSchema: z.object({ ownerId: z.string().default('current_case.id') }), defaultConfig: { account: { ownerId: 'current_account.id' } }, }); ``` `defaultConfig` overrides top-level schema defaults for new placements. Each set key must validate against `configSchema` and, when `layouts` is non-empty, be listed there. Keep `configSchema` to plain data shapes; hosts predating `defaultConfig` ignore it, so retain a sensible schema default. ### `uiConfigSchema` — config panel layout The host renders the config panel with RJSF. Pass an RJSF `uiSchema` as `uiConfigSchema`; it is already plain data, so the plugin copies it into the manifest unchanged. Its top-level keys are the `configSchema` property names, and each one holds that property's `ui:*` entries: ```ts defineLayoutModule({ // ... configSchema: z.object({ threshold: z.number().min(0).max(1).default(0.7), title: z.string().optional(), }), uiConfigSchema: { 'ui:order': ['title', 'threshold'], threshold: { 'ui:widget': 'range' }, }, }); ``` - **Labels and help text** come from the property's JSON Schema `title` / `description` — set them with zod's `.meta({ title, description })` — and fall back to the property name. Override them per field with `'ui:options': { title, description }`; the description shows as the label's tooltip. - **Field order** is `'ui:order'` at the top level (use `'*'` for "everything else"). - **Controls are picked from the schema** without a `uiConfigSchema` entry: an enum renders as a dropdown, an array of enum values with `uniqueItems: true` in its JSON Schema as a multi-select, a boolean as a checkbox, and an array of objects as a list whose items open in an editing panel. - **Everything is JSON.** The manifest is static JSON, so function-valued RJSF options (`createItem`, `itemLabel`, …) and custom React fields/widgets cannot be supplied by a module — only the `ui:field` names and options the host already registers, listed below. #### `DataSourceField` — pick a value from the record `'ui:field': 'DataSourceField'` turns a property into the host's data-source input: an autocompleting method-chain input (e.g. `current_case.id`, or `query.<variable>` for a layout variable) checked against the platform schema. Declare the property as a **string** — the field then stores the chain itself as a string: ```ts defineLayoutModule({ // ... layouts: ['account'], defaultSize: { account: { width: 6, height: 24 } }, configSchema: z.object({ accountId: z.string().optional(), }), uiConfigSchema: { accountId: { 'ui:field': 'DataSourceField', 'ui:options': { title: 'Account ID', description: 'Account whose transactions are monitored', }, }, }, }); ``` `ui:options` it reads: - `title`, `description` — label and tooltip; default to the schema's `title` / `description`. - `expectedSchemaType` — the platform object type the chain must resolve to (e.g. `'case'`, `'user'`); a chain resolving to another type is flagged. Unset means any chain is accepted. An empty value is never validated. - `rootPath` — limits validation to chains under one top-level property (e.g. `'current_case'`). Only set it when `layouts` names a single layout that has that root; a chain outside `rootPath` still saves but is not validated. The config holds the **chain, not its value**. Resolve it at render time with `resolveDataPath` from `LayoutModuleBridge` (below), skipping an unset field: ```tsx import { useEffect, useState } from 'react'; import { type Json, type LayoutModuleBridgeRenderProps, } from '@persona/layout-editor-remote-module/bridge-frame'; function TransactionMonitor({ data, resolveDataPath, }: Pick<LayoutModuleBridgeRenderProps, 'data' | 'resolveDataPath'>) { const chain = (data as { accountId?: string } | null)?.accountId; const [accountId, setAccountId] = useState<Json>(null); useEffect(() => { setAccountId(null); if (!chain) return; let current = true; resolveDataPath(chain).then( (value) => current && setAccountId(value), () => current && setAccountId(null), ); return () => { current = false; }; }, [chain, resolveDataPath]); // ... } ``` The host pushes config updates into the mounted module, so the chain can change or be cleared at any time: reset the resolved value whenever it does, and ignore responses to an outdated chain. `resolveDataPath` resolves to `null` when the chain can't be resolved on the current record and rejects on a host error or timeout, so handle both. #### Other `ui:options` - `errorMessages` — per-keyword validation messages, e.g. `{ 'ui:options': { errorMessages: { pattern: 'Use letters only.' } } }` (keys are JSON Schema keywords: `pattern`, `minLength`, `maximum`, …). - On an enum multi-select: `unsetSelectsAll: true` shows an unset value as every option selected, for a key whose absence means "all" and should stay absent. - On an array of objects: `identifierField` (item property that labels each row), `emptyText`, `editLabel` (the editing panel's title), `listAriaLabel`, and `inlineItems: true` to render the items inline instead of as a list with an editing panel. ### `bridge-frame` — talking to the host from the iframed document The host renders a document-delivered module in an iframe and speaks to it over `postMessage`. Wrap the module's React root in `LayoutModuleBridge` from the `bridge-frame` entry; it owns the whole handshake and hands the module a render prop: ```tsx import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame'; export function App() { return ( <LayoutModuleBridge> {({ data, context, resolveDataPath }) => ( <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} /> )} </LayoutModuleBridge> ); } ``` - `children` is only invoked once the host has sent the initial layout data — it never sees `data: undefined`. Until then `fallback` (default `null`) is rendered, hidden under the host's loading card. After the first render the bridge posts `loaded` and the host removes the card, so render any data-loading state from `children`. - `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an update. - `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see below). - `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host owns the implementation. It rejects if the host reports an error or does not answer within `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened directly in a tab still renders. - Height is reported automatically: the bridge wraps `children` in a `display: flow-root` div and reports the height the frame document needs, including `body` and ancestor margins, padding, and borders, so the host can size the iframe (`observeHeight={false}` to opt out). The host uses the reported height only when [`context.dynamicHeight`](#host-context) is true; otherwise, the iframe keeps its grid size. - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown after the host loading overlay is dismissed. - Mount exactly one `LayoutModuleBridge` per document. Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`), `resolveTimeoutMs?: number`, `allowedHostOrigins?` (to add host origins outside the built-in allow-list). Built-in origins include `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`, and `127.0.0.1`. Dev and preview dashboards must be added explicitly; compose the built-in predicate with an app-specific check: ```tsx import { LayoutModuleBridge, personaDashboardHostOrigins, } from '@persona/layout-editor-remote-module/bridge-frame'; <LayoutModuleBridge allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)} > {({ data }) => <RiskSummary data={data} />} </LayoutModuleBridge>; ``` #### Host context `RemoteModuleContext` (exported as a type from `bridge-frame`) has: - `layoutType` — known values are `'account' | 'case' | 'transaction'`, but its type is `LayoutType | (string & {})` and the bridge accepts layout types added after the module's release. Handle unknown values with a default branch rather than a switch that tries to cover every case. - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a `case` layout), or `null` when there is none. - `isReadOnly` — the host object is locked for edits; hide or disable mutating actions. - `isInEditor` — rendered inside the layout editor rather than on a live record. - `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network calls or side effects keyed off the record. - `dynamicHeight` — `boolean`, optional in the type; `RemoteModuleFrame` always sets it. It is true when the host sizes the iframe to the height the bridge reports. When true, size to content (`height: auto`) instead of filling the viewport. When false or absent, the iframe has a fixed grid height, so filling it (`100vh`) is fine. Treat a `null` context (not embedded) like false; see [Viewport-relative height under dynamic height](#high-viewport-relative-height-under-dynamic-height). `context` is `null` only when the document is not embedded (opened directly in a tab); inside `children` it is otherwise always set, because `children` runs only after `init`. ```tsx import { LayoutModuleBridge, type RemoteModuleContext, } from '@persona/layout-editor-remote-module/bridge-frame'; function RiskSummary({ context }: { context: RemoteModuleContext | null }) { if (!context?.layoutObjectId) { return <EmptyState />; } return <RiskScore caseId={context.layoutObjectId} readOnly={context.isReadOnly} />; } ``` The protocol only ever adds fields. Receivers built on 0.5.3+ ignore unknown message types and strip unknown keys, so new context fields may appear in later host releases without a frame upgrade. Source: `src/bridge/protocol.ts`. #### Access token and environment id The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL parameters cannot leak through subsequent requests. ## Common Mistakes ### [HIGH] Reading layout data outside `LayoutModuleBridge` Do not invent APIs such as `requestDataSomehow()` or `window.resolveDataPath`. `data` and `resolveDataPath` exist only in the `LayoutModuleBridge` render prop; pass them to nested components from there. Source: `src/bridge/iframe/LayoutModuleBridge.tsx`. ### [MEDIUM] Host loading card stays visible forever when embedded The host removes its loading card once `LayoutModuleBridge` has rendered `children`, so a card that never clears usually means the handshake didn't complete. Check, in order: the document is really cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects unknown parents — pass your own only for a non-Persona dev host); the frame's `@persona/layout-editor-remote-module` is 0.5.3 or later (a frame on 0.5.2 or older rejects an `init` whose context has keys it doesn't know, e.g. `dynamicHeight`, sent by hosts on 0.8.0+, so `children` never runs); and the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s never send `init`). For a direct `createModuleBridge` integration, also confirm that a bridge created with `signalsLoaded: true` calls `signalLoaded()`. Opening the document directly in a tab is _not_ embedded — `data` and `context` resolve to `null` and `children` renders. Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`, `src/bridge/protocol.ts`. ### [HIGH] Viewport-relative height under dynamic height Wrong: the module root uses `height: 100vh` or `min-height: 100vh` (or `height: 100%` on `html` / `body` / root) regardless of context. With dynamic height on, the bridge reports the iframe's own height, so the module can never shrink. And because the reported height includes the frame document's `body`/ancestor margin and padding, `100vh` or `min-height: 100vh` combined with any body margin or padding reports a height larger than the iframe, so the iframe grows on every report. Correct: switch to `auto` when `context?.dynamicHeight` is true: ```tsx const sizing = context?.dynamicHeight ? 'content' : 'viewport'; // root: height: sizing === 'content' ? 'auto' : '100vh' // min-height: sizing === 'content' ? 'auto' : '100vh' ``` Source: `src/bridge/protocol.ts` (`remoteModuleContextSchema.dynamicHeight`), `src/bridge/host/RemoteModuleFrame.tsx`. ### [HIGH] `DataSourceField` on a non-string property, or read as the resolved value Declare a `DataSourceField` property as `z.string()`. On a non-string property the host stores a structured data-source object instead of the chain string, and `resolveDataPath` only accepts a string — it resolves to `null`. On a string property the stored value is the method chain the user typed (e.g. `"current_account.id"`), not the account ID: pass `data.accountId` to `resolveDataPath` rather than using it directly. ### [MEDIUM] A `ui:field` name the host doesn't register A `ui:field` name the host has no component for falls back to RJSF's default field for the property's type — no build or validation error, just a plain input instead of the intended control. Spell `'DataSourceField'` exactly; module-supplied field components are not possible (see "Everything is JSON" above). ### [HIGH] `key` uses the wrong charset or collides with another module `key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a persisted module identity string and DOM ids. A bad charset fails zod validation at build time (`closeBundle`); a duplicate throws `duplicate module key "..." — keys must be unique within an app`. Never change a released module's `key` — it's written into saved layouts. Source: `src/schema.ts` (`moduleKeySchema`), `src/vite-plugin.ts` (`assertUniqueKeys`). ### [HIGH] `defaultSize` missing an entry for a listed layout Wrong: ```ts defineLayoutModule({ layouts: ['case', 'account'], defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry }); ``` Correct: ```ts defineLayoutModule({ layouts: ['case', 'account'], defaultSize: { case: { width: 4, height: 20 }, account: { width: 6, height: 24 }, }, }); ``` The manifest schema requires an entry in `defaultSize` for every layout named in `layouts`; a missing one fails validation in `closeBundle` with `defaultSize must have an entry for every layout in \`layouts\``. This error surfaces at build time, not at the defineLayoutModule call site. This requirement applies to each listed layout; an empty `layouts` imposes no `defaultSize` requirement, and the host falls back to its own default size. Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`). ### [MEDIUM] `render.entry` is not an HTML document The entry must be an `.html` document. Other extensions fail in `config()` before the plugin checks whether the file exists: `[persona-layout-modules] <key>: render "<entry>" must be an .html document — only document-delivered modules are supported` ### [MEDIUM] `render.entry` doesn't resolve to a real file `personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry path doesn't exist on disk: `render "..." does not resolve to a file (...)`. This is nearly always a relative-path mistake: `entry` resolves against the Vite **root**, not against `vite.config.ts`'s directory, so a non-default `root` in the same config changes what a given relative `entry` points at. Source: `src/vite-plugin.ts` (`checkDeliveryExists`). ### [MEDIUM] `manifestPath` set to a directory instead of a file Use a file path such as `/manifests/my-app.json`, not a directory such as `/manifests/`. `manifestPath` defaults to `DEFAULT_MANIFEST_PATH` (`/_layout_modules_manifest.json`); a leading `/` is optional and stripped either way. Code outside `vite.config.ts` that needs the default (e.g. a subapp server adding CORS on the manifest route) should import `DEFAULT_MANIFEST_PATH` from the runtime-safe `.` entry to avoid pulling in Vite. Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment), `src/manifest-path.ts`.