@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.
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +25 -10
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/loadScript.d.ts.map +1 -1
- package/dist/loadScript.js +8 -1
- package/dist/loadScript.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +21 -0
- package/dist/log.js.map +1 -0
- package/dist/merge-tags.d.ts +15 -2
- package/dist/merge-tags.d.ts.map +1 -1
- package/dist/merge-tags.js +40 -22
- package/dist/merge-tags.js.map +1 -1
- package/dist/shared/instance-id.d.ts +13 -0
- package/dist/shared/instance-id.d.ts.map +1 -0
- package/dist/shared/instance-id.js +22 -0
- package/dist/shared/instance-id.js.map +1 -0
- package/dist/store.d.ts +19 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +109 -11
- package/dist/store.js.map +1 -1
- package/dist/tools.d.ts +12 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +47 -0
- package/dist/tools.js.map +1 -1
- package/dist/unlayer-interface.d.ts +11 -1
- package/dist/unlayer-interface.d.ts.map +1 -1
- package/dist/unlayer-interface.js.map +1 -1
- package/dist/unlayer.d.ts.map +1 -1
- package/dist/unlayer.js +90 -73
- package/dist/unlayer.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/load-script.test.ts +41 -0
- package/src/__tests__/log.test.ts +44 -0
- package/src/__tests__/merge-tags.test.ts +133 -18
- package/src/__tests__/store.merge-tags.test.ts +186 -0
- package/src/editor.tsx +20 -9
- package/src/index.ts +7 -1
- package/src/loadScript.ts +9 -1
- package/src/log.ts +24 -0
- package/src/merge-tags.ts +59 -25
- package/src/shared/instance-id.ts +23 -0
- package/src/store.ts +128 -10
- package/src/tools.ts +51 -0
- package/src/unlayer-interface.tsx +11 -1
- package/src/unlayer.tsx +81 -61
|
@@ -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
|
|
|
@@ -73,10 +75,10 @@ describe('toUnlayerMergeTags', () => {
|
|
|
73
75
|
name: 'Customer Name',
|
|
74
76
|
value: '{{Customer.Name | safe | nl2br }}',
|
|
75
77
|
},
|
|
76
|
-
custom_fields: {
|
|
78
|
+
'custom_fields': {
|
|
77
79
|
name: 'Custom Fields',
|
|
78
80
|
mergeTags: {
|
|
79
|
-
|
|
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
|
-
|
|
108
|
+
custom_field_12345: {
|
|
107
109
|
name: 'Contract Term',
|
|
108
110
|
value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
|
|
109
111
|
},
|
|
@@ -122,6 +124,101 @@ describe('toUnlayerMergeTags', () => {
|
|
|
122
124
|
});
|
|
123
125
|
});
|
|
124
126
|
|
|
127
|
+
describe('when group is a path', () => {
|
|
128
|
+
const intakeScoreTag = {
|
|
129
|
+
id: 'form-7-abc123',
|
|
130
|
+
name: 'Score',
|
|
131
|
+
propertyPath: '{{ __submission_fields["7"]["abc123"] | safe | nl2br }}',
|
|
132
|
+
group: ['Forms', 'Customer Intake'],
|
|
133
|
+
};
|
|
134
|
+
const intakeNotesTag = {
|
|
135
|
+
id: 'form-7-def456',
|
|
136
|
+
name: 'Notes',
|
|
137
|
+
propertyPath: '{{ __submission_fields["7"]["def456"] | safe | nl2br }}',
|
|
138
|
+
group: ['Forms', 'Customer Intake'],
|
|
139
|
+
};
|
|
140
|
+
const inspectionDateTag = {
|
|
141
|
+
id: 'form-9-fed987',
|
|
142
|
+
name: 'Inspection Date',
|
|
143
|
+
propertyPath: '{{ __submission_fields["9"]["fed987"] | safe | nl2br }}',
|
|
144
|
+
group: ['Forms', 'Inspection'],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const subject = () =>
|
|
148
|
+
toUnlayerMergeTags([
|
|
149
|
+
contractTermTag,
|
|
150
|
+
renewalDateTag,
|
|
151
|
+
intakeScoreTag,
|
|
152
|
+
intakeNotesTag,
|
|
153
|
+
inspectionDateTag,
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
test('nests one submenu per path segment', () => {
|
|
157
|
+
expect(subject()).toEqual({
|
|
158
|
+
...groupedCustomFields,
|
|
159
|
+
forms: {
|
|
160
|
+
name: 'Forms',
|
|
161
|
+
mergeTags: {
|
|
162
|
+
customer_intake: {
|
|
163
|
+
name: 'Customer Intake',
|
|
164
|
+
mergeTags: {
|
|
165
|
+
form_7_abc123: {
|
|
166
|
+
name: 'Score',
|
|
167
|
+
value: '{{ __submission_fields["7"]["abc123"] | safe | nl2br }}',
|
|
168
|
+
},
|
|
169
|
+
form_7_def456: {
|
|
170
|
+
name: 'Notes',
|
|
171
|
+
value: '{{ __submission_fields["7"]["def456"] | safe | nl2br }}',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
inspection: {
|
|
176
|
+
name: 'Inspection',
|
|
177
|
+
mergeTags: {
|
|
178
|
+
form_9_fed987: {
|
|
179
|
+
name: 'Inspection Date',
|
|
180
|
+
value: '{{ __submission_fields["9"]["fed987"] | safe | nl2br }}',
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('drops blank segments and keeps the remaining path', () => {
|
|
190
|
+
expect(toUnlayerMergeTags([{ ...intakeScoreTag, group: [' ', 'Forms', ''] }])).toEqual({
|
|
191
|
+
forms: {
|
|
192
|
+
name: 'Forms',
|
|
193
|
+
mergeTags: {
|
|
194
|
+
form_7_abc123: {
|
|
195
|
+
name: 'Score',
|
|
196
|
+
value: '{{ __submission_fields["7"]["abc123"] | safe | nl2br }}',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('treats an all-blank path as ungrouped', () => {
|
|
204
|
+
expect(toUnlayerMergeTags([{ ...intakeScoreTag, group: [] }])).toEqual({
|
|
205
|
+
form_7_abc123: {
|
|
206
|
+
name: 'Score',
|
|
207
|
+
value: '{{ __submission_fields["7"]["abc123"] | safe | nl2br }}',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('flattens the path when groups are disabled', () => {
|
|
213
|
+
expect(toUnlayerMergeTags([intakeScoreTag], { groups: false })).toEqual({
|
|
214
|
+
form_7_abc123: {
|
|
215
|
+
name: 'Score',
|
|
216
|
+
value: '{{ __submission_fields["7"]["abc123"] | safe | nl2br }}',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
125
222
|
describe('when group is blank', () => {
|
|
126
223
|
const subject = () =>
|
|
127
224
|
toUnlayerMergeTags([
|
|
@@ -140,4 +237,22 @@ describe('toUnlayerMergeTags', () => {
|
|
|
140
237
|
});
|
|
141
238
|
});
|
|
142
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('when groups are disabled', () => {
|
|
242
|
+
const subject = () =>
|
|
243
|
+
toUnlayerMergeTags([customerNameTag, contractTermTag], { groups: false });
|
|
244
|
+
|
|
245
|
+
test('keeps every tag flat like Unlayer 0.151', () => {
|
|
246
|
+
expect(subject()).toEqual({
|
|
247
|
+
'10': {
|
|
248
|
+
name: 'Customer Name',
|
|
249
|
+
value: '{{Customer.Name | safe | nl2br }}',
|
|
250
|
+
},
|
|
251
|
+
'custom_field_12345': {
|
|
252
|
+
name: 'Contract Term',
|
|
253
|
+
value: '{{ __custom_fields["Contract Term"] | safe | nl2br }}',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
143
258
|
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { UnlayerStore } from '../store';
|
|
2
|
+
import type { CreateUnlayerEditorProps } from '../unlayer-interface';
|
|
3
|
+
|
|
4
|
+
const editorMock = {
|
|
5
|
+
addEventListener: jest.fn(),
|
|
6
|
+
removeEventListener: jest.fn(),
|
|
7
|
+
registerCallback: jest.fn(),
|
|
8
|
+
unregisterCallback: jest.fn(),
|
|
9
|
+
setMergeTags: jest.fn(),
|
|
10
|
+
loadDesign: jest.fn(),
|
|
11
|
+
setBodyValues: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
jest.mock('../loadScript', () => ({ loadScript: jest.fn().mockResolvedValue(undefined) }));
|
|
15
|
+
jest.mock('../unlayer', () => ({
|
|
16
|
+
...jest.requireActual('../unlayer'),
|
|
17
|
+
createUnlayerEditor: jest.fn(() => editorMock),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const customerNameTag = {
|
|
21
|
+
id: '10',
|
|
22
|
+
name: 'Customer Name',
|
|
23
|
+
propertyPath: '{{Customer.Name | safe | nl2br }}',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const scoreTag = {
|
|
27
|
+
id: 'form-7-abc',
|
|
28
|
+
name: 'Score',
|
|
29
|
+
propertyPath: '{{ __submission_fields["7"]["abc"] | safe | nl2br }}',
|
|
30
|
+
group: ['Forms', 'Customer Intake'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const flatCustomer = {
|
|
34
|
+
'10': { name: 'Customer Name', value: '{{Customer.Name | safe | nl2br }}' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const groupedScore = {
|
|
38
|
+
forms: {
|
|
39
|
+
name: 'Forms',
|
|
40
|
+
mergeTags: {
|
|
41
|
+
customer_intake: {
|
|
42
|
+
name: 'Customer Intake',
|
|
43
|
+
mergeTags: {
|
|
44
|
+
form_7_abc: {
|
|
45
|
+
name: 'Score',
|
|
46
|
+
value: '{{ __submission_fields["7"]["abc"] | safe | nl2br }}',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fireEditorEvent = (event: string) => {
|
|
55
|
+
editorMock.addEventListener.mock.calls
|
|
56
|
+
.filter(([name]) => name === event)
|
|
57
|
+
.forEach(([, handler]) => handler());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('UnlayerStore merge tags', () => {
|
|
61
|
+
let store: UnlayerStore;
|
|
62
|
+
let consoleError: jest.SpyInstance;
|
|
63
|
+
|
|
64
|
+
const createStore = (props: Partial<CreateUnlayerEditorProps> = {}) => {
|
|
65
|
+
store = new UnlayerStore({ tools: [], ...props });
|
|
66
|
+
return store;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const initStore = async () => {
|
|
70
|
+
(window as any).unlayer = {};
|
|
71
|
+
const container = document.createElement('div');
|
|
72
|
+
container.appendChild(document.createElement('iframe'));
|
|
73
|
+
await store.init(container);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
jest.clearAllMocks();
|
|
78
|
+
consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
store.destroy();
|
|
83
|
+
consoleError.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('when the editor initializes with merge tags', () => {
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
createStore({ mergeTags: [customerNameTag, scoreTag] });
|
|
89
|
+
await initStore();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('does not touch the editor before it is ready', () => {
|
|
93
|
+
expect(editorMock.setMergeTags).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('applies the grouped shape once on editor:ready', () => {
|
|
97
|
+
fireEditorEvent('editor:ready');
|
|
98
|
+
fireEditorEvent('design:loaded');
|
|
99
|
+
|
|
100
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledWith({
|
|
102
|
+
...flatCustomer,
|
|
103
|
+
...groupedScore,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('applies on design:loaded when editor:ready never fires', () => {
|
|
108
|
+
fireEditorEvent('design:loaded');
|
|
109
|
+
|
|
110
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledWith({
|
|
112
|
+
...flatCustomer,
|
|
113
|
+
...groupedScore,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('retries on design:loaded when the first apply throws', () => {
|
|
118
|
+
editorMock.setMergeTags.mockImplementationOnce(() => {
|
|
119
|
+
throw new Error('not ready');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
fireEditorEvent('editor:ready');
|
|
123
|
+
fireEditorEvent('design:loaded');
|
|
124
|
+
fireEditorEvent('design:loaded');
|
|
125
|
+
|
|
126
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
128
|
+
'[dte-unlayer]',
|
|
129
|
+
'Failed to apply merge tags',
|
|
130
|
+
expect.any(Error),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('with setMergeTags', () => {
|
|
136
|
+
describe('when called before the editor is ready', () => {
|
|
137
|
+
beforeEach(async () => {
|
|
138
|
+
createStore({ mergeTags: [customerNameTag] });
|
|
139
|
+
store.unlayerRef.setMergeTags([customerNameTag, scoreTag]);
|
|
140
|
+
await initStore();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('replaces the initial tags and applies once on ready', () => {
|
|
144
|
+
fireEditorEvent('editor:ready');
|
|
145
|
+
|
|
146
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledWith({
|
|
148
|
+
...flatCustomer,
|
|
149
|
+
...groupedScore,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('when called after the editor is ready', () => {
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
createStore({ mergeTags: [customerNameTag] });
|
|
157
|
+
await initStore();
|
|
158
|
+
fireEditorEvent('editor:ready');
|
|
159
|
+
editorMock.setMergeTags.mockClear();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('applies immediately', () => {
|
|
163
|
+
store.unlayerRef.setMergeTags([customerNameTag, scoreTag]);
|
|
164
|
+
|
|
165
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledWith({
|
|
167
|
+
...flatCustomer,
|
|
168
|
+
...groupedScore,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('does not re-apply on later design:loaded events', () => {
|
|
173
|
+
store.unlayerRef.setMergeTags([customerNameTag, scoreTag]);
|
|
174
|
+
fireEditorEvent('design:loaded');
|
|
175
|
+
|
|
176
|
+
expect(editorMock.setMergeTags).toHaveBeenCalledTimes(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('ignores an empty set', () => {
|
|
180
|
+
store.unlayerRef.setMergeTags([]);
|
|
181
|
+
|
|
182
|
+
expect(editorMock.setMergeTags).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
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(() =>
|
|
51
|
-
|
|
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
|
-
{
|
|
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,13 @@ export * from './tools';
|
|
|
3
3
|
export * from './api-core';
|
|
4
4
|
export * from './api-custom-tools';
|
|
5
5
|
export * from './unlayer-interface';
|
|
6
|
-
export
|
|
6
|
+
export { isUnlayerMergeTagGroup, toUnlayerMergeTags } from './merge-tags';
|
|
7
|
+
export type {
|
|
8
|
+
ToUnlayerMergeTagsOptions,
|
|
9
|
+
UnlayerMergeTagGroup,
|
|
10
|
+
UnlayerMergeTagLeaf,
|
|
11
|
+
UnlayerMergeTagsConfig,
|
|
12
|
+
} from './merge-tags';
|
|
7
13
|
export * from './loadScript';
|
|
8
14
|
export * from './shared/const';
|
|
9
15
|
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
|
@@ -7,58 +7,92 @@ export interface UnlayerMergeTagLeaf {
|
|
|
7
7
|
|
|
8
8
|
export interface UnlayerMergeTagGroup {
|
|
9
9
|
name: string;
|
|
10
|
-
|
|
10
|
+
/** Nested submenu: leaves and/or further groups (Unlayer supports arbitrary depth). */
|
|
11
|
+
mergeTags: UnlayerMergeTagsConfig;
|
|
11
12
|
}
|
|
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
|
+
|
|
15
24
|
export function isUnlayerMergeTagGroup(
|
|
16
25
|
entry: UnlayerMergeTagLeaf | UnlayerMergeTagGroup,
|
|
17
26
|
): entry is UnlayerMergeTagGroup {
|
|
18
27
|
return 'mergeTags' in entry;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
const
|
|
22
|
-
const key =
|
|
30
|
+
const toMergeTagKey = (value: string) => {
|
|
31
|
+
const key = value
|
|
23
32
|
.toLowerCase()
|
|
24
33
|
.split(/[^a-z0-9]+/)
|
|
25
34
|
.filter(Boolean)
|
|
26
35
|
.join('_');
|
|
27
36
|
|
|
28
|
-
return key ||
|
|
37
|
+
return key || value;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalizes the host `group` into the submenu path the tag belongs to:
|
|
42
|
+
* `'Custom Fields'` → `['Custom Fields']`, `['Forms', 'Intake']` → `['Forms', 'Intake']`.
|
|
43
|
+
* Blank segments are dropped; an empty result means the tag stays at the top level.
|
|
44
|
+
*/
|
|
45
|
+
const toGroupPath = (group: UnlayerEditorMergeTagInfo['group']): string[] => {
|
|
46
|
+
const segments = Array.isArray(group) ? group : group === undefined ? [] : [group];
|
|
47
|
+
|
|
48
|
+
return segments.map(segment => segment.trim()).filter(Boolean);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Returns the `mergeTags` record of the (possibly nested) group at `path`, creating groups on the way. */
|
|
52
|
+
const ensureGroup = (root: UnlayerMergeTagsConfig, path: string[]) => {
|
|
53
|
+
let current = root;
|
|
54
|
+
|
|
55
|
+
for (const groupName of path) {
|
|
56
|
+
const groupKey = toMergeTagKey(groupName);
|
|
57
|
+
const existing = current[groupKey];
|
|
58
|
+
|
|
59
|
+
if (existing && isUnlayerMergeTagGroup(existing)) {
|
|
60
|
+
current = existing.mergeTags;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const created: UnlayerMergeTagGroup = { name: groupName, mergeTags: {} };
|
|
65
|
+
current[groupKey] = created;
|
|
66
|
+
current = created.mergeTags;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return current;
|
|
29
70
|
};
|
|
30
71
|
|
|
31
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Builds Unlayer's `mergeTags` config. With `groups` enabled (default) tags nest under the
|
|
74
|
+
* submenu path in `group`; `UnlayerStore` applies that shape once the editor is ready, since
|
|
75
|
+
* nested groups at `createEditor` time prevent DTE custom tools from loading.
|
|
76
|
+
*/
|
|
77
|
+
export const toUnlayerMergeTags = (
|
|
78
|
+
tags: UnlayerEditorMergeTagInfo[] | undefined,
|
|
79
|
+
options?: ToUnlayerMergeTagsOptions,
|
|
80
|
+
) => {
|
|
32
81
|
if (!tags?.length) {
|
|
33
82
|
return undefined;
|
|
34
83
|
}
|
|
35
84
|
|
|
85
|
+
const groupsEnabled = options?.groups !== false;
|
|
86
|
+
|
|
36
87
|
return tags.reduce<UnlayerMergeTagsConfig>((out, tag) => {
|
|
37
88
|
const mergeTag: UnlayerMergeTagLeaf = {
|
|
38
89
|
name: tag.name,
|
|
39
90
|
value: tag.propertyPath,
|
|
40
91
|
};
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (!groupName) {
|
|
44
|
-
out[tag.id] = mergeTag;
|
|
45
|
-
return out;
|
|
46
|
-
}
|
|
92
|
+
const tagKey = toMergeTagKey(tag.id);
|
|
93
|
+
const groupPath = groupsEnabled ? toGroupPath(tag.group) : [];
|
|
47
94
|
|
|
48
|
-
|
|
49
|
-
const existing = out[groupKey];
|
|
50
|
-
|
|
51
|
-
if (existing && isUnlayerMergeTagGroup(existing)) {
|
|
52
|
-
existing.mergeTags[tag.id] = mergeTag;
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
out[groupKey] = {
|
|
57
|
-
name: groupName,
|
|
58
|
-
mergeTags: {
|
|
59
|
-
[tag.id]: mergeTag,
|
|
60
|
-
},
|
|
61
|
-
};
|
|
95
|
+
ensureGroup(out, groupPath)[tagKey] = mergeTag;
|
|
62
96
|
|
|
63
97
|
return out;
|
|
64
98
|
}, {});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom tools whose `values.id` is a per-instance GUID that must stay unique
|
|
3
|
+
* within a design. When such a tool is duplicated (content or row duplicate),
|
|
4
|
+
* Unlayer copies the values as-is, so the host regenerates the id of the copy.
|
|
5
|
+
*/
|
|
6
|
+
export const INSTANCE_ID_TOOL_SLUGS = ['embedded-image'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates an RFC 4122 v4 GUID (`3fa85f64-5717-4562-b3fc-2c963f66afa6`) with the
|
|
10
|
+
* platform `crypto.randomUUID()`. It is only exposed in secure contexts (https /
|
|
11
|
+
* localhost); when missing the error is logged and no id is produced.
|
|
12
|
+
*/
|
|
13
|
+
export const generateInstanceId = (): string | undefined => {
|
|
14
|
+
if (typeof globalThis.crypto?.randomUUID !== 'function') {
|
|
15
|
+
// eslint-disable-next-line no-console
|
|
16
|
+
console.error(
|
|
17
|
+
'crypto.randomUUID is not available (insecure context?), cannot generate an instance id',
|
|
18
|
+
);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return globalThis.crypto.randomUUID();
|
|
23
|
+
};
|