@servicetitan/dte-unlayer 0.151.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.
@@ -0,0 +1,44 @@
1
+ import { getErrorMessage, logUnlayerError } from '../log';
2
+
3
+ describe('getErrorMessage', () => {
4
+ describe('when error is an Error', () => {
5
+ const subject = () => getErrorMessage(new Error('createEditor failed'));
6
+
7
+ test('returns the error message', () => {
8
+ expect(subject()).toBe('createEditor failed');
9
+ });
10
+ });
11
+
12
+ describe('when error is a string', () => {
13
+ const subject = () => getErrorMessage('script load failed');
14
+
15
+ test('returns the string', () => {
16
+ expect(subject()).toBe('script load failed');
17
+ });
18
+ });
19
+
20
+ describe('when error is unknown', () => {
21
+ test.each([undefined, null, 0, {}])('returns a fallback for %p', error => {
22
+ expect(getErrorMessage(error)).toBe('Unknown Unlayer error');
23
+ });
24
+ });
25
+ });
26
+
27
+ describe('logUnlayerError', () => {
28
+ afterEach(() => {
29
+ jest.restoreAllMocks();
30
+ });
31
+
32
+ test('writes a prefixed console error with the cause', () => {
33
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
34
+ const error = new Error('createEditor failed');
35
+
36
+ logUnlayerError('Failed to initialize Unlayer editor', error);
37
+
38
+ expect(consoleError).toHaveBeenCalledWith(
39
+ '[dte-unlayer]',
40
+ 'Failed to initialize Unlayer editor',
41
+ error,
42
+ );
43
+ });
44
+ });
@@ -0,0 +1,236 @@
1
+ import { scheduleUnlayerMergeTags, toUnlayerMergeTags } from '../merge-tags';
2
+
3
+ const customerNameTag = {
4
+ id: '10',
5
+ name: 'Customer Name',
6
+ propertyPath: '{{Customer.Name | safe | nl2br }}',
7
+ };
8
+
9
+ const contractTermTag = {
10
+ id: 'custom-field-12345',
11
+ name: 'Contract Term',
12
+ propertyPath: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
13
+ group: 'Custom Fields',
14
+ };
15
+
16
+ const renewalDateTag = {
17
+ id: 'custom-field-12346',
18
+ name: 'Renewal Date',
19
+ propertyPath: '{{ __custom_fields["Renewal Date"] | safe | nl2br }}',
20
+ group: 'Custom Fields',
21
+ };
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
+
39
+ describe('toUnlayerMergeTags', () => {
40
+ describe('when tags are missing', () => {
41
+ test.each([undefined, []])('returns undefined for %p', tags => {
42
+ const subject = () => toUnlayerMergeTags(tags);
43
+
44
+ expect(subject()).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe('when tags have no group', () => {
49
+ const subject = () => toUnlayerMergeTags([customerNameTag]);
50
+
51
+ test('returns flat merge tags', () => {
52
+ expect(subject()).toEqual({
53
+ '10': {
54
+ name: 'Customer Name',
55
+ value: '{{Customer.Name | safe | nl2br }}',
56
+ },
57
+ });
58
+ });
59
+ });
60
+
61
+ describe('when tags have a group', () => {
62
+ const subject = () => toUnlayerMergeTags([contractTermTag, renewalDateTag]);
63
+
64
+ test('nests tags under a submenu named by the host group', () => {
65
+ expect(subject()).toEqual(groupedCustomFields);
66
+ });
67
+ });
68
+
69
+ describe('when tags mix grouped and ungrouped', () => {
70
+ const subject = () => toUnlayerMergeTags([customerNameTag, contractTermTag]);
71
+
72
+ test('keeps ungrouped tags flat and grouped tags nested', () => {
73
+ expect(subject()).toEqual({
74
+ '10': {
75
+ name: 'Customer Name',
76
+ value: '{{Customer.Name | safe | nl2br }}',
77
+ },
78
+ custom_fields: {
79
+ name: 'Custom Fields',
80
+ mergeTags: {
81
+ custom_field_12345: {
82
+ name: 'Contract Term',
83
+ value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
84
+ },
85
+ },
86
+ },
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('when tags use more than one group', () => {
92
+ const subject = () =>
93
+ toUnlayerMergeTags([
94
+ contractTermTag,
95
+ {
96
+ id: 'street',
97
+ name: 'Street 1',
98
+ propertyPath: '{{shipping_address.address_1}}',
99
+ group: 'Shipping Address',
100
+ },
101
+ ]);
102
+
103
+ test('creates a submenu per host group label', () => {
104
+ expect(subject()).toEqual({
105
+ custom_fields: {
106
+ name: 'Custom Fields',
107
+ mergeTags: {
108
+ custom_field_12345: {
109
+ name: 'Contract Term',
110
+ value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
111
+ },
112
+ },
113
+ },
114
+ shipping_address: {
115
+ name: 'Shipping Address',
116
+ mergeTags: {
117
+ street: {
118
+ name: 'Street 1',
119
+ value: '{{shipping_address.address_1}}',
120
+ },
121
+ },
122
+ },
123
+ });
124
+ });
125
+ });
126
+
127
+ describe('when group is blank', () => {
128
+ const subject = () =>
129
+ toUnlayerMergeTags([
130
+ {
131
+ ...customerNameTag,
132
+ group: ' ',
133
+ },
134
+ ]);
135
+
136
+ test('treats the tag as ungrouped', () => {
137
+ expect(subject()).toEqual({
138
+ '10': {
139
+ name: 'Customer Name',
140
+ value: '{{Customer.Name | safe | nl2br }}',
141
+ },
142
+ });
143
+ });
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
+ });
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,6 +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 { isUnlayerMergeTagGroup, scheduleUnlayerMergeTags, toUnlayerMergeTags } from './merge-tags';
7
+ export type {
8
+ ToUnlayerMergeTagsOptions,
9
+ UnlayerMergeTagGroup,
10
+ UnlayerMergeTagLeaf,
11
+ UnlayerMergeTagsConfig,
12
+ UnlayerMergeTagsEditor,
13
+ } from './merge-tags';
6
14
  export * from './loadScript';
7
15
  export * from './shared/const';
8
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
+ };
@@ -0,0 +1,118 @@
1
+ import { logUnlayerError } from './log';
2
+ import { UnlayerEditorMergeTagInfo } from './unlayer-interface';
3
+
4
+ export interface UnlayerMergeTagLeaf {
5
+ name: string;
6
+ value: string;
7
+ }
8
+
9
+ export interface UnlayerMergeTagGroup {
10
+ name: string;
11
+ mergeTags: Record<string, UnlayerMergeTagLeaf>;
12
+ }
13
+
14
+ export type UnlayerMergeTagsConfig = Record<string, UnlayerMergeTagLeaf | UnlayerMergeTagGroup>;
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
+
29
+ export function isUnlayerMergeTagGroup(
30
+ entry: UnlayerMergeTagLeaf | UnlayerMergeTagGroup,
31
+ ): entry is UnlayerMergeTagGroup {
32
+ return 'mergeTags' in entry;
33
+ }
34
+
35
+ const toMergeTagKey = (value: string) => {
36
+ const key = value
37
+ .toLowerCase()
38
+ .split(/[^a-z0-9]+/)
39
+ .filter(Boolean)
40
+ .join('_');
41
+
42
+ return key || value;
43
+ };
44
+
45
+ export const toUnlayerMergeTags = (
46
+ tags: UnlayerEditorMergeTagInfo[] | undefined,
47
+ options?: ToUnlayerMergeTagsOptions,
48
+ ) => {
49
+ if (!tags?.length) {
50
+ return undefined;
51
+ }
52
+
53
+ const groupsEnabled = options?.groups !== false;
54
+
55
+ return tags.reduce<UnlayerMergeTagsConfig>((out, tag) => {
56
+ const mergeTag: UnlayerMergeTagLeaf = {
57
+ name: tag.name,
58
+ value: tag.propertyPath,
59
+ };
60
+ const tagKey = toMergeTagKey(tag.id);
61
+ const groupName = groupsEnabled ? tag.group?.trim() : undefined;
62
+
63
+ if (!groupName) {
64
+ out[tagKey] = mergeTag;
65
+ return out;
66
+ }
67
+
68
+ const groupKey = toMergeTagKey(groupName);
69
+ const existing = out[groupKey];
70
+
71
+ if (existing && isUnlayerMergeTagGroup(existing)) {
72
+ existing.mergeTags[tagKey] = mergeTag;
73
+ return out;
74
+ }
75
+
76
+ out[groupKey] = {
77
+ name: groupName,
78
+ mergeTags: {
79
+ [tagKey]: mergeTag,
80
+ },
81
+ };
82
+
83
+ return out;
84
+ }, {});
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 },
@@ -51,6 +51,7 @@ export interface UnlayerEditorMergeTagInfo {
51
51
  id: string;
52
52
  name: string;
53
53
  propertyPath: string;
54
+ group?: string;
54
55
  }
55
56
 
56
57
  export interface UnlayerEditorCustomTool {