|
| 1 | +# Plan: CMS Preview SDK & Component Mapping System |
| 2 | + |
| 3 | +> Source PRD: `ai-context/prds/cms-preview-sdk.md` |
| 4 | +
|
| 5 | +## Architectural decisions |
| 6 | + |
| 7 | +Durable decisions that apply across all phases: |
| 8 | + |
| 9 | +- **Packages**: Three packages — `@webiny/cms-sdk` (core), `@webiny/cms-nextjs` (Next.js binding), and the existing `@webiny/sdk` for API calls. Mirrors the WB pattern: `website-builder-sdk` → `website-builder-react` → `website-builder-nextjs`. |
| 10 | +- **Model schema**: `CmsModel.settings.previewUrl: string` for the preview URL. `CmsDynamicZoneTemplate.componentName: string` for the component mapping. Both exposed via the existing `getContentModel` query. |
| 11 | +- **SDK modes**: Three modes detected automatically — `live` (production, fetches published), `preview` (drafts via `preview: true`), `editing` (iframe, receives data via postMessage). Same environment detection as WB SDK (`window.parent !== window`). |
| 12 | +- **Bridge protocol**: Reuses the WB `Messenger` pattern — `postMessage` with origin validation and namespaced event types (e.g., `cms.preview.*`). |
| 13 | +- **Template discriminator**: `_templateId` is the universal discriminator. No `__typename` anywhere in the SDK or rendering pipeline. |
| 14 | +- **Entry fetching**: Uses the existing Webiny SDK's `getEntry`/`listEntries`. The SDK passes `["values.*"]` as the field selection — a new wildcard convention. The backend's `getEntry` resolver expands `values.*` to the full field selection using the model's `ValuesSelectionGenerator` (the model and AST are already available in the resolver). This means the SDK never needs to know about field types or DZ structures — it just asks for "all values" and the server handles it. `ref` fields are excluded from the generated selection. |
| 15 | +- **Component resolution**: `_templateId` → model template definition → `componentName` → component registry lookup → render. |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Phase 0: Wildcard field selection (`values.*`) |
| 20 | + |
| 21 | +**User stories**: 1 |
| 22 | + |
| 23 | +### What to build |
| 24 | + |
| 25 | +Add support for the `values.*` wildcard in the backend's generic `getEntry`/`listEntries` resolvers. This is the foundation that lets the SDK (and any API consumer) fetch all entry values without knowing the model's field structure. |
| 26 | + |
| 27 | +**Backend**: In the generic CMS resolver (`getEntryResolver.ts`), when the `fields` array contains `"values.*"`, expand it to `values { <full selection> }` using the model's `ValuesSelectionGenerator`. The model is already resolved in the resolver, and the `ValuesSelectionGenerator` is already a registered service. The expansion replaces the `values.*` entry in the fields array with the generated selection; other fields (like `id`, `entryId`, `createdOn`) pass through unchanged. |
| 28 | + |
| 29 | +The same wildcard support should apply to `listEntries` as well. |
| 30 | + |
| 31 | +**Test**: Write tests that verify: |
| 32 | + |
| 33 | +- `values.*` expands to the correct GraphQL selection for models with scalar fields, object fields, DZ fields (with templates), and nested DZs |
| 34 | +- Mixing `values.*` with explicit system fields (`id`, `entryId`) works correctly |
| 35 | +- Models with no fields produce a valid (empty or minimal) selection |
| 36 | +- The expanded query actually returns the expected entry data end-to-end (integration test via the existing CMS test infrastructure) |
| 37 | + |
| 38 | +### Acceptance criteria |
| 39 | + |
| 40 | +- [ ] `getEntry` resolver expands `values.*` to the full values selection using `ValuesSelectionGenerator` |
| 41 | +- [ ] `listEntries` resolver supports the same `values.*` wildcard |
| 42 | +- [ ] Explicit fields (`id`, `entryId`, `createdOn`) can be mixed with `values.*` |
| 43 | +- [ ] DZ template values are returned with `_templateId` and all template fields |
| 44 | +- [ ] Object fields and nested structures are fully expanded |
| 45 | +- [ ] Models with no fields don't error out |
| 46 | +- [ ] Integration tests pass for models with scalar, object, DZ, and nested DZ fields |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Phase 1: Model preview URL + SDK skeleton + basic entry rendering |
| 51 | + |
| 52 | +**User stories**: 1, 4, 12, 13, 17 |
| 53 | + |
| 54 | +### What to build |
| 55 | + |
| 56 | +The thinnest possible end-to-end slice: a CMS model gets a preview URL setting, a new SDK package can fetch and display that model's entries on a Next.js page. |
| 57 | + |
| 58 | +**API side**: Add `previewUrl` to the `CmsModel.settings` schema. The model editor UI gets a "Preview URL" text field in the model settings area. The value is persisted and returned in `getContentModel` responses. |
| 59 | + |
| 60 | +**SDK packages**: Create `@webiny/cms-sdk` with the core architecture — `ContentSdk` singleton with `init(config)`, environment detection (`isClient`, `isServer`, `isEditing`), and a data provider that wraps the existing `@webiny/sdk` `CmsSdk`. The SDK always passes `["id", "entryId", "values.*"]` to `getEntry`. Create `@webiny/cms-nextjs` as a thin Next.js wrapper (headers provider, re-exports). |
| 61 | + |
| 62 | +**Next.js demo**: A basic Next.js page fetches an entry via the SDK and renders scalar fields (title, slug, body) as plain HTML. No DZ component rendering yet — DZ values are accessible as raw data. |
| 63 | + |
| 64 | +### Acceptance criteria |
| 65 | + |
| 66 | +- [ ] `CmsModel.settings.previewUrl` is persisted and returned in the `getContentModel` GraphQL response |
| 67 | +- [ ] Model editor UI has a "Preview URL" field in model settings |
| 68 | +- [ ] `@webiny/cms-sdk` package exists with `ContentSdk.init(config)`, environment detection, and `getEntry(modelId, entryId)` that passes `["id", "entryId", "values.*"]` |
| 69 | +- [ ] `@webiny/cms-nextjs` package exists and re-exports the core SDK with Next.js headers provider |
| 70 | +- [ ] A Next.js page can fetch and render an entry's scalar fields without writing any GraphQL |
| 71 | +- [ ] SDK supports `preview: true` config to fetch draft entries |
| 72 | +- [ ] SDK works in both SSR and client-side contexts |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## Phase 2: Component registration + bridge discovery |
| 77 | + |
| 78 | +**User stories**: 2, 3, 5, 20 |
| 79 | + |
| 80 | +### What to build |
| 81 | + |
| 82 | +The component catalog system: frontend developers register components with the SDK, and the CMS model editor discovers them via an iframe bridge. |
| 83 | + |
| 84 | +**SDK side**: Add `createComponent(ReactComponent, manifest)` factory (mirrors WB's pattern). Add `ComponentRegistry` that stores registered components. When the SDK detects editing mode (iframe), it establishes a `Messenger` connection and sends `cms.preview.component.register` messages for each registered component — including name, label, and input definitions. |
| 85 | + |
| 86 | +**Admin side**: The DZ template editing dialog gets a "Component" picker. When the model has a `previewUrl`, the dialog opens a hidden iframe to that URL. The bridge receives component registrations and populates the picker dropdown. The user selects which component renders this template. |
| 87 | + |
| 88 | +**Per-route scoping**: Components are passed to the SDK's entry renderer (like WB's `DocumentRenderer` receiving `components` prop), so each route can declare its own component catalog. |
| 89 | + |
| 90 | +### Acceptance criteria |
| 91 | + |
| 92 | +- [ ] `createComponent(Component, manifest)` creates a component blueprint with name, label, and input definitions |
| 93 | +- [ ] Components are registered with the SDK and sent to the CMS editor via the bridge when in editing mode |
| 94 | +- [ ] The DZ template editor shows a "Component" dropdown populated from the bridge-discovered catalog |
| 95 | +- [ ] Only components registered on the preview URL page for that model appear in the picker |
| 96 | +- [ ] Component discovery happens live — adding a new component on the frontend and refreshing the iframe makes it appear immediately |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## Phase 3: Component mapping storage + DZ rendering |
| 101 | + |
| 102 | +**User stories**: 6, 9, 10, 14, 16 |
| 103 | + |
| 104 | +### What to build |
| 105 | + |
| 106 | +Store the template→component mapping and render DZ entries as actual React components. |
| 107 | + |
| 108 | +**API side**: Add `componentName: string` to `CmsDynamicZoneTemplate`. When the user selects a component in the template editor (Phase 2), the `componentName` is saved on the template. It's returned as part of the model definition in `getContentModel`. |
| 109 | + |
| 110 | +**SDK side**: Add `ComponentResolver` that takes entry data and the model definition, iterates DZ values, reads `_templateId`, looks up the template to get `componentName`, resolves it in the `ComponentRegistry`, and renders the matched React component with the template's field values as props. |
| 111 | + |
| 112 | +**Entry renderer**: Add `<EntryRenderer entry={entry} model={model} components={components} />` React component. It renders DZ field values as mapped components. Scalar fields are passed through as-is for the developer's layout to consume. |
| 113 | + |
| 114 | +### Acceptance criteria |
| 115 | + |
| 116 | +- [ ] `CmsDynamicZoneTemplate.componentName` is persisted and returned in model definition queries |
| 117 | +- [ ] SDK fetches model definition and builds the `_templateId` → `componentName` mapping automatically |
| 118 | +- [ ] `<EntryRenderer>` renders DZ template values as the mapped React components |
| 119 | +- [ ] Components receive the template's field values as props/inputs |
| 120 | +- [ ] Scalar fields (title, slug, etc.) are accessible on the entry object for manual rendering |
| 121 | +- [ ] `_templateId` is the only discriminator used — no `__typename` dependency |
| 122 | +- [ ] Nested DZ fields (DZ inside a DZ template) resolve correctly |
| 123 | + |
| 124 | +--- |
| 125 | + |
| 126 | +## Phase 4: Live preview in iframe |
| 127 | + |
| 128 | +**User stories**: 7, 8, 11, 19 |
| 129 | + |
| 130 | +### What to build |
| 131 | + |
| 132 | +Real-time live preview: the CMS entry editor embeds the preview URL in an iframe and pushes form data on every change. |
| 133 | + |
| 134 | +**Admin side**: The entry editor detects that the model has a `previewUrl` and embeds it in a preview pane (side-by-side layout, similar to the existing `extensions/livePreview/` pattern). On every form field change, the editor sends the full entry data to the iframe via `cms.preview.entry.update` postMessage. |
| 135 | + |
| 136 | +**SDK side**: The `EditingSdk` variant listens for `cms.preview.entry.update` messages and updates its internal entry state. The `<EntryRenderer>` reactively re-renders when the entry data changes. For models without DZ fields, the developer's layout still receives updated scalar field values. |
| 137 | + |
| 138 | +**Data flow**: Editor form data → postMessage → SDK EditingSdk → reactive state update → `<EntryRenderer>` re-render. Full entry sent on every change (not patches). |
| 139 | + |
| 140 | +### Acceptance criteria |
| 141 | + |
| 142 | +- [ ] CMS entry editor embeds the preview URL iframe when the model has a `previewUrl` |
| 143 | +- [ ] Every form field change sends the full entry data to the iframe via postMessage |
| 144 | +- [ ] The SDK receives live updates and re-renders the entry in real time |
| 145 | +- [ ] DZ template components update live as the user edits template field values |
| 146 | +- [ ] Scalar field changes (title, slug) propagate to the preview immediately |
| 147 | +- [ ] Models without DZ fields still get live preview of scalar fields |
| 148 | +- [ ] The preview pane shows a side-by-side layout (editor + preview) |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +## Phase 5: Component input definitions in template editor |
| 153 | + |
| 154 | +**User story**: 15 |
| 155 | + |
| 156 | +### What to build |
| 157 | + |
| 158 | +When a component is mapped to a template, the template editor displays the component's input definitions so the content modeler understands what the component expects. |
| 159 | + |
| 160 | +**Admin side**: After the user selects a component in the template editor's component picker, the dialog shows a read-only summary of the component's declared inputs — name, label, description, type. This helps the modeler understand whether the template's fields align with what the component needs. |
| 161 | + |
| 162 | +### Acceptance criteria |
| 163 | + |
| 164 | +- [ ] Template editor shows the mapped component's input definitions (name, label, description) after selection |
| 165 | +- [ ] Input definitions are sourced from the component manifest received via the bridge |
| 166 | +- [ ] The display is read-only and informational — no editing of component inputs from the CMS side |
| 167 | + |
| 168 | +--- |
| 169 | + |
| 170 | +## Phase 6: Reference field hooks |
| 171 | + |
| 172 | +**User story**: 18 |
| 173 | + |
| 174 | +### What to build |
| 175 | + |
| 176 | +Explicit SDK utilities for developers to fetch referenced entries where auto-fetching doesn't cover them. |
| 177 | + |
| 178 | +**SDK side**: Provide hooks or methods (e.g., `useEntryRef(refValue)` or `sdk.resolveRef(refValue)`) that take a ref field value (`{ entryId, modelId }`) and fetch the referenced entry using the SDK's data provider. This gives developers control over when and how references are resolved, without the SDK attempting recursive auto-fetching. |
| 179 | + |
| 180 | +### Acceptance criteria |
| 181 | + |
| 182 | +- [ ] SDK provides a method to resolve a ref field value to a full entry |
| 183 | +- [ ] The method uses the same SDK config (preview mode, auth) as the main entry fetching |
| 184 | +- [ ] Developers can call the ref resolver explicitly in their component code |
| 185 | +- [ ] The ref resolver works in all three SDK modes (live, preview, editing) |
0 commit comments