@nyaruka/temba-components 0.130.5 → 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 (27) hide show
  1. package/CHANGELOG.md +3 -20
  2. package/dist/temba-components.js +65 -64
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/flow/nodes/split_by_random.js +1 -0
  5. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  6. package/out-tsc/src/flow/nodes/wait_for_response.js +254 -65
  7. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  8. package/out-tsc/src/form/ArrayEditor.js +195 -2
  9. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  10. package/out-tsc/src/form/select/Omnibox.js +4 -0
  11. package/out-tsc/src/form/select/Omnibox.js.map +1 -1
  12. package/out-tsc/test/nodes/wait_for_response.test.js +373 -8
  13. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
  14. package/package.json +1 -1
  15. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  16. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  17. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  18. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  19. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  20. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  21. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  22. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  23. package/src/flow/nodes/split_by_random.ts +1 -0
  24. package/src/flow/nodes/wait_for_response.ts +327 -72
  25. package/src/form/ArrayEditor.ts +260 -2
  26. package/src/form/select/Omnibox.ts +3 -0
  27. package/test/nodes/wait_for_response.test.ts +426 -8
@@ -28,6 +28,129 @@ const TIMEOUT_OPTIONS = [
28
28
  { value: '604800', name: '1 week' }
29
29
  ];
30
30
 
31
+ // Helper function to check if a category is a system category
32
+ const isSystemCategory = (categoryName: string): boolean => {
33
+ return ['No Response', 'Other', 'All Responses', 'Timeout'].includes(
34
+ categoryName
35
+ );
36
+ };
37
+
38
+ // Helper function to check if a UUID belongs to a system category
39
+ const isSystemCategoryUuid = (
40
+ uuid: string,
41
+ categories: Category[]
42
+ ): boolean => {
43
+ const category = categories.find((cat) => cat.uuid === uuid);
44
+ return category ? isSystemCategory(category.name) : false;
45
+ };
46
+
47
+ // Helper function to generate default category name based on operator and operands
48
+ const generateDefaultCategoryName = (
49
+ operator: string,
50
+ value1?: string,
51
+ value2?: string
52
+ ): string => {
53
+ const operatorConfig = getOperatorConfig(operator);
54
+ if (!operatorConfig) return '';
55
+
56
+ // Fixed category names (no operands)
57
+ if (operatorConfig.operands === 0) {
58
+ return operatorConfig.categoryName || '';
59
+ }
60
+
61
+ // Dynamic category names based on operands
62
+ const cleanValue1 = (value1 || '').trim();
63
+ const cleanValue2 = (value2 || '').trim();
64
+
65
+ // Helper to capitalize first letter
66
+ const capitalize = (str: string) => {
67
+ if (!str) return '';
68
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
69
+ };
70
+
71
+ // Handle different operator types
72
+ switch (operator) {
73
+ // Word/phrase operators - capitalize first letter of value
74
+ case 'has_any_word':
75
+ case 'has_all_words':
76
+ case 'has_phrase':
77
+ case 'has_only_phrase':
78
+ case 'has_beginning':
79
+ return cleanValue1 ? capitalize(cleanValue1) : '';
80
+
81
+ // Pattern operators - show as-is
82
+ case 'has_pattern':
83
+ return cleanValue1;
84
+
85
+ // Number comparison operators - include symbol
86
+ case 'has_number_eq':
87
+ return cleanValue1 ? `= ${cleanValue1}` : '';
88
+ case 'has_number_lt':
89
+ return cleanValue1 ? `< ${cleanValue1}` : '';
90
+ case 'has_number_lte':
91
+ return cleanValue1 ? `≤ ${cleanValue1}` : '';
92
+ case 'has_number_gt':
93
+ return cleanValue1 ? `> ${cleanValue1}` : '';
94
+ case 'has_number_gte':
95
+ return cleanValue1 ? `≥ ${cleanValue1}` : '';
96
+
97
+ // Number between - range format
98
+ case 'has_number_between':
99
+ if (cleanValue1 && cleanValue2) {
100
+ return `${cleanValue1} - ${cleanValue2}`;
101
+ }
102
+ return '';
103
+
104
+ // Date operators - format with relative expressions
105
+ case 'has_date_lt':
106
+ case 'has_date_lte':
107
+ if (cleanValue1) {
108
+ // Parse relative date expression (e.g., "today + 5" or "today - 3")
109
+ const match = cleanValue1.match(/^(today)\s*([+-])\s*(\d+)$/i);
110
+ if (match) {
111
+ const [, base, operator, days] = match;
112
+ const dayWord = days === '1' ? 'day' : 'days';
113
+ return `Before ${base} ${operator} ${days} ${dayWord}`;
114
+ }
115
+ // Fallback for other date formats
116
+ return `Before ${cleanValue1}`;
117
+ }
118
+ return '';
119
+
120
+ case 'has_date_gt':
121
+ case 'has_date_gte':
122
+ if (cleanValue1) {
123
+ // Parse relative date expression
124
+ const match = cleanValue1.match(/^(today)\s*([+-])\s*(\d+)$/i);
125
+ if (match) {
126
+ const [, base, operator, days] = match;
127
+ const dayWord = days === '1' ? 'day' : 'days';
128
+ return `After ${base} ${operator} ${days} ${dayWord}`;
129
+ }
130
+ // Fallback for other date formats
131
+ return `After ${cleanValue1}`;
132
+ }
133
+ return '';
134
+
135
+ case 'has_date_eq':
136
+ if (cleanValue1) {
137
+ // Parse relative date expression
138
+ const match = cleanValue1.match(/^(today)\s*([+-])\s*(\d+)$/i);
139
+ if (match) {
140
+ const [, base, operator, days] = match;
141
+ const dayWord = days === '1' ? 'day' : 'days';
142
+ return `${base} ${operator} ${days} ${dayWord}`;
143
+ }
144
+ return cleanValue1;
145
+ }
146
+ return '';
147
+
148
+ default:
149
+ // Fallback - capitalize first value
150
+ return cleanValue1 ? capitalize(cleanValue1) : '';
151
+ }
152
+ };
153
+
31
154
  // Helper function to create a wait_for_response router with user rules
