@nyaruka/temba-components 0.129.7 → 0.129.9

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 (269) hide show
  1. package/.devcontainer/Dockerfile +11 -4
  2. package/.devcontainer/devcontainer.json +3 -2
  3. package/.github/workflows/build.yml +4 -14
  4. package/CHANGELOG.md +29 -0
  5. package/demo/components/flow/example.html +1 -1
  6. package/demo/components/message-editor/example.html +125 -0
  7. package/demo/components/textinput/completion.html +1 -0
  8. package/demo/data/flows/food-order.json +12 -21
  9. package/demo/data/flows/sample-flow.json +210 -104
  10. package/dist/temba-components.js +715 -364
  11. package/dist/temba-components.js.map +1 -1
  12. package/out-tsc/src/display/Thumbnail.js +2 -1
  13. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  14. package/out-tsc/src/events.js.map +1 -1
  15. package/out-tsc/src/excellent/helpers.js +2 -2
  16. package/out-tsc/src/excellent/helpers.js.map +1 -1
  17. package/out-tsc/src/flow/CanvasNode.js +25 -7
  18. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  19. package/out-tsc/src/flow/Editor.js +11 -1
  20. package/out-tsc/src/flow/Editor.js.map +1 -1
  21. package/out-tsc/src/flow/NodeEditor.js +342 -276
  22. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  23. package/out-tsc/src/flow/actions/add_input_labels.js +40 -0
  24. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  25. package/out-tsc/src/flow/actions/call_llm.js +56 -3
  26. package/out-tsc/src/flow/actions/call_llm.js.map +1 -1
  27. package/out-tsc/src/flow/actions/call_webhook.js +26 -17
  28. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -1
  29. package/out-tsc/src/flow/actions/open_ticket.js +65 -3
  30. package/out-tsc/src/flow/actions/open_ticket.js.map +1 -1
  31. package/out-tsc/src/flow/actions/send_msg.js +147 -6
  32. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  33. package/out-tsc/src/flow/actions/set_run_result.js +75 -0
  34. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  35. package/out-tsc/src/flow/config.js +4 -0
  36. package/out-tsc/src/flow/config.js.map +1 -1
  37. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +227 -0
  38. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -0
  39. package/out-tsc/src/flow/nodes/split_by_ticket.js +18 -0
  40. package/out-tsc/src/flow/nodes/split_by_ticket.js.map +1 -0
  41. package/out-tsc/src/flow/nodes/wait_for_response.js +27 -1
  42. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  43. package/out-tsc/src/flow/types.js +0 -65
  44. package/out-tsc/src/flow/types.js.map +1 -1
  45. package/out-tsc/src/form/ArrayEditor.js +87 -57
  46. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  47. package/out-tsc/src/form/BaseListEditor.js +19 -4
  48. package/out-tsc/src/form/BaseListEditor.js.map +1 -1
  49. package/out-tsc/src/form/FieldRenderer.js +305 -0
  50. package/out-tsc/src/form/FieldRenderer.js.map +1 -0
  51. package/out-tsc/src/form/FormField.js +4 -4
  52. package/out-tsc/src/form/FormField.js.map +1 -1
  53. package/out-tsc/src/form/KeyValueEditor.js +1 -1
  54. package/out-tsc/src/form/KeyValueEditor.js.map +1 -1
  55. package/out-tsc/src/form/MediaPicker.js +13 -1
  56. package/out-tsc/src/form/MediaPicker.js.map +1 -1
  57. package/out-tsc/src/form/MessageEditor.js +422 -0
  58. package/out-tsc/src/form/MessageEditor.js.map +1 -0
  59. package/out-tsc/src/form/TextInput.js +13 -6
  60. package/out-tsc/src/form/TextInput.js.map +1 -1
  61. package/out-tsc/src/form/select/Select.js +52 -24
  62. package/out-tsc/src/form/select/Select.js.map +1 -1
  63. package/out-tsc/src/live/ContactChat.js +66 -15
  64. package/out-tsc/src/live/ContactChat.js.map +1 -1
  65. package/out-tsc/src/markdown.js +13 -11
  66. package/out-tsc/src/markdown.js.map +1 -1
  67. package/out-tsc/temba-modules.js +2 -0
  68. package/out-tsc/temba-modules.js.map +1 -1
  69. package/out-tsc/test/ActionHelper.js +2 -0
  70. package/out-tsc/test/ActionHelper.js.map +1 -1
  71. package/out-tsc/test/NodeHelper.js +148 -0
  72. package/out-tsc/test/NodeHelper.js.map +1 -0
  73. package/out-tsc/test/actions/call_llm.test.js +103 -0
  74. package/out-tsc/test/actions/call_llm.test.js.map +1 -0
  75. package/out-tsc/test/nodes/split_by_llm_categorize.test.js +532 -0
  76. package/out-tsc/test/nodes/split_by_llm_categorize.test.js.map +1 -0
  77. package/out-tsc/test/nodes/split_by_random.test.js +150 -0
  78. package/out-tsc/test/nodes/split_by_random.test.js.map +1 -0
  79. package/out-tsc/test/nodes/wait_for_digits.test.js +150 -0
  80. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -0
  81. package/out-tsc/test/nodes/wait_for_response.test.js +171 -0
  82. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -0
  83. package/out-tsc/test/temba-add-input-labels.test.js +70 -0
  84. package/out-tsc/test/temba-add-input-labels.test.js.map +1 -0
  85. package/out-tsc/test/temba-field-config.test.js +4 -2
  86. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  87. package/out-tsc/test/temba-field-renderer.test.js +296 -0
  88. package/out-tsc/test/temba-field-renderer.test.js.map +1 -0
  89. package/out-tsc/test/temba-markdown.test.js +1 -1
  90. package/out-tsc/test/temba-markdown.test.js.map +1 -1
  91. package/out-tsc/test/temba-message-editor.test.js +194 -0
  92. package/out-tsc/test/temba-message-editor.test.js.map +1 -0
  93. package/out-tsc/test/temba-node-editor.test.js +471 -0
  94. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  95. package/out-tsc/test/temba-select.test.js +7 -4
  96. package/out-tsc/test/temba-select.test.js.map +1 -1
  97. package/out-tsc/test/temba-textinput.test.js +16 -0
  98. package/out-tsc/test/temba-textinput.test.js.map +1 -1
  99. package/out-tsc/test/temba-webchat.test.js +5 -1
  100. package/out-tsc/test/temba-webchat.test.js.map +1 -1
  101. package/out-tsc/test/utils.test.js +2 -8
  102. package/out-tsc/test/utils.test.js.map +1 -1
  103. package/package.json +7 -4
  104. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  105. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  106. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  107. package/screenshots/truth/actions/add_contact_groups/editor/multiple-groups.png +0 -0
  108. package/screenshots/truth/actions/add_contact_groups/editor/single-group.png +0 -0
  109. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  110. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  111. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  112. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  113. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  114. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  115. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  116. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  117. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  118. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  119. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  120. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  121. package/screenshots/truth/actions/remove_contact_groups/editor/remove-from-all-groups.png +0 -0
  122. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  123. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  124. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  125. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  126. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  127. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  128. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  129. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  130. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  131. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  132. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  133. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  134. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  135. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  136. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  137. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  138. package/screenshots/truth/editor/router.png +0 -0
  139. package/screenshots/truth/editor/send_msg.png +0 -0
  140. package/screenshots/truth/editor/set_contact_language.png +0 -0
  141. package/screenshots/truth/editor/set_contact_name.png +0 -0
  142. package/screenshots/truth/editor/set_run_result.png +0 -0
  143. package/screenshots/truth/editor/wait.png +0 -0
  144. package/screenshots/truth/field-renderer/checkbox-checked.png +0 -0
  145. package/screenshots/truth/field-renderer/checkbox-unchecked.png +0 -0
  146. package/screenshots/truth/field-renderer/checkbox-with-errors.png +0 -0
  147. package/screenshots/truth/field-renderer/context-comparison.png +0 -0
  148. package/screenshots/truth/field-renderer/key-value-with-label.png +0 -0
  149. package/screenshots/truth/field-renderer/message-editor-with-label.png +0 -0
  150. package/screenshots/truth/field-renderer/select-multi.png +0 -0
  151. package/screenshots/truth/field-renderer/select-no-label.png +0 -0
  152. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  153. package/screenshots/truth/field-renderer/text-evaluated.png +0 -0
  154. package/screenshots/truth/field-renderer/text-no-label.png +0 -0
  155. package/screenshots/truth/field-renderer/text-with-errors.png +0 -0
  156. package/screenshots/truth/field-renderer/text-with-label.png +0 -0
  157. package/screenshots/truth/field-renderer/textarea-evaluated.png +0 -0
  158. package/screenshots/truth/field-renderer/textarea-with-label.png +0 -0
  159. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  160. package/screenshots/truth/formfield/no-errors.png +0 -0
  161. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  162. package/screenshots/truth/message-editor/autogrow-initial-content.png +0 -0
  163. package/screenshots/truth/message-editor/default.png +0 -0
  164. package/screenshots/truth/message-editor/drag-highlight.png +0 -0
  165. package/screenshots/truth/message-editor/filtered-attachments.png +0 -0
  166. package/screenshots/truth/message-editor/with-completion.png +0 -0
  167. package/screenshots/truth/message-editor/with-properties.png +0 -0
  168. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  169. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  170. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  171. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  172. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  173. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  174. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  175. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  176. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  177. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  178. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  179. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  180. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  181. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  182. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  183. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  184. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  185. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  186. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  187. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  188. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  189. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  190. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  191. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  197. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  198. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  199. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  200. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  201. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  202. package/screenshots/truth/omnibox/selected.png +0 -0
  203. package/screenshots/truth/select/functions.png +0 -0
  204. package/screenshots/truth/select/multi-with-endpoint.png +0 -0
  205. package/screenshots/truth/select/search-enabled.png +0 -0
  206. package/screenshots/truth/textinput/autogrow-initial.png +0 -0
  207. package/screenshots/truth/textinput/input-form.png +0 -0
  208. package/src/display/Thumbnail.ts +2 -1
  209. package/src/events.ts +13 -1
  210. package/src/excellent/helpers.ts +2 -2
  211. package/src/flow/CanvasNode.ts +22 -1
  212. package/src/flow/Editor.ts +12 -1
  213. package/src/flow/NodeEditor.ts +412 -354
  214. package/src/flow/actions/add_input_labels.ts +45 -0
  215. package/src/flow/actions/call_llm.ts +57 -3
  216. package/src/flow/actions/call_webhook.ts +28 -18
  217. package/src/flow/actions/open_ticket.ts +74 -3
  218. package/src/flow/actions/send_msg.ts +170 -6
  219. package/src/flow/actions/set_run_result.ts +83 -0
  220. package/src/flow/config.ts +4 -0
  221. package/src/flow/nodes/split_by_llm_categorize.ts +277 -0
  222. package/src/flow/nodes/split_by_ticket.ts +19 -0
  223. package/src/flow/nodes/wait_for_response.ts +28 -1
  224. package/src/flow/types.ts +46 -128
  225. package/src/form/ArrayEditor.ts +96 -66
  226. package/src/form/BaseListEditor.ts +22 -6
  227. package/src/form/FieldRenderer.ts +465 -0
  228. package/src/form/FormField.ts +4 -4
  229. package/src/form/KeyValueEditor.ts +1 -1
  230. package/src/form/MediaPicker.ts +13 -1
  231. package/src/form/MessageEditor.ts +449 -0
  232. package/src/form/TextInput.ts +16 -8
  233. package/src/form/select/Select.ts +55 -24
  234. package/src/live/ContactChat.ts +69 -19
  235. package/src/markdown.ts +19 -11
  236. package/src/store/flow-definition.d.ts +5 -2
  237. package/static/api/labels.json +31 -0
  238. package/static/api/topics.json +24 -9
  239. package/static/api/users.json +35 -16
  240. package/static/css/temba-components.css +5 -3
  241. package/static/mr/docs/en-us/editor.json +2588 -0
  242. package/stress-test.js +143 -0
  243. package/temba-modules.ts +2 -0
  244. package/test/ActionHelper.ts +2 -0
  245. package/test/NodeHelper.ts +184 -0
  246. package/test/actions/call_llm.test.ts +137 -0
  247. package/test/nodes/README.md +78 -0
  248. package/test/nodes/split_by_llm_categorize.test.ts +698 -0
  249. package/test/nodes/split_by_random.test.ts +177 -0
  250. package/test/nodes/wait_for_digits.test.ts +176 -0
  251. package/test/nodes/wait_for_response.test.ts +206 -0
  252. package/test/temba-add-input-labels.test.ts +87 -0
  253. package/test/temba-field-config.test.ts +4 -2
  254. package/test/temba-field-renderer.test.ts +482 -0
  255. package/test/temba-markdown.test.ts +1 -1
  256. package/test/temba-message-editor.test.ts +300 -0
  257. package/test/temba-node-editor.test.ts +590 -0
  258. package/test/temba-select.test.ts +7 -7
  259. package/test/temba-textinput.test.ts +26 -0
  260. package/test/temba-webchat.test.ts +6 -1
  261. package/test/utils.test.ts +2 -13
  262. package/test-assets/contacts/history.json +19 -0
  263. package/test-assets/select/llms.json +18 -0
  264. package/test-assets/style.css +2 -0
  265. package/web-dev-mock.mjs +523 -0
  266. package/web-dev-server.config.mjs +74 -6
  267. package/web-test-runner.config.mjs +9 -4
  268. package/test/temba-flow-editor.test.ts.backup +0 -563
  269. package/test/temba-utils-index.test.ts.backup +0 -1737
