@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
@@ -49,14 +49,25 @@ describe('wait_for_response node config', () => {
49
49
  },
50
50
  result_name: 'response',
51
51
  categories: [
52
+ {
53
+ uuid: 'all-responses-cat-1',
54
+ name: 'All Responses',
55
+ exit_uuid: 'all-responses-exit-1'
56
+ },
52
57
  {
53
58
  uuid: 'timeout-cat-1',
54
59
  name: 'No Response',
55
60
  exit_uuid: 'timeout-exit-1'
56
61
  }
57
- ]
62
+ ],
63
+ cases: [],
64
+ operand: '@input.text',
65
+ default_category_uuid: 'all-responses-cat-1'
58
66
  },
59
- exits: [{ uuid: 'timeout-exit-1', destination_uuid: null }]
67
+ exits: [
68
+ { uuid: 'all-responses-exit-1', destination_uuid: null },
69
+ { uuid: 'timeout-exit-1', destination_uuid: null }
70
+ ]
60
71
  } as Node,
61
72
  { type: 'wait_for_response' },
62
73
  'basic-wait'
@@ -79,14 +90,25 @@ describe('wait_for_response node config', () => {
79
90
  },
80
91
  result_name: 'user_input',
81
92
  categories: [
93
+ {
94
+ uuid: 'all-responses-cat-2',
95
+ name: 'All Responses',
96
+ exit_uuid: 'all-responses-exit-2'
97
+ },
82
98
  {
83
99
  uuid: 'timeout-cat-2',
84
100
  name: 'No Response',
85
101
  exit_uuid: 'timeout-exit-2'
86
102
  }
87
- ]
103
+ ],
104
+ cases: [],
105
+ operand: '@input.text',
106
+ default_category_uuid: 'all-responses-cat-2'
88
107
  },
89
- exits: [{ uuid: 'timeout-exit-2', destination_uuid: null }]
108
+ exits: [
109
+ { uuid: 'all-responses-exit-2', destination_uuid: null },
110
+ { uuid: 'timeout-exit-2', destination_uuid: null }
111
+ ]
90
112
  } as Node,
91
113
  { type: 'wait_for_response' },
92
114
  'custom-result-name'
@@ -109,14 +131,25 @@ describe('wait_for_response node config', () => {
109
131
  },
110
132
  result_name: 'quick_response',
111
133
  categories: [
134
+ {
135
+ uuid: 'all-responses-cat-3',
136
+ name: 'All Responses',
137
+ exit_uuid: 'all-responses-exit-3'
138
+ },
112
139
  {
113
140
  uuid: 'timeout-cat-3',
114
141
  name: 'No Response',
115
142
  exit_uuid: 'timeout-exit-3'
116
143
  }
117
- ]
144
+ ],
145
+ cases: [],
146
+ operand: '@input.text',
147
+ default_category_uuid: 'all-responses-cat-3'
118
148
  },
119
- exits: [{ uuid: 'timeout-exit-3', destination_uuid: null }]
149
+ exits: [
150
+ { uuid: 'all-responses-exit-3', destination_uuid: null },
151
+ { uuid: 'timeout-exit-3', destination_uuid: null }
152
+ ]
120
153
  } as Node,
121
154
  { type: 'wait_for_response' },
122
155
  'short-timeout'
@@ -135,9 +168,18 @@ describe('wait_for_response node config', () => {
135
168
  // No timeout specified
136
169
  },
137
170
  result_name: 'response',
138
- categories: []
171
+ categories: [
172
+ {
173
+ uuid: 'all-responses-cat-4',
174
+ name: 'All Responses',
175
+ exit_uuid: 'all-responses-exit-4'
176
+ }
177
+ ],
178
+ cases: [],
179
+ operand: '@input.text',
180
+ default_category_uuid: 'all-responses-cat-4'
139
181
  },
