@nyaruka/temba-components 0.130.4 → 0.130.5

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 (90) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/demo/sortable-rules-demo.html +155 -0
  3. package/dist/temba-components.js +132 -142
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/events.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasNode.js +13 -7
  7. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  8. package/out-tsc/src/flow/actions/send_msg.js +1 -0
  9. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  10. package/out-tsc/src/flow/nodes/split_by_groups.js +149 -1
  11. package/out-tsc/src/flow/nodes/split_by_groups.js.map +1 -1
  12. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +1 -0
  13. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  14. package/out-tsc/src/flow/nodes/wait_for_response.js +81 -75
  15. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  16. package/out-tsc/src/form/ArrayEditor.js +106 -28
  17. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  18. package/out-tsc/src/form/select/Select.js +21 -25
  19. package/out-tsc/src/form/select/Select.js.map +1 -1
  20. package/out-tsc/src/list/SortableList.js +214 -140
  21. package/out-tsc/src/list/SortableList.js.map +1 -1
  22. package/out-tsc/src/live/ContactChat.js +9 -5
  23. package/out-tsc/src/live/ContactChat.js.map +1 -1
  24. package/out-tsc/test/nodes/split_by_groups.test.js +130 -0
  25. package/out-tsc/test/nodes/split_by_groups.test.js.map +1 -0
  26. package/out-tsc/test/nodes/wait_for_response.test.js +149 -0
  27. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
  28. package/out-tsc/test/temba-field-config.test.js +56 -0
  29. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  30. package/package.json +1 -1
  31. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  32. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  33. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  34. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  35. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  36. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  37. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  38. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  39. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  40. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  41. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  42. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  43. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  44. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  45. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  46. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  47. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  48. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  49. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  50. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  51. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  52. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  53. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  54. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  55. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  56. package/screenshots/truth/editor/wait.png +0 -0
  57. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  58. package/screenshots/truth/list/fields-dragging.png +0 -0
  59. package/screenshots/truth/list/sortable-dragging.png +0 -0
  60. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  61. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  62. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  63. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  64. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  65. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  66. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  67. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  68. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  69. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  70. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  71. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  72. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  73. package/screenshots/truth/select/search-enabled.png +0 -0
  74. package/screenshots/truth/select/search-selected-focus.png +0 -0
  75. package/screenshots/truth/select/search-selected.png +0 -0
  76. package/screenshots/truth/templates/default.png +0 -0
  77. package/screenshots/truth/templates/unapproved.png +0 -0
  78. package/src/events.ts +6 -6
  79. package/src/flow/CanvasNode.ts +15 -13
  80. package/src/flow/actions/send_msg.ts +1 -0
  81. package/src/flow/nodes/split_by_groups.ts +190 -1
  82. package/src/flow/nodes/split_by_llm_categorize.ts +1 -0
  83. package/src/flow/nodes/wait_for_response.ts +98 -74
  84. package/src/form/ArrayEditor.ts +112 -28
  85. package/src/form/select/Select.ts +24 -25
  86. package/src/list/SortableList.ts +250 -149
  87. package/src/live/ContactChat.ts +11 -5
  88. package/test/nodes/split_by_groups.test.ts +165 -0
  89. package/test/nodes/wait_for_response.test.ts +182 -0
  90. package/test/temba-field-config.test.ts +69 -0
