@nyaruka/temba-components 0.129.8 → 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 (203) hide show
  1. package/CHANGELOG.md +27 -3
  2. package/demo/data/flows/sample-flow.json +186 -96
  3. package/dist/temba-components.js +414 -351
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/events.js.map +1 -1
  6. package/out-tsc/src/excellent/helpers.js +2 -2
  7. package/out-tsc/src/excellent/helpers.js.map +1 -1
  8. package/out-tsc/src/flow/CanvasNode.js +25 -7
  9. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  10. package/out-tsc/src/flow/Editor.js +11 -1
  11. package/out-tsc/src/flow/Editor.js.map +1 -1
  12. package/out-tsc/src/flow/NodeEditor.js +133 -290
  13. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  14. package/out-tsc/src/flow/actions/add_input_labels.js +40 -0
  15. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  16. package/out-tsc/src/flow/actions/call_llm.js +56 -3
  17. package/out-tsc/src/flow/actions/call_llm.js.map +1 -1
  18. package/out-tsc/src/flow/actions/call_webhook.js +1 -1
  19. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -1
  20. package/out-tsc/src/flow/actions/open_ticket.js +65 -3
  21. package/out-tsc/src/flow/actions/open_ticket.js.map +1 -1
  22. package/out-tsc/src/flow/actions/set_run_result.js +75 -0
  23. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  24. package/out-tsc/src/flow/config.js +4 -0
  25. package/out-tsc/src/flow/config.js.map +1 -1
  26. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +227 -0
  27. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -0
  28. package/out-tsc/src/flow/nodes/split_by_ticket.js +18 -0
  29. package/out-tsc/src/flow/nodes/split_by_ticket.js.map +1 -0
  30. package/out-tsc/src/flow/nodes/wait_for_response.js +27 -1
  31. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  32. package/out-tsc/src/flow/types.js +0 -65
  33. package/out-tsc/src/flow/types.js.map +1 -1
  34. package/out-tsc/src/form/ArrayEditor.js +18 -61
  35. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  36. package/out-tsc/src/form/FieldRenderer.js +305 -0
  37. package/out-tsc/src/form/FieldRenderer.js.map +1 -0
  38. package/out-tsc/src/form/FormField.js +3 -3
  39. package/out-tsc/src/form/FormField.js.map +1 -1
  40. package/out-tsc/src/form/TextInput.js +1 -1
  41. package/out-tsc/src/form/TextInput.js.map +1 -1
  42. package/out-tsc/src/form/select/Select.js +48 -20
  43. package/out-tsc/src/form/select/Select.js.map +1 -1
  44. package/out-tsc/src/live/ContactChat.js +39 -13
  45. package/out-tsc/src/live/ContactChat.js.map +1 -1
  46. package/out-tsc/src/markdown.js +13 -11
  47. package/out-tsc/src/markdown.js.map +1 -1
  48. package/out-tsc/test/ActionHelper.js +2 -0
  49. package/out-tsc/test/ActionHelper.js.map +1 -1
  50. package/out-tsc/test/NodeHelper.js +148 -0
  51. package/out-tsc/test/NodeHelper.js.map +1 -0
  52. package/out-tsc/test/actions/call_llm.test.js +103 -0
  53. package/out-tsc/test/actions/call_llm.test.js.map +1 -0
  54. package/out-tsc/test/nodes/split_by_llm_categorize.test.js +532 -0
  55. package/out-tsc/test/nodes/split_by_llm_categorize.test.js.map +1 -0
  56. package/out-tsc/test/nodes/split_by_random.test.js +150 -0
  57. package/out-tsc/test/nodes/split_by_random.test.js.map +1 -0
  58. package/out-tsc/test/nodes/wait_for_digits.test.js +150 -0
  59. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -0
  60. package/out-tsc/test/nodes/wait_for_response.test.js +171 -0
  61. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -0
  62. package/out-tsc/test/temba-add-input-labels.test.js +70 -0
  63. package/out-tsc/test/temba-add-input-labels.test.js.map +1 -0
  64. package/out-tsc/test/temba-field-renderer.test.js +296 -0
  65. package/out-tsc/test/temba-field-renderer.test.js.map +1 -0
  66. package/out-tsc/test/temba-markdown.test.js +1 -1
  67. package/out-tsc/test/temba-markdown.test.js.map +1 -1
  68. package/out-tsc/test/temba-node-editor.test.js +400 -0
  69. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  70. package/out-tsc/test/temba-select.test.js +6 -3
  71. package/out-tsc/test/temba-select.test.js.map +1 -1
  72. package/out-tsc/test/temba-webchat.test.js +1 -1
  73. package/out-tsc/test/temba-webchat.test.js.map +1 -1
  74. package/package.json +1 -1
  75. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  76. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  77. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  78. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  79. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  80. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  81. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  82. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  83. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  84. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  85. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  86. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  87. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  88. package/screenshots/truth/actions/remove_contact_groups/editor/remove-from-all-groups.png +0 -0
  89. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  90. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  91. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  92. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  93. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  94. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  95. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  96. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  97. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  98. package/screenshots/truth/editor/router.png +0 -0
  99. package/screenshots/truth/editor/send_msg.png +0 -0
  100. package/screenshots/truth/editor/set_contact_language.png +0 -0
  101. package/screenshots/truth/editor/set_contact_name.png +0 -0
  102. package/screenshots/truth/editor/set_run_result.png +0 -0
  103. package/screenshots/truth/editor/wait.png +0 -0
  104. package/screenshots/truth/field-renderer/checkbox-checked.png +0 -0
  105. package/screenshots/truth/field-renderer/checkbox-unchecked.png +0 -0
  106. package/screenshots/truth/field-renderer/checkbox-with-errors.png +0 -0
  107. package/screenshots/truth/field-renderer/context-comparison.png +0 -0
  108. package/screenshots/truth/field-renderer/key-value-with-label.png +0 -0
  109. package/screenshots/truth/field-renderer/message-editor-with-label.png +0 -0
  110. package/screenshots/truth/field-renderer/select-multi.png +0 -0
  111. package/screenshots/truth/field-renderer/select-no-label.png +0 -0
  112. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  113. package/screenshots/truth/field-renderer/text-evaluated.png +0 -0
  114. package/screenshots/truth/field-renderer/text-no-label.png +0 -0
  115. package/screenshots/truth/field-renderer/text-with-errors.png +0 -0
  116. package/screenshots/truth/field-renderer/text-with-label.png +0 -0
  117. package/screenshots/truth/field-renderer/textarea-evaluated.png +0 -0
  118. package/screenshots/truth/field-renderer/textarea-with-label.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.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/editor/ab-test-multiple-variants.png +0 -0
  130. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  131. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  132. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  133. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  134. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  135. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  136. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  141. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  142. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  143. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  144. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  145. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  146. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  147. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  148. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  149. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  150. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  152. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  153. package/screenshots/truth/omnibox/selected.png +0 -0
  154. package/screenshots/truth/select/functions.png +0 -0
  155. package/screenshots/truth/select/multi-with-endpoint.png +0 -0
  156. package/screenshots/truth/select/search-enabled.png +0 -0
  157. package/src/events.ts +8 -1
  158. package/src/excellent/helpers.ts +2 -2
  159. package/src/flow/CanvasNode.ts +22 -1
  160. package/src/flow/Editor.ts +12 -1
  161. package/src/flow/NodeEditor.ts +186 -374
  162. package/src/flow/actions/add_input_labels.ts +45 -0
  163. package/src/flow/actions/call_llm.ts +57 -3
  164. package/src/flow/actions/call_webhook.ts +1 -1
  165. package/src/flow/actions/open_ticket.ts +74 -3
  166. package/src/flow/actions/set_run_result.ts +83 -0
  167. package/src/flow/config.ts +4 -0
  168. package/src/flow/nodes/split_by_llm_categorize.ts +277 -0
  169. package/src/flow/nodes/split_by_ticket.ts +19 -0
  170. package/src/flow/nodes/wait_for_response.ts +28 -1
  171. package/src/flow/types.ts +26 -127
  172. package/src/form/ArrayEditor.ts +34 -82
  173. package/src/form/FieldRenderer.ts +465 -0
  174. package/src/form/FormField.ts +3 -3
  175. package/src/form/TextInput.ts +1 -1
  176. package/src/form/select/Select.ts +51 -20
  177. package/src/live/ContactChat.ts +39 -15
  178. package/src/markdown.ts +19 -11
  179. package/src/store/flow-definition.d.ts +5 -2
  180. package/static/api/labels.json +31 -0
  181. package/static/api/topics.json +24 -9
  182. package/static/api/users.json +35 -16
  183. package/static/css/temba-components.css +3 -3
  184. package/stress-test.js +18 -13
  185. package/test/ActionHelper.ts +2 -0
  186. package/test/NodeHelper.ts +184 -0
  187. package/test/actions/call_llm.test.ts +137 -0
  188. package/test/nodes/README.md +78 -0
  189. package/test/nodes/split_by_llm_categorize.test.ts +698 -0
  190. package/test/nodes/split_by_random.test.ts +177 -0
  191. package/test/nodes/wait_for_digits.test.ts +176 -0
  192. package/test/nodes/wait_for_response.test.ts +206 -0
  193. package/test/temba-add-input-labels.test.ts +87 -0
  194. package/test/temba-field-renderer.test.ts +482 -0
  195. package/test/temba-markdown.test.ts +1 -1
  196. package/test/temba-node-editor.test.ts +496 -0
  197. package/test/temba-select.test.ts +6 -6
  198. package/test/temba-webchat.test.ts +1 -1
  199. package/test-assets/select/llms.json +18 -0
  200. package/web-dev-mock.mjs +96 -6
  201. package/web-dev-server.config.mjs +29 -7
  202. package/test/temba-flow-editor.test.ts.backup +0 -563
  203. package/test/temba-utils-index.test.ts.backup +0 -1737