140
- exits: []
182
+ exits: [{ uuid: 'all-responses-exit-4', destination_uuid: null }]
141
183
  } as Node,
142
184
  { type: 'wait_for_response' },
143
185
  'no-timeout'
@@ -450,6 +492,247 @@ describe('wait_for_response node config', () => {
450
492
  'timeout-cat'
451
493
  );
452
494
  });
495
+
496
+ it('ensures user categories never share UUIDs with system categories', () => {
497
+ const formData = {
498
+ uuid: 'test-node',
499
+ result_name: 'response',
500
+ rules: [
501
+ {
502
+ operator: 'has_any_word',
503
+ value1: 'hello',
504
+ value2: '',
505
+ category: 'Greeting'
506
+ }
507
+ ]
508
+ };
509
+
510
+ // Original node with existing system categories
511
+ const originalNode: Node = {
512
+ uuid: 'test-node',
513
+ actions: [],
514
+ exits: [
515
+ { uuid: 'system-other-exit', destination_uuid: null },
516
+ { uuid: 'system-all-responses-exit', destination_uuid: null }
517
+ ],
518
+ router: {
519
+ type: 'switch',
520
+ result_name: 'response',
521
+ categories: [
522
+ {
523
+ uuid: 'system-other-uuid',
524
+ name: 'Other',
525
+ exit_uuid: 'system-other-exit'
526
+ },
527
+ {
528
+ uuid: 'system-all-responses-uuid',
529
+ name: 'All Responses',
530
+ exit_uuid: 'system-all-responses-exit'
531
+ }
532
+ ]
533
+ }
534
+ };
535
+
536
+ const result = wait_for_response.fromFormData!(formData, originalNode);
537
+
538
+ // Get all category UUIDs
539
+ const categoryUUIDs = result.router!.categories.map((cat) => cat.uuid);
540
+ const exitUUIDs = result.exits.map((exit) => exit.uuid);
541
+
542
+ // Find user category (Greeting)
543
+ const userCategory = result.router!.categories.find(
544
+ (cat) => cat.name === 'Greeting'
545
+ );
546
+ const systemOtherCategory = result.router!.categories.find(
547
+ (cat) => cat.name === 'Other'
548
+ );
549
+
550
+ expect(userCategory).to.exist;
551
+ expect(systemOtherCategory).to.exist;
552
+
553
+ // Verify that user category UUID is different from any system category UUID
554
+ expect(userCategory!.uuid).to.not.equal('system-other-uuid');
555
+ expect(userCategory!.uuid).to.not.equal('system-all-responses-uuid');
556
+ expect(userCategory!.exit_uuid).to.not.equal('system-other-exit');
557
+ expect(userCategory!.exit_uuid).to.not.equal('system-all-responses-exit');
558
+
559
+ // Verify all UUIDs are unique
560
+ expect(new Set(categoryUUIDs)).to.have.lengthOf(categoryUUIDs.length);
561
+ expect(new Set(exitUUIDs)).to.have.lengthOf(exitUUIDs.length);
562
+ });
563
+
564
+ it('removes No Response category when timeout is disabled and no user rules', () => {
565
+ // Start with a node that has timeout enabled and No Response category
566
+ const originalNode: Node = {
567
+ uuid: 'test-node',
568
+ actions: [],
569
+ exits: [
570
+ { uuid: 'all-responses-exit', destination_uuid: null },
571
+ { uuid: 'no-response-exit', destination_uuid: null }
572
+ ],
573
+ router: {
574
+ type: 'switch',
575
+ result_name: 'response',
576
+ categories: [
577
+ {
578
+ uuid: 'all-responses-cat',
579
+ name: 'All Responses',
580
+ exit_uuid: 'all-responses-exit'
581
+ },
582
+ {
583
+ uuid: 'no-response-cat',
584
+ name: 'No Response',
585
+ exit_uuid: 'no-response-exit'
586
+ }
587
+ ],
588
+ cases: [],
589
+ operand: '@input.text',
590
+ default_category_uuid: 'all-responses-cat',
591
+ wait: {
592
+ type: 'msg',
593
+ timeout: {
594
+ seconds: 300,
595
+ category_uuid: 'no-response-cat'
596
+ }
597
+ }
598
+ }
599
+ };
600
+
601
+ // Form data with timeout disabled and no user rules
602
+ const formData = {
603
+ uuid: 'test-node',
604
+ result_name: 'response',
605
+ rules: [], // No user rules
606
+ timeout_enabled: false, // Timeout disabled
607
+ timeout_duration: null
608
+ };
609
+
610
+ const result = wait_for_response.fromFormData!(formData, originalNode);
611
+
612
+ // Should only have "All Responses" category, not "No Response"
613
+ expect(result.router?.categories).to.have.length(1);
614
+ expect(result.router?.categories[0].name).to.equal('All Responses');
615
+ expect(result.exits).to.have.length(1);
616
+ expect(result.exits[0].uuid).to.equal('all-responses-exit');
617
+
618
+ // Should not have timeout configuration
619
+ expect(result.router?.wait?.timeout).to.be.undefined;
620
+ });
621
+
622
+ it('adds No Response category when timeout is enabled and no user rules', () => {
623
+ // Start with a node that has no timeout and only "All Responses"
624
+ const originalNode: Node = {
625
+ uuid: 'test-node',
626
+ actions: [],
627
+ exits: [{ uuid: 'all-responses-exit', destination_uuid: null }],
628
+ router: {
629
+ type: 'switch',
630
+ result_name: 'response',
631
+ categories: [
632
+ {
633
+ uuid: 'all-responses-cat',
634
+ name: 'All Responses',
635
+ exit_uuid: 'all-responses-exit'
636
+ }
637
+ ],
638
+ cases: [],
639
+ operand: '@input.text',
640
+ default_category_uuid: 'all-responses-cat',
641
+ wait: {
642
+ type: 'msg'
643
+ }
644
+ }
645
+ };
646
+
647
+ // Form data with timeout enabled and no user rules
648
+ const formData = {
649
+ uuid: 'test-node',
650
+ result_name: 'response',
651
+ rules: [], // No user rules
652
+ timeout_enabled: true, // Timeout enabled
653
+ timeout_duration: { value: '300', name: '5 minutes' }
654
+ };
655
+
656
+ const result = wait_for_response.fromFormData!(formData, originalNode);
657
+
658
+ // Should have both "All Responses" and "No Response" categories
659
+ expect(result.router?.categories).to.have.length(2);
660
+ const categoryNames = result.router!.categories.map((cat) => cat.name);
661
+ expect(categoryNames).to.include.members([
662
+ 'All Responses',
663
+ 'No Response'
664
+ ]);
665
+
666
+ // Should have 2 exits
667
+ expect(result.exits).to.have.length(2);
668
+
669
+ // Should have timeout configuration
670
+ expect(result.router?.wait?.timeout).to.exist;
671
+ expect(result.router?.wait?.timeout?.seconds).to.equal(300);
672
+
673
+ // Timeout should point to "No Response" category
674
+ const noResponseCategory = result.router!.categories.find(
675
+ (cat) => cat.name === 'No Response'
676
+ );
677
+ expect(result.router?.wait?.timeout?.category_uuid).to.equal(
678
+ noResponseCategory?.uuid
679
+ );
680
+ });
681
+
682
+ it('handles enabling timeout on node with non-extensible exits array', () => {
683
+ // Create a node with a frozen/sealed exits array to simulate the error condition
684
+ const exitArray = [
685
+ { uuid: 'all-responses-exit', destination_uuid: null }
686
+ ];
687
+ Object.freeze(exitArray); // This makes the array non-extensible
688
+
689
+ const originalNode: Node = {
690
+ uuid: 'test-node',
691
+ actions: [],
692
+ exits: exitArray, // This array is now frozen
693
+ router: {
694
+ type: 'switch',
695
+ result_name: 'response',
696
+ categories: [
697
+ {
698
+ uuid: 'all-responses-cat',
699
+ name: 'All Responses',
700
+ exit_uuid: 'all-responses-exit'
701
+ }
702
+ ],
703
+ cases: [],
704
+ operand: '@input.text',
705
+ default_category_uuid: 'all-responses-cat',
706
+ wait: {
707
+ type: 'msg'
708
+ }
709
+ }
710
+ };
711
+
712
+ // Form data with timeout being enabled (this should trigger the error)
713
+ const formData = {
714
+ uuid: 'test-node',
715
+ result_name: 'response',
716
+ rules: [], // No user rules
717
+ timeout_enabled: true, // Enable timeout (this is the key change)
718
+ timeout_duration: { value: '300', name: '5 minutes' }
719
+ };
720
+
721
+ // This should not throw an error even with a frozen exits array
722
+ expect(() => {
723
+ const result = wait_for_response.fromFormData!(formData, originalNode);
724
+
725
+ // Verify the result is correct
726
+ expect(result.router?.categories).to.have.length(2);
727
+ const categoryNames = result.router!.categories.map((cat) => cat.name);
728
+ expect(categoryNames).to.include.members([
729
+ 'All Responses',
730
+ 'No Response'
731
+ ]);
732
+ expect(result.exits).to.have.length(2);
733
+ expect(result.router?.wait?.timeout).to.exist;
734
+ }).to.not.throw();
735
+ });
453
736
  });