@@ -0,0 +1,165 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { split_by_groups } from '../../src/flow/nodes/split_by_groups';
3
+ import { Node } from '../../src/store/flow-definition.d';
4
+
5
+ describe('temba-split-by-groups', () => {
6
+ it('should transform from flow definition to form data correctly', () => {
7
+ const node: Node = {
8
+ uuid: 'test-node-uuid',
9
+ actions: [],
10
+ router: {
11
+ type: 'switch',
12
+ cases: [
13
+ {
14
+ uuid: 'case-1',
15
+ type: 'has_group',
16
+ arguments: ['group-uuid-1', 'Group 1'],
17
+ category_uuid: 'cat-1'
18
+ },
19
+ {
20
+ uuid: 'case-2',
21
+ type: 'has_group',
22
+ arguments: ['group-uuid-2', 'Group 2'],
23
+ category_uuid: 'cat-2'
24
+ }
25
+ ],
26
+ categories: [
27
+ { uuid: 'cat-1', name: 'Group 1', exit_uuid: 'exit-1' },
28
+ { uuid: 'cat-2', name: 'Group 2', exit_uuid: 'exit-2' },
29
+ { uuid: 'cat-other', name: 'Other', exit_uuid: 'exit-other' }
30
+ ],
31
+ default_category_uuid: 'cat-other',
32
+ operand: '@contact.groups',
33
+ result_name: ''
34
+ },
35
+ exits: [
36
+ { uuid: 'exit-1', destination_uuid: null },
37
+ { uuid: 'exit-2', destination_uuid: null },
38
+ { uuid: 'exit-other', destination_uuid: null }
39
+ ]
40
+ };
41
+
42
+ const formData = split_by_groups.toFormData!(node);
43
+
44
+ expect(formData.uuid).to.equal('test-node-uuid');
45
+ expect(formData.groups).to.have.lengthOf(2);
46
+ expect(formData.groups[0]).to.deep.equal({
47
+ uuid: 'group-uuid-1',
48
+ name: 'Group 1'
49
+ });
50
+ expect(formData.groups[1]).to.deep.equal({
51
+ uuid: 'group-uuid-2',
52
+ name: 'Group 2'
53
+ });
54
+ });
55
+
56
+ it('should transform from form data to flow definition correctly', () => {
57
+ const originalNode: Node = {
58
+ uuid: 'test-node-uuid',
59
+ actions: [],
60
+ exits: []
61
+ };
62
+
63
+ const formData = {
64
+ uuid: 'test-node-uuid',
65
+ groups: [
66
+ { uuid: 'group-uuid-1', name: 'Group 1' },
67
+ { uuid: 'group-uuid-2', name: 'Group 2' }
68
+ ]
69
+ };
70
+
71
+ const resultNode = split_by_groups.fromFormData!(formData, originalNode);
72
+
73
+ expect(resultNode.uuid).to.equal('test-node-uuid');
74
+ expect(resultNode.router!.type).to.equal('switch');
75
+ expect(resultNode.router!.operand).to.equal('@contact.groups');
76
+ expect(resultNode.router!.cases).to.have.lengthOf(2);
77
+ expect(resultNode.router!.categories).to.have.lengthOf(3); // 2 groups + Other
78
+ expect(resultNode.exits).to.have.lengthOf(3); // 2 groups + Other
79
+
80
+ // Check first group case
81
+ const case1 = resultNode.router!.cases![0];
82
+ expect(case1.type).to.equal('has_group');
83
+ expect(case1.arguments).to.deep.equal(['group-uuid-1', 'Group 1']);
84
+
85
+ // Check that "Other" category exists
86
+ const otherCategory = resultNode.router!.categories!.find(
87
+ (cat) => cat.name === 'Other'
88
+ );
89
+ expect(otherCategory).to.exist;
90
+ expect(resultNode.router!.default_category_uuid).to.equal(
91
+ otherCategory!.uuid
92
+ );
93
+ });
94
+
95
+ it('should validate form data correctly', () => {
96
+ // Valid form data
97
+ const validData = {
98
+ groups: [{ uuid: 'group-uuid-1', name: 'Group 1' }]
99
+ };
100
+
101
+ const validResult = split_by_groups.validate!(validData);
102
+ expect(validResult.valid).to.be.true;
103
+ expect(Object.keys(validResult.errors)).to.have.lengthOf(0);
104
+
105
+ // Invalid form data - no groups
106
+ const invalidData = {
107
+ groups: []
108
+ };
109
+
110
+ const invalidResult = split_by_groups.validate!(invalidData);
111
+ expect(invalidResult.valid).to.be.false;
112
+ expect(invalidResult.errors.groups).to.equal(
113
+ 'At least one group is required'
114
+ );
115
+
116
+ // Invalid form data - missing groups
117
+ const missingGroupsData = {};
118
+
119
+ const missingResult = split_by_groups.validate!(missingGroupsData);
120
+ expect(missingResult.valid).to.be.false;
121
+ expect(missingResult.errors.groups).to.equal(
122
+ 'At least one group is required'
123
+ );
124
+ });
125
+
126
+ it('should handle arbitrary groups correctly', () => {
127
+ const originalNode: Node = {
128
+ uuid: 'test-node-uuid',
129
+ actions: [],
130
+ exits: []
131
+ };
132
+
133
+ const formData = {
134
+ uuid: 'test-node-uuid',
135
+ groups: [
136
+ { uuid: 'group-uuid-1', name: 'Existing Group' },
137
+ { name: 'New Group', arbitrary: true }
138
+ ]
139
+ };
140
+
141
+ const resultNode = split_by_groups.fromFormData!(formData, originalNode);
142
+
143
+ expect(resultNode.router!.cases).to.have.lengthOf(2);
144
+
145
+ // Check existing group
146
+ const existingGroupCase = resultNode.router!.cases!.find(
147
+ (c) => c.arguments![0] === 'group-uuid-1'
148
+ );
149
+ expect(existingGroupCase).to.exist;
150
+ expect(existingGroupCase!.arguments).to.deep.equal([
151
+ 'group-uuid-1',
152
+ 'Existing Group'
153
+ ]);
154
+
155
+ // Check arbitrary group (should have generated UUID)
156
+ const arbitraryGroupCase = resultNode.router!.cases!.find(
157
+ (c) => c.arguments![1] === 'New Group'
158
+ );
159
+ expect(arbitraryGroupCase).to.exist;
160
+ expect(arbitraryGroupCase!.arguments![0]).to.match(
161
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
162
+ );
163
+ expect(arbitraryGroupCase!.arguments![1]).to.equal('New Group');
164
+ });
165
+ });
@@ -755,5 +755,187 @@ describe('wait_for_response node config', () => {
755
755
  );
756
756
  expect(secondCases).to.have.length(1);
757
757
  });