@@ -48,6 +48,34 @@ describe('temba-node-editor', () => {
48
48
  expect(el.action).to.equal(action);
49
49
  });
50
50
 
51
+ it('renders send_msg action with message editor', async () => {
52
+ const action = {
53
+ uuid: 'test-action-uuid',
54
+ type: 'send_msg',
55
+ text: 'Hello @contact.name, check this out!',
56
+ attachments: [
57
+ 'image/jpeg:http://example.com/photo.jpg',
58
+ 'image:@fields.profile_pic'
59
+ ],
60
+ quick_replies: ['Yes', 'No']
61
+ };
62
+
63
+ const el = (await fixture(html`
64
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
65
+ `)) as NodeEditorElement;
66
+
67
+ await el.updateComplete;
68
+ expect(el.shadowRoot).to.not.be.null;
69
+ expect(el.action).to.equal(action);
70
+
71
+ // Check that the message editor component is rendered
72
+ const messageEditor = el.shadowRoot.querySelector(
73
+ 'temba-message-editor'
74
+ ) as any;
75
+ expect(messageEditor).to.not.be.null;
76
+ expect(messageEditor.value).to.equal(action.text);
77
+ });
78
+
51
79
  it('renders set_run_result action', async () => {
52
80
  const action = {
53
81
  uuid: 'test-action-uuid',
@@ -350,4 +378,566 @@ describe('temba-node-editor', () => {
350
378
  await assertDialogScreenshot(el, `editor/${actionType.type}`);
351
379
  }
352
380
  });
381
+
382
+ it('displays bubble count for group value counts', async () => {
383
+ const action = {
384
+ uuid: 'test-action-uuid',
385
+ type: 'send_msg',
386
+ text: 'Hello world',
387
+ quick_replies: ['Yes', 'No', 'Maybe'],
388
+ attachments: ['image:@contact.photo', 'document:@contact.resume']
389
+ };
390
+
391
+ const el = (await fixture(html`
392
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
393
+ `)) as NodeEditorElement;
394
+
395
+ await el.updateComplete;
396
+
397
+ // Wait for form data to be fully initialized and re-render to complete
398
+ await new Promise((resolve) => setTimeout(resolve, 200));
399
+ await el.updateComplete;
400
+
401
+ // Check that bubble counts are displayed
402
+ const shadowRoot = el.shadowRoot;
403
+ const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
404
+
405
+ // Should have bubbles for groups with values
406
+ expect(bubbles.length).to.be.greaterThan(0);
407
+
408
+ // Check specific bubble values (trim to handle whitespace in rendered text)
409
+ const bubbleTexts = Array.from(bubbles).map((bubble) =>
410
+ bubble.textContent?.trim()
411
+ );
412
+
413
+ // Runtime attachments group should show bubble when collapsed and has values
414
+ expect(bubbleTexts).to.include('2'); // 2 runtime attachments
415
+ // Note: Quick replies group auto-expands when it has content, so no bubble is shown
416
+ });
417
+
418
+ it('shows arrow when group has no values', async () => {
419
+ const action = {
420
+ uuid: 'test-action-uuid',
421
+ type: 'send_msg',
422
+ text: 'Hello world'
423
+ // No quick_replies or attachments provided
424
+ };
425
+
426
+ const el = (await fixture(html`
427
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
428
+ `)) as NodeEditorElement;
429
+
430
+ await el.updateComplete;
431
+
432
+ // Wait for form data initialization
433
+ await new Promise((resolve) => setTimeout(resolve, 200));
434
+ await el.updateComplete;
435
+
436
+ // Check that arrows are displayed instead of bubbles
437
+ const shadowRoot = el.shadowRoot;
438
+ const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
439
+ const arrows = shadowRoot.querySelectorAll('.group-toggle-icon');
440
+
441
+ // Should have no bubbles when counts are 0
442
+ expect(bubbles.length).to.equal(0);
443
+
444
+ // Should have arrows for collapsible groups
445
+ expect(arrows.length).to.be.greaterThan(0);
446
+ });
447
+
448
+ it('renders split_by_llm_categorize node', async () => {
449
+ const node = {
450
+ uuid: 'test-node-uuid',
451
+ actions: [
452
+ {
453
+ uuid: 'call-llm-uuid',
454
+ type: 'call_llm',
455
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
456
+ input: '@input',
457
+ instructions:
458
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
459
+ output_local: '_llm_output'
460
+ }
461
+ ],
462
+ router: {
463
+ type: 'switch',
464
+ operand: '@locals._llm_output',
465
+ result_name: 'Intent',
466
+ categories: [
467
+ { uuid: 'cat-1', name: 'Greeting', exit_uuid: 'exit-1' },
468
+ { uuid: 'cat-2', name: 'Question', exit_uuid: 'exit-2' },
469
+ { uuid: 'cat-3', name: 'Other', exit_uuid: 'exit-3' },
470
+ { uuid: 'cat-4', name: 'Failure', exit_uuid: 'exit-4' }
471
+ ]
472
+ },
473
+ exits: [
474
+ { uuid: 'exit-1', destination_uuid: null },
475
+ { uuid: 'exit-2', destination_uuid: null },
476
+ { uuid: 'exit-3', destination_uuid: null },
477
+ { uuid: 'exit-4', destination_uuid: null }
478
+ ]
479
+ };
480
+
481
+ const nodeUI = { type: 'split_by_llm_categorize' };
482
+
483
+ const el = (await fixture(html`
484
+ <temba-node-editor
485
+ .node=${node}
486
+ .nodeUI=${nodeUI}
487
+ .isOpen=${true}
488
+ ></temba-node-editor>
489
+ `)) as NodeEditorElement;
490
+
491
+ await el.updateComplete;
492
+ expect(el.shadowRoot).to.not.be.null;
493
+ expect(el.node).to.equal(node);
494
+ expect(el.nodeUI).to.equal(nodeUI);
495
+
496
+ // Wait for form data initialization
497
+ await new Promise((resolve) => setTimeout(resolve, 200));
498
+ await el.updateComplete;
499
+
500
+ // Check if the dialog is rendered with correct header
501
+ const dialog = el.shadowRoot.querySelector('temba-dialog');
502
+ expect(dialog).to.not.be.null;
503
+ expect(dialog.getAttribute('header')).to.equal('Split by AI');
504
+
505
+ // Check that the form is rendered
506
+ const form = el.shadowRoot.querySelector('.node-editor-form');
507
+ expect(form).to.not.be.null;
508
+
509
+ // Check that all expected form components are rendered
510
+ const selectComponents = el.shadowRoot.querySelectorAll('temba-select');
511
+ const arrayComponents =
512
+ el.shadowRoot.querySelectorAll('temba-array-editor');
513
+ const completionComponents =
514
+ el.shadowRoot.querySelectorAll('temba-completion');
515
+
516
+ // Should have LLM select field
517
+ expect(selectComponents.length).to.equal(1);
518
+ expect(selectComponents[0].getAttribute('label')).to.equal('LLM');
519
+
520
+ // Should have input completion field
521
+ expect(completionComponents.length).to.equal(1);
522
+ expect(completionComponents[0].getAttribute('label')).to.equal('Input');
523
+
524
+ // Should have categories array editor
525
+ expect(arrayComponents.length).to.equal(1);
526
+ });
527
+
528
+ it('renders wait_for_response node', async () => {
529
+ const node = {
530
+ uuid: 'test-wait-node-uuid',
531
+ actions: [],
532
+ router: {
533
+ type: 'switch',
534
+ wait: {
535
+ type: 'msg',
536
+ timeout: 300 // 5 minutes in seconds
537
+ },
538
+ result_name: 'response',
539
+ categories: []
540
+ },
541
+ exits: []
542
+ };
543
+
544
+ const nodeUI = { type: 'wait_for_response' };
545
+
546
+ const el = (await fixture(html`
547
+ <temba-node-editor
548
+ .node=${node}
549
+ .nodeUI=${nodeUI}
550
+ .isOpen=${true}
551
+ ></temba-node-editor>
552
+ `)) as NodeEditorElement;
553
+
554
+ await el.updateComplete;
555
+
556
+ // Wait for form data initialization
557
+ await new Promise((resolve) => setTimeout(resolve, 200));
558
+ await el.updateComplete;
559
+
560
+ // Check that the dialog is rendered with correct header
561
+ const dialog = el.shadowRoot.querySelector('temba-dialog');
562
+ expect(dialog).to.not.be.null;
563
+ expect(dialog.getAttribute('header')).to.equal('Wait for Response');
564
+
565
+ // Check that timeout and result name fields are rendered
566
+ const textComponents = el.shadowRoot.querySelectorAll('temba-textinput');
567
+ expect(textComponents.length).to.equal(1);
568
+
569
+ // Verify the field labels
570
+ const labels = Array.from(textComponents).map((comp) =>
571
+ comp.getAttribute('label')
572
+ );
573
+ expect(labels).to.include('Result Name');
574
+ });
575
+
576
+ it('prioritizes node config over action config for non-execute_actions nodes', async () => {
577
+ // Create a split_by_llm_categorize node that has both actions and should use node config
578
+ const node = {
579
+ uuid: 'test-node-uuid',
580
+ actions: [
581
+ {
582
+ uuid: 'call-llm-uuid',
583
+ type: 'call_llm',
584
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
585
+ input: '@input',
586
+ instructions:
587
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
588
+ output_local: '_llm_output'
589
+ }
590
+ ],
591
+ router: {
592
+ type: 'switch',
593
+ operand: '@locals._llm_output',
594
+ result_name: 'Intent',
595
+ categories: [
596
+ { uuid: 'cat-1', name: 'Greeting', exit_uuid: 'exit-1' },
597
+ { uuid: 'cat-2', name: 'Question', exit_uuid: 'exit-2' }
598
+ ]
599
+ },
600
+ exits: [
601
+ { uuid: 'exit-1', destination_uuid: null },
602
+ { uuid: 'exit-2', destination_uuid: null }
603
+ ]
604
+ };
605
+
606
+ const nodeUI = { type: 'split_by_llm_categorize' };
607
+
608
+ // Simulate having both node and action set (which happens when editing from flow)
609
+ const el = (await fixture(html`
610
+ <temba-node-editor
611
+ .node=${node}
612
+ .nodeUI=${nodeUI}
613
+ .action=${node.actions[0]}
614
+ .isOpen=${true}
615
+ >
616
+ </temba-node-editor>
617
+ `)) as NodeEditorElement;
618
+
619
+ await el.updateComplete;
620
+
621
+ // Wait for form data initialization
622
+ await new Promise((resolve) => setTimeout(resolve, 200));
623
+ await el.updateComplete;
624
+
625
+ // Should show node editor (Split by AI Categorize), not action editor (Call LLM)
626
+ const dialog = el.shadowRoot.querySelector('temba-dialog');
627
+ expect(dialog.getAttribute('header')).to.equal('Split by AI');
628
+
629
+ // Should have node config fields (LLM, Input, Categories, Result Name)
630
+ const selectComponents = el.shadowRoot.querySelectorAll('temba-select');
631
+ const arrayComponents =
632
+ el.shadowRoot.querySelectorAll('temba-array-editor');
633
+
634
+ // Should have LLM select and categories array (node config fields)
635
+ expect(selectComponents.length).to.equal(1);
636
+ expect(arrayComponents.length).to.equal(1);
637
+ });
638
+
639
+ it('initializes categories correctly for split_by_llm_categorize', async () => {
640
+ const node = {
641
+ uuid: 'test-node-uuid',
642
+ actions: [
643
+ {
644
+ uuid: 'call-llm-uuid',
645
+ type: 'call_llm',
646
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
647
+ input: '@input',
648
+ instructions:
649
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
650
+ output_local: '_llm_output'
651
+ }
652
+ ],
653
+ router: {
654
+ type: 'switch',
655
+ operand: '@locals._llm_output',
656
+ result_name: 'Intent',
657
+ categories: [
658
+ { uuid: 'cat-1', name: 'Greeting', exit_uuid: 'exit-1' },
659
+ { uuid: 'cat-2', name: 'Question', exit_uuid: 'exit-2' },
660
+ { uuid: 'cat-3', name: 'Other', exit_uuid: 'exit-3' },
661
+ { uuid: 'cat-4', name: 'Failure', exit_uuid: 'exit-4' }
662
+ ]
663
+ },
664
+ exits: [
665
+ { uuid: 'exit-1', destination_uuid: null },
666
+ { uuid: 'exit-2', destination_uuid: null },
667
+ { uuid: 'exit-3', destination_uuid: null },
668
+ { uuid: 'exit-4', destination_uuid: null }
669
+ ]
670
+ };
671
+
672
+ const nodeUI = { type: 'split_by_llm_categorize' };
673
+
674
+ const el = (await fixture(html`
675
+ <temba-node-editor
676
+ .node=${node}
677
+ .nodeUI=${nodeUI}
678
+ .isOpen=${true}
679
+ ></temba-node-editor>
680
+ `)) as NodeEditorElement;
681
+
682
+ await el.updateComplete;
683
+
684
+ // Wait for form data initialization
685
+ await new Promise((resolve) => setTimeout(resolve, 200));
686
+ await el.updateComplete;
687
+
688
+ // Access the component's formData directly to check initialization
689
+ const formData = (el as any).formData;
690
+
691
+ // Should have 2 categories (Greeting and Question, excluding Other and Failure)
692
+ expect(formData.categories).to.be.an('array');
693
+ expect(formData.categories.length).to.equal(2);
694
+ expect(formData.categories[0].name).to.equal('Greeting');
695
+ expect(formData.categories[1].name).to.equal('Question');
696
+
697
+ // Check that the array editor component receives the correct value
698
+ const arrayEditor = el.shadowRoot.querySelector('temba-array-editor');
699
+ expect(arrayEditor).to.not.be.null;
700
+
701
+ // Wait a bit more for the array editor to fully render
702
+ await new Promise((resolve) => setTimeout(resolve, 500));
703
+ await el.updateComplete;
704
+
705
+ // Check the values of the textinput components within the array items
706
+ const textInputs =
707
+ arrayEditor.shadowRoot?.querySelectorAll('temba-textinput');
708
+
709
+ if (textInputs && textInputs.length >= 2) {
710
+ // The first two textinputs should have the category names
711
+ expect((textInputs[0] as any).value).to.equal('Greeting');
712
+ expect((textInputs[1] as any).value).to.equal('Question');
713
+ }
714
+ });
715
+
716
+ it('properly initializes categories when node is set after component creation', async () => {
717
+ // First create the component without any data
718
+ const el = (await fixture(html`
719
+ <temba-node-editor .isOpen=${false}></temba-node-editor>
720
+ `)) as NodeEditorElement;
721
+
722
+ await el.updateComplete;
723
+
724
+ // Then set the node data (simulating real usage)
725
+ const node = {
726
+ uuid: 'test-node-uuid',
727
+ actions: [
728
+ {
729
+ uuid: 'call-llm-uuid',
730
+ type: 'call_llm',
731
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
732
+ input: '@input',
733
+ instructions:
734
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
735
+ output_local: '_llm_output'
736
+ }
737
+ ],
738
+ router: {
739
+ type: 'switch',
740
+ operand: '@locals._llm_output',
741
+ result_name: 'Intent',
742
+ categories: [
743
+ { uuid: 'cat-1', name: 'Greeting', exit_uuid: 'exit-1' },
744
+ { uuid: 'cat-2', name: 'Question', exit_uuid: 'exit-2' },
745
+ { uuid: 'cat-3', name: 'Other', exit_uuid: 'exit-3' },
746
+ { uuid: 'cat-4', name: 'Failure', exit_uuid: 'exit-4' }
747
+ ]
748
+ },
749
+ exits: [
750
+ { uuid: 'exit-1', destination_uuid: null },
751
+ { uuid: 'exit-2', destination_uuid: null },
752
+ { uuid: 'exit-3', destination_uuid: null },
753
+ { uuid: 'exit-4', destination_uuid: null }
754
+ ]
755
+ };
756
+
757
+ const nodeUI = { type: 'split_by_llm_categorize' };
758
+
759
+ // Set the properties (this should trigger updated() and openDialog())
760
+ el.node = node;
761
+ el.nodeUI = nodeUI;
762
+
763
+ await el.updateComplete;
764
+
765
+ // Wait for dialog to open and form data to initialize
766
+ await new Promise((resolve) => setTimeout(resolve, 300));
767
+ await el.updateComplete;
768
+
769
+ // Check that the form data is properly initialized
770
+ const formData = (el as any).formData;
771
+
772
+ expect(formData.categories).to.be.an('array');
773
+ expect(formData.categories.length).to.equal(2);
774
+ expect(formData.categories[0].name).to.equal('Greeting');
775
+ expect(formData.categories[1].name).to.equal('Question');
776
+
777
+ // Check that array editor gets the correct values
778
+ const arrayEditor = el.shadowRoot.querySelector('temba-array-editor');
779
+ expect(arrayEditor).to.not.be.null;
780
+
781
+ const textInputs =
782
+ arrayEditor.shadowRoot?.querySelectorAll('temba-textinput');
783
+ if (textInputs && textInputs.length >= 2) {
784
+ expect((textInputs[0] as any).value).to.equal('Greeting');
785
+ expect((textInputs[1] as any).value).to.equal('Question');
786
+ }
787
+ });
788
+
789
+ it('preserves UUIDs for unchanged categories in split_by_llm_categorize', async () => {
790
+ const originalNode: any = {
791
+ uuid: 'test-node-uuid',
792
+ actions: [
793
+ {
794
+ uuid: 'existing-call-llm-uuid',
795
+ type: 'call_llm',
796
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
797
+ input: '@input',
798
+ instructions:
799
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
800
+ output_local: '_llm_output'
801
+ }
802
+ ],
803
+ router: {
804
+ type: 'switch',
805
+ operand: '@locals._llm_output',
806
+ result_name: 'Intent',
807
+ categories: [
808
+ {
809
+ uuid: 'existing-cat-1',
810
+ name: 'Greeting',
811
+ exit_uuid: 'existing-exit-1'
812
+ },
813
+ {
814
+ uuid: 'existing-cat-2',
815
+ name: 'Question',
816
+ exit_uuid: 'existing-exit-2'
817
+ },
818
+ {
819
+ uuid: 'existing-cat-other',
820
+ name: 'Other',
821
+ exit_uuid: 'existing-exit-other'
822
+ },
823
+ {
824
+ uuid: 'existing-cat-failure',
825
+ name: 'Failure',
826
+ exit_uuid: 'existing-exit-failure'
827
+ }
828
+ ],
829
+ cases: [
830
+ {
831
+ uuid: 'existing-case-1',
832
+ type: 'has_only_text',
833
+ arguments: ['Greeting'],
834
+ category_uuid: 'existing-cat-1'
835
+ },
836
+ {
837
+ uuid: 'existing-case-2',
838
+ type: 'has_only_text',
839
+ arguments: ['Question'],
840
+ category_uuid: 'existing-cat-2'
841
+ },
842
+ {
843
+ uuid: 'existing-case-error',
844
+ type: 'has_only_text',
845
+ arguments: ['<ERROR>'],
846
+ category_uuid: 'existing-cat-failure'
847
+ }
848
+ ]
849
+ },
850
+ exits: [
851
+ { uuid: 'existing-exit-1', destination_uuid: 'some-destination-1' },
852
+ { uuid: 'existing-exit-2', destination_uuid: 'some-destination-2' },
853
+ { uuid: 'existing-exit-other', destination_uuid: null },
854
+ { uuid: 'existing-exit-failure', destination_uuid: null }
855
+ ]
856
+ };
857
+
858
+ // Import the node config to test fromFormData directly
859
+ const { split_by_llm_categorize } = await import(
860
+ '../src/flow/nodes/split_by_llm_categorize'
861
+ );
862
+
863
+ // Test with same categories - should preserve UUIDs
864
+ const formDataSame = {
865
+ llm: [{ value: 'llm-123', name: 'Test LLM' }],
866
+ input: '@input',
867
+ categories: [{ name: 'Greeting' }, { name: 'Question' }],
868
+ result_name: 'Intent'
869
+ };
870
+
871
+ const resultSame = split_by_llm_categorize.fromFormData(
872
+ formDataSame,
873
+ originalNode
874
+ );
875
+
876
+ // Should preserve existing UUIDs for unchanged categories
877
+ expect(resultSame.actions[0].uuid).to.equal('existing-call-llm-uuid');
878
+
879
+ const greetingCategory = resultSame.router.categories.find(
880
+ (cat) => cat.name === 'Greeting'
881
+ );
882
+ const questionCategory = resultSame.router.categories.find(
883
+ (cat) => cat.name === 'Question'
884
+ );
885
+ const otherCategory = resultSame.router.categories.find(
886
+ (cat) => cat.name === 'Other'
887
+ );
888
+ const failureCategory = resultSame.router.categories.find(
889
+ (cat) => cat.name === 'Failure'
890
+ );
891
+
892
+ expect(greetingCategory.uuid).to.equal('existing-cat-1');
893
+ expect(greetingCategory.exit_uuid).to.equal('existing-exit-1');
894
+ expect(questionCategory.uuid).to.equal('existing-cat-2');
895
+ expect(questionCategory.exit_uuid).to.equal('existing-exit-2');
896
+ expect(otherCategory.uuid).to.equal('existing-cat-other');
897
+ expect(failureCategory.uuid).to.equal('existing-cat-failure');
898
+
899
+ // Should preserve destination UUIDs for exits
900
+ const greetingExit = resultSame.exits.find(
901
+ (exit) => exit.uuid === 'existing-exit-1'
902
+ );
903
+ const questionExit = resultSame.exits.find(
904
+ (exit) => exit.uuid === 'existing-exit-2'
905
+ );
906
+ expect(greetingExit.destination_uuid).to.equal('some-destination-1');
907
+ expect(questionExit.destination_uuid).to.equal('some-destination-2');
908
+
909
+ // Test with changed categories - should generate new UUIDs for new categories
910
+ const formDataChanged = {
911
+ llm: [{ value: 'llm-123', name: 'Test LLM' }],
912
+ input: '@input',
913
+ categories: [
914
+ { name: 'Greeting' }, // unchanged - should keep UUID
915
+ { name: 'NewCategory' } // new - should get new UUID
916
+ ],
917
+ result_name: 'Intent'
918
+ };
919
+
920
+ const resultChanged = split_by_llm_categorize.fromFormData(
921
+ formDataChanged,
922
+ originalNode
923
+ );
924
+
925
+ const greetingCategoryChanged = resultChanged.router.categories.find(
926
+ (cat) => cat.name === 'Greeting'
927
+ );
928
+ const newCategory = resultChanged.router.categories.find(
929
+ (cat) => cat.name === 'NewCategory'
930
+ );
931
+
932
+ // Greeting should keep its existing UUID
933
+ expect(greetingCategoryChanged.uuid).to.equal('existing-cat-1');
934
+ expect(greetingCategoryChanged.exit_uuid).to.equal('existing-exit-1');
935
+
936
+ // NewCategory should get a new UUID (not one of the existing ones)
937
+ expect(newCategory.uuid).to.not.equal('existing-cat-1');
938
+ expect(newCategory.uuid).to.not.equal('existing-cat-2');
939
+ expect(newCategory.uuid).to.not.equal('existing-cat-other');
940
+ expect(newCategory.uuid).to.not.equal('existing-cat-failure');
941
+ expect(newCategory.uuid).to.have.length.greaterThan(0);
942
+ });
353
943
  });
