@nyaruka/temba-components 0.132.0 → 0.133.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 (143) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/demo/components/flow/example.html +1 -0
  3. package/demo/static/css/tailwind.css +30019 -0
  4. package/dist/temba-components.js +434 -402
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/display/Chat.js +26 -6
  7. package/out-tsc/src/display/Chat.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +4 -4
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/events.js.map +1 -1
  11. package/out-tsc/src/flow/CanvasNode.js +124 -58
  12. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  13. package/out-tsc/src/flow/Editor.js +66 -30
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/layout/FloatingWindow.js +1 -2
  16. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  17. package/out-tsc/src/list/ContentMenu.js +1 -0
  18. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  19. package/out-tsc/src/list/SortableList.js +3 -2
  20. package/out-tsc/src/list/SortableList.js.map +1 -1
  21. package/out-tsc/src/live/ContactChat.js +63 -35
  22. package/out-tsc/src/live/ContactChat.js.map +1 -1
  23. package/out-tsc/src/store/AppState.js +31 -0
  24. package/out-tsc/src/store/AppState.js.map +1 -1
  25. package/out-tsc/src/utils.js +3 -3
  26. package/out-tsc/src/utils.js.map +1 -1
  27. package/out-tsc/test/ActionHelper.js +6 -5
  28. package/out-tsc/test/ActionHelper.js.map +1 -1
  29. package/out-tsc/test/actions/send_broadcast.test.js +1 -1
  30. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  31. package/out-tsc/test/temba-contact-chat.test.js +1 -1
  32. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  33. package/out-tsc/test/temba-floating-window.test.js +0 -2
  34. package/out-tsc/test/temba-floating-window.test.js.map +1 -1
  35. package/out-tsc/test/temba-flow-editor-node.test.js +109 -0
  36. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  37. package/out-tsc/test/temba-utils-uuid.test.js +45 -1
  38. package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
  39. package/out-tsc/test/utils.test.js +2 -2
  40. package/out-tsc/test/utils.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  43. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  47. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  57. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  63. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/contacts/chat-failure.png +0 -0
  91. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  92. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  93. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  94. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  95. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  96. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  97. package/screenshots/truth/floating-tab/default.png +0 -0
  98. package/screenshots/truth/floating-tab/gray.png +0 -0
  99. package/screenshots/truth/floating-tab/green.png +0 -0
  100. package/screenshots/truth/floating-tab/hover.png +0 -0
  101. package/screenshots/truth/floating-tab/purple.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  103. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  104. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  105. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  106. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  107. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  108. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  109. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  110. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  111. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  112. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  113. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  114. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  115. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  116. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  117. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  118. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  119. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  120. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  121. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  124. package/src/display/Chat.ts +29 -7
  125. package/src/display/FloatingTab.ts +4 -4
  126. package/src/events.ts +1 -4
  127. package/src/flow/CanvasNode.ts +130 -57
  128. package/src/flow/Editor.ts +84 -30
  129. package/src/layout/FloatingWindow.ts +1 -3
  130. package/src/list/ContentMenu.ts +1 -0
  131. package/src/list/SortableList.ts +3 -2
  132. package/src/live/ContactChat.ts +68 -42
  133. package/src/store/AppState.ts +41 -0
  134. package/src/utils.ts +3 -3
  135. package/test/ActionHelper.ts +13 -5
  136. package/test/actions/send_broadcast.test.ts +2 -1
  137. package/test/temba-contact-chat.test.ts +1 -1
  138. package/test/temba-floating-window.test.ts +0 -2
  139. package/test/temba-flow-editor-node.test.ts +129 -0
  140. package/test/temba-utils-uuid.test.ts +61 -1
  141. package/test/utils.test.ts +7 -2
  142. package/test-assets/contacts/history.json +22 -9
  143. package/web-test-runner.config.mjs +3 -3
@@ -10,6 +10,7 @@ import {
10
10
  } from '../interfaces';