32
155
  const createWaitForResponseRouter = (
33
156
  userRules: any[],
@@ -41,10 +164,7 @@ const createWaitForResponseRouter = (
41
164
 
42
165
  // Filter existing categories to get only user-defined rules (exclude system categories)
43
166
  const existingUserCategories = existingCategories.filter(
44
- (cat) =>
45
- cat.name !== 'No Response' &&
46
- cat.name !== 'Other' &&
47
- cat.name !== 'Timeout'
167
+ (cat) => !isSystemCategory(cat.name)
48
168
  );
49
169
 
50
170
  // Track categories as we create them (case-insensitive lookup)
@@ -74,15 +194,29 @@ const createWaitForResponseRouter = (
74
194
  !existingCategory &&
75
195
  categoryCreationOrder < existingUserCategories.length
76
196
  ) {
77
- existingCategory = existingUserCategories[categoryCreationOrder];
197
+ const candidateCategory = existingUserCategories[categoryCreationOrder];
198
+ // Double-check that this candidate is not a system category UUID
199
+ if (
200
+ candidateCategory &&
201
+ !isSystemCategoryUuid(candidateCategory.uuid, existingCategories)
202
+ ) {
203
+ existingCategory = candidateCategory;
204
+ }
78
205
  }
79
206
 
80
207
  const existingExit = existingCategory
81
208
  ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
82
209
  : null;
83
210
 
84
- const exitUuid = existingExit?.uuid || generateUUID();
85
- const categoryUuid = existingCategory?.uuid || generateUUID();
211
+ // Generate UUIDs, ensuring we don't reuse system category UUIDs
212
+ let exitUuid = existingExit?.uuid || generateUUID();
213
+ let categoryUuid = existingCategory?.uuid || generateUUID();
214
+
215
+ // Additional safety check: if somehow we got a system category UUID, generate new ones
216
+ if (isSystemCategoryUuid(categoryUuid, existingCategories)) {
217
+ categoryUuid = generateUUID();
218
+ exitUuid = generateUUID();
219
+ }
86
220
 
87
221
  categoryInfo = {
88
222
  uuid: categoryUuid,
@@ -169,52 +303,69 @@ const createWaitForResponseRouter = (
169
303
  });
170
304
  });
