@servicetitan/dte-unlayer 0.153.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/src/store.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadScript } from './loadScript';
2
2
  import { logUnlayerError } from './log';
3
+ import { toUnlayerMergeTags, type UnlayerMergeTagsConfig } from './merge-tags';
3
4
  import { defaultImageValidation } from './shared/configs';
4
5
  import type { UnlayerEditorTwin, UnlayerEventConfig, UnlayerEventRegister } from './shared/const';
5
6
  import { unlayerSupportedFonts } from './shared/fonts';
@@ -11,14 +12,19 @@ import {
11
12
  } from './shared/forms';
12
13
  import { schemaBuildMap, schemaIsDate } from './shared/schema';
13
14
  import { unlayerToolsParseTwinKey } from './shared/tools';
14
- import { unlayerToolsIterate } from './tools';
15
+ import { unlayerToolsDedupeInstanceIds, unlayerToolsIterate } from './tools';
15
16
  import {
16
17
  createUnlayerEditor,
17
18
  DesignUpdatedEventType,
18
19
  EventDesignUpdated,
19
20
  Unlayer,
20
21
  } from './unlayer';
21
- import { CreateUnlayerEditorProps, UnlayerDesignFormat, UnlayerRef } from './unlayer-interface';
22
+ import {
23
+ CreateUnlayerEditorProps,
24
+ UnlayerDesignFormat,
25
+ UnlayerEditorMergeTagInfo,
26
+ UnlayerRef,
27
+ } from './unlayer-interface';
22
28
 
23
29
  const defaultScriptUrl = 'https://editor.unlayer.com/embed.js?2';
24
30
  const UNLAYER_TOOLS_READY_TIMEOUT_MS = 30000;
@@ -191,6 +197,9 @@ export class UnlayerStore {
191
197
  private toolsReadyWatchdogId?: number;
192
198
  private formFieldsByFormId: Record<number, FormFieldInfo[]> = {};
193
199
  private formFieldSamples: Record<string, unknown> = {};
200
+ private isEditorReady = false;
201
+ private isMergeTagsApplied = false;
202
+ private mergeTagsConfig?: UnlayerMergeTagsConfig;
194
203
 
195
204
  private onMessageCB?: (type: string, data: any) => void;
196
205
  private onChangeCB?: (info: UnlayerDesignChangeInfo) => void;
@@ -236,6 +245,9 @@ export class UnlayerStore {
236
245
  sendCalcFieldLabels: labels => {
237
246
  this.sendCalcFieldLabels(labels);
238
247
  },
248
+ setMergeTags: tags => {
249
+ this.setMergeTags(tags);
250
+ },
239
251
  };
240
252
  }
241
253
 
@@ -256,6 +268,9 @@ export class UnlayerStore {
256
268
  }
257
269
 
258
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);
259
274
  this.editor.addEventListener('design:loaded', this.onDesignLoaded);
260
275
  this.editor.addEventListener('design:updated', this.onDesignUpdated);
261
276
  this.editor.registerCallback('image', this.uploadImage);
@@ -280,6 +295,7 @@ export class UnlayerStore {
280
295
  window.removeEventListener('message', this.onPostMessage);
281
296
 
282
297
  if (this.editor) {
298
+ this.editor.removeEventListener('editor:ready', this.onEditorReady);
283
299
  this.editor.removeEventListener('design:loaded', this.onDesignLoaded);
284
300
  this.editor.removeEventListener('design:updated', this.onDesignUpdated);
285
301
  this.editor.unregisterCallback('image', this.uploadImage);
@@ -355,7 +371,46 @@ export class UnlayerStore {
355
371
  this.sendMessage('--calc-field-labels', { labels });
356
372
  };
357
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
+
358
410
  private onDesignLoaded = () => {
411
+ // A loaded design means the editor is up even if `editor:ready` never reached us.
412
+ this.onEditorReady();
413
+
359
414
  if (!this.hasDesign) {
360
415
  this.hasDesign = true;
361
416
  this.editor?.setBodyValues({
@@ -391,6 +446,11 @@ export class UnlayerStore {
391
446
  }
392
447
  });
393
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
+
394
454
  if (hasChanges) {
395
455
  this.setDesign(data.design);
396
456
  }
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,7 +1,7 @@
1
1
  import { emitDisplayCondition } from './display-conditions/displayConditionController';
2
2
  import { editorCoreScript, editorCoreStyles, editorCoreTools } from './editor-core';
3
3
  import { logUnlayerError } from './log';
4
- import { scheduleUnlayerMergeTags, toUnlayerMergeTags } from './merge-tags';
4
+ import { toUnlayerMergeTags } from './merge-tags';
5
5
  import { constGenericsEditor } from './shared/const';
6
6
  import { unlayerSupportedFonts } from './shared/fonts';
7
7
  import {
@@ -299,6 +299,10 @@ export const createUnlayerEditor = (
299
299
  tables: true,
300
300
  },
301
301
  },
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
+ */
302
306
  mergeTags: toUnlayerMergeTags(mergeTags, { groups: false }),
303
307
  projectId: 5713,
304
308
  version: latest ? undefined : versions.unlayer,
@@ -362,8 +366,6 @@ export const createUnlayerEditor = (
362
366
  document.getElementById = currentFind;
363
367
  }
364
368
 
365
- scheduleUnlayerMergeTags(result, mergeTags);
366
-
367
369
  if (displayConditions) {
368
370
  result.registerCallback(
369
371
  'displayCondition',