@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
@@ -3,6 +3,7 @@ import { split_by_run_result } from '../../src/flow/nodes/split_by_run_result';
3
3
  import { Node } from '../../src/store/flow-definition';
4
4
  import { NodeTest } from '../NodeHelper';
5
5
  import { zustand } from '../../src/store/AppState';
6
+ import { NODE_CONFIG } from '../../src/flow/config';
6
7
 
7
8
  /**
8
9
  * Test suite for the split_by_run_result node configuration.
@@ -928,6 +929,34 @@ describe('split_by_run_result node config', () => {
928
929
  expect(config.index).to.be.undefined;
929
930
  expect(config.delimiter).to.be.undefined;
930
931
  });
932
+
933
+ it('should set type to split_by_run_result_delimited when delimiter is enabled', () => {
934
+ const formData = {
935
+ result: [{ value: 'favorite_color', name: 'Favorite Color' }],
936
+ delimit_by: [{ value: '+', name: 'plusses' }],
937
+ delimit_index: [{ value: '3', name: 'fourth' }],
938
+ rules: [],
939
+ result_name: ''
940
+ };
941
+
942
+ const config = split_by_run_result.toUIConfig!(formData);
943
+
944
+ expect(config.type).to.equal('split_by_run_result_delimited');
945
+ });
946
+
947
+ it('should set type to split_by_run_result when delimiter is not enabled', () => {
948
+ const formData = {
949
+ result: [{ value: 'favorite_color', name: 'Favorite Color' }],
950
+ delimit_by: [{ value: '', name: "Don't delimit" }],
951
+ delimit_index: [{ value: '0', name: 'first' }],
952
+ rules: [],
953
+ result_name: ''
954
+ };
955
+
956
+ const config = split_by_run_result.toUIConfig!(formData);
957
+
958
+ expect(config.type).to.equal('split_by_run_result');
959
+ });
931
960
  });
932
961
 
933
962
  describe('round-trip tests', () => {
@@ -1106,4 +1135,74 @@ describe('split_by_run_result node config', () => {
1106
1135
  });
1107
1136
  });
1108
1137
  });
1138
+
1139
+ describe('backwards compatibility', () => {
1140
+ it('should support split_by_run_result_delimited type from old flows', () => {
1141
+ // Verify that split_by_run_result_delimited points to the same config as split_by_run_result
1142
+ expect(NODE_CONFIG['split_by_run_result_delimited']).to.equal(
1143
+ NODE_CONFIG['split_by_run_result']
1144
+ );
1145
+ expect(NODE_CONFIG['split_by_run_result_delimited']).to.equal(
1146
+ split_by_run_result
1147
+ );
1148
+
1149
+ // Verify we can look up the config with the old type name
1150
+ const config = NODE_CONFIG['split_by_run_result_delimited'];
1151
+ expect(config).to.not.be.undefined;
1152
+ expect(config.name).to.equal('Split by Result');
1153
+ });
1154
+
1155
+ it('should correctly process old flow with split_by_run_result_delimited type', () => {
1156
+ // Simulate a node from an old flow with the delimited type
1157
+ const oldNode: Node = {
1158
+ uuid: 'old-node-uuid',
1159
+ actions: [],
1160
+ router: {
1161
+ type: 'switch',
1162
+ operand: '@(field(results.bloop, 0, "+"))',
1163
+ cases: [
1164
+ {
1165
+ uuid: 'case-1',
1166
+ type: 'has_any_word',
1167
+ arguments: ['red'],
1168
+ category_uuid: 'cat-1'
1169
+ }
1170
+ ],
1171
+ categories: [
1172
+ { uuid: 'cat-1', name: 'Red', exit_uuid: 'exit-1' },
1173
+ { uuid: 'cat-other', name: 'Other', exit_uuid: 'exit-other' }
1174
+ ],
1175
+ default_category_uuid: 'cat-other'
1176
+ },
1177
+ exits: [
1178
+ { uuid: 'exit-1', destination_uuid: null },
1179
+ { uuid: 'exit-other', destination_uuid: null }
1180
+ ]
1181
+ };
1182
+
1183
+ const oldNodeUI = {
1184
+ type: 'split_by_run_result_delimited',
1185
+ config: {
1186
+ operand: {
1187
+ id: 'bloop',
1188
+ name: 'Bloop',
1189
+ type: 'result'
1190
+ },
1191
+ index: 0,
1192
+ delimiter: '+'
1193
+ }
1194
+ };
1195
+
1196
+ // Get config using the old type name - this should work due to backwards compatibility
1197
+ const config = NODE_CONFIG[oldNodeUI.type];
1198
+ expect(config).to.not.be.undefined;
1199
+
1200
+ // Verify we can convert to form data
1201
+ const formData = config.toFormData!(oldNode, oldNodeUI);
1202
+ expect(formData.result[0].id).to.equal('bloop');
1203
+ expect(formData.result[0].name).to.equal('Bloop');
1204
+ expect(formData.delimit_by[0].value).to.equal('+');
1205
+ expect(formData.delimit_index[0].value).to.equal('0');
1206
+ });
1207
+ });
1109
1208
  });
@@ -0,0 +1,37 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { NODE_CONFIG } from '../src/flow/config';
3
+
4
+ describe('Backwards Compatibility', () => {
5
+ describe('split_by_run_result_delimited alias', () => {
6
+ it('should map split_by_run_result_delimited to split_by_run_result config', () => {
7
+ // verify the alias exists in NODE_CONFIG
8
+ expect(NODE_CONFIG['split_by_run_result_delimited']).to.exist;
9
+
10
+ // verify it points to the same config as split_by_run_result
11
+ expect(NODE_CONFIG['split_by_run_result_delimited']).to.equal(
12
+ NODE_CONFIG['split_by_run_result']
13
+ );
14
+ });
15
+
16
+ it('should have the correct type on the split_by_run_result config', () => {
17
+ const config = NODE_CONFIG['split_by_run_result'];
18
+ expect(config.type).to.equal('split_by_run_result');
19
+ });
20
+
21
+ it('should declare the alias in the config', () => {
22
+ const config = NODE_CONFIG['split_by_run_result'];
23
+ expect(config.aliases).to.exist;
24
+ expect(config.aliases).to.include('split_by_run_result_delimited');
25
+ });
26
+
27
+ it('should allow old flows with split_by_run_result_delimited type to load', () => {
28
+ // simulate loading an old flow with the delimited type
29
+ const oldType = 'split_by_run_result_delimited';
30
+ const config = NODE_CONFIG[oldType];
31
+
32
+ expect(config).to.exist;
33
+ expect(config.name).to.equal('Split by Result');
34
+ expect(config.group).to.equal('split');
35
+ });
36
+ });
37
+ });
@@ -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
 
@@ -125,11 +125,16 @@ describe('temba-contact-chat', () => {
125
125
  await updateComponent(compose, text);
126
126
 
127
127
  const response_body = {
128
- contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
129
- text: text,
130
- attachments: []
128
+ event: {
129
+ uuid: 'msg-uuid',
130
+ contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
131
+ msg: {
132
+ text: text,
133
+ attachments: []
134
+ }
135
+ }
131
136
  };
132
- mockPOST(/api\/v2\/messages\.json/, response_body);
137
+ mockPOST(/contact\/chat\/contact-dave-active\//, response_body);
133
138
 
134
139
  const listener = oneEvent(compose, CustomEventType.Submitted, false);
135
140
  await typeInto('temba-contact-chat:temba-compose', text, true, true);
@@ -149,14 +154,19 @@ describe('temba-contact-chat', () => {
149
154
  await updateComponent(compose, null, attachments);
150
155
  const response_attachments = getResponseSuccessFiles(attachments);
151
156
  const response_body = {
152
- contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
153
- text: '',
154
- attachments: response_attachments
157
+ event: {
158
+ uuid: 'msg-uuid',
159
+ contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
160
+ msg: {
161
+ text: '',
162
+ attachments: response_attachments
163
+ }
164
+ }
155
165
  };
156
166
  const response_headers = {};
157
167
  const response_status = '200';
158
168
  mockPOST(
159
- /api\/v2\/messages\.json/,
169
+ /contact\/chat\/contact-dave-active\//,
160
170
  response_body,
161
171
  response_headers,
162
172
  response_status
@@ -184,11 +194,16 @@ describe('temba-contact-chat', () => {
184
194
  await updateComponent(compose, text, attachments);
185
195
  const response_attachments = getResponseSuccessFiles(attachments);
186
196
  const response_body = {
187
- contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
188
- text: text,
189
- attachments: response_attachments
197
+ event: {
198
+ uuid: 'msg-uuid',
199
+ contact: { uuid: 'contact-dave-active', name: 'Dave Matthews' },
200
+ msg: {
201
+ text,
202
+ attachments: response_attachments
203
+ }
204
+ }
190
205
  };
191
- mockPOST(/api\/v2\/messages\.json/, response_body);
206
+ mockPOST(/contact\/chat\/contact-dave-active\//, response_body);
192
207
 
193
208
  // press enter
194
209
  const listener = oneEvent(compose, CustomEventType.Submitted, false);
@@ -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', () => {
@@ -13,7 +13,22 @@ describe('Localization Editing', () => {
13
13
  const languageNames: Record<string, string> = {
14
14
  eng: 'English',
15
15
  fra: 'French',
16
- esp: 'Spanish'
16
+ spa: 'Spanish'
17
+ };
18
+
19
+ const setupWorkspace = () => {
20
+ zustand.setState({
21
+ workspace: {
22
+ uuid: 'test-workspace',
23
+ name: 'Test Workspace',
24
+ languages: ['eng', 'fra', 'spa'],
25
+ primary_language: 'eng',
26
+ timezone: 'UTC',
27
+ date_style: 'day_first',
28
+ country: 'US',
29
+ anon: false
30
+ }
31
+ });
17
32
  };
18
33
 
19
34
  const buildCategoryFlowDefinition = (
@@ -89,6 +104,9 @@ describe('Localization Editing', () => {
89
104
  });
90
105
 
91
106
  beforeEach(async () => {
107
+ // Set workspace with languages
108
+ setupWorkspace();
109
+
92
110
  // Create a flow definition with localization data
93
111
  const flowDefinition: FlowDefinition = {
94
112
  uuid: 'test-flow',
@@ -98,7 +116,7 @@ describe('Localization Editing', () => {
98
116
  revision: 1,
99
117
  spec_version: '14.3',
100
118
  localization: {
101
- esp: {
119
+ spa: {
102
120
  'action-1': {
103
121
  text: ['Hola mundo'],
104
122
  quick_replies: ['Sí', 'No']
@@ -225,12 +243,12 @@ describe('Localization Editing', () => {
225
243
  const select = windowEl.querySelector('temba-select') as any;
226
244
  expect(select).to.exist;
227
245
 
228
- select.values = [{ name: 'Spanish', value: 'esp' }];
246
+ select.values = [{ name: 'Spanish', value: 'spa' }];
229
247
  select.dispatchEvent(new CustomEvent('change', { bubbles: true }));
230
248
  await editor.updateComplete;
231
249
 
232
250
  const state = zustand.getState();
233
- expect(state.languageCode).to.equal('esp');
251
+ expect(state.languageCode).to.equal('spa');
234
252
  const summary = windowEl.querySelector('.localization-progress-summary');
235
253
  expect(summary?.textContent.trim()).to.equal('All items are translated.');
236
254
  });
@@ -238,6 +256,8 @@ describe('Localization Editing', () => {
238
256
  it('should include category translations when include categories is enabled', async () => {
239
257
  editor?.remove();
240
258
 
259
+ setupWorkspace();
260
+
241
261
  const categoryFlowDefinition: FlowDefinition =
242
262
  buildCategoryFlowDefinition();
243
263
 
@@ -286,6 +306,8 @@ describe('Localization Editing', () => {
286
306
  editor?.remove();
287
307
  editor = null;
288
308
 
309
+ setupWorkspace();
310
+
289
311
  const flowDefinition = buildCategoryFlowDefinition({
290
312
  fra: {
291
313
  'cat-1': { name: ['Premier choix'] },
@@ -334,6 +356,8 @@ describe('Localization Editing', () => {
334
356
  editor?.remove();
335
357
  editor = null;
336
358
 
359
+ setupWorkspace();
360
+
337
361
  const flowDefinition = buildCategoryFlowDefinition({
338
362
  fra: {
339
363
  'cat-1': { name: ['Premier choix'] }
@@ -518,7 +542,7 @@ describe('Localization Editing', () => {
518
542
 
519
543
  it('should include language name in dialog header when translating', async () => {
520
544
  // Switch to Spanish
521
- zustand.getState().setLanguageCode('esp');
545
+ zustand.getState().setLanguageCode('spa');
522
546
 
523
547
  const action: SendMsg = {
524
548
  type: 'send_msg',
@@ -238,8 +238,9 @@ describe('temba-node-type-selector', () => {
238
238
  item.textContent?.trim()
239
239
  );
240
240
 
241
- // with ai feature, should have Split by AI
242
- expect(titles).to.include('Split by AI');
241
+ // split_by_llm_categorize (Split by AI) is filtered out for old editor compatibility
242
+ // so it should NOT appear even when AI feature is enabled
243
+ expect(titles).to.not.include('Split by AI');
243
244
  });
244
245
 
245
246
  it('filters by features - without AI feature, AI splits are hidden', async () => {
@@ -256,7 +257,8 @@ describe('temba-node-type-selector', () => {
256
257
  item.textContent?.trim()
257
258
  );
258
259
 
259
- // without ai feature, should not have Split by AI
260
+ // without ai feature, should not have Call AI or Split by AI
261
+ expect(titles).to.not.include('Call AI');
260
262
  expect(titles).to.not.include('Split by AI');
261
263
  });
262
264
 
@@ -352,4 +354,88 @@ describe('temba-node-type-selector', () => {
352
354
  );
353
355
  expect(isAvailableVoice).to.be.true;
354
356
  });
357
+
358
+ describe('alias filtering', () => {
359
+ it('should not show split_by_run_result twice when aliases exist', async () => {
360
+ const selector = await createSelector();
361
+
362
+ selector.show('split', { x: 100, y: 100 });
363
+ await selector.updateComplete;
364
+
365
+ // Get all the node items rendered in the selector
366
+ const nodeItems = selector.shadowRoot!.querySelectorAll('.node-item');
367
+
368
+ // Count how many times "Split by Result" appears
369
+ let splitByResultCount = 0;
370
+ nodeItems.forEach((item) => {
371
+ const title = item.querySelector('.node-item-title');
372
+ if (title?.textContent?.includes('Split by Result')) {
373
+ splitByResultCount++;
374
+ }
375
+ });
376
+
377
+ // Should only appear once, not twice
378
+ expect(splitByResultCount).to.equal(1);
379
+ });
380
+
381
+ it('should not show split_by_run_result_delimited type in the selector', async () => {
382
+ const selector = await createSelector();
383
+
384
+ selector.show('split', { x: 100, y: 100 });
385
+ await selector.updateComplete;
386
+
387
+ // Get all the node items and check their data-type attributes
388
+ const nodeItems = selector.shadowRoot!.querySelectorAll('.node-item');
389
+
390
+ let foundDelimitedType = false;
391
+ nodeItems.forEach((item) => {
392
+ const typeAttr = item.getAttribute('data-type');
393
+ if (typeAttr === 'split_by_run_result_delimited') {
394
+ foundDelimitedType = true;
395
+ }
396
+ });
397
+
398
+ expect(foundDelimitedType).to.be.false;
399
+ });
400
+
401
+ it('should not show split_by_llm_categorize in split mode', async () => {
402
+ const selector = await createSelector();
403
+
404
+ selector.show('split', { x: 100, y: 100 });
405
+ await selector.updateComplete;
406
+
407
+ // Get all the node items and check their data-type attributes
408
+ const nodeItems = selector.shadowRoot!.querySelectorAll('.node-item');
409
+
410
+ let foundLLMCategorize = false;
411
+ nodeItems.forEach((item) => {
412
+ const typeAttr = item.getAttribute('data-type');
413
+ if (typeAttr === 'split_by_llm_categorize') {
414
+ foundLLMCategorize = true;
415
+ }
416
+ });
417
+
418
+ expect(foundLLMCategorize).to.be.false;
419
+ });
420
+
421
+ it('should not show split_by_llm_categorize in action mode', async () => {
422
+ const selector = await createSelector();
423
+
424
+ selector.show('action', { x: 100, y: 100 });
425
+ await selector.updateComplete;
426
+
427
+ // Get all the node items and check their data-type attributes
428
+ const nodeItems = selector.shadowRoot!.querySelectorAll('.node-item');
429
+
430
+ let foundLLMCategorize = false;
431
+ nodeItems.forEach((item) => {
432
+ const typeAttr = item.getAttribute('data-type');
433
+ if (typeAttr === 'split_by_llm_categorize') {
434
+ foundLLMCategorize = true;
435
+ }
436
+ });
437
+
438
+ expect(foundLLMCategorize).to.be.false;
439
+ });
440
+ });
355
441
  });
@@ -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
  });
@@ -13,7 +13,7 @@ import { expect, fixture, html, assert } from '@open-wc/testing';
13
13
  const style = document.createElement('style');
14
14
  style.textContent = `
15
15
  * {
16
- --transition-duration: 0ms !important;
16
+ --transition-speed: 0ms !important;
17
17
  }
18
18
  `;
19
19
  document.head.appendChild(style);
@@ -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) {