11
11
  import {
12
12
  fetchResults,
13
+ generateUUIDv7,
13
14
  getUrl,
14
15
  oxfordFn,
15
16
  postJSON,
@@ -525,10 +526,12 @@ export class ContactChat extends ContactStoreElement {
525
526
  private chat: Chat;
526
527
 
527
528
  ticket = null;
528
- lastEventTime = null;
529
- newestEventTime = null;
529
+ beforeUUID: string = null; // for scrolling back through history
530
+ afterUUID: string = null; // for polling new messages
530
531
  refreshId = null;
531
532
  polling = false;
533
+ pollingInterval = 2000; // start at 2 seconds
534
+ lastFetchTime: number = null;
532
535
 
533
536
  constructor() {
534
537
  super();
@@ -582,10 +585,12 @@ export class ContactChat extends ContactStoreElement {
582
585
  }
583
586
  this.blockFetching = false;
584
587
  this.ticket = null;
585
- this.lastEventTime = null;
586
- this.newestEventTime = null;
588
+ this.beforeUUID = null;
589
+ this.afterUUID = null;
587
590
  this.refreshId = null;
588
591
  this.polling = false;
592
+ this.pollingInterval = 2000;
593
+ this.lastFetchTime = null;
589
594
  this.errorMessage = null;
590
595
 
591
596
  const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
@@ -634,6 +639,8 @@ export class ContactChat extends ContactStoreElement {
634
639
  if (response.status < 400) {
635
640
  const msg = this.createChatForMessageEvent(response.json.event);
636
641
  this.chat.addMessages([msg], null, true);
642
+ // reset polling interval to 2 seconds after sending a message
643
+ this.pollingInterval = 2000;
637
644
  this.checkForNewMessages();
638
645
  composeEle.reset();
639
646
  this.fireCustomEvent(CustomEventType.MessageSent, {
@@ -651,30 +658,28 @@ export class ContactChat extends ContactStoreElement {
651
658
 
652
659
  private getEndpoint() {
653
660
  if (this.contact) {
654
- return `/contact/history/${this.contact}/?_format=json`;
661
+ return `/contact/chat/${this.contact}/`;
655
662
  }
656
663
  return null;
657
664
  }
658
665
 
659
- private scheduleRefresh() {
660
- // knock five seconds off the newest event time so we are
661
- // a little more aggressive about refreshing short term
662
- let window = new Date().getTime() - this.newestEventTime / 1000 - 5000;
663
-
666
+ private scheduleRefresh(hasNewEvents = false) {
664
667
  if (this.refreshId) {
665
668
  clearTimeout(this.refreshId);
666
669
  this.refreshId = null;
667
670
  }
668
671
 
669
- // wait no longer than 15 seconds
670
- window = Math.min(window, 15000);
671
-
672
- // wait at least 2 seconds
673
- window = Math.max(window, 2000);
672
+ // reset to 2 seconds if we received new events
673
+ if (hasNewEvents) {
674
+ this.pollingInterval = 2000;
675
+ } else {
676
+ // increase interval by 1 second up to max of 15 seconds
677
+ this.pollingInterval = Math.min(this.pollingInterval + 1000, 15000);
678
+ }
674
679
 
675
680
  this.refreshId = setTimeout(() => {
676
681
  this.checkForNewMessages();
677
- }, window);
682
+ }, this.pollingInterval);
678
683
  }
679
684
 
680
685
  public getEventMessage(event: ContactEvent): ChatEvent {
@@ -857,9 +862,12 @@ export class ContactChat extends ContactStoreElement {
857
862
  if (page.events) {
858
863
  let messages = [];
859
864
  page.events.forEach((event) => {
860
- const ts = new Date(event.created_on).getTime() * 1000;
861
- if (ts > this.newestEventTime) {
862
- this.newestEventTime = ts;
865
+ // track the UUID of the newest event for polling
866
+ if (
867
+ !this.afterUUID ||
868
+ event.uuid.toLowerCase() > this.afterUUID.toLowerCase()
869
+ ) {
870
+ this.afterUUID = event.uuid;
863
871
  }
864
872
 
865
873
  if (event.type === 'ticket_note_added') {
@@ -914,8 +922,9 @@ export class ContactChat extends ContactStoreElement {
914
922
 
915
923
  const chat = this.chat;
916
924
  const contactChat = this;
917
- if (this.currentContact && this.newestEventTime) {
925
+ if (this.currentContact && this.afterUUID) {
918
926
  this.polling = true;
927
+ this.lastFetchTime = Date.now();
919
928
  const endpoint = this.getEndpoint();
920
929
  if (!endpoint) {
921
930
  return;
@@ -924,23 +933,24 @@ export class ContactChat extends ContactStoreElement {
924
933
  const fetchContact = this.currentContact.uuid;
925
934
 
926
935
  fetchContactHistory(
927
- false,
928
936
  endpoint,
929
937
  this.currentTicket?.uuid,
930
938
  null,
931
- this.newestEventTime
939
+ this.afterUUID
932
940
  ).then((page: ContactHistoryPage) => {
933
941
  if (fetchContact === this.currentContact.uuid) {
934
- this.lastEventTime = page.next_before;
935
942
  const messages = this.createMessages(page);
943
+ const hasNewEvents = messages.length > 0;
936
944
  if (messages.length === 0) {
937
945
  contactChat.blockFetching = true;
938
946
  }
939
947
  messages.reverse();
940
948
  chat.addMessages(messages, null, true);
949
+ this.polling = false;
950
+ this.scheduleRefresh(hasNewEvents);
951
+ } else {
952
+ this.polling = false;
941
953
  }
942
- this.polling = false;
943
- this.scheduleRefresh();
944
954
  });
945
955
  }
946
956
  }
@@ -959,19 +969,37 @@ export class ContactChat extends ContactStoreElement {
959
969
  return;
960
970
  }
961
971
 
972
+ // initialize anchor UUID if not set (first fetch)
973
+ if (!this.beforeUUID && !this.afterUUID) {
974
+ // generate a UUID v7 for current time as the anchor
975
+ const anchorUUID = generateUUIDv7();
976
+ this.beforeUUID = anchorUUID;
977
+ this.afterUUID = anchorUUID;
978
+ }
979
+
962
980
  fetchContactHistory(
963
- false,
964
981
  endpoint,
965
982
  this.currentTicket?.uuid,
966
- this.lastEventTime
983
+ this.beforeUUID,
984
+ null
967
985
  ).then((page: ContactHistoryPage) => {
968
- this.lastEventTime = page.next_before;
969
986
  const messages = this.createMessages(page);
970
987
  messages.reverse();
971
988
 
972
989
  if (messages.length === 0) {
973
990
  contactChat.blockFetching = true;
991
+ } else if (page.next) {
992
+ // update beforeUUID for next fetch of older messages
993
+ this.beforeUUID = page.next;
994
+ } else {
995
+ // no more history, mark end and show oldest event date
996
+ contactChat.blockFetching = true;
997
+ if (page.events && page.events.length > 0) {
998
+ const oldestEvent = page.events[page.events.length - 1];
999
+ chat.setEndOfHistory(new Date(oldestEvent.created_on));
1000
+ }
974
1001
  }
1002
+
975
1003
  chat.addMessages(messages);
976
1004
  this.scheduleRefresh();
977
1005
  });
@@ -1238,34 +1266,32 @@ export const fetchContact = (endpoint: string): Promise<Contact> => {
1238
1266
  });
1239
1267
  };
1240
1268
  export const fetchContactHistory = (
1241
- reset: boolean,
1242
1269
  endpoint: string,
1243
- ticket: string,
1244
- before: number = undefined,
1245
- after: number = undefined
1270
+ ticket: string = undefined,
1271
+ before: string = undefined,
1272
+ after: string = undefined
1246
1273
  ): Promise<ContactHistoryPage> => {
1247
- if (reset) {
1248
- pendingRequests.forEach((controller) => {
1249
- controller.abort();
1250
- });
1251
- pendingRequests = [];
1252
- }
1253
-
1254
1274
  return new Promise<ContactHistoryPage>((resolve) => {
1255
1275
  const controller = new AbortController();
1256
1276
  pendingRequests.push(controller);
1257
1277
 
1258
1278
  let url = endpoint;
1279
+ const params = [];
1280
+
1259
1281
  if (before) {
1260
- url += `&before=${before}`;
1282
+ params.push(`before=${before}`);
1261
1283
  }
1262
1284
 
1263
1285
  if (after) {
1264
- url += `&after=${after}`;
1286
+ params.push(`after=${after}`);
1265
1287
  }
1266
1288
 
1267
1289
  if (ticket) {
1268
- url += `&ticket=${ticket}`;
1290
+ params.push(`ticket=${ticket}`);
1291
+ }
1292
+
1293
+ if (params.length > 0) {
1294
+ url += (url.includes('?') ? '&' : '?') + params.join('&');
1269
1295
  }
1270
1296
 
1271
1297
  const store = document.querySelector('temba-store') as Store;
@@ -88,6 +88,7 @@ export interface AppState {
88
88
 
89
89
  setFlowContents: (flow: FlowContents) => void;
90
90
  setFlowInfo: (info: FlowInfo) => void;
91
+ setRevision: (revision: number) => void;
91
92
  setLanguageCode: (languageCode: string) => void;
92
93
  setDirtyDate: (date: Date) => void;
93
94
  expandCanvas: (width: number, height: number) => void;
@@ -216,6 +217,12 @@ export const zustand = createStore<AppState>()(
216
217
  });
217
218
  },
218
219
 
220
+ setRevision: (revision: number) => {
221
+ set((state: AppState) => {
222
+ state.flowDefinition.revision = revision;
223
+ });
224
+ },
225
+
219
226
  setLanguageCode: (languageCode: string) => {
220
227
  set((state: AppState) => {
221
228
  state.languageCode = languageCode;
@@ -264,10 +271,44 @@ export const zustand = createStore<AppState>()(
264
271
  }
265
272
 
266
273
  state.flowDefinition = produce(state.flowDefinition, (draft) => {
274
+ // For each node being removed, check if we should reroute connections
275
+ uuids.forEach((removedUuid) => {
276
+ const removedNode = draft.nodes.find(
277
+ (n) => n.uuid === removedUuid
278
+ );
279
+
280
+ if (!removedNode || !removedNode.exits.length) return;
281
+
282
+ // Get all destinations (filter out null/undefined)
283
+ const destinations = removedNode.exits
284
+ .map((exit) => exit.destination_uuid)
285
+ .filter((dest) => dest);
286
+
287
+ // Only proceed if all exits have destinations and they all point to the same place
288
+ if (
289
+ destinations.length === removedNode.exits.length &&
290
+ destinations.every((dest) => dest === destinations[0])
291
+ ) {
292
+ const targetDestination = destinations[0];
293
+
294
+ // Find all nodes with exits pointing to the node being removed
295
+ draft.nodes.forEach((node) => {
296
+ node.exits.forEach((exit) => {
297
+ if (exit.destination_uuid === removedUuid) {
298
+ // Reroute to the same destination the removed node was going to
299
+ exit.destination_uuid = targetDestination;
300
+ }
301
+ });
302
+ });
303
+ }
304
+ });
305
+
306
+ // Remove the nodes
267
307
  draft.nodes = draft.nodes.filter(
268
308
  (node) => !uuids.includes(node.uuid)
269
309
  );
270
310
 
311
+ // Clear any remaining connections to removed nodes that weren't rerouted
271
312
  draft.nodes.forEach((node) => {
272
313
  node.exits.forEach((exit) => {
273
314
  if (uuids.includes(exit.destination_uuid)) {
package/src/utils.ts CHANGED
@@ -5,7 +5,7 @@ import { Dialog } from './layout/Dialog';
5
5
  import { Attachment, ContactField, Shortcut, Ticket, User } from './interfaces';
6
6
  import ColorHash from 'color-hash';
7
7
  import { Toast } from './display/Toast';
8
- import { v4 as generateUUID } from 'uuid';
8
+ import { v4 as generateUUID, v7 as generateUUIDv7 } from 'uuid';
9
9
 
10
10
  export const DEFAULT_MEDIA_ENDPOINT = '/api/v2/media.json';
11
11
 
@@ -918,8 +918,8 @@ export const getMiddle = (a: DOMRect, b: DOMRect) => {
918
918
  return a.top + a.height / 2 - b.height / 2;
919
919
  };
920
920
 
921
- // Export the UUID function from the uuid package
922
- export { generateUUID };
921
+ // Export the UUID functions from the uuid package
922
+ export { generateUUID, generateUUIDv7 };
923
923
 
924
924
  // Helper types for router creation
925
925
  export interface RouterCategory {
@@ -71,12 +71,13 @@ export class ActionTest<T extends Action> {
71
71
  */
72
72
  private async assertDialogScreenshot(
73
73
  el: HTMLElement,
74
- screenshotName: string
74
+ screenshotName: string,
75
+ waitForNetwork: boolean = false
75
76
  ) {
76
77
  const dialog = el.shadowRoot
77
78
  .querySelector('temba-dialog')
78
79
  .shadowRoot.querySelector('.dialog-container') as HTMLElement;
79
- await assertScreenshot(screenshotName, getClip(dialog));
80
+ await assertScreenshot(screenshotName, getClip(dialog), waitForNetwork);
80
81
  }
81
82
 
82
83
  /**
@@ -84,22 +85,29 @@ export class ActionTest<T extends Action> {
84
85
  * 1. Renders the action in a flow node (with screenshot)
85
86
  * 2. Opens the node editor (with screenshot)
86
87
  * 3. Simulates save and validates round-trip conversion
88
+ * @param waitForNetwork - If true, waits longer for network idle (useful for slow loading resources)
87
89
  */
88
- async testAction(action: T, testName: string) {
90
+ async testAction(
91
+ action: T,
92
+ testName: string,
93
+ waitForNetwork: boolean = false
94
+ ) {
89
95
  it(`${testName}`, async () => {
90
96
  // Step 1: Render action in flow node
91
97
  const flowNode = await this.renderAction(action);
92
98
  expect(flowNode.querySelector('.body')).to.exist;
93
99
  await assertScreenshot(
94
100
  `actions/${this.actionName}/render/${testName}`,
95
- getClip(flowNode)
101
+ getClip(flowNode),
102
+ waitForNetwork
96
103
  );
97
104
 
98
105
  // Step 2: Open node editor
99
106
  const nodeEditor = await this.openNodeEditor(action);
100
107
  await this.assertDialogScreenshot(
101
108
  nodeEditor,
102
- `actions/${this.actionName}/editor/${testName}`
109
+ `actions/${this.actionName}/editor/${testName}`,
110
+ waitForNetwork
103
111
  );
104
112
 
105
113
  // Step 3: Test round-trip conversion (simulates save workflow)
@@ -80,7 +80,8 @@ describe('send_broadcast action config', () => {
80
80
  'application/pdf:https://example.com/document.pdf'
81
81
  ]
82
82
  } as SendBroadcast,
83
- 'with-attachments'
83
+ 'with-attachments',
84
+ true
84
85
  );
85
86
 
86
87
  helper.testAction(
@@ -55,7 +55,7 @@ describe('temba-contact-chat', () => {
55
55
  mockedNow = mockNow('2021-03-31T00:31:00.000-00:00');
56
56
  clearMockPosts();
57
57
  mockGET(
58
- /\/contact\/history\/contact-.*/,
58
+ /\/contact\/chat\/contact-.*/,
59
59
  '/test-assets/contacts/history.json'
60
60
  );
61
61
 
@@ -51,7 +51,6 @@ describe('temba-floating-window', () => {
51
51
  )) as FloatingWindow;
52
52
 
53
53
  expect(window.hidden).to.equal(true);
54
- expect(window.classList.contains('hidden')).to.equal(true);
55
54
  });
56
55
 
57
56
  it('can be shown and hidden', async () => {
@@ -74,7 +73,6 @@ describe('temba-floating-window', () => {
74
73
  window.hide();
75
74
  await window.updateComplete;
76
75
  expect(window.hidden).to.equal(true);
77
- expect(window.classList.contains('hidden')).to.equal(true);
78
76
  });
79
77
 
80
78
  it('fires close event when close button clicked', async () => {
@@ -1200,6 +1200,135 @@ describe('EditorNode', () => {
1200
1200
  // 3. New JSPlumb connections are created with connectIds
1201
1201
  // This sequence ensures JSPlumb visuals stay in sync with the flow definition
1202
1202
  });
1203
+
1204
+ it('reroutes connections when removing node with multiple exits pointing to same destination', async () => {
1205
+ // Test case: node with multiple exits, but all point to the same destination
1206
+ const mockNode: Node = {
1207
+ uuid: 'test-node',
1208
+ actions: [
1209
+ {
1210
+ type: 'send_msg',
1211
+ uuid: 'action-1',
1212
+ text: 'Hello',
1213
+ quick_replies: []
1214
+ } as any
1215
+ ],
1216
+ exits: [
1217
+ { uuid: 'exit-1', destination_uuid: 'node-after' },
1218
+ { uuid: 'exit-2', destination_uuid: 'node-after' },
1219
+ { uuid: 'exit-3', destination_uuid: 'node-after' }
1220
+ ]
1221
+ };
1222
+
1223
+ editorNode['node'] = mockNode;
1224
+
1225
+ const mockFlowDefinition = {
1226
+ nodes: [
1227
+ {
1228
+ uuid: 'node-before',
1229
+ exits: [{ uuid: 'exit-before', destination_uuid: 'test-node' }]
1230
+ },
1231
+ mockNode,
1232
+ {
1233
+ uuid: 'node-after',
1234
+ exits: []
1235
+ }
1236
+ ]
1237
+ };
1238
+
1239
+ // Verify all exits point to the same destination
1240
+ const destinations = mockNode.exits
1241
+ .map((exit) => exit.destination_uuid)
1242
+ .filter((dest) => dest);
1243
+
1244
+ expect(destinations).to.have.length(3);
1245
+ expect(destinations.every((dest) => dest === 'node-after')).to.be.true;
1246
+
1247
+ // Find incoming connections
1248
+ const incomingConnections: {
1249
+ exitUuid: string;
1250
+ sourceNodeUuid: string;
1251
+ }[] = [];
1252
+
1253
+ for (const node of mockFlowDefinition.nodes) {
1254
+ if (node.uuid !== mockNode.uuid) {
1255
+ for (const exit of node.exits) {
1256
+ if (exit.destination_uuid === mockNode.uuid) {
1257
+ incomingConnections.push({
1258
+ exitUuid: exit.uuid,
1259
+ sourceNodeUuid: node.uuid
1260
+ });
1261
+ }
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ // Verify we found incoming connections
1267
+ expect(incomingConnections).to.have.length(1);
1268
+ expect(incomingConnections[0].exitUuid).to.equal('exit-before');
1269
+
1270
+ // This validates that when a node has multiple exits but they all point
1271
+ // to the same destination, the rerouting logic should still apply
1272
+ });
1273
+
1274
+ it('does not reroute connections when node has exits with different destinations', async () => {
1275
+ // Test case: node with multiple exits pointing to different destinations
1276
+ const mockNode: Node = {
1277
+ uuid: 'test-node',
1278
+ actions: [
1279
+ {
1280
+ type: 'send_msg',
1281
+ uuid: 'action-1',
1282
+ text: 'Hello',
1283
+ quick_replies: []
1284
+ } as any
1285
+ ],
1286
+ exits: [
1287
+ { uuid: 'exit-1', destination_uuid: 'node-after-1' },
1288
+ { uuid: 'exit-2', destination_uuid: 'node-after-2' }
1289
+ ]
1290
+ };
1291
+
1292
+ const destinations = mockNode.exits
1293
+ .map((exit) => exit.destination_uuid)
1294
+ .filter((dest) => dest);
1295
+
1296
+ // Verify exits point to different destinations
1297
+ expect(destinations).to.have.length(2);
1298
+ expect(destinations.every((dest) => dest === destinations[0])).to.be
1299
+ .false;
1300
+
1301
+ // This validates that rerouting does NOT apply when exits point to different places
1302
+ });
1303
+
1304
+ it('does not reroute connections when node has exits with null destinations', async () => {
1305
+ // Test case: node with some exits having null destinations
1306
+ const mockNode: Node = {
1307
+ uuid: 'test-node',
1308
+ actions: [
1309
+ {
1310
+ type: 'send_msg',
1311
+ uuid: 'action-1',
1312
+ text: 'Hello',
1313
+ quick_replies: []
1314
+ } as any
1315
+ ],
1316
+ exits: [
1317
+ { uuid: 'exit-1', destination_uuid: 'node-after' },
1318
+ { uuid: 'exit-2', destination_uuid: null }
1319
+ ]
1320
+ };
1321
+
1322
+ const destinations = mockNode.exits
1323
+ .map((exit) => exit.destination_uuid)
1324
+ .filter((dest) => dest);
1325
+
1326
+ // Verify not all exits have destinations
1327
+ expect(destinations).to.have.length(1);
1328
+ expect(destinations.length).to.not.equal(mockNode.exits.length);
1329
+
1330
+ // This validates that rerouting does NOT apply when some exits have no destination
1331
+ });
1203
1332
  });
1204
1333
 
1205
1334
  describe('add action button', () => {
@@ -1,5 +1,5 @@
1
1
  import { assert } from '@open-wc/testing';
2
- import { generateUUID } from '../src/utils';
2
+ import { generateUUID, generateUUIDv7 } from '../src/utils';
3
3
 
4
4
  describe('UUID Generation', () => {
5
5
  it('generates a valid UUID v4 format', () => {
@@ -45,4 +45,64 @@ describe('UUID Generation', () => {
45
45
  // All should be unique
46
46
  assert.equal(uuids.size, count, 'All generated UUIDs should be unique');
47
47
  });
48
+
49
+ it('generates a valid UUID v7 format', () => {
50
+ const uuid = generateUUIDv7();
51
+
52
+ // check that it's a string
53
+ assert.isString(uuid);
54
+
55
+ // check UUID v7 format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx
56
+ const uuidPattern =
57
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
58
+ assert.match(uuid, uuidPattern, 'Should match UUID v7 format');
59
+
60
+ // check length
61
+ assert.equal(uuid.length, 36);
62
+
63
+ // check that it contains hyphens in the right places
64
+ assert.equal(uuid[8], '-');
65
+ assert.equal(uuid[13], '-');
66
+ assert.equal(uuid[18], '-');
67
+ assert.equal(uuid[23], '-');
68
+ });
69
+
70
+ it('generates unique UUIDs v7', () => {
71
+ const uuid1 = generateUUIDv7();
72
+ const uuid2 = generateUUIDv7();
73
+ const uuid3 = generateUUIDv7();
74
+
75
+ // all should be different
76
+ assert.notEqual(uuid1, uuid2);
77
+ assert.notEqual(uuid2, uuid3);
78
+ assert.notEqual(uuid1, uuid3);
79
+ });
80
+
81
+ it('generates time-ordered UUIDs v7', () => {
82
+ const uuid1 = generateUUIDv7();
83
+ // small delay to ensure different timestamp
84
+ const delayPromise = new Promise((resolve) => setTimeout(resolve, 5));
85
+ return delayPromise.then(() => {
86
+ const uuid2 = generateUUIDv7();
87
+
88
+ // uuid v7 should be sortable by timestamp
89
+ // the first uuid should come before the second when compared as strings
90
+ assert.isTrue(
91
+ uuid1 < uuid2,
92
+ 'Earlier UUID should be lexicographically smaller'
93
+ );
94
+ });
95
+ });
96
+
97
+ it('generates many unique UUIDs v7', () => {
98
+ const uuids = new Set();
99
+ const count = 1000;
100
+
101
+ for (let i = 0; i < count; i++) {
102
+ uuids.add(generateUUIDv7());
103
+ }
104
+
105
+ // all should be unique
106
+ assert.equal(uuids.size, count, 'All generated UUIDs should be unique');
107
+ });
48
108
  });
@@ -240,7 +240,11 @@ export const waitForCondition = async (
240
240
  }
241
241
  };
242
242
 
243
- export const assertScreenshot = async (filename: string, clip: Clip) => {
243
+ export const assertScreenshot = async (
244
+ filename: string,
245
+ clip: Clip,
246
+ waitForNetwork: boolean = false
247
+ ) => {
244
248
  // detect if we're running in copilot's environment and use adaptive threshold
245
249
  const isCopilotEnvironment = (window as any).isCopilotEnvironment;
246
250
  const threshold = isCopilotEnvironment ? 1.0 : 0.1;
@@ -251,7 +255,8 @@ export const assertScreenshot = async (filename: string, clip: Clip) => {
251
255
  `${filename}.png`,
252
256
  clip,
253
257
  exclude,
254
- threshold
258
+ threshold,
259
+ waitForNetwork
255
260
  );
256
261
  } catch (error) {
257
262
  if (error.message) {