@nyaruka/temba-components 0.130.4 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +10 -13
  2. package/demo/sortable-rules-demo.html +155 -0
  3. package/dist/temba-components.js +150 -159
  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/split_by_random.js +1 -0
  15. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  16. package/out-tsc/src/flow/nodes/wait_for_response.js +332 -137
  17. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  18. package/out-tsc/src/form/ArrayEditor.js +301 -30
  19. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  20. package/out-tsc/src/form/select/Omnibox.js +4 -0
  21. package/out-tsc/src/form/select/Omnibox.js.map +1 -1
  22. package/out-tsc/src/form/select/Select.js +21 -25
  23. package/out-tsc/src/form/select/Select.js.map +1 -1
  24. package/out-tsc/src/list/SortableList.js +214 -140
  25. package/out-tsc/src/list/SortableList.js.map +1 -1
  26. package/out-tsc/src/live/ContactChat.js +9 -5
  27. package/out-tsc/src/live/ContactChat.js.map +1 -1
  28. package/out-tsc/test/nodes/split_by_groups.test.js +130 -0
  29. package/out-tsc/test/nodes/split_by_groups.test.js.map +1 -0
  30. package/out-tsc/test/nodes/wait_for_response.test.js +522 -8
  31. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
  32. package/out-tsc/test/temba-field-config.test.js +56 -0
  33. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  34. package/package.json +1 -1
  35. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  36. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  37. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  38. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  39. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  40. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  41. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  42. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  43. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  44. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  45. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  46. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  47. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  48. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  49. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  50. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  51. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  52. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  53. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  54. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  55. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  56. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  57. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  58. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  59. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  60. package/screenshots/truth/editor/wait.png +0 -0
  61. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  62. package/screenshots/truth/list/fields-dragging.png +0 -0
  63. package/screenshots/truth/list/sortable-dragging.png +0 -0
  64. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  65. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  66. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  67. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  68. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  69. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  70. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  71. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  72. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  73. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  74. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  75. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  76. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  77. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  78. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  79. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  80. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  81. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  82. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  83. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  84. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  85. package/screenshots/truth/select/search-enabled.png +0 -0
  86. package/screenshots/truth/select/search-selected-focus.png +0 -0
  87. package/screenshots/truth/select/search-selected.png +0 -0
  88. package/screenshots/truth/templates/default.png +0 -0
  89. package/screenshots/truth/templates/unapproved.png +0 -0
  90. package/src/events.ts +6 -6
  91. package/src/flow/CanvasNode.ts +15 -13
  92. package/src/flow/actions/send_msg.ts +1 -0
  93. package/src/flow/nodes/split_by_groups.ts +190 -1
  94. package/src/flow/nodes/split_by_llm_categorize.ts +1 -0
  95. package/src/flow/nodes/split_by_random.ts +1 -0
  96. package/src/flow/nodes/wait_for_response.ts +424 -145
  97. package/src/form/ArrayEditor.ts +372 -30
  98. package/src/form/select/Omnibox.ts +3 -0
  99. package/src/form/select/Select.ts +24 -25
  100. package/src/list/SortableList.ts +250 -149
  101. package/src/live/ContactChat.ts +11 -5
  102. package/test/nodes/split_by_groups.test.ts +165 -0
  103. package/test/nodes/wait_for_response.test.ts +608 -8
  104. package/test/temba-field-config.test.ts +69 -0
