@nyaruka/temba-components 0.131.3 → 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 (169) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/demo/components/flow/example.html +1 -0
  3. package/demo/static/css/tailwind.css +30019 -0
  4. package/dist/temba-components.js +449 -417
  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 +89 -40
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/flow/NodeTypeSelector.js +8 -2
  16. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  17. package/out-tsc/src/flow/config.js +17 -4
  18. package/out-tsc/src/flow/config.js.map +1 -1
  19. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +2 -2
  20. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  21. package/out-tsc/src/flow/nodes/split_by_run_result.js +6 -0
  22. package/out-tsc/src/flow/nodes/split_by_run_result.js.map +1 -1
  23. package/out-tsc/src/flow/types.js.map +1 -1
  24. package/out-tsc/src/layout/FloatingWindow.js +1 -2
  25. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  26. package/out-tsc/src/list/ContentMenu.js +1 -0
  27. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  28. package/out-tsc/src/list/SortableList.js +3 -2
  29. package/out-tsc/src/list/SortableList.js.map +1 -1
  30. package/out-tsc/src/live/ContactChat.js +105 -69
  31. package/out-tsc/src/live/ContactChat.js.map +1 -1
  32. package/out-tsc/src/store/AppState.js +39 -1
  33. package/out-tsc/src/store/AppState.js.map +1 -1
  34. package/out-tsc/src/utils.js +3 -3
  35. package/out-tsc/src/utils.js.map +1 -1
  36. package/out-tsc/test/ActionHelper.js +6 -5
  37. package/out-tsc/test/ActionHelper.js.map +1 -1
  38. package/out-tsc/test/actions/send_broadcast.test.js +1 -1
  39. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  40. package/out-tsc/test/nodes/split_by_run_result.test.js +83 -0
  41. package/out-tsc/test/nodes/split_by_run_result.test.js.map +1 -1
  42. package/out-tsc/test/temba-backwards-compatibility.test.js +30 -0
  43. package/out-tsc/test/temba-backwards-compatibility.test.js.map +1 -0
  44. package/out-tsc/test/temba-contact-chat.test.js +28 -13
  45. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  46. package/out-tsc/test/temba-floating-window.test.js +0 -2
  47. package/out-tsc/test/temba-floating-window.test.js.map +1 -1
  48. package/out-tsc/test/temba-flow-editor-node.test.js +109 -0
  49. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  50. package/out-tsc/test/temba-localization.test.js +24 -5
  51. package/out-tsc/test/temba-localization.test.js.map +1 -1
  52. package/out-tsc/test/temba-node-type-selector.test.js +70 -3
  53. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  54. package/out-tsc/test/temba-utils-uuid.test.js +45 -1
  55. package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
  56. package/out-tsc/test/utils.test.js +3 -3
  57. package/out-tsc/test/utils.test.js.map +1 -1
  58. package/package.json +1 -1
  59. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  61. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  62. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  63. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  67. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  68. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  69. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  70. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  71. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  72. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  73. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  74. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  75. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  76. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  77. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  78. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  79. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  80. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  81. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  82. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  83. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  84. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  86. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  87. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  88. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  89. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  90. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  91. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  92. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  93. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  94. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  95. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  96. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  97. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  98. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  99. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  100. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  101. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  102. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  103. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  104. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  105. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  106. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  107. package/screenshots/truth/contacts/chat-failure.png +0 -0
  108. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  109. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  110. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  111. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  112. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  113. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  114. package/screenshots/truth/floating-tab/default.png +0 -0
  115. package/screenshots/truth/floating-tab/gray.png +0 -0
  116. package/screenshots/truth/floating-tab/green.png +0 -0
  117. package/screenshots/truth/floating-tab/hover.png +0 -0
  118. package/screenshots/truth/floating-tab/purple.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  124. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  125. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  126. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  127. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  128. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  129. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  130. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  131. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  132. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  141. package/src/display/Chat.ts +29 -7
  142. package/src/display/FloatingTab.ts +4 -4
  143. package/src/events.ts +1 -4
  144. package/src/flow/CanvasNode.ts +130 -57
  145. package/src/flow/Editor.ts +107 -40
  146. package/src/flow/NodeTypeSelector.ts +7 -1
  147. package/src/flow/config.ts +22 -4
  148. package/src/flow/nodes/split_by_llm_categorize.ts +2 -8
  149. package/src/flow/nodes/split_by_run_result.ts +7 -0
  150. package/src/flow/types.ts +2 -0
  151. package/src/layout/FloatingWindow.ts +1 -3
  152. package/src/list/ContentMenu.ts +1 -0
  153. package/src/list/SortableList.ts +3 -2
  154. package/src/live/ContactChat.ts +112 -78
  155. package/src/store/AppState.ts +53 -1
  156. package/src/utils.ts +3 -3
  157. package/test/ActionHelper.ts +13 -5
  158. package/test/actions/send_broadcast.test.ts +2 -1
  159. package/test/nodes/split_by_run_result.test.ts +99 -0
  160. package/test/temba-backwards-compatibility.test.ts +37 -0
  161. package/test/temba-contact-chat.test.ts +28 -13
  162. package/test/temba-floating-window.test.ts +0 -2
  163. package/test/temba-flow-editor-node.test.ts +129 -0
  164. package/test/temba-localization.test.ts +29 -5
  165. package/test/temba-node-type-selector.test.ts +89 -3
  166. package/test/temba-utils-uuid.test.ts +61 -1
  167. package/test/utils.test.ts +8 -3
  168. package/test-assets/contacts/history.json +22 -9
  169. package/web-test-runner.config.mjs +3 -3