171
305
 
172
- // Preserve existing timeout categories like "No Response"
173
- existingCategories.forEach((category) => {
174
- if (category.name === 'No Response' || category.name === 'Timeout') {
175
- const existingExit = existingExits.find(
176
- (exit) => exit.uuid === category.exit_uuid
177
- );
306
+ // Add default category (always present)
307
+ // Name is "Other" if there are user rules, "All Responses" if there are no user rules
308
+ const defaultCategoryName = userRules.length > 0 ? 'Other' : 'All Responses';
178
309
 
179
- if (existingExit) {
180
- categories.push(category);
181
- exits.push(existingExit);
182
- }
183
- }
310
+ // Try to find existing default category by name (prefer exact match)
311
+ let existingDefaultCategory = existingCategories.find(
312
+ (cat) => cat.name === defaultCategoryName
313
+ );
314
+
315
+ // If no exact match, try to find the other possible default category name
316
+ if (!existingDefaultCategory) {
317
+ const alternateName = userRules.length > 0 ? 'All Responses' : 'Other';
318
+ existingDefaultCategory = existingCategories.find(
319
+ (cat) => cat.name === alternateName
320
+ );
321
+ }
322
+
323
+ const existingDefaultExit = existingDefaultCategory
324
+ ? existingExits.find(
325
+ (exit) => exit.uuid === existingDefaultCategory.exit_uuid
326
+ )
327
+ : null;
328
+
329
+ const defaultExitUuid = existingDefaultExit?.uuid || generateUUID();
330
+ const defaultCategoryUuid = existingDefaultCategory?.uuid || generateUUID();
331
+
332
+ categories.push({
333
+ uuid: defaultCategoryUuid,
334
+ name: defaultCategoryName,
335
+ exit_uuid: defaultExitUuid
336
+ });
337
+
338
+ exits.push({
339
+ uuid: defaultExitUuid,
340
+ destination_uuid: existingDefaultExit?.destination_uuid || null
184
341
  });
185
342
 
186
- // Add "Other" category (default) only if there are user rules
187
- if (userRules.length > 0) {
188
- const existingOtherCategory = existingCategories.find(
189
- (cat) => cat.name === 'Other'
343
+ // Add "No Response" category last (if it exists in the original)
344
+ const existingNoResponseCategory = existingCategories.find(
345
+ (cat) => cat.name === 'No Response' || cat.name === 'Timeout'
346
+ );
347
+
348
+ if (existingNoResponseCategory) {
349
+ const existingNoResponseExit = existingExits.find(
350
+ (exit) => exit.uuid === existingNoResponseCategory.exit_uuid
190
351
  );
191
- const existingOtherExit = existingOtherCategory
192
- ? existingExits.find(
193
- (exit) => exit.uuid === existingOtherCategory.exit_uuid
194
- )
195
- : null;
196
-
197
- const otherExitUuid = existingOtherExit?.uuid || generateUUID();
198
- const otherCategoryUuid = existingOtherCategory?.uuid || generateUUID();
199
-
200
- categories.push({
201
- uuid: otherCategoryUuid,
202
- name: 'Other',
203
- exit_uuid: otherExitUuid
204
- });
205
352
 
206
- exits.push({
207
- uuid: otherExitUuid,
208
- destination_uuid: existingOtherExit?.destination_uuid || null
209
- });
353
+ if (existingNoResponseExit) {
354
+ categories.push(existingNoResponseCategory);
355
+ exits.push(existingNoResponseExit);
356
+ }
210
357
  }
211
358
 
359
+ // Find the default category (either "Other" or "All Responses")
360
+ const defaultCategory = categories.find(
361
+ (cat) => cat.name === 'Other' || cat.name === 'All Responses'
362
+ );
363
+
212
364
  return {
213
365
  router: {
214
366
  type: 'switch' as const,
215
367
  categories: categories,
216
- default_category_uuid: categories.find((cat) => cat.name === 'Other')
217
- ?.uuid,
368
+ default_category_uuid: defaultCategory?.uuid,
218
369
  operand: '@input.text',
219
370
  cases: cases
220
371
  },
@@ -286,6 +437,79 @@ export const wait_for_response: NodeConfig = {
286
437
  // No value required for this operator
287
438
  return false;
288
439
  },
440
+ onItemChange: (
441
+ itemIndex: number,
442
+ field: string,
443
+ value: any,
444
+ allItems: any[]
445
+ ) => {
446
+ const updatedItems = [...allItems];
447
+ const item = { ...updatedItems[itemIndex] };
448
+
449
+ // Helper to get operator value from various formats
450
+ const getOperatorValue = (operator: any): string => {
451
+ if (typeof operator === 'string') {
452
+ return operator.trim();
453
+ } else if (Array.isArray(operator) && operator.length > 0) {
454
+ const firstOperator = operator[0];
455
+ if (
456
+ firstOperator &&
457
+ typeof firstOperator === 'object' &&
458
+ firstOperator.value
459
+ ) {
460
+ return firstOperator.value.trim();
461
+ }
462
+ } else if (
463
+ operator &&
464
+ typeof operator === 'object' &&
465
+ operator.value
466
+ ) {
467
+ return operator.value.trim();
468
+ }
469
+ return '';
470
+ };
471
+
472
+ // Update the changed field
473
+ item[field] = value;
474
+
475
+ // Get operator values (before and after the change)
476
+ const oldItem = allItems[itemIndex] || {};
477
+ const oldOperatorValue =
478
+ field === 'operator'
479
+ ? getOperatorValue(oldItem.operator)
480
+ : getOperatorValue(item.operator);
481
+ const newOperatorValue = getOperatorValue(item.operator);
482
+
483
+ // Calculate what the default category name should be before the change
484
+ const oldDefaultCategory = generateDefaultCategoryName(
485
+ oldOperatorValue,
486
+ field === 'value1' ? oldItem.value1 : item.value1,
487
+ field === 'value2' ? oldItem.value2 : item.value2
488
+ );
489
+
490
+ // Calculate what the new default category name should be after the change
491
+ const newDefaultCategory = generateDefaultCategoryName(
492
+ newOperatorValue,
493
+ item.value1,
494
+ item.value2
495
+ );
496
+
497
+ // Determine if we should auto-update the category
498
+ const shouldUpdateCategory =
499
+ // Category is empty
500
+ !item.category ||
501
+ item.category.trim() === '' ||
502
+ // Category matches the old default (user hasn't customized it)
503
+ item.category === oldDefaultCategory;
504
+
505
+ // Auto-populate or update category if conditions are met
506
+ if (shouldUpdateCategory && newDefaultCategory) {
507
+ item.category = newDefaultCategory;
508
+ }
509
+
510
+ updatedItems[itemIndex] = item;
511
+ return updatedItems;
512
+ },
289
513
  itemConfig: {
290
514
  operator: {
291
515
  type: 'select',
@@ -432,12 +656,8 @@ export const wait_for_response: NodeConfig = {
432
656
  (cat) => cat.uuid === case_.category_uuid
433
657
  );
434
658
 
435
- // Skip timeout/system categories like "No Response"
436
- if (
437
- category &&
438
- category.name !== 'No Response' &&
439
- category.name !== 'Other'
440
- ) {
659
+ // Skip system categories
660
+ if (category && !isSystemCategory(category.name)) {
441
661
  // Handle different operator types
442
662
  const operatorConfig = getOperatorConfig(case_.type);
443
663
  const operatorDisplayName = operatorConfig
@@ -574,16 +794,60 @@ export const wait_for_response: NodeConfig = {
574
794
 
575
795
  // If no user rules, clear cases but preserve other router config
576
796
  if (userRules.length === 0) {
577
- const router: any = {
578
- ...originalNode.router,
579
- result_name: formData.result_name || 'response'
580
- };
797
+ // Get existing router data for preservation
798
+ let existingCategories = originalNode.router?.categories || [];
799
+ const existingExits = [...(originalNode.exits || [])]; // Create a copy to avoid extensibility issues
581
800
 
582
- // Only set cases to empty if the original node had cases
583
- if (originalNode.router?.cases !== undefined) {
584
- router.cases = []; // Clear all cases when no rules
801
+ // Handle timeout: ensure "No Response" category exists if timeout is enabled,
802
+ // or remove it if timeout is disabled
803
+ if (formData.timeout_enabled) {
804
+ let noResponseCategory = existingCategories.find(
805
+ (cat: any) => cat.name === 'No Response'
806
+ );
807
+
808
+ if (!noResponseCategory) {
809
+ // Create new "No Response" category and exit
810
+ const noResponseExitUuid = generateUUID();
811
+ noResponseCategory = {
812
+ uuid: generateUUID(),
813
+ name: 'No Response',
814
+ exit_uuid: noResponseExitUuid
815
+ };
816
+
817
+ // Add to existing categories for processing
818
+ existingCategories = [...existingCategories, noResponseCategory];
819
+
820
+ // Add corresponding exit if it doesn't exist
821
+ if (!existingExits.find((exit) => exit.uuid === noResponseExitUuid)) {
822
+ existingExits.push({
823
+ uuid: noResponseExitUuid,
824
+ destination_uuid: null
825
+ });
826
+ }
827
+ }
828
+ } else {
829
+ // If timeout is disabled, remove "No Response" category from existing categories
830
+ existingCategories = existingCategories.filter(
831
+ (cat: any) => cat.name !== 'No Response'
832
+ );
585
833
  }
586
834
 
835
+ // Create router with "All Responses" as default category
836
+ // This will now properly handle the "No Response" category if it exists
837
+ const { router: noRulesRouter, exits: noRulesExits } =
838
+ createWaitForResponseRouter(
839
+ [], // No user rules
840
+ existingCategories,
841
+ existingExits,
842
+ [] // No cases
843
+ );
844
+
845
+ const router: any = {
846
+ ...noRulesRouter,
847
+ result_name: formData.result_name || 'response',
848
+ cases: [] // Clear all cases when no rules
849
+ };
850
+
587
851
  // Build wait configuration based on form data
588
852
  const waitConfig: any = {
589
853
  type: 'msg'
@@ -622,34 +886,25 @@ export const wait_for_response: NodeConfig = {
622
886
  timeoutSeconds = 300; // Default to 5 minutes
623
887
  }
624
888
 
625
- // Find or create the "No Response" category
626
- let noResponseCategory = originalNode.router?.categories?.find(
889
+ // Find the "No Response" category (should exist now)
890
+ const noResponseCategory = router.categories.find(
627
891
  (cat: any) => cat.name === 'No Response'
628
892
  );
629
893
 
630
- if (!noResponseCategory) {
631
- noResponseCategory = {
632
- uuid: generateUUID(),
633
- name: 'No Response',
634
- exit_uuid: generateUUID()
894
+ if (noResponseCategory) {
895
+ waitConfig.timeout = {
896
+ seconds: timeoutSeconds,
897
+ category_uuid: noResponseCategory.uuid
635
898
  };
636
-
637
- // Add to router categories
638
- router.categories = router.categories || [];
639
- router.categories.push(noResponseCategory);
640
899
  }
641
-
642
- waitConfig.timeout = {
643
- seconds: timeoutSeconds,
644
- category_uuid: noResponseCategory.uuid
645
- };
646
900
  }
647
901
 
648
902
  router.wait = waitConfig;
649
903
 
650
904
  return {
651
905
  ...originalNode,
652
- router
906
+ router,
907
+ exits: noRulesExits
653
908
  };
654
909
  }
655
910