@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.
@@ -12,9 +12,11 @@ import {
12
12
  BUILDER_SAVE,
13
13
  EMBED_PARAMS,
14
14
  EMBED_ROUTE,
15
+ HOST_CONFIG,
15
16
  HOST_LOAD,
16
17
  HOST_SAVED,
17
18
  builderRoute,
19
+ type CustomMetadataField,
18
20
  type BuilderContentData,
19
21
  type BuilderContentMessage,
20
22
  type BuilderDirtyData,
@@ -25,6 +27,13 @@ import {
25
27
  type BuilderSaveMessage,
26
28
  type BuilderTheme,
27
29
  } from './protocol'
30
+ import {
31
+ storeTemplateInDam,
32
+ type DamStoreAuth,
33
+ type StoredTemplate,
34
+ } from './dam-store'
35
+
36
+ export type { StoredTemplate, DamStoreAuth } from './dam-store'
28
37
 
29
38
  export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
30
39
 
@@ -34,11 +43,14 @@ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
34
43
  * - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
35
44
  * reports the resulting `uuid`.
36
45
  * - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
37
- * the edited template for the host to persist.
46
+ * the edited template for the host to persist. With `dam-store` the element
47
+ * uploads a rendering copy to Filerobot first, and the detail additionally
48
+ * carries `stored` (the copy's uuid + CDN URL) — or `storeError` when that
49
+ * copy could not be made; the raw `content` arrives either way.
38
50
  */
39
51
  export type TemplateBuilderSaveDetail =
40
52
  | BuilderSaveData
41
- | BuilderContentData
53
+ | (BuilderContentData & { stored?: StoredTemplate; storeError?: string })
42
54
  | undefined
43
55
 
44
56
  export interface TemplateBuilderEventMap {
@@ -195,6 +207,81 @@ export class SfxTemplateBuilder extends LitElement {
195
207
  @property({ attribute: 'brand-color' }) brandColor = ''
196
208
  /** Colour scheme for the editor chrome. Empty leaves the app's own default. */
197
209
  @property() theme: BuilderTheme | '' = ''
210
+ /**
211
+ * Metadata model to offer in the editor as the **Custom metadata** value
212
+ * source: `[{ key, title?, group? }]`. Authors then bind a text variable to
213
+ * one of your field names instead of to a bare slug.
214
+ *
215
+ * Names only — no values travel with the model, and the app resolves nothing
216
+ * against it. The bound key is stored in the template as `custom_ckey`, and
217
+ * the variable renders like any free-text one: your pipeline puts
218
+ * `$slug=value` in the render query. Read the key back from the saved `.fdt`
219
+ * to know which of your fields each variable expects.
220
+ *
221
+ * Leave it empty and the source is not offered at all, so hosts that send
222
+ * nothing see the editor they always had. Its main use is `sec-template` /
223
+ * stateless embeds, where the Hub project model — and with it the "File
224
+ * metadata" source — is unavailable.
225
+ *
226
+ * Settable as a property (an array) or as a `custom-metadata` attribute
227
+ * holding that array as JSON. Neither is trusted to be well-formed:
228
+ * unparseable JSON is warned about, a value that is not an array is treated as
229
+ * no model, and individual fields the editor cannot use are dropped there —
230
+ * a config typo costs the field, not the editor.
231
+ *
232
+ * Lit compares by identity: assign a new array to change the model, don't
233
+ * mutate the one you passed.
234
+ */
235
+ @property({
236
+ attribute: 'custom-metadata',
237
+ converter: {
238
+ fromAttribute: (value: string | null): CustomMetadataField[] => {
239
+ if (!value) return []
240
+ try {
241
+ const parsed: unknown = JSON.parse(value)
242
+ return Array.isArray(parsed) ? (parsed as CustomMetadataField[]) : []
243
+ } catch {
244
+ console.warn('[sfx-template-builder] custom-metadata is not valid JSON — ignoring.')
245
+ return []
246
+ }
247
+ },
248
+ toAttribute: (value: CustomMetadataField[]): string => JSON.stringify(value ?? []),
249
+ },
250
+ })
251
+ customMetadata: CustomMetadataField[] = []
252
+ /**
253
+ * Display name for the custom-metadata value source in the editor's UI.
254
+ * Empty means the editor's default ("Custom metadata"); a host can rename
255
+ * it after its own domain — e.g. "External metadata". Pure wording: the
256
+ * stored template is unaffected.
257
+ */
258
+ @property({ attribute: 'custom-metadata-label' }) customMetadataLabel = ''
259
+ /**
260
+ * Stateless only: store each save in Filerobot too, so the CDN can render
261
+ * it. The `save` event then carries `stored: { uuid, url }` next to the raw
262
+ * `content` — or `storeError` when the copy failed (the raw data arrives
263
+ * either way; whether that fails the save is the host's call via the ack).
264
+ * Uses the element's own credentials; a security template needs a scope
265
+ * that allows uploads.
266
+ */
267
+ @property({ type: Boolean, attribute: 'dam-store' }) damStore = false
268
+ /**
269
+ * Folder new templates are stored into when `dam-store` is on and the
270
+ * template id names no existing DAM file (an existing file's own folder
271
+ * always wins, so same name + folder versions it in place).
272
+ */
273
+ @property({ attribute: 'store-folder' }) storeFolder = '/'
274
+ /**
275
+ * `dam-store`: the uuid of the rendering copy this document already has —
276
+ * the `stored.uuid` a previous session's save reported, passed back in by
277
+ * the host alongside the content. Without it the element only remembers
278
+ * copies it made itself, so after a reload an unchanged re-save cannot find
279
+ * its own file and reports a spurious `storeError`, and a changed one starts
280
+ * a fresh file instead of versioning the existing one. Set it when reopening
281
+ * a stored template; it belongs to the document, so hosts that swap
282
+ * documents must swap (or clear) it too — `load()` does this for you.
283
+ */
284
+ @property({ attribute: 'stored-uuid' }) storedUuid = ''
198
285
  /** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
199
286
  @property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
200
287
 
@@ -216,6 +303,21 @@ export class SfxTemplateBuilder extends LitElement {
216
303
  * back on save.
217
304
  */
218
305
  private _sentKey?: string
306
+ /**
307
+ * Identity of the last SAVE the app handed back, in the same shape as
308
+ * `_sentKey`. A host echoing the full save detail into the props (content,
309
+ * name, templateQuery) matches this key rather than `_sentKey`, whose name
310
+ * and query still describe what the template was loaded with — without it
311
+ * the echo would post a HOST_LOAD that reloads the editor and wipes undo
312
+ * history on nearly every save (a save almost always changes the query).
313
+ */
314
+ private _sentSaveKey?: string
315
+ /**
316
+ * Identity of the config already delivered, so re-renders don't re-post it.
317
+ * Undefined means the app has not been told anything yet — set back to that
318
+ * whenever a new app instance loads.
319
+ */
320
+ private _sentConfigKey?: string
219
321
  /**
220
322
  * The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
221
323
  * on every update cycle, so without this a bad URL re-emits `error` forever —
@@ -265,11 +367,13 @@ export class SfxTemplateBuilder extends LitElement {
265
367
  templateId,
266
368
  name,
267
369
  templateQuery,
370
+ storedUuid,
268
371
  }: {
269
372
  content: string
270
373
  templateId?: string
271
374
  name?: string
272
375
  templateQuery?: string
376
+ storedUuid?: string
273
377
  }): void {
274
378
  if (templateId !== undefined) this.templateId = templateId
275
379
  if (name !== undefined) this.templateName = name
@@ -277,6 +381,10 @@ export class SfxTemplateBuilder extends LitElement {
277
381
  // previous template's query in place while the new content goes out would
278
382
  // open the new document on the old layout and values.
279
383
  if (templateQuery !== undefined) this.templateQuery = templateQuery
384
+ // Omission clears rather than keeps: the seed names the DOCUMENT's stored
385
+ // copy, and carrying one document's uuid into the next would anchor its
386
+ // saves to the wrong file. "Unknown" is the safe reading of not saying.
387
+ this.storedUuid = storedUuid ?? ''
280
388
  // A template of your own is not the blank one: clearing the flag lets a
281
389
  // host alternate between `createNew()` and `load()` on one element.
282
390
  this.newTemplate = false
@@ -305,8 +413,11 @@ export class SfxTemplateBuilder extends LitElement {
305
413
  if (name !== undefined) this.templateName = name
306
414
  // A new document has no layouts and no variables, so there is no render
307
415
  // for a query to select — and a leftover one would name layouts and
308
- // variables of the previous template.
416
+ // variables of the previous template. Same for the stored-copy seed: a
417
+ // blank document has no copy, and a stale one would anchor the first save
418
+ // to the previous document's file.
309
419
  this.templateQuery = ''
420
+ this.storedUuid = ''
310
421
  this.newTemplate = true
311
422
  this.content = ''
312
423
  this._open = true
@@ -347,6 +458,21 @@ export class SfxTemplateBuilder extends LitElement {
347
458
  super.disconnectedCallback()
348
459
  window.removeEventListener('message', this._onMessage)
349
460
  this._clearHandshakeTimer()
461
+ // Element-level listeners still fire on a detached element, so a vanilla
462
+ // host that removes the widget mid-upload still gets its raw save. (A
463
+ // framework wrapper unsubscribes its listeners before the node detaches,
464
+ // which is why the public `flushPendingSaves()` exists — the React wrapper
465
+ // calls it from its cleanup, while its listeners still hear the event.)
466
+ //
467
+ // Deferred a task: REPARENTING (appendChild into another container)
468
+ // detaches and reattaches synchronously, and flushing on the detach half
469
+ // would report a false storeError for an upload that lands fine. Only a
470
+ // detach that is still detached a tick later is a real removal.
471
+ setTimeout(() => {
472
+ if (!this.isConnected) {
473
+ this._flushPendingSaves('widget removed before the rendering copy completed')
474
+ }
475
+ }, 0)
350
476
  }
351
477
 
352
478
  protected willUpdate(changed: PropertyValues): void {
@@ -367,8 +493,14 @@ export class SfxTemplateBuilder extends LitElement {
367
493
  this._clearHandshakeTimer()
368
494
  // A new document means a new app instance: it has not asked for content
369
495
  // yet, and nothing has been delivered to it.
496
+ // Delivery state only — this is a new APP instance, not a new document.
497
+ // `_lastDocKey` / `_docEpoch` / `_storeMemory` describe the document and
498
+ // survive: resetting them here made every modal close or theme swap
499
+ // forget the stored copy and fork the file on the next save.
370
500
  this._contentRequested = false
371
501
  this._sentKey = undefined
502
+ this._sentSaveKey = undefined
503
+ this._sentConfigKey = undefined
372
504
  if (this._isDirty) {
373
505
  this._isDirty = false
374
506
  this._emit('dirtychange', { isDirty: false })
@@ -387,6 +519,16 @@ export class SfxTemplateBuilder extends LitElement {
387
519
  ) {
388
520
  this._maybeSendContent()
389
521
  }
522
+ // Config is independent of the template: it also has to reach DAM-backed
523
+ // embeds, which never send a content request. Only once the app is ready,
524
+ // though — before that there is nothing listening, and the ready signal
525
+ // sends whatever the latest value is anyway.
526
+ if (
527
+ (changed.has('customMetadata') || changed.has('customMetadataLabel')) &&
528
+ this._status === 'ready'
529
+ ) {
530
+ this._maybeSendConfig()
531
+ }
390
532
  }
391
533
 
392
534
  render() {
@@ -528,6 +670,17 @@ export class SfxTemplateBuilder extends LitElement {
528
670
  this._status = 'ready'
529
671
  this._emit('ready')
530
672
  }
673
+ // Config rides the ready signal: the app has its listener attached by
674
+ // the time it announces, and a remount inside an unchanged iframe
675
+ // re-announces — so this is also the resend point after the editor
676
+ // reloads and forgets what it was told.
677
+ //
678
+ // Keyed on READY alone, because a single mount announces READY *and*
679
+ // OPEN and resetting on both would post the same config twice. OPEN
680
+ // still calls in, without the reset: after a READY that is a no-op, and
681
+ // it is the only signal an app deployment older than protocol v1 sends.
682
+ if (msg.type === BUILDER_READY) this._sentConfigKey = undefined
683
+ this._maybeSendConfig()
531
684
  if (msg.type === BUILDER_OPEN) this._emit('open')
532
685
  break
533
686
  case BUILDER_SAVE:
@@ -540,6 +693,7 @@ export class SfxTemplateBuilder extends LitElement {
540
693
  // iframe, so resend even if this content went out already — otherwise
541
694
  // the editor waits on a skeleton forever.
542
695
  this._sentKey = undefined
696
+ this._sentSaveKey = undefined
543
697
  this._maybeSendContent()
544
698
  break
545
699
  case BUILDER_CONTENT: {
@@ -547,11 +701,47 @@ export class SfxTemplateBuilder extends LitElement {
547
701
  // regardless of mode; the detail shape follows the mode they chose.
548
702
  const data = (msg as BuilderContentMessage).data
549
703
  // The saved document is what the editor now holds. A host that stores
550
- // it and echoes it back into `content` — the natural controlled
704
+ // it and echoes it back into the props — the natural controlled
551
705
  // pattern — must not trigger a HOST_LOAD reload that wipes the
552
- // editor's undo history behind a skeleton flash.
706
+ // editor's undo history behind a skeleton flash. TWO keys are
707
+ // recorded because hosts echo different subsets: the prop-based key
708
+ // covers "content only, other props untouched", and the detail-based
709
+ // key covers "the whole save detail" — whose name and templateQuery
710
+ // (a save nearly always changes the query) differ from the props the
711
+ // template was loaded with.
553
712
  this._sentKey = this._contentKey(data.content)
554
- this._emit('save', data)
713
+ this._sentSaveKey = this._detailKey(data)
714
+ if (this.damStore) {
715
+ // Serialized, not fired in parallel: the app discards a failure ack
716
+ // that has newer saves still outstanding as superseded, which is
717
+ // only sound while acks come back in post order — and ack order
718
+ // follows save-event order. Two racing uploads could swap it.
719
+ this._pendingSaves.add(data)
720
+ // Epoch, folder and credentials are captured now, not when the
721
+ // queued upload runs: the save belongs to the document — and the
722
+ // configuration — the app posted it under, and the host may have
723
+ // swapped both by the time the queue reaches it. Only the
724
+ // known-uuid seed stays live (validated against the epoch), because
725
+ // an earlier queued save of the SAME document must be able to hand
726
+ // its uuid to the next one.
727
+ const session = this._docEpoch
728
+ const job = {
729
+ auth: {
730
+ token: this.token,
731
+ sassKey: this.sassKey,
732
+ secTemplate: this.secTemplate,
733
+ sessionUuid: this.sessionUuid,
734
+ companyUuid: this.companyUuid,
735
+ projectUuid: this.projectUuid,
736
+ },
737
+ storeFolder: this.storeFolder || '/',
738
+ }
739
+ this._storeQueue = this._storeQueue.then(() =>
740
+ this._storeAndEmitSave(data, session, job),
741
+ )
742
+ } else {
743
+ this._emit('save', data)
744
+ }
555
745
  break
556
746
  }
557
747
  case BUILDER_DIRTY: {
@@ -561,6 +751,13 @@ export class SfxTemplateBuilder extends LitElement {
561
751
  break
562
752
  }
563
753
  case BUILDER_CLOSE:
754
+ // Pending `dam-store` uploads are deliberately NOT flushed here: the
755
+ // element outlives a close (inline keeps rendering, modal just drops
756
+ // its overlay), so a save still inside its upload window emits with
757
+ // its real outcome moments later — flushing would hand the host a
758
+ // `storeError` for a copy that lands fine. The paths where waiting
759
+ // genuinely loses the event — element removal — are covered by
760
+ // `disconnectedCallback` and the React wrapper's cleanup flush.
564
761
  this._emit('close')
565
762
  if (this.mode === 'modal') this._open = false
566
763
  break
@@ -570,6 +767,122 @@ export class SfxTemplateBuilder extends LitElement {
570
767
  }
571
768
  }
572
769
 
770
+ /**
771
+ * Pending `dam-store` uploads, chained so saves emit in the order the app
772
+ * posted them. `_storeAndEmitSave` never rejects (its catch emits
773
+ * `storeError`), so the chain cannot wedge.
774
+ */
775
+ private _storeQueue: Promise<void> = Promise.resolve()
776
+
777
+ /**
778
+ * Saves handed over by the app whose `save` event has not fired yet — the
779
+ * upload window. Insertion-ordered; membership is what makes a flush and a
780
+ * completing upload not double-emit the same save.
781
+ */
782
+ private _pendingSaves = new Set<BuilderContentData>()
783
+
784
+ /**
785
+ * Identity (id, content, name — not query) of the last document posted to
786
+ * the app — updated on every ship, and on an accepted save echo (the echoed
787
+ * document IS the current one; leaving the pre-save key here would make a
788
+ * later content-request redelivery look like a new document and wipe the
789
+ * store memory below).
790
+ */
791
+ private _lastDocKey?: string
792
+
793
+ /**
794
+ * Monotonic id of the current DOCUMENT. Bumped in exactly one place: when
795
+ * `_maybeSendContent` ships a genuinely different document (docKey change).
796
+ * Deliberately NOT bumped on iframe/src changes — a modal close, a theme
797
+ * swap or an in-place remount is the same document, and treating it as new
798
+ * forked the file on every such boundary.
799
+ */
800
+ private _docEpoch = 0
801
+
802
+ /**
803
+ * The copy this element last stored, tagged with the epoch of the document
804
+ * it belongs to. Written when an upload lands (with the SAVE's epoch, so a
805
+ * late-landing upload can never masquerade as another document's copy) and
806
+ * validated at read time — there is no eager reset to get wrong.
807
+ */
808
+ private _storeMemory?: { epoch: number; uuid: string }
809
+
810
+ /**
811
+ * Emit every not-yet-emitted `dam-store` save immediately, raw content with
812
+ * `storeError` in place of the links. Called when waiting any longer risks
813
+ * the event finding no listener; a still-running upload for a flushed save
814
+ * is left to finish (the copy usually lands) but will not emit again.
815
+ *
816
+ * Public for framework wrappers: one that unsubscribes its listeners before
817
+ * unmounting must call this first, while they are still attached — the
818
+ * element's own disconnect-time flush fires only after the wrapper has
819
+ * stopped listening, and the raw save would be lost. The React wrapper does
820
+ * this; a vanilla host never needs to call it.
821
+ */
822
+ flushPendingSaves(
823
+ reason = 'widget removed before the rendering copy completed',
824
+ ): void {
825
+ this._flushPendingSaves(reason)
826
+ }
827
+
828
+ private _flushPendingSaves(reason: string): void {
829
+ for (const data of this._pendingSaves) {
830
+ this._pendingSaves.delete(data)
831
+ this._emit('save', { ...data, storeError: reason })
832
+ }
833
+ }
834
+
835
+ /**
836
+ * The `dam-store` save path: upload the edited template to Filerobot, then
837
+ * emit `save` with the stored copy's links on the detail. The upload is the
838
+ * render side of the save — the CDN renders only stored files — while the
839
+ * raw `content` stays the host's copy exactly as without the flag.
840
+ *
841
+ * A failed upload still emits `save` (the raw data must reach the host
842
+ * either way), with `storeError` in place of `stored`; whether a save
843
+ * without a rendering copy counts as saved is the host's decision, made
844
+ * where it always is — the save ack.
845
+ */
846
+ private async _storeAndEmitSave(
847
+ data: BuilderContentData,
848
+ session: number,
849
+ job: { auth: DamStoreAuth; storeFolder: string },
850
+ ): Promise<void> {
851
+ if (!this._pendingSaves.has(data)) return
852
+ // The seeds are validated against the save's own document epoch. The
853
+ // element's memory carries the epoch it was recorded under; the host's
854
+ // `storedUuid` property is live and describes the CURRENT document, so it
855
+ // only applies while the save's epoch is still the current one — anchoring
856
+ // a stale save's upload to it would put the old document into the new
857
+ // one's file. Folder and credentials come from the job captured at
858
+ // enqueue time, for the same reason.
859
+ const knownUuid =
860
+ (this._storeMemory?.epoch === session
861
+ ? this._storeMemory.uuid
862
+ : undefined) ||
863
+ (session === this._docEpoch ? this.storedUuid || undefined : undefined)
864
+ let stored: StoredTemplate | undefined
865
+ let storeError: string | undefined
866
+ try {
867
+ stored = await storeTemplateInDam(data, job.auth, job.storeFolder, knownUuid)
868
+ } catch (err) {
869
+ storeError = err instanceof Error ? err.message : String(err)
870
+ }
871
+ // A flush mid-upload already delivered this save; the copy (if it landed)
872
+ // is simply not reported. Emitting again would double the host's write —
873
+ // and recording the uuid would poison the session memory of whatever
874
+ // document has loaded since (the flush usually precedes a swap), pointing
875
+ // its next save at this document's file.
876
+ if (!this._pendingSaves.delete(data)) return
877
+ // Recorded under the SAVE's epoch, unconditionally: a copy always belongs
878
+ // to the document it was saved from. A later document's saves carry a
879
+ // higher epoch and simply never match this record — no current-vs-then
880
+ // comparison to get wrong. (Uploads complete in queue order, so the last
881
+ // write is always the newest save.)
882
+ if (stored) this._storeMemory = { epoch: session, uuid: stored.uuid }
883
+ this._emit('save', stored ? { ...data, stored } : { ...data, storeError })
884
+ }
885
+
573
886
  /**
574
887
  * Deliver `content` to the app once both sides are ready: it has asked, and
575
888
  * we have something new to give it. Skips a re-send of identical content so
@@ -592,10 +905,81 @@ export class SfxTemplateBuilder extends LitElement {
592
905
  templateQuery: this.templateQuery || undefined,
593
906
  }
594
907
  const key = this._contentKey(content)
595
- if (key === this._sentKey) return
908
+ // The document's identity — id, content, name; NOT the query. A
909
+ // query-only resend ("same XML, different render") and a content-request
910
+ // redelivery after an in-place remount are the same document, and
911
+ // treating them as new would orphan the stored copy.
912
+ const docKey = JSON.stringify({
913
+ templateId: this.templateId || undefined,
914
+ content,
915
+ name: this.templateName || undefined,
916
+ })
917
+ // `_sentSaveKey` counts as delivered too: matching it means the host has
918
+ // echoed the last save back into the props, and the editor already holds
919
+ // exactly that document — reloading would wipe its undo history. The doc
920
+ // key is still refreshed: the echoed document IS the current one, and
921
+ // leaving the pre-save key in place would make the next redelivery of
922
+ // this same content look like a document change and orphan the copy.
923
+ if (key === this._sentKey || key === this._sentSaveKey) {
924
+ this._lastDocKey = docKey
925
+ return
926
+ }
596
927
 
597
928
  if (!this._postToApp({ type: HOST_LOAD, data })) return
929
+ // A genuinely different document starts a new epoch — the file the
930
+ // previous one made must not resolve this one's folder or unchanged
931
+ // re-saves.
932
+ if (docKey !== this._lastDocKey) this._docEpoch++
933
+ this._lastDocKey = docKey
598
934
  this._sentKey = key
935
+ // A new document shipped: the previous save's echo key no longer names
936
+ // what the editor holds, and matching it later would wrongly skip a load.
937
+ this._sentSaveKey = undefined
938
+ }
939
+
940
+ /**
941
+ * Deliver host config to the app. Unlike content this is not requested — the
942
+ * app has no way to know a host means to send any — so it goes out on the
943
+ * ready signal and on every later change.
944
+ *
945
+ * Sending an empty model is meaningful: it is how a host clears one it set
946
+ * before. What is skipped is only a *repeat* of what the app already holds,
947
+ * and the very first send when there was never anything to say.
948
+ */
949
+ private _maybeSendConfig(): void {
950
+ // Hosts are plain JS as often as not, and the property has no converter to
951
+ // vet what lands on it the way the attribute does. Anything that is not a
952
+ // list of fields is treated as no model rather than posted onward as
953
+ // something the app would have to make sense of.
954
+ const customMetadata = Array.isArray(this.customMetadata)
955
+ ? this.customMetadata
956
+ : []
957
+ const customMetadataLabel =
958
+ typeof this.customMetadataLabel === 'string'
959
+ ? this.customMetadataLabel.trim()
960
+ : ''
961
+ const key = JSON.stringify({ customMetadata, customMetadataLabel })
962
+ if (key === this._sentConfigKey) return
963
+ if (
964
+ this._sentConfigKey === undefined &&
965
+ customMetadata.length === 0 &&
966
+ customMetadataLabel === ''
967
+ ) {
968
+ return
969
+ }
970
+
971
+ if (
972
+ !this._postToApp({
973
+ type: HOST_CONFIG,
974
+ data: {
975
+ customMetadata,
976
+ ...(customMetadataLabel ? { customMetadataLabel } : {}),
977
+ },
978
+ })
979
+ ) {
980
+ return
981
+ }
982
+ this._sentConfigKey = key
599
983
  }
600
984
 
601
985
  /** Identity of a delivered template, as compared against `_sentKey`. */
@@ -608,6 +992,20 @@ export class SfxTemplateBuilder extends LitElement {
608
992
  })
609
993
  }
610
994
 
995
+ /**
996
+ * Identity of a save the app handed back — the same shape as
997
+ * `_contentKey`, but built from the save detail rather than the props, so
998
+ * a host echoing the detail (whose name/query the save changed) matches.
999
+ */
1000
+ private _detailKey(data: BuilderContentData): string {
1001
+ return JSON.stringify({
1002
+ templateId: data.templateId || undefined,
1003
+ content: data.content,
1004
+ name: data.name || undefined,
1005
+ templateQuery: data.templateQuery || undefined,
1006
+ })
1007
+ }
1008
+
611
1009
  /** Post into the iframe, targeted at the app origin. False if not mounted. */
612
1010
  private _postToApp(message: unknown): boolean {
613
1011
  const target = this.shadowRoot?.querySelector('iframe')?.contentWindow
@@ -1,53 +0,0 @@
1
- "use strict";const p=require("lit"),i=require("lit/decorators.js"),S=2,u="design-templates:builder:ready",d="design-templates:builder:open",_="design-templates:builder:close",y="design-templates:builder:save",f="design-templates:builder:error",E="design-templates:builder:content-request",b="design-templates:builder:content",T="design-templates:builder:dirty",R="design-templates:host:load",U=`<?xml version="1.0" encoding="UTF-8"?>
2
- <template><templateInfo><version>0.3</version></templateInfo><rootStack/><layouts/><variables/></template>`,O="design-templates:host:saved",n={SESSION_UUID:"suuid",COMPANY_UUID:"cuuid",PROJECT_UUID:"puuid",SASS_KEY:"sassKey",FILEROBOT_TOKEN:"ftoken",SEC_TEMPLATE:"secTemplate",IFRAME:"iframe",EMBED_ORIGIN:"embedOrigin",BRAND_COLOR:"brandColor",THEME:"theme"},v={SESSION:"session",SEC_TEMPLATE:"secTemplate"},I=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function D(h){return h?`/templates/${encodeURIComponent(h)}/edit`:"/templates/new"}const g="/templates/embed";var L=Object.defineProperty,r=(h,e,t,a)=>{for(var o=void 0,l=h.length-1,m;l>=0;l--)(m=h[l])&&(o=m(e,t,o)||o);return o&&L(e,t,o),o};const c=class c extends p.LitElement{constructor(){super(...arguments),this.baseUrl="",this.token="",this.sassKey="",this.sessionUuid="",this.secTemplate="",this.companyUuid="",this.projectUuid="",this.templateId="",this.mode="inline",this.stateless=!1,this.content="",this.newTemplate=!1,this.templateName="",this.templateQuery="",this.brandColor="",this.theme="",this.readyTimeout=2e4,this._status="idle",this._open=!1,this._src="",this._contentRequested=!1,this._reportedStatelessRequired=!1,this._isDirty=!1,this._autoOpenDecided=!1,this._onMessage=e=>{if(!this._open||!e.origin||e.origin!==this._appOrigin)return;const t=this.shadowRoot?.querySelector("iframe");if(!t||e.source!==t.contentWindow)return;const a=e.data;if(!(!a||typeof a.type!="string"))switch(a.type){case u:case d:this._clearHandshakeTimer(),this._status!=="ready"&&(this._status="ready",this._emit("ready")),a.type===d&&this._emit("open");break;case y:this._emit("save",a.data);break;case E:this._contentRequested=!0,this._sentKey=void 0,this._maybeSendContent();break;case b:{const o=a.data;this._sentKey=this._contentKey(o.content),this._emit("save",o);break}case T:{const o=a.data;this._isDirty=!!o?.isDirty,this._emit("dirtychange",{isDirty:this._isDirty});break}case _:this._emit("close"),this.mode==="modal"&&(this._open=!1);break;case f:this._fail(a.data??{code:"unknown"});break}}}get status(){return this._status}get isDirty(){return this._isDirty}open(e){e!==void 0&&(this.templateId=e),this._open=!0}close(){this._open=!1}load({content:e,templateId:t,name:a,templateQuery:o}){t!==void 0&&(this.templateId=t),a!==void 0&&(this.templateName=a),o!==void 0&&(this.templateQuery=o),this.newTemplate=!1,this.content=e,this._open=!0}createNew({templateId:e,name:t}={}){e!==void 0&&(this.templateId=e),t!==void 0&&(this.templateName=t),this.templateQuery="",this.newTemplate=!0,this.content="",this._open=!0}confirmSave(e,t){this.stateless&&this._postToApp({type:O,data:{ok:e,message:t}})}connectedCallback(){super.connectedCallback(),window.addEventListener("message",this._onMessage)}disconnectedCallback(){super.disconnectedCallback(),window.removeEventListener("message",this._onMessage),this._clearHandshakeTimer()}willUpdate(e){super.willUpdate(e),this._autoOpenDecided||(this._autoOpenDecided=!0,this.mode==="inline"&&(this._open=!0));const t=this._computeSrc();t!==this._src&&(this._src=t,this._status=t?"loading":"idle")}updated(e){e.has("_src")&&(this._clearHandshakeTimer(),this._contentRequested=!1,this._sentKey=void 0,this._isDirty&&(this._isDirty=!1,this._emit("dirtychange",{isDirty:!1})),this._src&&this._startHandshakeTimer()),(e.has("content")||e.has("newTemplate")||e.has("templateId")||e.has("templateName")||e.has("templateQuery"))&&this._maybeSendContent()}render(){const e=this._src?p.html`<iframe
3
- part="iframe"
4
- title="Template builder"
5
- src=${this._src}
6
- allow="clipboard-read; clipboard-write"
7
- ></iframe>`:p.nothing,t=this._status==="loading"?p.html`<div class="spinner" part="spinner"></div>`:p.nothing;return this.mode==="modal"?this._open?p.html`<div class="overlay" part="overlay">
8
- <div class="stage">${e}${t}</div>
9
- </div>`:p.nothing:p.html`${e}${t}`}_computeSrc(){if(!this._open||!this.baseUrl||!this.token)return"";if(this.secTemplate){if(!this.stateless)return this._reportedStatelessRequired||(this._reportedStatelessRequired=!0,queueMicrotask(()=>this._fail({code:"invalid-config",message:"sec-template requires stateless mode — the app accepts a security template on the stateless embed route only."}))),"";this._reportedStatelessRequired=!1}else if(!this.sassKey||!this.sessionUuid)return"";let e;try{const t=this.stateless?g:D(this.templateId||void 0),a=this.baseUrl.endsWith("/")?this.baseUrl:`${this.baseUrl}/`;e=new URL(t.replace(/^\//,""),a)}catch{return this._reportedBadBaseUrl!==this.baseUrl&&(this._reportedBadBaseUrl=this.baseUrl,queueMicrotask(()=>this._fail({code:"invalid-base-url",message:`base-url is not a valid URL: ${this.baseUrl}`}))),""}return this._reportedBadBaseUrl=void 0,e.searchParams.set(n.FILEROBOT_TOKEN,this.token),this.secTemplate?e.searchParams.set(n.SEC_TEMPLATE,this.secTemplate):(e.searchParams.set(n.SASS_KEY,this.sassKey),e.searchParams.set(n.SESSION_UUID,this.sessionUuid),this.companyUuid&&e.searchParams.set(n.COMPANY_UUID,this.companyUuid),this.projectUuid&&e.searchParams.set(n.PROJECT_UUID,this.projectUuid)),this.brandColor&&e.searchParams.set(n.BRAND_COLOR,this.brandColor),this.theme&&e.searchParams.set(n.THEME,this.theme),e.searchParams.set(n.IFRAME,"1"),e.searchParams.set(n.EMBED_ORIGIN,window.location.origin),e.toString()}get _appOrigin(){try{return new URL(this.baseUrl).origin}catch{return null}}_maybeSendContent(){if(!this.stateless||!this._contentRequested)return;const e=this.content||(this.newTemplate?U:"");if(!e)return;const t={templateId:this.templateId||void 0,content:e,name:this.templateName||void 0,templateQuery:this.templateQuery||void 0},a=this._contentKey(e);a!==this._sentKey&&this._postToApp({type:R,data:t})&&(this._sentKey=a)}_contentKey(e){return JSON.stringify({templateId:this.templateId||void 0,content:e,name:this.templateName||void 0,templateQuery:this.templateQuery||void 0})}_postToApp(e){const t=this.shadowRoot?.querySelector("iframe")?.contentWindow,a=this._appOrigin;return!t||!a?!1:(t.postMessage(e,a),!0)}_startHandshakeTimer(){this.readyTimeout<=0||(this._handshakeTimer=window.setTimeout(()=>{this._fail({code:"handshake-timeout",message:`No ready signal from ${this.baseUrl} within ${this.readyTimeout}ms. Check that this origin is in the app's frame-ancestors allowlist and that third-party cookies are not blocked.`})},this.readyTimeout))}_clearHandshakeTimer(){this._handshakeTimer!==void 0&&(window.clearTimeout(this._handshakeTimer),this._handshakeTimer=void 0)}_fail(e){this._clearHandshakeTimer(),this._status="error",this._emit("error",e)}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}};c.styles=p.css`
10
- :host {
11
- display: block;
12
- position: relative;
13
- }
14
- :host([mode='modal']) {
15
- display: contents;
16
- }
17
- .overlay {
18
- position: fixed;
19
- inset: 0;
20
- z-index: 2147483000;
21
- background: rgba(0, 0, 0, 0.55);
22
- display: flex;
23
- }
24
- .stage {
25
- position: relative;
26
- flex: 1;
27
- display: flex;
28
- }
29
- iframe {
30
- border: 0;
31
- flex: 1;
32
- width: 100%;
33
- height: 100%;
34
- }
35
- .spinner {
36
- position: absolute;
37
- inset: 0;
38
- margin: auto;
39
- width: 32px;
40
- height: 32px;
41
- border: 3px solid rgba(128, 128, 128, 0.3);
42
- border-top-color: currentColor;
43
- border-radius: 50%;
44
- animation: sfx-tb-spin 0.8s linear infinite;
45
- pointer-events: none;
46
- }
47
- @keyframes sfx-tb-spin {
48
- to {
49
- transform: rotate(360deg);
50
- }
51
- }
52
- `;let s=c;r([i.property({attribute:"base-url"})],s.prototype,"baseUrl");r([i.property()],s.prototype,"token");r([i.property({attribute:"sass-key"})],s.prototype,"sassKey");r([i.property({attribute:"session-uuid"})],s.prototype,"sessionUuid");r([i.property({attribute:"sec-template"})],s.prototype,"secTemplate");r([i.property({attribute:"company-uuid"})],s.prototype,"companyUuid");r([i.property({attribute:"project-uuid"})],s.prototype,"projectUuid");r([i.property({attribute:"template-id"})],s.prototype,"templateId");r([i.property({reflect:!0})],s.prototype,"mode");r([i.property({type:Boolean,reflect:!0})],s.prototype,"stateless");r([i.property({attribute:!1})],s.prototype,"content");r([i.property({type:Boolean,attribute:"new-template"})],s.prototype,"newTemplate");r([i.property({attribute:"template-name"})],s.prototype,"templateName");r([i.property({attribute:"template-query"})],s.prototype,"templateQuery");r([i.property({attribute:"brand-color"})],s.prototype,"brandColor");r([i.property()],s.prototype,"theme");r([i.property({type:Number,attribute:"ready-timeout"})],s.prototype,"readyTimeout");r([i.state()],s.prototype,"_status");r([i.state()],s.prototype,"_open");r([i.state()],s.prototype,"_src");r([i.state()],s.prototype,"_isDirty");exports.AUTH_MODES=v;exports.BLANK_TEMPLATE_XML=U;exports.BRAND_COLOR_PATTERN=I;exports.BUILDER_CLOSE=_;exports.BUILDER_CONTENT=b;exports.BUILDER_CONTENT_REQUEST=E;exports.BUILDER_DIRTY=T;exports.BUILDER_ERROR=f;exports.BUILDER_OPEN=d;exports.BUILDER_READY=u;exports.BUILDER_SAVE=y;exports.EMBED_PARAMS=n;exports.EMBED_ROUTE=g;exports.HOST_LOAD=R;exports.HOST_SAVED=O;exports.PROTOCOL_VERSION=S;exports.SfxTemplateBuilder=s;exports.builderRoute=D;
53
- //# sourceMappingURL=template-builder-CK2Zlo7E.cjs.map