@scaleflex/template-builder 0.1.1 → 0.4.0

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/src/index.ts CHANGED
@@ -5,5 +5,7 @@ export {
5
5
  type TemplateBuilderStatus,
6
6
  type TemplateBuilderEventMap,
7
7
  type TemplateBuilderSaveDetail,
8
+ type StoredTemplate,
9
+ type DamStoreAuth,
8
10
  } from './template-builder'
9
11
  export * from './protocol'
package/src/protocol.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // so older widgets keep working against newer app deployments and vice versa.
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- export const PROTOCOL_VERSION = 2
12
+ export const PROTOCOL_VERSION = 3
13
13
 
14
14
  // App → embedder ------------------------------------------------------------
15
15
 
@@ -195,6 +195,96 @@ export interface HostLoadMessage {
195
195
  data: HostLoadData
196
196
  }
197
197
 
198
+ /**
199
+ * An empty `.fdt` document: no layouts, no layers, no variables. What the
200
+ * widget sends as `HOST_LOAD` content when the host asked for a new template
201
+ * (`new-template`) instead of supplying one, so starting from scratch costs a
202
+ * host no knowledge of the template format.
203
+ *
204
+ * The editor opens on its empty state — "No layouts yet. Click + Add to create
205
+ * one." — and the user picks the canvas size there. Save is refused until a
206
+ * layout exists, and hands back a fully-formed document serialized by the app,
207
+ * not this skeleton.
208
+ *
209
+ * Sent as ordinary `HOST_LOAD` content rather than a new message so it works
210
+ * against app deployments that predate this widget version: the document is
211
+ * the whole signal, and every app that can parse a template can parse this.
212
+ *
213
+ * `version` tracks the app's `TEMPLATE_VERSION` for the benefit of whoever
214
+ * reads this next: nothing consumes it. The parser never looks at it, and the
215
+ * backend never sees this document — the app refuses to save a template with
216
+ * no layouts, so what reaches the render pipeline was re-serialized by the app
217
+ * with a layout present. A widget lagging the app by a version still loads.
218
+ * `design-templates` pins the pair in
219
+ * `src/lib/xml/__tests__/blank-template.test.ts`.
220
+ */
221
+ export const BLANK_TEMPLATE_XML =
222
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
223
+ '<template><templateInfo><version>0.3</version></templateInfo>' +
224
+ '<rootStack/><layouts/><variables/></template>'
225
+
226
+ /**
227
+ * Host-supplied editor configuration (protocol v3). Sent once the app reports
228
+ * `BUILDER_READY`, and again whenever the host changes it.
229
+ *
230
+ * Separate from `HOST_LOAD` because it is not per-template and because it must
231
+ * also reach DAM-backed embeds, which never receive a `HOST_LOAD` at all — the
232
+ * app loads those templates itself.
233
+ *
234
+ * Purely additive: an app deployment that predates this message ignores it and
235
+ * behaves exactly as before, so a newer widget stays compatible with an older
236
+ * app. Held to the same origin bar as `HOST_LOAD`.
237
+ */
238
+ export const HOST_CONFIG = 'design-templates:host:config'
239
+
240
+ /**
241
+ * One field of a host-supplied metadata model, offered in the editor as the
242
+ * "Custom metadata" value source.
243
+ *
244
+ * The model is a vocabulary, not data: it names the fields the host can fill at
245
+ * render time, so an author can bind a variable to `sku` rather than having to
246
+ * remember that the variable's slug happens to mean the SKU. No value travels
247
+ * with it — the host substitutes one by putting `$slug=value` in the render
248
+ * query, exactly as it would for a free-text variable.
249
+ *
250
+ * This is what makes named fields workable in `secTemplate` / stateless embeds,
251
+ * where the Hub project model (and with it the "File metadata" source) is
252
+ * unavailable.
253
+ */
254
+ export interface CustomMetadataField {
255
+ /**
256
+ * Stable identifier stored in the template as `custom_ckey`. The host's own
257
+ * key for the field — the app never resolves it against anything.
258
+ */
259
+ key: string
260
+ /** Label shown in the editor's field picker. Falls back to `key` when empty. */
261
+ title?: string
262
+ /** Optional section header, used to group fields in the picker. */
263
+ group?: string
264
+ }
265
+
266
+ export interface HostConfigData {
267
+ /**
268
+ * Metadata model offered as the "Custom metadata" value source. Omitted or
269
+ * empty hides that source in the editor, so a host that sends nothing sees
270
+ * the two sources it always had.
271
+ */
272
+ customMetadata?: CustomMetadataField[]
273
+ /**
274
+ * Display name for the custom-metadata value source in the editor's UI
275
+ * (source dropdowns, properties-panel section). Defaults to "Custom
276
+ * metadata"; a host can rename it after its own domain — e.g. "External
277
+ * metadata" or "Product attributes". Pure wording: the stored template is
278
+ * unaffected.
279
+ */
280
+ customMetadataLabel?: string
281
+ }
282
+
283
+ export interface HostConfigMessage {
284
+ type: typeof HOST_CONFIG
285
+ data: HostConfigData
286
+ }
287
+
198
288
  /**
199
289
  * Stateless mode only (protocol v2). Reports whether the host managed to
200
290
  * persist the content it received in `BUILDER_CONTENT`.
@@ -219,7 +309,7 @@ export interface HostSavedMessage {
219
309
  data: HostSavedData
220
310
  }
221
311
 
222
- export type HostMessage = HostLoadMessage | HostSavedMessage
312
+ export type HostMessage = HostLoadMessage | HostSavedMessage | HostConfigMessage
223
313
 
224
314
  // Embed URL contract ----------------------------------------------------------
225
315
 
package/src/react.ts CHANGED
@@ -10,7 +10,12 @@ import {
10
10
  import './define'
11
11
  import type { SfxTemplateBuilder } from './template-builder'
12
12
  import type { TemplateBuilderSaveDetail } from './template-builder'
13
- import type { BuilderDirtyData, BuilderErrorData, BuilderTheme } from './protocol'
13
+ import type {
14
+ BuilderDirtyData,
15
+ BuilderErrorData,
16
+ BuilderTheme,
17
+ CustomMetadataField,
18
+ } from './protocol'
14
19
 
15
20
  /**
16
21
  * Hub session — the full-featured credential.
@@ -50,6 +55,13 @@ export interface TemplateBuilderBaseProps {
50
55
  stateless?: boolean
51
56
  /** Stateless mode: the template to edit, as `.fdt` XML. */
