@nyaruka/temba-components 0.139.0 → 0.140.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 (155) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +17 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/temba-components.js +562 -296
  6. package/dist/temba-components.js.map +1 -1
  7. package/out-tsc/src/display/Chat.js +10 -7
  8. package/out-tsc/src/display/Chat.js.map +1 -1
  9. package/out-tsc/src/display/Dropdown.js +3 -1
  10. package/out-tsc/src/display/Dropdown.js.map +1 -1
  11. package/out-tsc/src/display/FloatingTab.js +3 -3
  12. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  13. package/out-tsc/src/display/Thumbnail.js +163 -5
  14. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  15. package/out-tsc/src/flow/CanvasNode.js +64 -22
  16. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  17. package/out-tsc/src/flow/Editor.js +142 -8
  18. package/out-tsc/src/flow/Editor.js.map +1 -1
  19. package/out-tsc/src/flow/NodeEditor.js +118 -10
  20. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  21. package/out-tsc/src/flow/StickyNote.js +13 -4
  22. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  23. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  24. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  25. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  26. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  27. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  28. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  29. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  30. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  31. package/out-tsc/src/flow/config.js +11 -3
  32. package/out-tsc/src/flow/config.js.map +1 -1
  33. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  34. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  35. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  36. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  37. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  38. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  39. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  40. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  41. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  42. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  43. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  44. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  45. package/out-tsc/src/flow/operators.js +21 -5
  46. package/out-tsc/src/flow/operators.js.map +1 -1
  47. package/out-tsc/src/flow/types.js.map +1 -1
  48. package/out-tsc/src/flow/utils.js +79 -3
  49. package/out-tsc/src/flow/utils.js.map +1 -1
  50. package/out-tsc/src/form/ArrayEditor.js +4 -2
  51. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  52. package/out-tsc/src/form/FieldRenderer.js +49 -0
  53. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  54. package/out-tsc/src/interfaces.js +1 -0
  55. package/out-tsc/src/interfaces.js.map +1 -1
  56. package/out-tsc/src/layout/Dialog.js +52 -7
  57. package/out-tsc/src/layout/Dialog.js.map +1 -1
  58. package/out-tsc/src/live/TembaChart.js.map +1 -1
  59. package/out-tsc/src/simulator/Simulator.js +10 -4
  60. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  61. package/out-tsc/src/store/AppState.js +89 -3
  62. package/out-tsc/src/store/AppState.js.map +1 -1
  63. package/out-tsc/test/actions/play_audio.test.js +118 -0
  64. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  65. package/out-tsc/test/actions/say_msg.test.js +158 -0
  66. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  67. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  68. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  69. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  70. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  71. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  72. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  73. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  74. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  75. package/out-tsc/test/temba-flow-collision.test.js +261 -6
  76. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  77. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  78. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  79. package/package.json +1 -1
  80. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  81. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  82. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  83. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  84. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  86. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  87. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  88. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  89. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  90. package/screenshots/truth/editor/router.png +0 -0
  91. package/screenshots/truth/editor/wait.png +0 -0
  92. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  93. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  94. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  95. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  96. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  97. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  98. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  99. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  100. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  101. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  102. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  103. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  104. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  105. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  112. package/src/display/Chat.ts +13 -7
  113. package/src/display/Dropdown.ts +3 -1
  114. package/src/display/FloatingTab.ts +3 -3
  115. package/src/display/Thumbnail.ts +162 -2
  116. package/src/flow/CanvasNode.ts +69 -23
  117. package/src/flow/Editor.ts +156 -13
  118. package/src/flow/NodeEditor.ts +137 -9
  119. package/src/flow/StickyNote.ts +14 -4
  120. package/src/flow/actions/audio-player.ts +127 -0
  121. package/src/flow/actions/enter_flow.ts +44 -0
  122. package/src/flow/actions/play_audio.ts +64 -5
  123. package/src/flow/actions/say_msg.ts +94 -4
  124. package/src/flow/config.ts +11 -3
  125. package/src/flow/nodes/shared-rules.ts +1 -1
  126. package/src/flow/nodes/terminal.ts +9 -0
  127. package/src/flow/nodes/wait_for_audio.ts +88 -0
  128. package/src/flow/nodes/wait_for_dial.ts +176 -0
  129. package/src/flow/nodes/wait_for_digits.ts +86 -2
  130. package/src/flow/nodes/wait_for_menu.ts +209 -3
  131. package/src/flow/operators.ts +23 -5
  132. package/src/flow/types.ts +23 -1
  133. package/src/flow/utils.ts +82 -3
  134. package/src/form/ArrayEditor.ts +4 -2
  135. package/src/form/FieldRenderer.ts +64 -1
  136. package/src/interfaces.ts +2 -1
  137. package/src/layout/Dialog.ts +53 -7
  138. package/src/live/TembaChart.ts +1 -1
  139. package/src/simulator/Simulator.ts +13 -4
  140. package/src/store/AppState.ts +105 -1
  141. package/src/store/flow-definition.d.ts +2 -0
  142. package/test/actions/play_audio.test.ts +155 -0
  143. package/test/actions/say_msg.test.ts +196 -0
  144. package/test/nodes/wait_for_audio.test.ts +182 -0
  145. package/test/nodes/wait_for_dial.test.ts +382 -0
  146. package/test/nodes/wait_for_digits.test.ts +233 -109
  147. package/test/nodes/wait_for_menu.test.ts +383 -0
  148. package/test/temba-flow-collision.test.ts +286 -6
  149. package/test/temba-node-type-selector.test.ts +6 -6
  150. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  152. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  153. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  154. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  155. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -20,6 +20,7 @@ import { remove_contact_groups } from './actions/remove_contact_groups';