454
737
 
455
738
  describe('validation', () => {
@@ -938,4 +1221,139 @@ describe('wait_for_response node config', () => {
938
1221
  expect(categoryNames).to.deep.equal(['Shared', 'Different', 'Other']);
939
1222
  });
940
1223
  });
1224
+
1225
+ describe('category auto-population', () => {
1226
+ it('auto-populates fixed category names for operators with no operands', () => {
1227
+ // Test with has_text operator (0 operands)
1228
+ const rulesConfig = wait_for_response.form.rules as any;
1229
+ const onItemChange = rulesConfig.onItemChange;
1230
+
1231
+ const items = [
1232
+ { operator: 'has_text', value1: '', value2: '', category: '' }
1233
+ ];
1234
+ const result = onItemChange(0, 'operator', 'has_text', items);
1235
+
1236
+ expect(result[0].category).to.equal('Has Text');
1237
+ });
1238
+
1239
+ it('auto-populates category name for single operand operators', () => {
1240
+ const rulesConfig = wait_for_response.form.rules as any;
1241
+ const onItemChange = rulesConfig.onItemChange;
1242
+
1243
+ // Test has_any_word - should capitalize first letter
1244
+ const items = [
1245
+ { operator: 'has_any_word', value1: '', value2: '', category: '' }
1246
+ ];
1247
+ let result = onItemChange(0, 'value1', 'red', items);
1248
+ expect(result[0].category).to.equal('Red');
1249
+
1250
+ // Test has_number_lt - should include < symbol
1251
+ const items2 = [
1252
+ { operator: 'has_number_lt', value1: '', value2: '', category: '' }
1253
+ ];
1254
+ result = onItemChange(0, 'value1', '5', items2);
1255
+ expect(result[0].category).to.equal('< 5');
1256
+
1257
+ // Test has_number_eq - should include = symbol
1258
+ const items3 = [
1259
+ { operator: 'has_number_eq', value1: '', value2: '', category: '' }
1260
+ ];
1261
+ result = onItemChange(0, 'value1', '10', items3);
1262
+ expect(result[0].category).to.equal('= 10');
1263
+ });
1264
+
1265
+ it('auto-populates category name for two operand operators', () => {
1266
+ const rulesConfig = wait_for_response.form.rules as any;
1267
+ const onItemChange = rulesConfig.onItemChange;
1268
+
1269
+ // Test has_number_between - should format as range
1270
+ const items = [
1271
+ { operator: 'has_number_between', value1: '', value2: '', category: '' }
1272
+ ];
1273
+ let result = onItemChange(0, 'value1', '45', items);
1274
+ // Should not populate yet (need both values)
1275
+ expect(result[0].category).to.equal('');
1276
+
1277
+ result = onItemChange(0, 'value2', '85', result);
1278
+ expect(result[0].category).to.equal('45 - 85');
1279
+ });
1280
+
1281
+ it('auto-populates category name for date operators with relative expressions', () => {
1282
+ const rulesConfig = wait_for_response.form.rules as any;
1283
+ const onItemChange = rulesConfig.onItemChange;
1284
+
1285
+ // Test has_date_gt - should format as "After today + X days"
1286
+ let items = [
1287
+ { operator: 'has_date_gt', value1: '', value2: '', category: '' }
1288
+ ];
1289
+ let result = onItemChange(0, 'value1', 'today + 5', items);
1290
+ expect(result[0].category).to.equal('After today + 5 days');
1291
+
1292
+ // Test has_date_lt with single day - should use "day" not "days"
1293
+ items = [
1294
+ { operator: 'has_date_lt', value1: '', value2: '', category: '' }
1295
+ ];
1296
+ result = onItemChange(0, 'value1', 'today + 1', items);
1297
+ expect(result[0].category).to.equal('Before today + 1 day');
1298
+
1299
+ // Test has_date_eq
1300
+ items = [
1301
+ { operator: 'has_date_eq', value1: '', value2: '', category: '' }
1302
+ ];
1303
+ result = onItemChange(0, 'value1', 'today - 3', items);
1304
+ expect(result[0].category).to.equal('today - 3 days');
1305
+ });
1306
+
1307
+ it('updates category name when value changes if category matches old default', () => {
1308
+ const rulesConfig = wait_for_response.form.rules as any;
1309
+ const onItemChange = rulesConfig.onItemChange;
1310
+
1311
+ // Start with "red"
1312
+ const items = [
1313
+ { operator: 'has_any_word', value1: 'red', value2: '', category: 'Red' }
1314
+ ];
1315
+
1316
+ // Change to "blue" - category should update since it matches old default
1317
+ const result = onItemChange(0, 'value1', 'blue', items);
1318
+ expect(result[0].category).to.equal('Blue');
1319
+ });
1320
+
1321
+ it('does not update category name when value changes if user customized it', () => {
1322
+ const rulesConfig = wait_for_response.form.rules as any;
1323
+ const onItemChange = rulesConfig.onItemChange;
1324
+
1325
+ // Start with "red" but custom category "Color"
1326
+ const items = [
1327
+ {
1328
+ operator: 'has_any_word',
1329
+ value1: 'red',
1330
+ value2: '',
1331
+ category: 'Color'
1332
+ }
1333
+ ];
1334
+
1335
+ // Change to "blue" - category should NOT update (user customized it)
1336
+ const result = onItemChange(0, 'value1', 'blue', items);
1337
+ expect(result[0].category).to.equal('Color');
1338
+ });
1339
+
1340
+ it('updates category when operator changes', () => {
1341
+ const rulesConfig = wait_for_response.form.rules as any;
1342
+ const onItemChange = rulesConfig.onItemChange;
1343
+
1344
+ // Start with has_any_word
1345
+ const items = [
1346
+ {
1347
+ operator: 'has_any_word',
1348
+ value1: 'test',
1349
+ value2: '',
1350
+ category: 'Test'
1351
+ }
1352
+ ];
1353
+
1354
+ // Change to has_text (0 operands) - category should update to fixed name
1355
+ const result = onItemChange(0, 'operator', 'has_text', items);
1356
+ expect(result[0].category).to.equal('Has Text');
1357
+ });
1358
+ });
941
1359
  });