52
57
  content?: string
58
+ /**
59
+ * Stateless mode: open on a new, empty template instead of supplying
60
+ * `content`. The widget provides the blank document, the user picks the
61
+ * canvas size in the editor, and `onSave` receives a complete `.fdt` to
62
+ * store. Ignored when `content` is set.
63
+ */
64
+ newTemplate?: boolean
53
65
  /** Stateless mode: display name for the editor header. */
54
66
  templateName?: string
55
67
  /**
@@ -62,6 +74,39 @@ export interface TemplateBuilderBaseProps {
62
74
  brandColor?: string
63
75
  /** Colour scheme for the editor chrome. */
64
76
  theme?: BuilderTheme
77
+ /**
78
+ * Metadata model offered in the editor as the "Custom metadata" value source
79
+ * — names only, no values. Omit it and the source is not offered.
80
+ *
81
+ * Compared by identity, like every other prop here, so a freshly built array
82
+ * counts as a change. Nothing is re-sent to the editor over it — the element
83
+ * de-dupes by value — but hoisting or memoising the array avoids the churn.
84
+ */
85
+ customMetadata?: CustomMetadataField[]
86
+ /**
87
+ * Display name for the custom-metadata value source in the editor's UI —
88
+ * e.g. "External metadata". Wording only: the stored template is unaffected.
89
+ * Empty uses the editor's default, "Custom metadata".
90
+ */
91
+ customMetadataLabel?: string
92
+ /**
93
+ * Stateless only: store each save in Filerobot too, so the CDN can render
94
+ * it. `onSave`'s detail then carries `stored: { uuid, url }` next to the raw
95
+ * `content` — or `storeError` when the copy failed.
96
+ */
97
+ damStore?: boolean
98
+ /**
99
+ * Folder new templates land in under `damStore` when the template id names
100
+ * no existing DAM file (an existing file's own folder always wins).
101
+ */
102
+ storeFolder?: string
103
+ /**
104
+ * `damStore`: the `stored.uuid` a previous session's save reported for THIS
105
+ * document, so re-saves after a reload resolve to (and version) the copy
106
+ * that already exists instead of erroring on unchanged content or starting
107
+ * a fresh file. Per-document — pass it with the content it belongs to.
108
+ */
109
+ storedUuid?: string
65
110
  readyTimeout?: number