20
20
  import { request_optin } from './actions/request_optin';
21
21
  import { say_msg } from './actions/say_msg';
22
22
  import { play_audio } from './actions/play_audio';
23
+ import { enter_flow } from './actions/enter_flow';
23
24
 
24
25
  // Import all node configurations
25
26
  import { execute_actions } from './nodes/execute_actions';
@@ -31,11 +32,14 @@ import { split_by_random } from './nodes/split_by_random';
31
32
  import { split_by_run_result } from './nodes/split_by_run_result';
32
33
  import { split_by_scheme } from './nodes/split_by_scheme';
33
34
  import { split_by_subflow } from './nodes/split_by_subflow';
35
+ import { terminal } from './nodes/terminal';
34
36
  import { split_by_ticket } from './nodes/split_by_ticket';
35
37
  import { split_by_webhook } from './nodes/split_by_webhook';
36
38
  import { split_by_resthook } from './nodes/split_by_resthook';
37
39
  import { split_by_llm } from './nodes/split_by_llm';
38
40
  import { split_by_llm_categorize } from './nodes/split_by_llm_categorize';
41
+ import { wait_for_audio } from './nodes/wait_for_audio';
42
+ import { wait_for_dial } from './nodes/wait_for_dial';
39
43
  import { wait_for_digits } from './nodes/wait_for_digits';
40
44
  import { wait_for_menu } from './nodes/wait_for_menu';
41
45
  import { wait_for_response } from './nodes/wait_for_response';
@@ -59,7 +63,8 @@ export const ACTION_CONFIG: {
59
63
  set_contact_status,
60
64
  add_contact_urn,
61
65
  add_input_labels,
62
- request_optin
66
+ request_optin,
67
+ enter_flow
63
68
  });
64
69
 
65
70
  // Helper to register a config and its aliases
