@servicetitan/dte-unlayer 0.152.0 → 0.154.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.
Files changed (51) hide show
  1. package/dist/editor.d.ts.map +1 -1
  2. package/dist/editor.js +25 -10
  3. package/dist/editor.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/loadScript.d.ts.map +1 -1
  9. package/dist/loadScript.js +8 -1
  10. package/dist/loadScript.js.map +1 -1
  11. package/dist/log.d.ts +3 -0
  12. package/dist/log.d.ts.map +1 -0
  13. package/dist/log.js +21 -0
  14. package/dist/log.js.map +1 -0
  15. package/dist/merge-tags.d.ts +15 -2
  16. package/dist/merge-tags.d.ts.map +1 -1
  17. package/dist/merge-tags.js +40 -22
  18. package/dist/merge-tags.js.map +1 -1
  19. package/dist/shared/instance-id.d.ts +13 -0
  20. package/dist/shared/instance-id.d.ts.map +1 -0
  21. package/dist/shared/instance-id.js +22 -0
  22. package/dist/shared/instance-id.js.map +1 -0
  23. package/dist/store.d.ts +19 -1
  24. package/dist/store.d.ts.map +1 -1
  25. package/dist/store.js +109 -11
  26. package/dist/store.js.map +1 -1
  27. package/dist/tools.d.ts +12 -0
  28. package/dist/tools.d.ts.map +1 -1
  29. package/dist/tools.js +47 -0
  30. package/dist/tools.js.map +1 -1
  31. package/dist/unlayer-interface.d.ts +11 -1
  32. package/dist/unlayer-interface.d.ts.map +1 -1
  33. package/dist/unlayer-interface.js.map +1 -1
  34. package/dist/unlayer.d.ts.map +1 -1
  35. package/dist/unlayer.js +90 -73
  36. package/dist/unlayer.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/load-script.test.ts +41 -0
  39. package/src/__tests__/log.test.ts +44 -0
  40. package/src/__tests__/merge-tags.test.ts +133 -18
  41. package/src/__tests__/store.merge-tags.test.ts +186 -0
  42. package/src/editor.tsx +20 -9
  43. package/src/index.ts +7 -1
  44. package/src/loadScript.ts +9 -1
  45. package/src/log.ts +24 -0
  46. package/src/merge-tags.ts +59 -25
  47. package/src/shared/instance-id.ts +23 -0
  48. package/src/store.ts +128 -10
  49. package/src/tools.ts +51 -0
  50. package/src/unlayer-interface.tsx +11 -1
  51. package/src/unlayer.tsx +81 -61
package/src/store.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { loadScript } from './loadScript';
2
+ import { logUnlayerError } from './log';
3
+ import { toUnlayerMergeTags, type UnlayerMergeTagsConfig } from './merge-tags';
2
4
  import { defaultImageValidation } from './shared/configs';
3
5
  import type { UnlayerEditorTwin, UnlayerEventConfig, UnlayerEventRegister } from './shared/const';
4
6
  import { unlayerSupportedFonts } from './shared/fonts';
@@ -10,16 +12,22 @@ import {
10
12
  } from './shared/forms';
11
13
  import { schemaBuildMap, schemaIsDate } from './shared/schema';
12
14
  import { unlayerToolsParseTwinKey } from './shared/tools';
13
- import { unlayerToolsIterate } from './tools';
15
+ import { unlayerToolsDedupeInstanceIds, unlayerToolsIterate } from './tools';
14
16
  import {
15
17
  createUnlayerEditor,
16
18
  DesignUpdatedEventType,
17
19
  EventDesignUpdated,
18
20
  Unlayer,
19
21
  } from './unlayer';
20
- import { CreateUnlayerEditorProps, UnlayerDesignFormat, UnlayerRef } from './unlayer-interface';
22
+ import {
23
+ CreateUnlayerEditorProps,
24
+ UnlayerDesignFormat,
25
+ UnlayerEditorMergeTagInfo,
26
+ UnlayerRef,
27
+ } from './unlayer-interface';
21
28
 
22
29
  const defaultScriptUrl = 'https://editor.unlayer.com/embed.js?2';
30
+ const UNLAYER_TOOLS_READY_TIMEOUT_MS = 30000;
23
31
 
24
32
  const normalizeFontToken = (font: string) =>