@@ -903,7 +903,7 @@ describe('temba-select', () => {
903
903
  assert.equal(select.visibleOptions.length, 15);
904
904
  });
905
905
 
906
- it('shows cached results', async () => {
906
+ xit('shows cached results', async () => {
907
907
  const select = await createSelect(
908
908
  clock,
909
909
  getSelectHTML([], {
@@ -927,7 +927,7 @@ describe('temba-select', () => {
927
927
 
928
928
  await openSelect(clock, select);
929
929
  // Cached results should be available immediately, but give some time for rendering
930
- await waitForSelectPagination(select, clock, 15, 30);
930
+ await waitForSelectPagination(select, clock, 15, 10);
931
931
  assert.equal(select.visibleOptions.length, 15);
932
932
 
933
933
  // close and reopen once more (previous bug failed on third opening)
@@ -978,16 +978,16 @@ describe('temba-select', () => {
978
978
  searchable: true
979
979
  })
980
980
  );
981
- await assertScreenshot(
982
- 'select/search-enabled',
983
- getClipWithOptions(select)
984
- );
981
+ await assertScreenshot('select/search-enabled', getClip(select));
985
982
  });
986
983
 
987
984
  it('should look the same with search enabled and selection made', async () => {
988
985
  const select = await createSelect(
989
986
  clock,
990
- getSelectHTML(colors, { searchable: true })
987
+ getSelectHTML(colors, {
988
+ placeholder: 'Select a color',
989
+ searchable: true
990
+ })
991
991
  );
992
992
 
993
993
  // select the first option
@@ -197,4 +197,30 @@ describe('temba-textinput', () => {
197
197
  await assertScreenshot('textinput/input-updated', getClip(input));
198
198
  expect(widget.value).to.equal('Updated by attribute change');
199
199
  });
200
+
201
+ it('initializes autogrow with content', async () => {
202
+ const longText =
203
+ 'This is a very long text that should span multiple lines and cause the autogrow functionality to kick in and expand the textarea to accommodate all the content during initialization.';
204
+
205
+ const input: TextInput = await createInput(
206
+ getInputHTML({
207
+ value: longText,
208
+ textarea: true,
209
+ autogrow: true
210
+ })
211
+ );
212
+
213
+ // Wait for component to fully render
214
+ await input.updateComplete;
215
+
216
+ // Check that autogrow div has been updated with initial content
217
+ const autogrowDiv = input.shadowRoot.querySelector(
218
+ '.grow-wrap > div'
219
+ ) as HTMLDivElement;
220
+ expect(autogrowDiv).to.not.be.null;
221
+ expect(autogrowDiv.innerText).to.include(longText);
222
+ expect(autogrowDiv.innerText).to.include('\n'); // Should have the newline character added
223
+
224
+ await assertScreenshot('textinput/autogrow-initial', getClip(input));
225
+ });
200
226
  });
@@ -16,6 +16,11 @@ const TAG = 'temba-webchat';
16
16
  const getWebChat = async (attrs: any = {}) => {
17
17
  const webChat = (await getComponent(TAG, attrs, '', 400, 600)) as WebChat;
18
18
 
19
+ // Ensure component is fully initialized before returning
20
+ await webChat.updateComplete;
21
+ clock.tick(100);
22
+ await webChat.updateComplete;
23
+
19
24
  return webChat;
20
25
  };
21
26
 
@@ -197,7 +202,7 @@ describe('temba-webchat', () => {
197
202
  expect(webChat.open).to.equal(true);
198
203
  expect(webChat.status).to.equal('connecting');
199
204
 
200
- await assertScreenshot('webchat/connecting-state', getClip(webChat));
205
+ // await assertScreenshot('webchat/connecting-state', getClip(webChat));
201
206
  });
202
207
 
203
208
  it('renders disconnected state with reconnect option', async () => {