@@ -96,8 +101,11 @@ export const NODE_CONFIG: {
96
101
  split_by_ticket,
97
102
  split_by_webhook,
98
103
  split_by_resthook,
99
- wait_for_digits,
100
104
  wait_for_menu,
105
+ wait_for_digits,
106
+ wait_for_audio,
107
+ wait_for_dial,
101
108
  wait_for_response,
102
- split_by_airtime
109
+ split_by_airtime,
110
+ terminal // Temporary: legacy support for terminal nodes (see AppState.ts)
103
111
  });
@@ -132,7 +132,7 @@ export const createRulesItemConfig = () => ({
132
132
  multi: false,
133
133
  options: [], // Will be set by the caller
134
134
  flavor: 'xsmall' as const,
135
- width: '200px'
135
+ width: '220px'
136
136
  },
137
137
  value1: {
138
138
  type: 'text' as const,
@@ -0,0 +1,9 @@
1
+ // Temporary: Legacy support for terminal nodes (nodes with a terminal action
2
+ // like enter_flow with terminal: true). This node type and its reclassification
3
+ // logic in AppState.ts can be removed once we stop supporting terminal nodes.
4
+
5
+ import { NodeConfig } from '../types';
6
+
7
+ export const terminal: NodeConfig = {
8
+ type: 'terminal'
9
+ };
@@ -0,0 +1,88 @@
1
+ import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
+ import { Node, Category, Exit } from '../../store/flow-definition';
3
+ import { generateUUID } from '../../utils';
4
+ import {
5
+ categoriesToLocalizationFormData,
6
+ localizationFormDataToCategories
7
+ } from './shared';
8
+
9
+ export const wait_for_audio: NodeConfig = {
10
+ type: 'wait_for_audio',
11
+ name: 'Make Recording',
12
+ group: SPLIT_GROUPS.wait,
13
+ flowTypes: [FlowTypes.VOICE],
14
+ form: {
15
+ result_name: {
16
+ type: 'text',
17
+ label: 'Result Name',
18
+ required: false,
19
+ placeholder: '(optional)',
20
+ helpText: 'The name to use to reference this result in the flow'
21
+ }
22
+ },
23
+ layout: ['result_name'],
24
+ toFormData: (node: Node) => {
25
+ return {
26
+ uuid: node.uuid,
27
+ result_name: node.router?.result_name || ''
28
+ };
29
+ },
30
+ fromFormData: (formData: FormData, originalNode: Node): Node => {
31
+ // Preserve or create "All Responses" category
32
+ const existingCategories = originalNode.router?.categories || [];
33
+ const existingExits = originalNode.exits || [];
34
+
35
+ let allResponsesCategory = existingCategories.find(
36
+ (cat: Category) => cat.name === 'All Responses'
37
+ );
38
+
39
+ let allResponsesExit: Exit;
40
+
41
+ if (allResponsesCategory) {
42
+ allResponsesExit = existingExits.find(
43
+ (exit: Exit) => exit.uuid === allResponsesCategory!.exit_uuid
44
+ ) || {
45
+ uuid: allResponsesCategory.exit_uuid,
46
+ destination_uuid: null
47
+ };
48
+ } else {
49
+ const exitUuid = generateUUID();
50
+ allResponsesCategory = {
51
+ uuid: generateUUID(),
52
+ name: 'All Responses',
53
+ exit_uuid: exitUuid
54
+ };
55
+ allResponsesExit = {
56
+ uuid: exitUuid,
57
+ destination_uuid: null
58
+ };
59
+ }
60
+
61
+ const router: any = {
62
+ type: 'switch',
63
+ operand: '@input',
64
+ default_category_uuid: allResponsesCategory.uuid,
65
+ cases: [],
66
+ categories: [allResponsesCategory],
67
+ wait: {
68
+ type: 'msg',
69
+ hint: {
70
+ type: 'audio'
71
+ }
72
+ }
73
+ };
74
+
75
+ if (formData.result_name && formData.result_name.trim() !== '') {
76
+ router.result_name = formData.result_name.trim();
77
+ }
78
+
79
+ return {
80
+ ...originalNode,
81
+ router,
82
+ exits: [allResponsesExit]
83
+ };
84
+ },
85
+ localizable: 'categories',
86
+ toLocalizationFormData: categoriesToLocalizationFormData,
87
+ fromLocalizationFormData: localizationFormDataToCategories
88
+ };
@@ -0,0 +1,176 @@
1
+ import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
+ import { Node, Category, Exit } from '../../store/flow-definition';
3
+ import { generateUUID } from '../../utils';
4
+ import {
5
+ resultNameField,
6
+ categoriesToLocalizationFormData,
7
+ localizationFormDataToCategories
8
+ } from './shared';
9
+
10
+ const DIAL_CATEGORIES = ['Answered', 'No Answer', 'Busy', 'Failed'];
11
+ const DIAL_CASES = [
12
+ { type: 'has_only_text', arguments: ['answered'], categoryName: 'Answered' },
13
+ {
14
+ type: 'has_only_text',
15
+ arguments: ['no_answer'],
16
+ categoryName: 'No Answer'
17
+ },
18
+ { type: 'has_only_text', arguments: ['busy'], categoryName: 'Busy' }
19
+ ];
20
+
21
+ export const wait_for_dial: NodeConfig = {
22
+ type: 'wait_for_dial',
23
+ name: 'Redirect Call',
24
+ group: SPLIT_GROUPS.wait,
25
+ flowTypes: [FlowTypes.VOICE],
26
+ router: {
27
+ type: 'switch',
28
+ defaultCategory: 'Failed',
29
+ rules: DIAL_CASES.map((c) => ({
30
+ type: c.type as any,
31
+ arguments: c.arguments,
32
+ categoryName: c.categoryName
33
+ }))
34
+ },
35
+ form: {
36
+ phone: {
37
+ type: 'text',
38
+ label: 'Phone Number',
39
+ required: true,
40
+ evaluated: true,
41
+ placeholder: 'Phone number or expression'
42
+ },
43
+ dial_limit_seconds: {
44
+ type: 'text',
45
+ label: 'Dial Limit (seconds)',
46
+ required: false,
47
+ placeholder: '60'
48
+ },
49
+ call_limit_seconds: {
50
+ type: 'text',
51
+ label: 'Call Limit (seconds)',
52
+ required: false,
53
+ placeholder: '7200'
54
+ },
55
+ result_name: resultNameField
56
+ },
57
+ layout: [
58
+ 'phone',
59
+ {
60
+ type: 'row',
61
+ items: ['dial_limit_seconds', 'call_limit_seconds']
62
+ },
63
+ 'result_name'
64
+ ],
65
+ toFormData: (node: Node) => {
66
+ const wait = node.router?.wait;
67
+ return {
68
+ uuid: node.uuid,
69
+ phone: wait?.phone || '',
70
+ dial_limit_seconds: wait?.dial_limit_seconds
71
+ ? String(wait.dial_limit_seconds)
72
+ : '',
73
+ call_limit_seconds: wait?.call_limit_seconds
74
+ ? String(wait.call_limit_seconds)
75
+ : '',
76
+ result_name: node.router?.result_name || ''
77
+ };
78
+ },
79
+ fromFormData: (formData: FormData, originalNode: Node): Node => {
80
+ const existingCategories = originalNode.router?.categories || [];
81
+ const existingExits = originalNode.exits || [];
82
+ const existingCases = originalNode.router?.cases || [];
83
+
84
+ const categories: Category[] = [];
85
+ const exits: Exit[] = [];
86
+ const cases: any[] = [];
87
+
88
+ // Build categories and cases for each dial outcome
89
+ for (const catName of DIAL_CATEGORIES) {
90
+ const existing = existingCategories.find(
91
+ (c: Category) => c.name === catName
92
+ );
93
+
94
+ if (existing) {
95
+ categories.push(existing);
96
+ const existingExit = existingExits.find(
97
+ (e: Exit) => e.uuid === existing.exit_uuid
98
+ );
99
+ exits.push(
100
+ existingExit || {
101
+ uuid: existing.exit_uuid,
102
+ destination_uuid: null
103
+ }
104
+ );
105
+ } else {
106
+ const exitUuid = generateUUID();
107
+ categories.push({
108
+ uuid: generateUUID(),
109
+ name: catName,
110
+ exit_uuid: exitUuid
111
+ });
112
+ exits.push({ uuid: exitUuid, destination_uuid: null });
113
+ }
114
+ }
115
+
116
+ // Build cases for non-default categories
117
+ for (const dialCase of DIAL_CASES) {
118
+ const category = categories.find((c) => c.name === dialCase.categoryName);
119
+ if (!category) continue;
120
+
121
+ const existingCase = existingCases.find(
122
+ (c: any) =>
123
+ c.type === dialCase.type && c.arguments?.[0] === dialCase.arguments[0]
124
+ );
125
+
126
+ cases.push({
127
+ uuid: existingCase?.uuid || generateUUID(),
128
+ type: dialCase.type,
129
+ arguments: dialCase.arguments,
130
+ category_uuid: category.uuid
131
+ });
132
+ }
133
+
134
+ const failedCategory = categories.find((c) => c.name === 'Failed');
135
+
136
+ // Build wait config
137
+ const phone = (formData.phone || '').trim();
138
+ const dialLimit = parseInt(formData.dial_limit_seconds, 10);
139
+ const callLimit = parseInt(formData.call_limit_seconds, 10);
140
+
141
+ const waitConfig: any = {
142
+ type: 'dial',
143
+ phone
144
+ };
145
+
146
+ if (!isNaN(dialLimit) && dialLimit > 0) {
147
+ waitConfig.dial_limit_seconds = dialLimit;
148
+ }
149
+
150
+ if (!isNaN(callLimit) && callLimit > 0) {
151
+ waitConfig.call_limit_seconds = callLimit;
152
+ }
153
+
154
+ const router: any = {
155
+ type: 'switch',
156
+ operand: '@(default(resume.dial.status, ""))',
157
+ default_category_uuid: failedCategory?.uuid,
158
+ cases,
159
+ categories,
160
+ wait: waitConfig
161
+ };
162
+
163
+ if (formData.result_name && formData.result_name.trim() !== '') {
164
+ router.result_name = formData.result_name.trim();
165
+ }
166
+
167
+ return {
168
+ ...originalNode,
169
+ router,
170
+ exits
171
+ };
172
+ },
173
+ localizable: 'categories',
174
+ toLocalizationFormData: categoriesToLocalizationFormData,
175
+ fromLocalizationFormData: localizationFormDataToCategories
176
+ };
@@ -1,8 +1,92 @@
1
- import { SPLIT_GROUPS, NodeConfig, FlowTypes } from '../types';
1
+ import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
+ import { Node } from '../../store/flow-definition';
3
+ import { createRulesRouter } from '../../utils';
4
+ import {
5
+ getDigitOperators,
6
+ operatorsToSelectOptions,
7
+ getOperatorConfig
8
+ } from '../operators';
9
+ import {
10
+ resultNameField,
11
+ categoriesToLocalizationFormData,
12
+ localizationFormDataToCategories
13
+ } from './shared';
14
+ import {
15
+ createRulesArrayConfig,
16
+ extractUserRules,
17
+ casesToFormRules
18
+ } from './shared-rules';
2
19
 
3
20
  export const wait_for_digits: NodeConfig = {
4
21
  type: 'wait_for_digits',
5
22
  name: 'Wait for Digits',
6
23
  group: SPLIT_GROUPS.wait,
7
- flowTypes: [FlowTypes.VOICE]
24
+ flowTypes: [FlowTypes.VOICE],
25
+ dialogSize: 'large',
26
+ form: {
27
+ rules: createRulesArrayConfig(
28
+ operatorsToSelectOptions(getDigitOperators())
29
+ ),
30
+ result_name: resultNameField
31
+ },
32
+ layout: [
33
+ {
34
+ type: 'text',
35
+ text: 'Rules match against all digits pressed by the caller followed by the # sign.'
36
+ },
37
+ 'rules',
38
+ 'result_name'
39
+ ],
40
+ validate: (_formData: FormData) => {
41
+ return {
42
+ valid: true,
43
+ errors: {}
44
+ };
45
+ },
46
+ toFormData: (node: Node) => {
47
+ const rules = casesToFormRules(node);
48
+ return {
49
+ uuid: node.uuid,
50
+ rules,
51
+ result_name: node.router?.result_name || ''
52
+ };
53
+ },
54
+ fromFormData: (formData: FormData, originalNode: Node): Node => {
55
+ const userRules = extractUserRules(formData);
56
+ const existingCategories = originalNode.router?.categories || [];
57
+ const existingExits = originalNode.exits || [];
58
+ const existingCases = originalNode.router?.cases || [];
59
+
60
+ const { router, exits } = createRulesRouter(
61
+ '@input.text',
62
+ userRules,
63
+ getOperatorConfig,
64
+ existingCategories,
65
+ existingExits,
66
+ existingCases
67
+ );
68
+
69
+ const finalRouter: any = {
70
+ ...router,
71
+ wait: {
72
+ type: 'msg',
73
+ hint: {
74
+ type: 'digits'
75
+ }
76
+ }
77
+ };
78
+
79
+ if (formData.result_name && formData.result_name.trim() !== '') {
80
+ finalRouter.result_name = formData.result_name.trim();
81
+ }
82
+
83
+ return {
84
+ ...originalNode,
85
+ router: finalRouter,
86
+ exits
87
+ };
88
+ },
89
+ localizable: 'categories',
90
+ toLocalizationFormData: categoriesToLocalizationFormData,
91
+ fromLocalizationFormData: localizationFormDataToCategories
8
92
  };
@@ -1,8 +1,214 @@
1
- import { SPLIT_GROUPS, NodeConfig, FlowTypes } from '../types';
1
+ import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
+ import { Node, Category, Exit } from '../../store/flow-definition';
3
+ import { generateUUID } from '../../utils';
4
+ import {
5
+ resultNameField,
6
+ categoriesToLocalizationFormData,
7
+ localizationFormDataToCategories
8
+ } from './shared';
9
+
10
+ // Menu digits in display order: 1-9 then 0
11
+ const MENU_DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
12
+
13
+ function digitFieldKey(digit: string): string {
14
+ return `digit_${digit}`;
15
+ }
2
16
 
3
17
  export const wait_for_menu: NodeConfig = {
4
18
  type: 'wait_for_menu',
5
- name: 'Wait for Menu Selection',
19
+ name: 'Wait for Menu',
6
20
  group: SPLIT_GROUPS.wait,
7
- flowTypes: [FlowTypes.VOICE]
21
+ flowTypes: [FlowTypes.VOICE],
22
+ form: {
23
+ ...Object.fromEntries(
24
+ MENU_DIGITS.map((digit) => [
25
+ digitFieldKey(digit),
26
+ {
27
+ type: 'text' as const,
28
+ required: false,
29
+ placeholder: '',
30
+ flavor: 'xsmall' as const
31
+ }
32
+ ])
33
+ ),
34
+ result_name: resultNameField
35
+ },
36
+ layout: [
37
+ {
38
+ type: 'row' as const,
39
+ items: ['digit_1', 'digit_2', 'digit_3'],
40
+ gap: '2rem',
41
+ marginBottom: '0.5rem',
42
+ inlineLabels: { digit_1: '1', digit_2: '2', digit_3: '3' }
43
+ },
44
+ {
45
+ type: 'row' as const,
46
+ items: ['digit_4', 'digit_5', 'digit_6'],
47
+ gap: '2rem',
48
+ marginBottom: '0.5rem',
49
+ inlineLabels: { digit_4: '4', digit_5: '5', digit_6: '6' }
50
+ },
51
+ {
52
+ type: 'row' as const,
53
+ items: ['digit_7', 'digit_8', 'digit_9'],
54
+ gap: '2rem',
55
+ marginBottom: '0.5rem',
56
+ inlineLabels: { digit_7: '7', digit_8: '8', digit_9: '9' }
57
+ },
58
+ {
59
+ type: 'row' as const,
60
+ items: [
61
+ { type: 'spacer' as const },
62
+ 'digit_0',
63
+ { type: 'spacer' as const }
64
+ ],
65
+ gap: '2rem',
66
+ inlineLabels: { digit_0: '0' }
67
+ },
68
+ 'result_name'
69
+ ],
70
+ toFormData: (node: Node) => {
71
+ const formData: FormData = {
72
+ uuid: node.uuid,
73
+ result_name: node.router?.result_name || ''
74
+ };
75
+
76
+ // Initialize all digit fields as empty
77
+ for (const digit of MENU_DIGITS) {
78
+ formData[digitFieldKey(digit)] = '';
79
+ }
80
+
81
+ // Fill in category names from cases
82
+ if (node.router?.cases && node.router?.categories) {
83
+ for (const case_ of node.router.cases) {
84
+ if (case_.type === 'has_number_eq' && case_.arguments?.[0]) {
85
+ const digit = case_.arguments[0];
86
+ const category = node.router.categories.find(
87
+ (cat: Category) => cat.uuid === case_.category_uuid
88
+ );
89
+ if (category && MENU_DIGITS.includes(digit)) {
90
+ formData[digitFieldKey(digit)] = category.name;
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ return formData;
97
+ },
98
+ fromFormData: (formData: FormData, originalNode: Node): Node => {
99
+ const existingCategories = originalNode.router?.categories || [];
100
+ const existingExits = originalNode.exits || [];
101
+ const existingCases = originalNode.router?.cases || [];
102
+
103
+ const categories: Category[] = [];
104
+ const exits: Exit[] = [];
105
+ const cases: any[] = [];
106
+
107
+ // Build categories and cases for each filled digit
108
+ for (const digit of MENU_DIGITS) {
109
+ const categoryName = (formData[digitFieldKey(digit)] || '').trim();
110
+ if (!categoryName) continue;
111
+
112
+ // Check if a category with this name already exists in our new list
113
+ let category = categories.find((c) => c.name === categoryName);
114
+
115
+ if (!category) {
116
+ // Try to find existing category with same name to preserve UUIDs
117
+ const existingCat = existingCategories.find(
118
+ (c: Category) => c.name === categoryName
119
+ );
120
+
121
+ if (existingCat) {
122
+ category = existingCat;
123
+ const existingExit = existingExits.find(
124
+ (e: Exit) => e.uuid === existingCat.exit_uuid
125
+ );
126
+ categories.push(category);
127
+ exits.push(
128
+ existingExit || {
129
+ uuid: existingCat.exit_uuid,
130
+ destination_uuid: null
131
+ }
132
+ );
133
+ } else {
134
+ const exitUuid = generateUUID();
135
+ category = {
136
+ uuid: generateUUID(),
137
+ name: categoryName,
138
+ exit_uuid: exitUuid
139
+ };
140
+ categories.push(category);
141
+ exits.push({ uuid: exitUuid, destination_uuid: null });
142
+ }
143
+ }
144
+
145
+ // Find existing case for this digit to preserve UUID
146
+ const existingCase = existingCases.find(
147
+ (c: any) => c.type === 'has_number_eq' && c.arguments?.[0] === digit
148
+ );
149
+
150
+ cases.push({
151
+ uuid: existingCase?.uuid || generateUUID(),
152
+ type: 'has_number_eq',
153
+ arguments: [digit],
154
+ category_uuid: category.uuid
155
+ });
156
+ }
157
+
158
+ // Add "Other" default category
159
+ const existingOther = existingCategories.find(
160
+ (c: Category) => c.name === 'Other'
161
+ );
162
+
163
+ let otherCategory: Category;
164
+ if (existingOther) {
165
+ otherCategory = existingOther;
166
+ const existingExit = existingExits.find(
167
+ (e: Exit) => e.uuid === existingOther.exit_uuid
168
+ );
169
+ exits.push(
170
+ existingExit || {
171
+ uuid: existingOther.exit_uuid,
172
+ destination_uuid: null
173
+ }
174
+ );
175
+ } else {
176
+ const exitUuid = generateUUID();
177
+ otherCategory = {
178
+ uuid: generateUUID(),
179
+ name: 'Other',
180
+ exit_uuid: exitUuid
181
+ };
182
+ exits.push({ uuid: exitUuid, destination_uuid: null });
183
+ }
184
+ categories.push(otherCategory);
185
+
186
+ const router: any = {
187
+ type: 'switch',
188
+ operand: '@input.text',
189
+ default_category_uuid: otherCategory.uuid,
190
+ cases,
191
+ categories,
192
+ wait: {
193
+ type: 'msg',
194
+ hint: {
195
+ type: 'digits',
196
+ count: 1
197
+ }
198
+ }
199
+ };
200
+
201
+ if (formData.result_name && formData.result_name.trim() !== '') {
202
+ router.result_name = formData.result_name.trim();
203
+ }
204
+
205
+ return {
206
+ ...originalNode,
207
+ router,
208
+ exits
209
+ };
210
+ },
211
+ localizable: 'categories',
212
+ toLocalizationFormData: categoriesToLocalizationFormData,
213
+ fromLocalizationFormData: localizationFormDataToCategories
8
214
  };
@@ -44,11 +44,6 @@ export const OPERATORS: OperatorConfig[] = [
44
44
  operands: 0,
45
45
  categoryName: 'Has Text'
46
46
  },
47
- {
48
- type: 'has_pattern',
49
- name: 'matches regex',
50
- operands: 1
51
- },
52
47
 
53
48
  // Number operators
54
49
  {
@@ -180,6 +175,11 @@ export const OPERATORS: OperatorConfig[] = [
180
175
  operands: 0,
181
176
  categoryName: 'Not Empty',
182
177
  visibility: 'hidden'
178
+ },
179
+ {
180
+ type: 'has_pattern',
181
+ name: 'matches regex',
182
+ operands: 1
183
183
  }
184
184
  ];
185
185
 
@@ -190,6 +190,24 @@ export const getWaitForResponseOperators = (): OperatorConfig[] => {
190
190
  );
191
191
  };
192
192
 
193
+ // Number operator types used for digit-based routing
194
+ const DIGIT_OPERATOR_TYPES = new Set([
195
+ 'has_beginning',
196
+ 'has_number',
197
+ 'has_number_between',
198
+ 'has_number_lt',
199
+ 'has_number_lte',
200
+ 'has_number_eq',
201
+ 'has_number_gte',
202
+ 'has_number_gt',
203
+ 'has_pattern'
204
+ ]);
205
+
206
+ // Get operators suitable for wait_for_digits rules (number operators + regex)
207
+ export const getDigitOperators = (): OperatorConfig[] => {
208
+ return OPERATORS.filter((op) => DIGIT_OPERATOR_TYPES.has(op.type));
209
+ };
210
+
193
211
  // Get operator configuration by type
194
212
  export const getOperatorConfig = (type: string): OperatorConfig | undefined => {
195
213
  return OPERATORS.find((op) => op.type === type);