66
111
  className?: string
67
112
  style?: CSSProperties
@@ -147,6 +192,12 @@ export const TemplateBuilder = forwardRef<
147
192
  el.templateQuery = config.templateQuery ?? ''
148
193
  el.brandColor = config.brandColor ?? ''
149
194
  el.theme = config.theme ?? ''
195
+ el.newTemplate = config.newTemplate ?? false
196
+ el.customMetadata = config.customMetadata ?? []
197
+ el.customMetadataLabel = config.customMetadataLabel ?? ''
198
+ el.damStore = config.damStore ?? false
199
+ el.storeFolder = config.storeFolder ?? '/'
200
+ el.storedUuid = config.storedUuid ?? ''
150
201
  // Assigned last: the element sends content to the app as soon as it has
151
202
  // both a request and a value, so the id, name and query must already be
152
203
  // set — all four ship as one message.
@@ -164,13 +215,37 @@ export const TemplateBuilder = forwardRef<
164
215
  config.mode,
165
216
  config.stateless,
166
217
  config.content,
218
+ config.newTemplate,
167
219
  config.templateName,
168
220
  config.templateQuery,
169
221
  config.brandColor,
170
222
  config.theme,
223
+ config.customMetadata,
224
+ config.customMetadataLabel,
225
+ config.damStore,
226
+ config.storeFolder,
227
+ config.storedUuid,
171
228
  config.readyTimeout,
172
229
  ])
173
230
 
231
+ // The callbacks the listeners read at event time. A ref rather than effect
232
+ // dependencies: listeners are attached once per element (below), so a parent
233
+ // re-render swapping handler identities costs nothing — and, decisively, the
234
+ // listeners are still attached during the element's disconnect-time flush of
235
+ // pending dam-store saves, which a resubscribe-per-change cleanup would have
236
+ // already torn down.
237
+ const handlers = useRef({
238
+ onReady,
239
+ onOpen,
240
+ onClose,
241
+ onSave,
242
+ onError,
243
+ onDirtyChange,
244
+ })
245
+ useLayoutEffect(() => {
246
+ handlers.current = { onReady, onOpen, onClose, onSave, onError, onDirtyChange }
247
+ })
248
+
174
249
  // Layout effect, not passive: the element reports config errors (e.g.
175
250
  // `invalid-base-url`) in a microtask queued during this same commit, and a
176
251
  // passive effect would subscribe only after that microtask has fired —
@@ -180,43 +255,61 @@ export const TemplateBuilder = forwardRef<
180
255
  const el = ref.current
181
256
  if (!el) return
182
257
  const subs: Array<[string, EventListener]> = []
