@servicetitan/dte-unlayer 0.152.0 → 0.153.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.
@@ -1,4 +1,4 @@
1
- import { toUnlayerMergeTags } from '../merge-tags';
1
+ import { scheduleUnlayerMergeTags, toUnlayerMergeTags } from '../merge-tags';
2
2
 
3
3
  const customerNameTag = {
4
4
  id: '10',
@@ -20,6 +20,22 @@ const renewalDateTag = {
20
20
  group: 'Custom Fields',
21
21
  };
22
22
 
23
+ const groupedCustomFields = {
24
+ custom_fields: {
25
+ name: 'Custom Fields',
26
+ mergeTags: {
27
+ custom_field_12345: {
28
+ name: 'Contract Term',
29
+ value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
30
+ },
31
+ custom_field_12346: {
32
+ name: 'Renewal Date',
33
+ value: '{{ __custom_fields["Renewal Date"] | safe | nl2br }}',
34
+ },
35
+ },
36
+ },
37
+ };
38
+
23
39
  describe('toUnlayerMergeTags', () => {
24
40
  describe('when tags are missing', () => {
25
41
  test.each([undefined, []])('returns undefined for %p', tags => {
@@ -46,21 +62,7 @@ describe('toUnlayerMergeTags', () => {
46
62
  const subject = () => toUnlayerMergeTags([contractTermTag, renewalDateTag]);
47
63
 
48
64
  test('nests tags under a submenu named by the host group', () => {
49
- expect(subject()).toEqual({
50
- custom_fields: {
51
- name: 'Custom Fields',
52
- mergeTags: {
53
- 'custom-field-12345': {
54
- name: 'Contract Term',
55
- value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
56
- },
57
- 'custom-field-12346': {
58
- name: 'Renewal Date',
59
- value: '{{ __custom_fields["Renewal Date"] | safe | nl2br }}',
60
- },
61
- },
62
- },
63
- });
65
+ expect(subject()).toEqual(groupedCustomFields);
64
66
  });
65
67
  });
66
68
 
@@ -76,7 +78,7 @@ describe('toUnlayerMergeTags', () => {
76
78
  custom_fields: {
77
79
  name: 'Custom Fields',
78
80
  mergeTags: {
79
- 'custom-field-12345': {
81
+ custom_field_12345: {
80
82
  name: 'Contract Term',
81
83
  value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
82
84
  },
@@ -103,7 +105,7 @@ describe('toUnlayerMergeTags', () => {
103
105
  custom_fields: {
104
106
  name: 'Custom Fields',
105
107
  mergeTags: {
106
- 'custom-field-12345': {
108
+ custom_field_12345: {
107
109
  name: 'Contract Term',
108
110
  value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
109
111
  },
@@ -140,4 +142,95 @@ describe('toUnlayerMergeTags', () => {
140
142
  });
141
143
  });
142
144
  });
145
+
146
+ describe('when groups are disabled', () => {
147
+ const subject = () =>
148
+ toUnlayerMergeTags([customerNameTag, contractTermTag], { groups: false });
149
+
150
+ test('keeps every tag flat like Unlayer 0.151', () => {
151
+ expect(subject()).toEqual({
152
+ '10': {
153
+ name: 'Customer Name',
154
+ value: '{{Customer.Name | safe | nl2br }}',
155
+ },
156
+ custom_field_12345: {
157
+ name: 'Contract Term',
158
+ value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
159
+ },
160
+ });
161
+ });
162
+ });
163
+ });
164
+
165
+ describe('scheduleUnlayerMergeTags', () => {
166
+ describe('when grouped tags exist', () => {
167
+ const setMergeTags = jest.fn();
168
+ const addEventListener = jest.fn();
169
+
170
+ beforeEach(() => {
171
+ setMergeTags.mockReset();
172
+ addEventListener.mockReset();
173
+ scheduleUnlayerMergeTags(
174
+ { addEventListener, setMergeTags },
175
+ [customerNameTag, contractTermTag, renewalDateTag],
176
+ );
177
+ });
178
+
179
+ test('does not apply grouped merge tags until the editor is ready', () => {
180
+ expect(setMergeTags).not.toHaveBeenCalled();
181
+ expect(addEventListener).toHaveBeenCalledWith('editor:ready', expect.any(Function));
182
+ expect(addEventListener).toHaveBeenCalledWith('design:loaded', expect.any(Function));
183
+ });
184
+
185
+ test('applies grouped merge tags when the editor is ready', () => {
186
+ addEventListener.mock.calls[0][1]();
187
+
188
+ expect(setMergeTags).toHaveBeenCalledWith({
189
+ '10': {
190
+ name: 'Customer Name',
191
+ value: '{{Customer.Name | safe | nl2br }}',
192
+ },
193
+ ...groupedCustomFields,
194
+ });
195
+ });
196
+
197
+ test('applies grouped merge tags only once', () => {
198
+ addEventListener.mock.calls[0][1]();
199
+ addEventListener.mock.calls[1][1]();
200
+
201
+ expect(setMergeTags).toHaveBeenCalledTimes(1);
202
+ });
203
+
204
+ test('retries on design:loaded when setMergeTags throws', () => {
205
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
206
+ setMergeTags.mockImplementationOnce(() => {
207
+ throw new Error('setMergeTags failed');
208
+ });
209
+
210
+ addEventListener.mock.calls[0][1]();
211
+ addEventListener.mock.calls[1][1]();
212
+
213
+ expect(setMergeTags).toHaveBeenCalledTimes(2);
214
+ expect(consoleError).toHaveBeenCalledWith(
215
+ '[dte-unlayer]',
216
+ 'Failed to apply grouped merge tags',
217
+ expect.any(Error),
218
+ );
219
+ consoleError.mockRestore();
220
+ });
221
+ });
222
+
223
+ describe('when tags are missing', () => {
224
+ const setMergeTags = jest.fn();
225
+ const addEventListener = jest.fn();
226
+
227
+ beforeEach(() => {
228
+ scheduleUnlayerMergeTags({ addEventListener, setMergeTags }, undefined);
229
+ });
230
+
231
+ test('does not touch the editor', () => {
232
+ expect(setMergeTags).not.toHaveBeenCalled();
233
+ expect(addEventListener).not.toHaveBeenCalled();
234
+ });
235
+ });
143
236
  });
package/src/editor.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  useState,
9
9
  } from 'react';
10
10
  import { DisplayConditionModal } from './display-conditions/DisplayConditionModal';
11
+ import { getErrorMessage } from './log';
11
12
  import { UnlayerEditorTwin } from './shared/const';
12
13
  import { UnlayerStore, UnlayerDesignChangeInfo } from './store';
13
14
  import { UnlayerRef, CreateUnlayerEditorProps } from './unlayer-interface';
@@ -36,6 +37,7 @@ export const useUnlayerRef = () => useRef<UnlayerRef | null>(null);
36
37
 
37
38
  export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props, ref) => {
38
39
  const [isReady, setIsReady] = useState(false);
40
+ const [loadError, setLoadError] = useState<string>();
39
41
  const containerRef = useRef<HTMLDivElement | null>(null);
40
42
  const store = useMemo(
41
43
  () => new UnlayerStore(props.opts),
@@ -43,15 +45,30 @@ export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props,
43
45
  [],
44
46
  );
45
47
 
48
+ useEffect(() => {
49
+ store.setOnError(props.onError);
50
+
51
+ return () => store.setOnError();
52
+ }, [props.onError, store]);
53
+
46
54
  useEffect(() => {
47
55
  if (containerRef.current) {
48
56
  store
49
57
  .init(containerRef.current)
50
- .then(() => setIsReady(true))
51
- .catch(() => setIsReady(false));
58
+ .then(() => {
59
+ setLoadError(undefined);
60
+ setIsReady(true);
61
+ })
62
+ .catch((error: unknown) => {
63
+ const description = getErrorMessage(error);
64
+ setIsReady(false);
65
+ setLoadError(description);
66
+ props.onError?.('Editor failed to load', description);
67
+ });
52
68
  }
53
69
 
54
70
  return () => store.destroy();
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
72
  }, [store]);
56
73
  useImperativeHandle(ref, () => store.unlayerRef, [store]);
57
74
 
@@ -91,12 +108,6 @@ export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props,
91
108
  return () => store.setOnImage();
92
109
  }, [props.onImage, store]);
93
110
 
94
- useEffect(() => {
95
- store.setOnError(props.onError);
96
-
97
- return () => store.setOnError();
98
- }, [props.onError, store]);
99
-
100
111
  useEffect(() => {
101
112
  store.setOnMessage(props.onMessage);
102
113
 
@@ -125,7 +136,7 @@ export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props,
125
136
 
126
137
  return (
127
138
  <div style={{ minHeight, display: 'flex' }}>
128
- {!isReady && <p className="c-red-500">error loading editor</p>}
139
+ {loadError && <p className="c-red-500">error loading editor: {loadError}</p>}
129
140
  <div id={props.id ?? 'editor'} style={style} ref={containerRef} />
130
141
  {props.opts.displayConditions && (
131
142
  <DisplayConditionModal
package/src/index.ts CHANGED
@@ -3,7 +3,14 @@ export * from './tools';
3
3
  export * from './api-core';
4
4
  export * from './api-custom-tools';
5
5
  export * from './unlayer-interface';
6
- export * from './merge-tags';
6
+ export { isUnlayerMergeTagGroup, scheduleUnlayerMergeTags, toUnlayerMergeTags } from './merge-tags';
7
+ export type {
8
+ ToUnlayerMergeTagsOptions,
9
+ UnlayerMergeTagGroup,
10
+ UnlayerMergeTagLeaf,
11
+ UnlayerMergeTagsConfig,
12
+ UnlayerMergeTagsEditor,
13
+ } from './merge-tags';
7
14
  export * from './loadScript';
8
15
  export * from './shared/const';
9
16
  export * from './shared/forms';
package/src/loadScript.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { logUnlayerError } from './log';
2
+
1
3
  const isScriptInjected = (scriptUrl: string) => {
2
4
  const scripts = document.querySelectorAll('script');
3
5
  let injected = false;
@@ -12,9 +14,10 @@ const isScriptInjected = (scriptUrl: string) => {
12
14
  };
13
15
 
14
16
  export const loadScript = (scriptUrl: string): Promise<void> => {
15
- return new Promise<void>(resolve => {
17
+ return new Promise<void>((resolve, reject) => {
16
18
  if (isScriptInjected(scriptUrl)) {
17
19
  resolve();
20
+ return;
18
21
  }
19
22
 
20
23
  const embedScript = document.createElement('script');
@@ -22,6 +25,11 @@ export const loadScript = (scriptUrl: string): Promise<void> => {
22
25
  embedScript.onload = () => {
23
26
  resolve();
24
27
  };
28
+ embedScript.onerror = () => {
29
+ const error = new Error(`Failed to load Unlayer script: ${scriptUrl}`);
30
+ logUnlayerError('Unlayer embed script failed to load', error);
31
+ reject(error);
32
+ };
25
33
  document.head.appendChild(embedScript);
26
34
  });
27
35
  };
package/src/log.ts ADDED
@@ -0,0 +1,24 @@
1
+ const LOG_PREFIX = '[dte-unlayer]';
2
+
3
+ export const getErrorMessage = (error: unknown) => {
4
+ if (error instanceof Error && error.message) {
5
+ return error.message;
6
+ }
7
+
8
+ if (typeof error === 'string' && error) {
9
+ return error;
10
+ }
11
+
12
+ return 'Unknown Unlayer error';
13
+ };
14
+
15
+ export const logUnlayerError = (message: string, error?: unknown) => {
16
+ if (error === undefined) {
17
+ // eslint-disable-next-line no-console
18
+ console.error(LOG_PREFIX, message);
19
+ return;
20
+ }
21
+
22
+ // eslint-disable-next-line no-console
23
+ console.error(LOG_PREFIX, message, error);
24
+ };
package/src/merge-tags.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { logUnlayerError } from './log';
1
2
  import { UnlayerEditorMergeTagInfo } from './unlayer-interface';
2
3
 
3
4
  export interface UnlayerMergeTagLeaf {
@@ -12,54 +13,106 @@ export interface UnlayerMergeTagGroup {
12
13
 
13
14
  export type UnlayerMergeTagsConfig = Record<string, UnlayerMergeTagLeaf | UnlayerMergeTagGroup>;
14
15
 
16
+ export interface ToUnlayerMergeTagsOptions {
17
+ /**
18
+ * When false, ignore `group` and emit the flat 0.151 shape. Unlayer custom
19
+ * tools fail to register if nested groups are present during createEditor.
20
+ */
21
+ groups?: boolean;
22
+ }
23
+
24
+ export interface UnlayerMergeTagsEditor {
25
+ addEventListener(event: string, cb: (arg: any) => void): void;
26
+ setMergeTags(tags: UnlayerMergeTagsConfig): void;
27
+ }
28
+
15
29
  export function isUnlayerMergeTagGroup(
16
30
  entry: UnlayerMergeTagLeaf | UnlayerMergeTagGroup,
17
31
  ): entry is UnlayerMergeTagGroup {
18
32
  return 'mergeTags' in entry;
19
33
  }
20
34
 
21
- const toMergeTagGroupKey = (groupName: string) => {
22
- const key = groupName
35
+ const toMergeTagKey = (value: string) => {
36
+ const key = value
23
37
  .toLowerCase()
24
38
  .split(/[^a-z0-9]+/)
25
39
  .filter(Boolean)
26
40
  .join('_');
27
41
 
28
- return key || groupName;
42
+ return key || value;
29
43
  };
30
44
 
31
- export const toUnlayerMergeTags = (tags: UnlayerEditorMergeTagInfo[] | undefined) => {
45
+ export const toUnlayerMergeTags = (
46
+ tags: UnlayerEditorMergeTagInfo[] | undefined,
47
+ options?: ToUnlayerMergeTagsOptions,
48
+ ) => {
32
49
  if (!tags?.length) {
33
50
  return undefined;
34
51
  }
35
52
 
53
+ const groupsEnabled = options?.groups !== false;
54
+
36
55
  return tags.reduce<UnlayerMergeTagsConfig>((out, tag) => {
37
56
  const mergeTag: UnlayerMergeTagLeaf = {
38
57
  name: tag.name,
39
58
  value: tag.propertyPath,
40
59
  };
41
- const groupName = tag.group?.trim();
60
+ const tagKey = toMergeTagKey(tag.id);
61
+ const groupName = groupsEnabled ? tag.group?.trim() : undefined;
42
62
 
43
63
  if (!groupName) {
44
- out[tag.id] = mergeTag;
64
+ out[tagKey] = mergeTag;
45
65
  return out;
46
66
  }
47
67
 
48
- const groupKey = toMergeTagGroupKey(groupName);
68
+ const groupKey = toMergeTagKey(groupName);
49
69
  const existing = out[groupKey];
50
70
 
51
71
  if (existing && isUnlayerMergeTagGroup(existing)) {
52
- existing.mergeTags[tag.id] = mergeTag;
72
+ existing.mergeTags[tagKey] = mergeTag;
53
73
  return out;
54
74
  }
55
75
 
56
76
  out[groupKey] = {
57
77
  name: groupName,
58
78
  mergeTags: {
59
- [tag.id]: mergeTag,
79
+ [tagKey]: mergeTag,
60
80
  },
61
81
  };
62
82
 
63
83
  return out;
64
84
  }, {});
65
85
  };
86
+
87
+ /**
88
+ * Nested Unlayer groups passed to createEditor prevent DTE custom tools from
89
+ * loading in the host app. Apply the 0.151 flat shape at init, then upgrade to
90
+ * grouped submenus once the editor (and customJS tools) are ready.
91
+ */
92
+ export const scheduleUnlayerMergeTags = (
93
+ editor: UnlayerMergeTagsEditor,
94
+ tags: UnlayerEditorMergeTagInfo[] | undefined,
95
+ ) => {
96
+ const grouped = toUnlayerMergeTags(tags);
97
+
98
+ if (!grouped) {
99
+ return;
100
+ }
101
+
102
+ let isApplied = false;
103
+ const apply = () => {
104
+ if (isApplied) {
105
+ return;
106
+ }
107
+
108
+ try {
109
+ editor.setMergeTags(grouped);
110
+ isApplied = true;
111
+ } catch (error: unknown) {
112
+ logUnlayerError('Failed to apply grouped merge tags', error);
113
+ }
114
+ };
115
+
116
+ editor.addEventListener('editor:ready', apply);
117
+ editor.addEventListener('design:loaded', apply);
118
+ };
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadScript } from './loadScript';
2
+ import { logUnlayerError } from './log';
2
3
  import { defaultImageValidation } from './shared/configs';
3
4
  import type { UnlayerEditorTwin, UnlayerEventConfig, UnlayerEventRegister } from './shared/const';
4
5
  import { unlayerSupportedFonts } from './shared/fonts';
@@ -20,6 +21,7 @@ import {
20
21
  import { CreateUnlayerEditorProps, UnlayerDesignFormat, UnlayerRef } from './unlayer-interface';
21
22
 
22
23
  const defaultScriptUrl = 'https://editor.unlayer.com/embed.js?2';
24
+ const UNLAYER_TOOLS_READY_TIMEOUT_MS = 30000;
23
25
 
24
26
  const normalizeFontToken = (font: string) =>
25
27
  font.replace(/["']/g, '').split(',')[0].trim().toLowerCase();
@@ -184,6 +186,9 @@ export class UnlayerStore {
184
186
  private isInit = false;
185
187
  private iframe?: HTMLIFrameElement;
186
188
  private hasDesign = false;
189
+ private hasCoreLoaded = false;
190
+ private hasToolsRegistered = false;
191
+ private toolsReadyWatchdogId?: number;
187
192
  private formFieldsByFormId: Record<number, FormFieldInfo[]> = {};
188
193
  private formFieldSamples: Record<string, unknown> = {};
189
194
 
@@ -241,19 +246,37 @@ export class UnlayerStore {
241
246
 
242
247
  this.isInit = true;
243
248
 
244
- setTimeout(() => window.addEventListener('message', this.onPostMessage));
249
+ try {
250
+ setTimeout(() => window.addEventListener('message', this.onPostMessage));
251
+
252
+ await loadScript(defaultScriptUrl);
253
+
254
+ if (!(window as any).unlayer) {
255
+ throw new Error('Unlayer embed script loaded but window.unlayer is missing');
256
+ }
257
+
258
+ this.editor = createUnlayerEditor(container, this.props);
259
+ this.editor.addEventListener('design:loaded', this.onDesignLoaded);
260
+ this.editor.addEventListener('design:updated', this.onDesignUpdated);
261
+ this.editor.registerCallback('image', this.uploadImage);
245
262
 
246
- await loadScript(defaultScriptUrl);
263
+ this.iframe = container.querySelector(`iframe`) ?? undefined;
247
264
 
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);
265
+ if (!this.iframe) {
266
+ logUnlayerError('Unlayer iframe was not created after createEditor');
267
+ }
252
268
 
253
- this.iframe = container.querySelector(`iframe`) ?? undefined;
269
+ this.startToolsReadyWatchdog();
270
+ } catch (error: unknown) {
271
+ this.isInit = false;
272
+ this.clearToolsReadyWatchdog();
273
+ logUnlayerError('Failed to initialize Unlayer editor', error);
274
+ throw error;
275
+ }
254
276
  };
255
277
 
256
278
  destroy = () => {
279
+ this.clearToolsReadyWatchdog();
257
280
  window.removeEventListener('message', this.onPostMessage);
258
281
 
259
282
  if (this.editor) {
@@ -392,8 +415,11 @@ export class UnlayerStore {
392
415
  }
393
416
 
394
417
  if (type === '--ready' || type === '--registered') {
418
+ this.hasToolsRegistered = true;
419
+ this.clearToolsReadyWatchdog();
395
420
  this.onReadyCB?.();
396
421
  } else if (type === '--core-loaded') {
422
+ this.hasCoreLoaded = true;
397
423
  const configData: UnlayerEventConfig = {
398
424
  dummyData: this.props.dummyData,
399
425
  schema: this.props.schema,
@@ -588,6 +614,7 @@ export class UnlayerStore {
588
614
  });
589
615
 
590
616
  if (!res.isValid) {
617
+ logUnlayerError(`Image validation failed: ${res.title}`, res.description);
591
618
  this.onErrorCB?.(res.title, res.description);
592
619
  return;
593
620
  }
@@ -597,7 +624,8 @@ export class UnlayerStore {
597
624
  try {
598
625
  const { url } = await this.onImageCB(file);
599
626
  done({ progress: 100, url });
600
- } catch {
627
+ } catch (error: unknown) {
628
+ logUnlayerError('Image upload failed', error);
601
629
  this.onErrorCB?.(
602
630
  'Image upload failed',
603
631
  'Something went wrong while uploading the image. Please try again or select another image.',
@@ -606,6 +634,36 @@ export class UnlayerStore {
606
634
  }
607
635
  };
608
636
 
637
+ private startToolsReadyWatchdog = () => {
638
+ this.clearToolsReadyWatchdog();
639
+ this.toolsReadyWatchdogId = window.setTimeout(() => {
640
+ if (this.hasToolsRegistered) {
641
+ return;
642
+ }
643
+
644
+ logUnlayerError(
645
+ 'Unlayer custom tools did not finish loading. customJS may not have run, or tool registration stalled.',
646
+ {
647
+ hasCoreLoaded: this.hasCoreLoaded,
648
+ hasToolsRegistered: this.hasToolsRegistered,
649
+ },
650
+ );
651
+ this.onErrorCB?.(
652
+ 'Editor failed to load custom tools',
653
+ 'Unlayer custom tools did not finish loading. Check the browser console for [dte-unlayer] details.',
654
+ );
655
+ }, UNLAYER_TOOLS_READY_TIMEOUT_MS);
656
+ };
657
+
658
+ private clearToolsReadyWatchdog = () => {
659
+ if (this.toolsReadyWatchdogId === undefined) {
660
+ return;
661
+ }
662
+
663
+ window.clearTimeout(this.toolsReadyWatchdogId);
664
+ this.toolsReadyWatchdogId = undefined;
665
+ };
666
+
609
667
  private validateImage = async (
610
668
  file: File,
611
669
  imageValidation: { maxFileSize: number; maxWidth: number; maxHeight: number },