@@ -42,7 +42,7 @@ import { wait_for_response } from './nodes/wait_for_response';
42
42
 
43
43
  export const ACTION_CONFIG: {
44
44
  [key: string]: ActionConfig;
45
- } = {
45
+ } = registerConfigWithAliases({
46
46
  say_msg,
47
47
  play_audio,
48
48
  set_contact_field,
@@ -60,11 +60,29 @@ export const ACTION_CONFIG: {
60
60
  add_contact_urn,
61
61
  add_input_labels,
62
62
  request_optin
63
- };
63
+ });
64
+
65
+ // Helper to register a config and its aliases
66
+ function registerConfigWithAliases<T extends NodeConfig | ActionConfig>(
67
+ config: Record<string, T>
68
+ ): Record<string, T> {
69
+ const result = { ...config };
70
+
71
+ // Register aliases for each config
72
+ Object.values(config).forEach((cfg) => {
73
+ if (cfg.aliases) {
74
+ cfg.aliases.forEach((alias) => {
75
+ result[alias] = cfg;
76
+ });
77
+ }
78
+ });
79
+
80
+ return result;
81
+ }
64
82
 
65
83
  export const NODE_CONFIG: {
66
84
  [key: string]: NodeConfig;
67
- } = {
85
+ } = registerConfigWithAliases({
68
86
  execute_actions,
69
87
  split_by_contact_field,
70
88
  split_by_expression,
@@ -82,4 +100,4 @@ export const NODE_CONFIG: {
82
100
  wait_for_menu,
83
101
  wait_for_response,
84
102
  split_by_airtime
85
- };
103
+ });
@@ -1,10 +1,4 @@
1
- import {
2
- FormData,
3
- NodeConfig,
4
- ACTION_GROUPS,
5
- FlowTypes,
6
- Features
7
- } from '../types';
1
+ import { FormData, NodeConfig, ACTION_GROUPS, Features } from '../types';
8
2
  import { CallLLM, Node } from '../../store/flow-definition';
9
3
  import { generateUUID, createMultiCategoryRouter } from '../../utils';
10
4
  import { html } from 'lit';