183
- const on = (name: string, handler?: (detail: never) => void) => {
184
- if (!handler) return
185
- const listener = ((e: CustomEvent) => handler(e.detail as never)) as EventListener
258
+ const on = (
259
+ name: string,
260
+ pick: (h: typeof handlers.current) => ((detail: never) => void) | undefined,
261
+ ) => {
262
+ const listener = ((e: CustomEvent) =>
263
+ pick(handlers.current)?.(e.detail as never)) as EventListener
186
264
  el.addEventListener(name, listener)
187
265
  subs.push([name, listener])
188
266
  }
189
- on('ready', onReady)
190
- on('open', onOpen)
191
- on('close', onClose)
192
- on('error', onError)
193
- on('dirtychange', onDirtyChange)
267
+ on('ready', (h) => h.onReady)
268
+ on('open', (h) => h.onOpen)
269
+ on('close', (h) => h.onClose)
270
+ on('error', (h) => h.onError)
271
+ on('dirtychange', (h) => h.onDirtyChange)
194
272
 
195
273
  // `save` is not just re-emitted: in stateless mode the handler's outcome
196
274
  // is acked back, so a failed write on the host side doesn't leave the
197
275
  // editor showing the template as saved. `confirmSave` no-ops in DAM mode.
198
- if (onSave) {
199
- const listener = ((e: CustomEvent<TemplateBuilderSaveDetail>) => {
200
- // Wrapped in a promise so a synchronous throw is handled like a
201
- // rejection, and a sync `false` like a resolved one.
202
- Promise.resolve()
203
- .then(() => onSave(e.detail))
204
- .then((result) => el.confirmSave(result !== false))
205
- .catch((err) => {
206
- // No message: an internal error string is not something to put in
207
- // front of the end user. The editor uses its own wording.
208
- console.error('[sfx-template-builder] onSave failed:', err)
209
- el.confirmSave(false)
210
- })
211
- }) as EventListener
212
- el.addEventListener('save', listener)
213
- subs.push(['save', listener])
214
- }
276
+ const saveListener = ((e: CustomEvent<TemplateBuilderSaveDetail>) => {
277
+ const onSaveNow = handlers.current.onSave
278
+ if (!onSaveNow) return
279
+ // Wrapped in a promise so a synchronous throw is handled like a
280
+ // rejection, and a sync `false` like a resolved one.
281
+ Promise.resolve()
282
+ .then(() => onSaveNow(e.detail))
283
+ .then((result) => el.confirmSave(result !== false))
284
+ .catch((err) => {
285
+ // No message: an internal error string is not something to put in
286
+ // front of the end user. The editor uses its own wording.
287
+ console.error('[sfx-template-builder] onSave failed:', err)
288
+ el.confirmSave(false)
289
+ })
290
+ }) as EventListener
291
+ el.addEventListener('save', saveListener)
292
+ subs.push(['save', saveListener])
215
293
 
216
294
  return () => {
295
+ // React runs this cleanup BEFORE it detaches the node, so the element's
296
+ // own disconnect-time flush of in-flight dam-store saves would fire
297
+ // after every listener is gone — and the raw save would be silently
298
+ // lost. Flushing here, while the listeners are still attached, hands
299
+ // those saves (with `storeError` in place of the links) to `onSave`
300
+ // first. A no-op when nothing is pending, including StrictMode's
301
+ // simulated unmount at mount time.
302
+ //
303
+ // Guarded: when an older CDN bundle registered the tag first, `el` is
304
+ // that bundle's class and lacks the method — every other new-API use
305
+ // degrades silently via property assignment, and unmount must not be
306
+ // the one path that throws.
307
+ if (typeof el.flushPendingSaves === 'function') {
308
+ el.flushPendingSaves('widget removed before the rendering copy completed')
309
+ }
217
310
  for (const [name, listener] of subs) el.removeEventListener(name, listener)
218
311
  }
219
- }, [onReady, onOpen, onClose, onSave, onError, onDirtyChange])
312
+ }, [])
220
313
 
221
314
  // eslint-disable-next-line react-hooks/refs -- ref is forwarded as a prop, not read during render
222
315
  return createElement('sfx-template-builder', { ref, class: className, style })