758
+
759
+ it('preserves category UUIDs when rules are reordered', () => {
760
+ const originalNode = {
761
+ uuid: 'test-node',
762
+ actions: [],
763
+ router: {
764
+ type: 'switch' as const,
765
+ operand: '@input.text',
766
+ categories: [
767
+ {
768
+ uuid: 'yes-category-uuid',
769
+ name: 'Yes',
770
+ exit_uuid: 'yes-exit-uuid'
771
+ },
772
+ {
773
+ uuid: 'no-category-uuid',
774
+ name: 'No',
775
+ exit_uuid: 'no-exit-uuid'
776
+ },
777
+ {
778
+ uuid: 'other-category-uuid',
779
+ name: 'Other',
780
+ exit_uuid: 'other-exit-uuid'
781
+ }
782
+ ],
783
+ cases: [
784
+ {
785
+ uuid: 'yes-case-uuid',
786
+ type: 'has_phrase',
787
+ arguments: ['yes'],
788
+ category_uuid: 'yes-category-uuid'
789
+ },
790
+ {
791
+ uuid: 'no-case-uuid',
792
+ type: 'has_phrase',
793
+ arguments: ['no'],
794
+ category_uuid: 'no-category-uuid'
795
+ }
796
+ ]
797
+ },
798
+ exits: [
799
+ { uuid: 'yes-exit-uuid', destination_uuid: null },
800
+ { uuid: 'no-exit-uuid', destination_uuid: null },
801
+ { uuid: 'other-exit-uuid', destination_uuid: null }
802
+ ]
803
+ };
804
+
805
+ // Reorder the rules - put "No" first, "Yes" second
806
+ const formData = {
807
+ uuid: 'test-node',
808
+ result_name: 'response',
809
+ rules: [
810
+ {
811
+ operator: { value: 'has_phrase', name: 'contains phrase' },
812
+ value1: 'no',
813
+ category: 'No' // This rule was originally second
814
+ },
815
+ {
816
+ operator: { value: 'has_phrase', name: 'contains phrase' },
817
+ value1: 'yes',
818
+ category: 'Yes' // This rule was originally first
819
+ }
820
+ ]
821
+ };
822
+
823
+ const result = wait_for_response.fromFormData!(formData, originalNode);
824
+
825
+ // Verify that categories keep their original UUIDs despite reordering
826
+ expect(result.router?.categories).to.have.length(3); // Two rules + Other
827
+
828
+ const yesCategory = result.router!.categories.find(
829
+ (cat) => cat.name === 'Yes'
830
+ );
831
+ const noCategory = result.router!.categories.find(
832
+ (cat) => cat.name === 'No'
833
+ );
834
+ const otherCategory = result.router!.categories.find(
835
+ (cat) => cat.name === 'Other'
836
+ );
837
+
838
+ // Categories should preserve their original UUIDs
839
+ expect(yesCategory?.uuid).to.equal('yes-category-uuid');
840
+ expect(yesCategory?.exit_uuid).to.equal('yes-exit-uuid');
841
+
842
+ expect(noCategory?.uuid).to.equal('no-category-uuid');
843
+ expect(noCategory?.exit_uuid).to.equal('no-exit-uuid');
844
+
845
+ expect(otherCategory?.uuid).to.equal('other-category-uuid');
846
+ expect(otherCategory?.exit_uuid).to.equal('other-exit-uuid');
847
+
848
+ // Verify the cases are created in the new order but reference correct categories
849
+ expect(result.router?.cases).to.have.length(2);
850
+
851
+ const firstCase = result.router!.cases[0];
852
+ const secondCase = result.router!.cases[1];
853
+
854
+ // First case should be "no" and reference the No category
855
+ expect(firstCase.arguments).to.deep.equal(['no']);
856
+ expect(firstCase.category_uuid).to.equal('no-category-uuid');
857
+
858
+ // Second case should be "yes" and reference the Yes category
859
+ expect(secondCase.arguments).to.deep.equal(['yes']);
860
+ expect(secondCase.category_uuid).to.equal('yes-category-uuid');
861
+ });
862
+
863
+ it('preserves rule order when rules have duplicate categories', () => {
864
+ const formData = {
865
+ uuid: 'test-node',
866
+ result_name: 'response',
867
+ rules: [
868
+ {
869
+ operator: { value: 'has_phrase', name: 'contains phrase' },
870
+ value1: 'first',
871
+ category: 'Shared'
872
+ },
873
+ {
874
+ operator: { value: 'has_phrase', name: 'contains phrase' },
875
+ value1: 'middle',
876
+ category: 'Different'
877
+ },
878
+ {
879
+ operator: { value: 'has_phrase', name: 'contains phrase' },
880
+ value1: 'last',
881
+ category: 'Shared' // Same as first rule
882
+ }
883
+ ]
884
+ };
885
+
886
+ const originalNode: Node = {
887
+ uuid: 'test-node',
888
+ actions: [],
889
+ router: {
890
+ type: 'switch',
891
+ result_name: 'response',
892
+ categories: [],
893
+ cases: []
894
+ },
895
+ exits: []
896
+ };
897
+
898
+ const result = wait_for_response.fromFormData!(formData, originalNode);
899
+
900
+ // Should have 3 cases in the same order as the input rules
901
+ expect(result.router?.cases).to.have.length(3);
902
+
903
+ const cases = result.router!.cases;
904
+
905
+ // First case should be "first"
906
+ expect(cases[0].arguments).to.deep.equal(['first']);
907
+ expect(cases[0].type).to.equal('has_phrase');
908
+
909
+ // Second case should be "middle"
910
+ expect(cases[1].arguments).to.deep.equal(['middle']);
911
+ expect(cases[1].type).to.equal('has_phrase');
912
+
913
+ // Third case should be "last"
914
+ expect(cases[2].arguments).to.deep.equal(['last']);
915
+ expect(cases[2].type).to.equal('has_phrase');
916
+
917
+ // Find the shared category
918
+ const sharedCategory = result.router!.categories.find(
919
+ (cat) => cat.name === 'Shared'
920
+ );
921
+ const differentCategory = result.router!.categories.find(
922
+ (cat) => cat.name === 'Different'
923
+ );
924
+
925
+ expect(sharedCategory).to.exist;
926
+ expect(differentCategory).to.exist;
927
+
928
+ // First and third cases should reference the same "Shared" category
929
+ expect(cases[0].category_uuid).to.equal(sharedCategory!.uuid);
930
+ expect(cases[2].category_uuid).to.equal(sharedCategory!.uuid);
931
+
932
+ // Second case should reference the "Different" category
933
+ expect(cases[1].category_uuid).to.equal(differentCategory!.uuid);
934
+
935
+ // Should have 3 categories: Shared, Different, Other
936
+ expect(result.router?.categories).to.have.length(3);
937
+ const categoryNames = result.router!.categories.map((cat) => cat.name);
938
+ expect(categoryNames).to.deep.equal(['Shared', 'Different', 'Other']);
939
+ });
758
940
  });