@@ -17,7 +11,7 @@ export const split_by_llm_categorize: NodeConfig = {
17
11
  type: 'split_by_llm_categorize',
18
12
  name: 'Split by AI',
19
13
  group: ACTION_GROUPS.services,
20
- flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
14
+ flowTypes: [],
21
15
  features: [Features.AI],
22
16
  form: {
23
17
  llm: {
@@ -53,6 +53,7 @@ const DELIMIT_BY_OPTIONS = [
53
53
  export const split_by_run_result: NodeConfig = {
54
54
  type: 'split_by_run_result',
55
55
  name: 'Split by Result',
56
+ aliases: ['split_by_run_result_delimited'], // backwards compatibility with old flow editor
56
57
  group: SPLIT_GROUPS.split,
57
58
  flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
58
59
  dialogSize: 'large',
@@ -253,6 +254,12 @@ export const split_by_run_result: NodeConfig = {
253
254
  config.delimiter = delimitBy;
254
255
  }
255
256
 
257
+ // Set the type based on whether delimiter is configured
258
+ // Use split_by_run_result_delimited for backward compatibility with old editor
259
+ config.type = hasDelimiter
260
+ ? 'split_by_run_result_delimited'
261
+ : 'split_by_run_result';
262
+
256
263
  return config;
257
264
  },
258
265
 
package/src/flow/types.ts CHANGED
@@ -99,6 +99,7 @@ export interface FormConfig {
99
99
  export interface NodeConfig extends FormConfig {
100
100
  type: string;
101
101
  name?: string;
102
+ aliases?: string[]; // alternate type names for backwards compatibility (won't show in selector)
102
103
  group?: ActionGroup | SplitGroup; // Nodes can use either when showAsAction is true
103
104
  dialogSize?: 'small' | 'medium' | 'large' | 'xlarge';
104
105
  action?: ActionConfig;
@@ -384,6 +385,7 @@ export const SPLIT_GROUP_METADATA: Record<SplitGroup, GroupMetadata> = {
384
385
 
385
386
  export interface ActionConfig extends FormConfig {
386
387
  name: string;
388
+ aliases?: string[]; // alternate type names for backwards compatibility (won't show in selector)
387
389
  group: ActionGroup;
388
390
  dialogSize?: 'small' | 'medium' | 'large' | 'xlarge';
389
391
  evaluated?: string[];
@@ -18,7 +18,7 @@ export class FloatingWindow extends RapidElement {
18
18
  transition: transform var(--transition-duration, 300ms) ease-in-out,
19
19
  opacity var(--transition-duration, 300ms) ease-in-out;
20
20
  position: fixed;
21
- z-index: 9999;
21
+ z-index: 5000;
22
22
  top: 100px;
23
23
  background: white;
24
24
  border-radius: 8px;
@@ -193,8 +193,6 @@ export class FloatingWindow extends RapidElement {
193
193
  ): void {
194
194
  super.updated(changes);
195
195
  if (changes.has('hidden')) {
196
- this.classList.toggle('hidden', this.hidden);
197
-
198
196
  // when hiding, reset positioning behavior to original
199
197
  if (this.hidden && !changes.get('hidden')) {
200
198
  if (this.defaultLeft === -1) {
@@ -36,6 +36,7 @@ export class ContentMenu extends RapidElement {
36
36
  return css`
37
37
  :host {
38
38
  tabindex: 0;
39
+ z-index: 5000;
39
40
  }
40
41
  .container {
41
42
  --button-y: 0.4em;
@@ -577,8 +577,9 @@ export class SortableList extends RapidElement {
577
577
  const fromIdx = originalDragIdx;
578
578
  const toIdx = this.pendingDropIndex;
579
579
 
580
- // only fire if the position actually changed
581
- if (fromIdx !== toIdx) {
580
+ // only fire if the position actually changed AND this is not an external drag
581
+ // External drags are handled by external drop handlers
582
+ if (fromIdx !== toIdx && !this.isExternalDrag) {
582
583
  this.fireCustomEvent(CustomEventType.OrderChanged, {
583
584
  swap: [fromIdx, toIdx]
584
585
  });
@@ -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;
@@ -629,12 +634,19 @@ export class ContactChat extends ContactStoreElement {
629
634
  }
630
635
 
631
636
  const genericError = 'Send failed, please try again.';
632
- postJSON(`/api/v2/messages.json`, payload)
637
+ postJSON(`/contact/chat/${this.currentContact.uuid}/`, payload)
633
638
  .then((response) => {
634
639
  if (response.status < 400) {
640
+ const msg = this.createChatForMessageEvent(response.json.event);
641
+ this.chat.addMessages([msg], null, true);
642
+ // reset polling interval to 2 seconds after sending a message
643
+ this.pollingInterval = 2000;
635
644
  this.checkForNewMessages();
636
645
  composeEle.reset();
637
- this.fireCustomEvent(CustomEventType.MessageSent, { msg: payload });
646
+ this.fireCustomEvent(CustomEventType.MessageSent, {
647
+ msg: payload,
648
+ response
649
+ });
638
650
  } else {
639
651
  this.errorMessage = genericError;
640
652
  }
@@ -646,30 +658,28 @@ export class ContactChat extends ContactStoreElement {
646
658
 
647
659
  private getEndpoint() {
648
660
  if (this.contact) {
649
- return `/contact/history/${this.contact}/?_format=json`;
661
+ return `/contact/chat/${this.contact}/`;
650
662
  }
651
663
  return null;
652
664
  }
653
665
 
654
- private scheduleRefresh() {
655
- // knock five seconds off the newest event time so we are
656
- // a little more aggressive about refreshing short term
657
- let window = new Date().getTime() - this.newestEventTime / 1000 - 5000;
658
-
666
+ private scheduleRefresh(hasNewEvents = false) {
659
667
  if (this.refreshId) {
660
668
  clearTimeout(this.refreshId);
661
669
  this.refreshId = null;
662
670
  }
663
671
 
664
- // wait no longer than 15 seconds
665
- window = Math.min(window, 15000);
666
-
667
- // wait at least 2 seconds
668
- 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
+ }
669
679
 
670
680
  this.refreshId = setTimeout(() => {
671
681
  this.checkForNewMessages();
672
- }, window);
682
+ }, this.pollingInterval);
673
683
  }
674
684
 
675
685
  public getEventMessage(event: ContactEvent): ChatEvent {
@@ -812,13 +822,52 @@ export class ContactChat extends ContactStoreElement {
812
822
  return null;
813
823
  }
814
824
 
825
+ private createChatForMessageEvent(msgEvent: MsgEvent): any {
826
+ return {
827
+ id: msgEvent.uuid,
828
+ type: msgEvent.type === 'msg_received' ? 'msg_in' : 'msg_out',
829
+ user: this.getUserForEvent(msgEvent),
830
+ date: new Date(msgEvent.created_on),
831
+ attachments: msgEvent.msg.attachments,
832
+ text: msgEvent.msg.text,
833
+ sendError:
834
+ msgEvent._status &&
835
+ (msgEvent._status.status === 'errored' ||
836
+ msgEvent._status.status === 'failed'),
837
+ popup: html`<div
838
+ style="display: flex; flex-direction: row; align-items:center; justify-content: space-between;font-size:0.9em;line-height:1em;min-width:10em"
839
+ >
840
+ <div style="justify-content:left;text-align:left">
841
+ <temba-date
842
+ value=${msgEvent.created_on}
843
+ display="duration"
844
+ ></temba-date>
845
+
846
+ ${msgEvent.optin
847
+ ? html`<div style="font-size:0.9em;color:#aaa">
848
+ ${msgEvent.optin.name}
849
+ </div>`
850
+ : null}
851
+ </div>
852
+ ${msgEvent._logs_url
853
+ ? html`<a style="margin-left:0.5em" href="${msgEvent._logs_url}"
854
+ ><temba-icon name="log"></temba-icon
855
+ ></a>`
856
+ : null}
857
+ </div> `
858
+ };
859
+ }
860
+
815
861
  private createMessages(page: ContactHistoryPage): ChatEvent[] {
816
862
  if (page.events) {
817
863
  let messages = [];
818
864
  page.events.forEach((event) => {
819
- const ts = new Date(event.created_on).getTime() * 1000;
820
- if (ts > this.newestEventTime) {
821
- 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;
822
871
  }
823
872
 
824
873
  if (event.type === 'ticket_note_added') {
@@ -849,40 +898,7 @@ export class ContactChat extends ContactStoreElement {
849
898
  event.type === 'ivr_created'
850
899
  ) {
851
900
  const msgEvent = event as MsgEvent;
852
-
853
- messages.push({
854
- id: event.uuid,
855
- type: msgEvent.type === 'msg_received' ? 'msg_in' : 'msg_out',
856
- user: this.getUserForEvent(msgEvent),
857
- date: new Date(msgEvent.created_on),
858
- attachments: msgEvent.msg.attachments,
859
- text: msgEvent.msg.text,
860
- sendError:
861
- msgEvent._status &&
862
- (msgEvent._status.status === 'errored' ||
863
- msgEvent._status.status === 'failed'),
864
- popup: html`<div
865
- style="display: flex; flex-direction: row; align-items:center; justify-content: space-between;font-size:0.9em;line-height:1em;min-width:10em"
866
- >
867
- <div style="justify-content:left;text-align:left">
868
- <temba-date
869
- value=${msgEvent.created_on}
870
- display="duration"
871
- ></temba-date>
872
-
873
- ${msgEvent.optin
874
- ? html`<div style="font-size:0.9em;color:#aaa">
875
- ${msgEvent.optin.name}
876
- </div>`
877
- : null}
878
- </div>
879
- ${msgEvent._logs_url
880
- ? html`<a style="margin-left:0.5em" href="${msgEvent._logs_url}"
881
- ><temba-icon name="log"></temba-icon
882
- ></a>`
883
- : null}
884
- </div> `
885
- });
901
+ messages.push(this.createChatForMessageEvent(msgEvent));
886
902
  } else {
887
903
  const msg = this.getEventMessage(event);
888
904
  if (msg) {
@@ -906,8 +922,9 @@ export class ContactChat extends ContactStoreElement {
906
922
 
907
923
  const chat = this.chat;
908
924
  const contactChat = this;
909
- if (this.currentContact && this.newestEventTime) {
925
+ if (this.currentContact && this.afterUUID) {
910
926
  this.polling = true;
927
+ this.lastFetchTime = Date.now();
911
928
  const endpoint = this.getEndpoint();
912
929
  if (!endpoint) {
913
930
  return;
@@ -916,23 +933,24 @@ export class ContactChat extends ContactStoreElement {
916
933
  const fetchContact = this.currentContact.uuid;
917
934
 
918
935
  fetchContactHistory(
919
- false,
920
936
  endpoint,
921
937
  this.currentTicket?.uuid,
922
938
  null,
923
- this.newestEventTime
939
+ this.afterUUID
924
940
  ).then((page: ContactHistoryPage) => {
925
941
  if (fetchContact === this.currentContact.uuid) {
926
- this.lastEventTime = page.next_before;
927
942
  const messages = this.createMessages(page);
943
+ const hasNewEvents = messages.length > 0;
928
944
  if (messages.length === 0) {
929
945
  contactChat.blockFetching = true;
930
946
  }
931
947
  messages.reverse();
932
948
  chat.addMessages(messages, null, true);
949
+ this.polling = false;
950
+ this.scheduleRefresh(hasNewEvents);
951
+ } else {
952
+ this.polling = false;
933
953
  }
934
- this.polling = false;
935
- this.scheduleRefresh();
936
954
  });
937
955
  }
938
956
  }
@@ -951,19 +969,37 @@ export class ContactChat extends ContactStoreElement {
951
969
  return;
952
970
  }
953
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
+
954
980
  fetchContactHistory(
955
- false,
956
981
  endpoint,
957
982
  this.currentTicket?.uuid,
958
- this.lastEventTime
983
+ this.beforeUUID,
984
+ null
959
985
  ).then((page: ContactHistoryPage) => {
960
- this.lastEventTime = page.next_before;
961
986
  const messages = this.createMessages(page);
962
987
  messages.reverse();
963
988
 
964
989
  if (messages.length === 0) {
965
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
+ }
966
1001
  }
1002
+
967
1003
  chat.addMessages(messages);
968
1004
  this.scheduleRefresh();
969
1005
  });
@@ -1230,34 +1266,32 @@ export const fetchContact = (endpoint: string): Promise<Contact> => {
1230
1266
  });
1231
1267
  };
1232
1268
  export const fetchContactHistory = (
1233
- reset: boolean,
1234
1269
  endpoint: string,
1235
- ticket: string,
1236
- before: number = undefined,
1237
- after: number = undefined
1270
+ ticket: string = undefined,
1271
+ before: string = undefined,
1272
+ after: string = undefined
1238
1273
  ): Promise<ContactHistoryPage> => {
1239
- if (reset) {
1240
- pendingRequests.forEach((controller) => {
1241
- controller.abort();
1242
- });
1243
- pendingRequests = [];
1244
- }
1245
-
1246
1274
  return new Promise<ContactHistoryPage>((resolve) => {
1247
1275
  const controller = new AbortController();
1248
1276
  pendingRequests.push(controller);
1249
1277
 
1250
1278
  let url = endpoint;
1279
+ const params = [];
1280
+
1251
1281
  if (before) {
1252
- url += `&before=${before}`;
1282
+ params.push(`before=${before}`);
1253
1283
  }
1254
1284
 
1255
1285
  if (after) {
1256
- url += `&after=${after}`;
1286
+ params.push(`after=${after}`);
1257
1287
  }
1258
1288
 
1259
1289
  if (ticket) {
1260
- url += `&ticket=${ticket}`;
1290
+ params.push(`ticket=${ticket}`);
1291
+ }
1292
+
1293
+ if (params.length > 0) {
1294
+ url += (url.includes('?') ? '&' : '?') + params.join('&');
1261
1295
  }
1262
1296
 
1263
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)) {
@@ -308,10 +349,21 @@ export const zustand = createStore<AppState>()(
308
349
  updateNodeUIConfig: (uuid: string, config: Record<string, any>) => {
309
350
  set((state: AppState) => {
310
351
  if (state.flowDefinition._ui.nodes[uuid]) {
352
+ // Handle type separately if provided
353
+ if (config.type !== undefined) {
354
+ state.flowDefinition._ui.nodes[uuid].type = config.type;
355
+ }
356
+
357
+ // Update config (excluding type)
311
358
  if (!state.flowDefinition._ui.nodes[uuid].config) {
312
359
  state.flowDefinition._ui.nodes[uuid].config = {};
313
360
  }
314
- Object.assign(state.flowDefinition._ui.nodes[uuid].config, config);
361
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
362
+ const { type, ...configWithoutType } = config;
363
+ Object.assign(
364
+ state.flowDefinition._ui.nodes[uuid].config,
365
+ configWithoutType
366
+ );
315
367
  }
316
368
  state.dirtyDate = new Date();
317
369
  });
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(