@scaleflex/template-builder 0.2.0 → 0.4.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.
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
 
@@ -223,6 +223,68 @@ export const BLANK_TEMPLATE_XML =
223
223
  '<template><templateInfo><version>0.3</version></templateInfo>' +
224
224
  '<rootStack/><layouts/><variables/></template>'
225
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
+
226
288
  /**
227
289
  * Stateless mode only (protocol v2). Reports whether the host managed to
228
290
  * persist the content it received in `BUILDER_CONTENT`.
@@ -247,7 +309,7 @@ export interface HostSavedMessage {
247
309
  data: HostSavedData
248
310
  }
249
311
 
250
- export type HostMessage = HostLoadMessage | HostSavedMessage
312
+ export type HostMessage = HostLoadMessage | HostSavedMessage | HostConfigMessage
251
313
 
252
314
  // Embed URL contract ----------------------------------------------------------
253
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.
@@ -69,6 +74,39 @@ export interface TemplateBuilderBaseProps {
69
74
  brandColor?: string
70
75
  /** Colour scheme for the editor chrome. */
71
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
72
110
  readyTimeout?: number
73
111
  className?: string
74
112
  style?: CSSProperties
@@ -155,6 +193,11 @@ export const TemplateBuilder = forwardRef<
155
193
  el.brandColor = config.brandColor ?? ''
156
194
  el.theme = config.theme ?? ''
157
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 ?? ''
158
201
  // Assigned last: the element sends content to the app as soon as it has
159
202
  // both a request and a value, so the id, name and query must already be
160
203
  // set — all four ship as one message.
@@ -177,9 +220,32 @@ export const TemplateBuilder = forwardRef<
177
220
  config.templateQuery,
178
221
  config.brandColor,
179
222
  config.theme,
223
+ config.customMetadata,
224
+ config.customMetadataLabel,
225
+ config.damStore,
226
+ config.storeFolder,
227
+ config.storedUuid,
180
228
  config.readyTimeout,
181
229
  ])
182
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
+
183
249
  // Layout effect, not passive: the element reports config errors (e.g.
184
250
  // `invalid-base-url`) in a microtask queued during this same commit, and a
185
251
  // passive effect would subscribe only after that microtask has fired —
@@ -189,43 +255,61 @@ export const TemplateBuilder = forwardRef<
189
255
  const el = ref.current
190
256
  if (!el) return
191
257
  const subs: Array<[string, EventListener]> = []
192
- const on = (name: string, handler?: (detail: never) => void) => {
193
- if (!handler) return
194
- 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
195
264
  el.addEventListener(name, listener)
196
265
  subs.push([name, listener])
197
266
  }
198
- on('ready', onReady)
199
- on('open', onOpen)
200
- on('close', onClose)
201
- on('error', onError)
202
- 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)
203
272
 
204
273
  // `save` is not just re-emitted: in stateless mode the handler's outcome
205
274
  // is acked back, so a failed write on the host side doesn't leave the
206
275
  // editor showing the template as saved. `confirmSave` no-ops in DAM mode.
207
- if (onSave) {
208
- const listener = ((e: CustomEvent<TemplateBuilderSaveDetail>) => {
209
- // Wrapped in a promise so a synchronous throw is handled like a
210
- // rejection, and a sync `false` like a resolved one.
211
- Promise.resolve()
212
- .then(() => onSave(e.detail))
213
- .then((result) => el.confirmSave(result !== false))
214
- .catch((err) => {
215
- // No message: an internal error string is not something to put in
216
- // front of the end user. The editor uses its own wording.
217
- console.error('[sfx-template-builder] onSave failed:', err)
218
- el.confirmSave(false)
219
- })
220
- }) as EventListener
221
- el.addEventListener('save', listener)
222
- subs.push(['save', listener])
223
- }
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])
224
293
 
225
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
+ }
226
310
  for (const [name, listener] of subs) el.removeEventListener(name, listener)
227
311
  }
228
- }, [onReady, onOpen, onClose, onSave, onError, onDirtyChange])
312
+ }, [])
229
313
 
230
314
  // eslint-disable-next-line react-hooks/refs -- ref is forwarded as a prop, not read during render
231
315
  return createElement('sfx-template-builder', { ref, class: className, style })