759
941
  });
@@ -150,5 +150,74 @@ describe('Field Configuration System', () => {
150
150
  // Expects 3 items: 2 initial items + 1 auto-generated empty item
151
151
  expect(items?.length).to.equal(3);
152
152
  });
153
+
154
+ it('should render with sortable list when sortable=true', async () => {
155
+ const itemConfig = {
156
+ operator: { type: 'text', label: 'Operator' },
157
+ value: { type: 'text', label: 'Value' }
158
+ };
159
+
160
+ const initialValue = [
161
+ { operator: 'equals', value: 'test' },
162
+ { operator: 'contains', value: 'example' }
163
+ ];
164
+
165
+ const el = await fixture(html`
166
+ <temba-array-editor
167
+ .value=${initialValue}
168
+ .itemConfig=${itemConfig}
169
+ .sortable=${true}
170
+ itemLabel="Rule"
171
+ ></temba-array-editor>
172
+ `);
173
+
174
+ await (el as any).updateComplete;
175
+
176
+ expect(el).to.exist;
177
+
178
+ // Should have a sortable list component
179
+ const sortableList = el.shadowRoot?.querySelector('temba-sortable-list');
180
+ expect(sortableList).to.exist;
181
+
182
+ // Should have sortable items with proper classes and IDs
183
+ const sortableItems = el.shadowRoot?.querySelectorAll('.sortable');
184
+ expect(sortableItems?.length).to.equal(2); // Only non-empty items should be sortable
185
+
186
+ // Each sortable item should have a unique ID
187
+ const firstItem = sortableItems?.[0] as HTMLElement;
188
+ const secondItem = sortableItems?.[1] as HTMLElement;
189
+ expect(firstItem?.id).to.equal('array-item-0');
190
+ expect(secondItem?.id).to.equal('array-item-1');
191
+ });
192
+
193
+ it('should not render sortable list when sortable=false', async () => {
194
+ const itemConfig = {
195
+ operator: { type: 'text', label: 'Operator' },
196
+ value: { type: 'text', label: 'Value' }
197
+ };
198
+
199
+ const initialValue = [{ operator: 'equals', value: 'test' }];
200
+
201
+ const el = await fixture(html`
202
+ <temba-array-editor
203
+ .value=${initialValue}
204
+ .itemConfig=${itemConfig}
205
+ .sortable=${false}
206
+ itemLabel="Rule"
207
+ ></temba-array-editor>
208
+ `);
209
+
210
+ await (el as any).updateComplete;
211
+
212
+ expect(el).to.exist;
213
+
214
+ // Should not have a sortable list component
215
+ const sortableList = el.shadowRoot?.querySelector('temba-sortable-list');
216
+ expect(sortableList).to.not.exist;
217
+
218
+ // Should have regular list container instead
219
+ const listContainer = el.shadowRoot?.querySelector('.list-items');
220
+ expect(listContainer).to.exist;
221
+ });
153
222
  });
154
223
  });