package/src/events.ts CHANGED
@@ -57,13 +57,13 @@ export interface ChatStartedEvent extends ContactEvent {
57
57
 
58
58
  export interface MsgEvent extends ContactEvent {
59
59
  msg: Msg;
60
- status: string;
61
- failed_reason?: string;
62
- failed_reason_display?: string;
63
- logs_url: string;
64
- recipient_count?: number;
65
- created_by?: User;
66
60
  optin?: ObjectReference;
61
+ _status?: string;
62
+ _failed_reason?: string;
63
+ _logs_url?: string;
64
+ status?: string; // deprecated
65
+ failed_reason_display?: string; // deprecated
66
+ logs_url?: string; // deprecated
67
67
  }
68
68
 
69
69
  export interface RunEvent extends ContactEvent {
@@ -66,8 +66,14 @@ export class CanvasNode extends RapidElement {
66
66
  max-width: 200px;
67
67
  }
68
68
 
69
- .node:hover {
70
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
69
+ .node .action:last-child {
70
+ border-bottom-left-radius: var(--curvature);
71
+ border-bottom-right-radius: var(--curvature);
72
+ }
73
+
74
+ .node .action:first-child {
75
+ border-top-left-radius: var(--curvature);
76
+ border-top-right-radius: var(--curvature);
71
77
  }
72
78
 
73
79
  .node.dragging {
@@ -226,8 +232,8 @@ export class CanvasNode extends RapidElement {
226
232
  }
227
233
 
228
234
  .action-exits {
229
- padding-bottom: 0.6em;
230
- margin-top: -0.8em;
235
+ padding-bottom: 0.7em;
236
+ margin-top: -0.7em;
231
237
  }
232
238
 
233
239
  .category .cn-title {
@@ -860,7 +866,7 @@ export class CanvasNode extends RapidElement {
860
866
  class="action-content"
861
867
  @mousedown=${(e: MouseEvent) => this.handleActionMouseDown(e, action)}
862
868
  @mouseup=${(e: MouseEvent) => this.handleActionMouseUp(e, action)}
863
- style="cursor: pointer;"
869
+ style="cursor: pointer; background: #fff"
864
870
  >
865
871
  ${this.renderTitle(config, action, index, isRemoving)}
866
872
  <div class="body">
@@ -983,16 +989,12 @@ export class CanvasNode extends RapidElement {
983
989
  dragHandle="drag-handle"
984
990
  @temba-order-changed="${this.handleActionOrderChanged}"
985
991
  >
986
- ${repeat(
987
- this.node.actions,
988
- (action) => action.uuid,
989
- (action, index) => this.renderAction(this.node, action, index)
992
+ ${this.node.actions.map((action, index) =>
993
+ this.renderAction(this.node, action, index)
990
994
  )}
991
995
  </temba-sortable-list>`
992
- : html`${repeat(
993
- this.node.actions,
994
- (action) => action.uuid,
995
- (action, index) => this.renderAction(this.node, action, index)
996
+ : html`${this.node.actions.map((action, index) =>
997
+ this.renderAction(this.node, action, index)
996
998
  )}`
997
999
  : ''}
998
1000
  ${this.node.router
@@ -58,6 +58,7 @@ export const send_msg: ActionConfig = {
58
58
  type: 'array',
59
59
  helpText: 'Add dynamic attachments using expressions',
60
60
  itemLabel: 'Attachment',
61
+ sortable: true,
61
62
  maxItems: 10,
62
63
  isEmptyItem: (item: any) => {
63
64
  return !item.expression || item.expression.trim() === '';
@@ -1,7 +1,196 @@
1
1
  import { COLORS, NodeConfig } from '../types';
2
+ import { Node, Category, Exit, Case } from '../../store/flow-definition.d';
3
+ import { generateUUID } from '../../utils';
4
+
5
+ // Helper function to create a switch router with group cases
6
+ const createGroupRouter = (
7
+ userGroups: { uuid: string; name: string }[],
8
+ existingCategories: Category[] = [],
9
+ existingExits: Exit[] = [],
10
+ existingCases: Case[] = []
11
+ ) => {
12
+ const categories: Category[] = [];
13
+ const exits: Exit[] = [];
14
+ const cases: Case[] = [];
15
+
16
+ // Create categories, exits, and cases for each selected group
17
+ userGroups.forEach((group) => {
18
+ // Try to find existing category by group name
19
+ const existingCategory = existingCategories.find(
20
+ (cat) => cat.name === group.name
21
+ );
22
+ const existingExit = existingCategory
23
+ ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
24
+ : null;
25
+ const existingCase = existingCases.find(
26
+ (c) => c.arguments?.[0] === group.uuid
27
+ );
28
+
29
+ const exitUuid = existingExit?.uuid || generateUUID();
30
+ const categoryUuid = existingCategory?.uuid || generateUUID();
31
+ const caseUuid = existingCase?.uuid || generateUUID();
32
+
33
+ categories.push({
34
+ uuid: categoryUuid,
35
+ name: group.name,
36
+ exit_uuid: exitUuid
37
+ });
38
+
39
+ exits.push({
40
+ uuid: exitUuid,
41
+ destination_uuid: existingExit?.destination_uuid || null
42
+ });
43
+
44
+ cases.push({
45
+ uuid: caseUuid,
46
+ type: 'has_group',
47
+ arguments: [group.uuid, group.name],
48
+ category_uuid: categoryUuid
49
+ });
50
+ });
51
+
52
+ // Add default "Other" category for contacts not in any selected group
53
+ const existingOtherCategory = existingCategories.find(
54
+ (cat) =>
55
+ cat.name === 'Other' &&
56
+ !userGroups.some((group) => group.name === cat.name)
57
+ );
58
+ const existingOtherExit = existingOtherCategory
59
+ ? existingExits.find(
60
+ (exit) => exit.uuid === existingOtherCategory.exit_uuid
61
+ )
62
+ : null;
63
+
64
+ const otherExitUuid = existingOtherExit?.uuid || generateUUID();
65
+ const otherCategoryUuid = existingOtherCategory?.uuid || generateUUID();
66
+
67
+ categories.push({
68
+ uuid: otherCategoryUuid,
69
+ name: 'Other',
70
+ exit_uuid: otherExitUuid
71
+ });
72
+
73
+ exits.push({
74
+ uuid: otherExitUuid,
75
+ destination_uuid: existingOtherExit?.destination_uuid || null
76
+ });
77
+
78
+ return {
79
+ router: {
80
+ type: 'switch' as const,
81
+ cases: cases,
82
+ categories: categories,
83
+ default_category_uuid: otherCategoryUuid,
84
+ operand: '@contact.groups',
85
+ result_name: ''
86
+ },
87
+ exits: exits
88
+ };
89
+ };
2
90
 
3
91
  export const split_by_groups: NodeConfig = {
4
92
  type: 'split_by_groups',
5
93
  name: 'Split by Group',
6
- color: COLORS.split
94
+ color: COLORS.split,
95
+ form: {
96
+ groups: {
97
+ type: 'select',
98
+ label: 'Groups',
99
+ helpText:
100
+ 'Select the groups to split contacts by. Contacts will be routed based on their group membership.',
101
+ required: true,
102
+ options: [],
103
+ multi: true,
104
+ searchable: true,
105
+ endpoint: '/api/v2/groups.json',
106
+ valueKey: 'uuid',
107
+ nameKey: 'name',
108
+ placeholder: 'Search for groups...',
109
+ allowCreate: true,
110
+ createArbitraryOption: (input: string, options: any[]) => {
111
+ // Check if a group with this name already exists
112
+ const existing = options.find(
113
+ (option) =>
114
+ option.name.toLowerCase().trim() === input.toLowerCase().trim()
115
+ );
116
+ if (!existing && input.trim()) {
117
+ return {
118
+ name: input.trim(),
119
+ arbitrary: true
120
+ };
121
+ }
122
+ return null;
123
+ }
124
+ }
125
+ },
126
+ layout: ['groups'],
127
+ validate: (formData: any) => {
128
+ const errors: { [key: string]: string } = {};
129
+
130
+ if (
131
+ !formData.groups ||
132
+ !Array.isArray(formData.groups) ||
133
+ formData.groups.length === 0
134
+ ) {
135
+ errors.groups = 'At least one group is required';
136
+ }
137
+
138
+ return {
139
+ valid: Object.keys(errors).length === 0,
140
+ errors
141
+ };
142
+ },
143
+ toFormData: (node: Node) => {
144
+ // Extract groups from the existing node structure
145
+ const groups: { uuid: string; name: string }[] = [];
146
+
147
+ if (node.router?.cases) {
148
+ node.router.cases.forEach((c: Case) => {
149
+ if (c.type === 'has_group' && c.arguments?.length >= 2) {
150
+ groups.push({
151
+ uuid: c.arguments[0],
152
+ name: c.arguments[1]
153
+ });
154
+ }
155
+ });
156
+ }
157
+
158
+ return {
159
+ uuid: node.uuid,
160
+ groups: groups
161
+ };
162
+ },
163
+ fromFormData: (formData: any, originalNode: Node): Node => {
164
+ // Get selected groups
165
+ const selectedGroups = (formData.groups || [])
166
+ .filter((group: any) => group?.uuid || group?.arbitrary)
167
+ .map((group: any) => ({
168
+ uuid: group.uuid || generateUUID(), // Generate UUID for arbitrary groups
169
+ name: group.name
170
+ }));
171
+
172
+ // Create router and exits using existing data when possible
173
+ const existingCategories = originalNode.router?.categories || [];
174
+ const existingExits = originalNode.exits || [];
175
+ const existingCases = originalNode.router?.cases || [];
176
+
177
+ const { router, exits } = createGroupRouter(
178
+ selectedGroups,
179
+ existingCategories,
180
+ existingExits,
181
+ existingCases
182
+ );
183
+
184
+ // Return the complete node
185
+ return {
186
+ uuid: originalNode.uuid,
187
+ actions: originalNode.actions || [],
188
+ router: router,
189
+ exits: exits
190
+ };
191
+ },
192
+ router: {
193
+ type: 'switch',
194
+ operand: '@contact.groups'
195
+ }
7
196
  };
@@ -31,6 +31,7 @@ export const split_by_llm_categorize: NodeConfig = {
31
31
  label: 'Categories',
32
32
  helpText: 'Define the categories for classification',
33
33
  required: true,
34
+ sortable: true,
34
35
  itemLabel: 'Category',
35
36
  minItems: 1,
36
37
  maxItems: 10,
@@ -56,6 +56,7 @@ export const split_by_random: NodeConfig = {
56
56
  helpText: 'Define the buckets to randomly split contacts into',
57
57
  required: true,
58
58
  itemLabel: 'Bucket',
59
+ sortable: true,
59
60
  minItems: 2,
60
61
  maxItems: 10,
61
62
  isEmptyItem: (item: any) => {