@@ -444,4 +444,500 @@ describe('temba-node-editor', () => {
444
444
  // Should have arrows for collapsible groups
445
445
  expect(arrows.length).to.be.greaterThan(0);
446
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
+ });
447
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([], {
@@ -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
@@ -202,7 +202,7 @@ describe('temba-webchat', () => {
202
202
  expect(webChat.open).to.equal(true);
203
203
  expect(webChat.status).to.equal('connecting');
204
204
 
205
- await assertScreenshot('webchat/connecting-state', getClip(webChat));
205
+ // await assertScreenshot('webchat/connecting-state', getClip(webChat));
206
206
  });
207
207
 
208
208
  it('renders disconnected state with reconnect option', async () => {
@@ -0,0 +1,18 @@
1
+ {
2
+ "next": null,
3
+ "previous": null,
4
+ "results": [
5
+ {
6
+ "uuid": "2399e7d6-fcdf-4e47-a835-f3bdb7f80938",
7
+ "name": "GPT 4.1",
8
+ "value": "2399e7d6-fcdf-4e47-a835-f3bdb7f80938",
9
+ "type": "openai"
10
+ },
11
+ {
12
+ "uuid": "4399e7d6-fcdf-4e47-a835-f3bdb7f80938",
13
+ "name": "GPT 5",
14
+ "value": "4399e7d6-fcdf-4e47-a835-f3bdb7f80938",
15
+ "type": "openai"
16
+ }
17
+ ]
18
+ }
package/web-dev-mock.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Client as MinioClient } from 'minio';
2
2
  import busboy from 'busboy';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
+ import fs from 'fs';
4
5
 
5
6
  /**
6
7
  * Generates FlowInfo dynamically from a FlowDefinition
@@ -271,10 +272,11 @@ function extractDependenciesFromAction(action, dependencyMap, resultMap, nodeUui
271
272
  }
272
273
  } else {
273
274
  // Create new result
275
+ const categories = action.category ? [action.category] : ['All Responses'];
274
276
  resultMap.set(action.name, {
275
- key: action.name.toLowerCase(),
277
+ key: action.name.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
276
278
  name: action.name,
277
- categories: action.category ? [action.category] : [],
279
+ categories: categories,
278
280
  node_uuids: [nodeUuid]
279
281
  });
280
282
  }
@@ -288,14 +290,20 @@ function extractDependenciesFromRouter(router, dependencyMap, resultMap, nodeUui
288
290
  if (router.result_name && router.categories) {
289
291
  const existingResult = resultMap.get(router.result_name);
290
292
  if (existingResult) {
291
- // Add this node to existing result
292
- existingResult.node_uuids.push(nodeUuid);
293
+ // Add this node to existing result if not already present
294
+ if (!existingResult.node_uuids.includes(nodeUuid)) {
295
+ existingResult.node_uuids.push(nodeUuid);
296
+ }
293
297
  } else {
294
298
  // Create new result
299
+ const categories = router.categories.length > 0
300
+ ? router.categories.map((cat) => cat.name)
301
+ : ['All Responses'];
302
+
295
303
  const result = {
296
- key: router.result_name,
304
+ key: router.result_name.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
297
305
  name: router.result_name,
298
- categories: router.categories.map((cat) => cat.name),
306
+ categories: categories,
299
307
  node_uuids: [nodeUuid]
300
308
  };
301
309
  resultMap.set(router.result_name, result);
@@ -431,3 +439,85 @@ export function handleMinioUpload(context) {
431
439
  }
432
440
  });
433
441
  }
442
+
443
+ // Handle label creation for the labels API
444
+ export function handleLabelCreation(context) {
445
+ return new Promise((resolve) => {
446
+ let body = '';
447
+
448
+ context.req.on('data', chunk => {
449
+ body += chunk.toString();
450
+ });
451
+
452
+ context.req.on('end', () => {
453
+ try {
454
+ const requestData = JSON.parse(body);
455
+ const labelName = requestData.name || '';
456
+
457
+ if (!labelName.trim()) {
458
+ context.status = 400;
459
+ context.body = JSON.stringify({ error: 'Label name is required' });
460
+ resolve();
461
+ return;
462
+ }
463
+
464
+ // Read existing labels file
465
+ const labelsPath = './static/api/labels.json';
466
+ let labelsData;
467
+
468
+ try {
469
+ labelsData = JSON.parse(fs.readFileSync(labelsPath, 'utf-8'));
470
+ } catch (error) {
471
+ // If file doesn't exist, create basic structure
472
+ labelsData = {
473
+ next: null,
474
+ previous: null,
475
+ results: []
476
+ };
477
+ }
478
+
479
+ // Check if label already exists
480
+ const existingLabel = labelsData.results.find(
481
+ label => label.name.toLowerCase() === labelName.trim().toLowerCase()
482
+ );
483
+
484
+ if (existingLabel) {
485
+ // Return existing label
486
+ context.contentType = 'application/json';
487
+ context.body = JSON.stringify(existingLabel);
488
+ resolve();
489
+ return;
490
+ }
491
+
492
+ // Create new label with UUID
493
+ const newLabel = {
494
+ uuid: uuidv4(),
495
+ name: labelName.trim(),
496
+ count: 0
497
+ };
498
+
499
+ // Add to labels data
500
+ labelsData.results.push(newLabel);
501
+
502
+ // Write back to file
503
+ fs.writeFileSync(labelsPath, JSON.stringify(labelsData, null, 2));
504
+
505
+ // Return the new label
506
+ context.contentType = 'application/json';
507
+ context.body = JSON.stringify(newLabel);
508
+
509
+ console.log('📝 Label created:', newLabel);
510
+
511
+ } catch (error) {
512
+ console.error('Label creation error:', error);
513
+ context.status = 500;
514
+ context.body = JSON.stringify({
515
+ error: 'Label creation failed',
516
+ details: error.message
517
+ });
518
+ }
519
+
520
+ resolve();
521
+ });
522
+ });
523
+ }