25
33
  font.replace(/["']/g, '').split(',')[0].trim().toLowerCase();
@@ -184,8 +192,14 @@ export class UnlayerStore {
184
192
  private isInit = false;
185
193
  private iframe?: HTMLIFrameElement;
186
194
  private hasDesign = false;
195
+ private hasCoreLoaded = false;
196
+ private hasToolsRegistered = false;
197
+ private toolsReadyWatchdogId?: number;
187
198
  private formFieldsByFormId: Record<number, FormFieldInfo[]> = {};
188
199
  private formFieldSamples: Record<string, unknown> = {};
200
+ private isEditorReady = false;
201
+ private isMergeTagsApplied = false;
202
+ private mergeTagsConfig?: UnlayerMergeTagsConfig;
189
203
 
190
204
  private onMessageCB?: (type: string, data: any) => void;
191
205
  private onChangeCB?: (info: UnlayerDesignChangeInfo) => void;
@@ -231,6 +245,9 @@ export class UnlayerStore {
231
245
  sendCalcFieldLabels: labels => {
232
246
  this.sendCalcFieldLabels(labels);
233
247
  },
248
+ setMergeTags: tags => {
249
+ this.setMergeTags(tags);
250
+ },
234
251
  };
235
252
  }
236
253
 
@@ -241,22 +258,44 @@ export class UnlayerStore {
241
258
 
242
259
  this.isInit = true;
243
260
 
244
- setTimeout(() => window.addEventListener('message', this.onPostMessage));
261
+ try {
262
+ setTimeout(() => window.addEventListener('message', this.onPostMessage));
263
+
264
+ await loadScript(defaultScriptUrl);
265
+
266
+ if (!(window as any).unlayer) {
267
+ throw new Error('Unlayer embed script loaded but window.unlayer is missing');
268
+ }
269
+
270
+ this.editor = createUnlayerEditor(container, this.props);
271
+ // The host may already have pushed a newer set through `setMergeTags`; keep it.
272
+ this.mergeTagsConfig ??= toUnlayerMergeTags(this.props.mergeTags);
273
+ this.editor.addEventListener('editor:ready', this.onEditorReady);
274
+ this.editor.addEventListener('design:loaded', this.onDesignLoaded);
275
+ this.editor.addEventListener('design:updated', this.onDesignUpdated);
276
+ this.editor.registerCallback('image', this.uploadImage);
245
277
 
246
- await loadScript(defaultScriptUrl);
278
+ this.iframe = container.querySelector(`iframe`) ?? undefined;
247
279
 
248
- this.editor = createUnlayerEditor(container, this.props);
249
- this.editor.addEventListener('design:loaded', this.onDesignLoaded);
250
- this.editor.addEventListener('design:updated', this.onDesignUpdated);
251
- this.editor.registerCallback('image', this.uploadImage);
280
+ if (!this.iframe) {
281
+ logUnlayerError('Unlayer iframe was not created after createEditor');
282
+ }
252
283
 
253
- this.iframe = container.querySelector(`iframe`) ?? undefined;
284
+ this.startToolsReadyWatchdog();
285
+ } catch (error: unknown) {
286
+ this.isInit = false;
287
+ this.clearToolsReadyWatchdog();
288
+ logUnlayerError('Failed to initialize Unlayer editor', error);
289
+ throw error;
290
+ }
254
291
  };
255
292
 
256
293
  destroy = () => {
294
+ this.clearToolsReadyWatchdog();
257
295
  window.removeEventListener('message', this.onPostMessage);
258
296
 
259
297
  if (this.editor) {
298
+ this.editor.removeEventListener('editor:ready', this.onEditorReady);
260
299
  this.editor.removeEventListener('design:loaded', this.onDesignLoaded);
261
300
  this.editor.removeEventListener('design:updated', this.onDesignUpdated);
262
301
  this.editor.unregisterCallback('image', this.uploadImage);
@@ -332,7 +371,46 @@ export class UnlayerStore {
332
371
  this.sendMessage('--calc-field-labels', { labels });
333
372
  };
334
373
 
374
+ /**
375
+ * Replaces the editor's merge tag menu. Nested groups passed to `createEditor` stop DTE
376
+ * custom tools from registering, so init only carries the flat shape and the grouped
377
+ * config is applied here once the editor is ready. A call before that (including the
378
+ * initial props) is parked; a later call replaces it. A failed apply is retried on the
379
+ * next `design:loaded`.
380
+ */
381
+ setMergeTags = (tags: UnlayerEditorMergeTagInfo[] | undefined) => {
382
+ this.mergeTagsConfig = toUnlayerMergeTags(tags);
383
+ this.isMergeTagsApplied = false;
384
+ this.applyMergeTags();
385
+ };
386
+
387
+ private applyMergeTags = () => {
388
+ if (
389
+ !this.editor ||
390
+ !this.isEditorReady ||
391
+ this.isMergeTagsApplied ||
392
+ !this.mergeTagsConfig
393
+ ) {
394
+ return;
395
+ }
396
+
397
+ try {
398
+ this.editor.setMergeTags(this.mergeTagsConfig);
399
+ this.isMergeTagsApplied = true;
400
+ } catch (error: unknown) {
401
+ logUnlayerError('Failed to apply merge tags', error);
402
+ }
403
+ };
404
+
405
+ private onEditorReady = () => {
406
+ this.isEditorReady = true;
407
+ this.applyMergeTags();
408
+ };
409
+
335
410
  private onDesignLoaded = () => {
411
+ // A loaded design means the editor is up even if `editor:ready` never reached us.
412
+ this.onEditorReady();
413
+
336
414
  if (!this.hasDesign) {
337
415
  this.hasDesign = true;
338
416
  this.editor?.setBodyValues({
@@ -368,6 +446,11 @@ export class UnlayerStore {
368
446
  }
369
447
  });
370
448
 
449
+ // Duplicated contents/rows carry the original's instance GUIDs; give copies new ones.
450
+ if (unlayerToolsDedupeInstanceIds(data.design, event.item?.values?._meta?.htmlID)) {
451
+ hasChanges = true;
452
+ }
453
+
371
454
  if (hasChanges) {
372
455
  this.setDesign(data.design);
373
456
  }
@@ -392,8 +475,11 @@ export class UnlayerStore {
392
475
  }
393
476
 
394
477
  if (type === '--ready' || type === '--registered') {
478
+ this.hasToolsRegistered = true;
479
+ this.clearToolsReadyWatchdog();
395
480
  this.onReadyCB?.();
396
481
  } else if (type === '--core-loaded') {
482
+ this.hasCoreLoaded = true;
397
483
  const configData: UnlayerEventConfig = {
398
484
  dummyData: this.props.dummyData,
399
485
  schema: this.props.schema,
@@ -588,6 +674,7 @@ export class UnlayerStore {
588
674
  });
589
675
 
590
676
  if (!res.isValid) {
677
+ logUnlayerError(`Image validation failed: ${res.title}`, res.description);
591
678
  this.onErrorCB?.(res.title, res.description);
592
679
  return;
593
680
  }
@@ -597,7 +684,8 @@ export class UnlayerStore {
597
684
  try {
598
685
  const { url } = await this.onImageCB(file);
599
686
  done({ progress: 100, url });
600
- } catch {
687
+ } catch (error: unknown) {
688
+ logUnlayerError('Image upload failed', error);
601
689
  this.onErrorCB?.(
602
690
  'Image upload failed',
603
691
  'Something went wrong while uploading the image. Please try again or select another image.',
@@ -606,6 +694,36 @@ export class UnlayerStore {
606
694
  }
607
695
  };
608
696
 
697
+ private startToolsReadyWatchdog = () => {
698
+ this.clearToolsReadyWatchdog();
699
+ this.toolsReadyWatchdogId = window.setTimeout(() => {
700
+ if (this.hasToolsRegistered) {
701
+ return;
702
+ }
703
+
704
+ logUnlayerError(
705
+ 'Unlayer custom tools did not finish loading. customJS may not have run, or tool registration stalled.',
706
+ {
707
+ hasCoreLoaded: this.hasCoreLoaded,
708
+ hasToolsRegistered: this.hasToolsRegistered,
709
+ },
710
+ );
711
+ this.onErrorCB?.(
712
+ 'Editor failed to load custom tools',
713
+ 'Unlayer custom tools did not finish loading. Check the browser console for [dte-unlayer] details.',
714
+ );
715
+ }, UNLAYER_TOOLS_READY_TIMEOUT_MS);
716
+ };
717
+
718
+ private clearToolsReadyWatchdog = () => {
719
+ if (this.toolsReadyWatchdogId === undefined) {
720
+ return;
721
+ }
722
+
723
+ window.clearTimeout(this.toolsReadyWatchdogId);
724
+ this.toolsReadyWatchdogId = undefined;
725
+ };
726
+
609
727
  private validateImage = async (
610
728
  file: File,
611
729
  imageValidation: { maxFileSize: number; maxWidth: number; maxHeight: number },
package/src/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { constGenericsEditor, UnlayerToolMetaData } from './shared/const';
2
2
  import { editIconSvg } from './shared/edit-icon';
3
+ import { generateInstanceId, INSTANCE_ID_TOOL_SLUGS } from './shared/instance-id';
3
4
  import { unlayerToolsParseUnitKey } from './shared/tools';
4
5
  import { versions } from './unlayer';
5
6
 
@@ -202,6 +203,56 @@ export const unlayerToolsListCustom = (design: UnlayerDesignFormat): UnlayerDesi
202
203
  return tools;
203
204
  };
204
205
 
206
+ /**
207
+ * Makes `values.id` unique across all instance-id tools (see `INSTANCE_ID_TOOL_SLUGS`)
208
+ * in the design. Duplicating content/rows in Unlayer copies the values verbatim, so
209
+ * every copy ends up with the original's GUID; this gives each copy a fresh one.
210
+ *
211
+ * Of the contents sharing an id, the one that keeps it is the first in document
212
+ * order that is not the just-added content (`addedHtmlID`), so the original always
213
+ * wins over its duplicate regardless of where the copy was inserted.
214
+ *
215
+ * @returns `true` when at least one id was regenerated.
216
+ */
217
+ export const unlayerToolsDedupeInstanceIds = (
218
+ design: UnlayerDesignFormat,
219
+ addedHtmlID?: string
220
+ ): boolean => {
221
+ const byId = new Map<string, UnlayerDesignCustomTool[]>();
222
+
223
+ unlayerToolsIterateCustom(design, tool => {
224
+ const id = tool.values?.id;
225
+
226
+ if (INSTANCE_ID_TOOL_SLUGS.includes(tool.slug) && typeof id === 'string' && id) {
227
+ const key = `${tool.slug}|${id}`;
228
+ byId.set(key, [...(byId.get(key) ?? []), tool]);
229
+ }
230
+ });
231
+
232
+ let hasChanges = false;
233
+
234
+ byId.forEach(tools => {
235
+ if (tools.length < 2) {
236
+ return;
237
+ }
238
+
239
+ const keeper = tools.find(tool => tool.values?._meta?.htmlID !== addedHtmlID) ?? tools[0];
240
+
241
+ for (const tool of tools) {
242
+ if (tool !== keeper) {
243
+ /*
244
+ * If no id can be generated the copy is left without one, which is still
245
+ * better than two instances bound to the same image.
246
+ */
247
+ tool.values.id = generateInstanceId();
248
+ hasChanges = true;
249
+ }
250
+ }
251
+ });
252
+
253
+ return hasChanges;
254
+ };
255
+
205
256
  export const unlayerToolsBuildDesign = (tools: UnlayerDesignTool[]): UnlayerDesignFormat => {
206
257
  const design = unlayerGetDefaultDesign();
207
258
 
@@ -45,13 +45,23 @@ export interface UnlayerRef {
45
45
  sendFormList(forms: FormInfo[]): void;
46
46
  sendFormFields(formId: number, fields: FormFieldInfo[]): void;
47
47
  sendCalcFieldLabels(labels: Record<string, string>): void;
48
+ /**
49
+ * Replaces the editor's merge tag menu with `tags` (grouped). Safe to call before the editor is
50
+ * ready — the tags are applied once it is. Lets hosts add tags that finish loading after init
51
+ * (e.g. form fields) without remounting the editor.
52
+ */
53
+ setMergeTags(tags: UnlayerEditorMergeTagInfo[] | undefined): void;
48
54
  }
49
55
 
50
56
  export interface UnlayerEditorMergeTagInfo {
51
57
  id: string;
52
58
  name: string;
53
59
  propertyPath: string;
54
- group?: string;
60
+ /**
61
+ * Submenu the tag is listed under. A string is a single submenu (`'Custom Fields'`);
62
+ * an array nests submenus in order (`['Forms', 'Customer Intake']` → Forms ▸ Customer Intake ▸ tag).
63
+ */
64
+ group?: string | string[];
55
65
  }
56
66
 
57
67
  export interface UnlayerEditorCustomTool {
package/src/unlayer.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { emitDisplayCondition } from './display-conditions/displayConditionController';
2
2
  import { editorCoreScript, editorCoreStyles, editorCoreTools } from './editor-core';
3
+ import { logUnlayerError } from './log';
3
4
  import { toUnlayerMergeTags } from './merge-tags';
4
5
  import { constGenericsEditor } from './shared/const';
5
6
  import { unlayerSupportedFonts } from './shared/fonts';
@@ -276,75 +277,94 @@ export const createUnlayerEditor = (
276
277
  },
277
278
  };
278
279
 
279
- const result = (window as any).unlayer.createEditor({
280
- displayMode: 'web',
281
- devices: ['desktop'],
282
- features: {
283
- preview: true,
284
- userUploads: false,
285
- stockImages: false,
286
- textEditor: {
287
- tables: true,
280
+ let result: Unlayer;
281
+
282
+ try {
283
+ const unlayerApi = (window as any).unlayer;
284
+
285
+ if (!unlayerApi?.createEditor) {
286
+ throw new Error(
287
+ 'Unlayer embed is not available. window.unlayer.createEditor is missing.',
288
+ );
289
+ }
290
+
291
+ result = unlayerApi.createEditor({
292
+ displayMode: 'web',
293
+ devices: ['desktop'],
294
+ features: {
295
+ preview: true,
296
+ userUploads: false,
297
+ stockImages: false,
298
+ textEditor: {
299
+ tables: true,
300
+ },
288
301
  },
289
- },
290
- mergeTags: toUnlayerMergeTags(mergeTags),
291
- projectId: 5713,
292
- version: latest ? undefined : versions.unlayer,
293
- appearance: {
294
- theme: 'classic_light',
295
- },
296
- tools: {
297
- 'carousel': { enabled: false },
298
- 'button': { enabled: false },
299
- 'html': {
300
- enabled: enableHTMLEditing,
301
- properties: {
302
- html: {
303
- editor: {
304
- widgetParams: {
305
- codeMirrorOptions: {
306
- readOnly: enableHTMLEditingReadonly,
302
+ /*
303
+ * Flat only: nested groups here break custom tool registration. UnlayerStore applies
304
+ * the grouped config via setMergeTags once the editor is ready.
305
+ */
306
+ mergeTags: toUnlayerMergeTags(mergeTags, { groups: false }),
307
+ projectId: 5713,
308
+ version: latest ? undefined : versions.unlayer,
309
+ appearance: {
310
+ theme: 'classic_light',
311
+ },
312
+ tools: {
313
+ 'carousel': { enabled: false },
314
+ 'button': { enabled: false },
315
+ 'html': {
316
+ enabled: enableHTMLEditing,
317
+ properties: {
318
+ html: {
319
+ editor: {
320
+ widgetParams: {
321
+ codeMirrorOptions: {
322
+ readOnly: enableHTMLEditingReadonly,
323
+ },
307
324
  },
308
325
  },
309
326
  },
310
327
  },
311
328
  },
329
+ 'table': {
330
+ enabled: true,
331
+ },
332
+ 'menu': { enabled: false },
333
+ 'form': { enabled: false },
334
+ ...(noCoreTools
335
+ ? {
336
+ columns: { enabled: false },
337
+ text: { enabled: false },
338
+ heading: { enabled: false },
339
+ divider: { enabled: false },
340
+ image: { enabled: false },
341
+ }
342
+ : {}),
343
+ ...unitsConfig,
344
+ 'custom#e-sign': eSignComponentRecipients,
312
345
  },
313
- 'table': {
314
- enabled: true,
346
+ editor: {
347
+ autoSelectOnDrop: true,
315
348
  },
316
- 'menu': { enabled: false },
317
- 'form': { enabled: false },
318
- ...(noCoreTools
319
- ? {
320
- columns: { enabled: false },
321
- text: { enabled: false },
322
- heading: { enabled: false },
323
- divider: { enabled: false },
324
- image: { enabled: false },
325
- }
326
- : {}),
327
- ...unitsConfig,
328
- 'custom#e-sign': eSignComponentRecipients,
329
- },
330
- editor: {
331
- autoSelectOnDrop: true,
332
- },
333
- customJS: customScripts,
334
- customCSS: customStyles,
335
- id,
336
- fonts: {
337
- showDefaultFonts: false,
338
- customFonts: unlayerSupportedFonts,
339
- },
340
- tabs: {
341
- content: {},
342
- body: {},
343
- blocks: { enabled: false },
344
- },
345
- });
346
-
347
- document.getElementById = currentFind;
349
+ customJS: customScripts,
350
+ customCSS: customStyles,
351
+ id,
352
+ fonts: {
353
+ showDefaultFonts: false,
354
+ customFonts: unlayerSupportedFonts,
355
+ },
356
+ tabs: {
357
+ content: {},
358
+ body: {},
359
+ blocks: { enabled: false },
360
+ },
361
+ });
362
+ } catch (error: unknown) {
363
+ logUnlayerError('unlayer.createEditor failed', error);
364
+ throw error;
365
+ } finally {
366
+ document.getElementById = currentFind;
367
+ }
348
368
 
349
369
  if (displayConditions) {
350
370
  result.registerCallback(