@nyaruka/temba-components 0.138.6 → 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 (196) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +26 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/locales/es.js +5 -5
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +5 -5
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/locale-codes.js +2 -11
  10. package/dist/locales/locale-codes.js.map +1 -1
  11. package/dist/locales/pt.js +5 -5
  12. package/dist/locales/pt.js.map +1 -1
  13. package/dist/temba-components.js +1112 -882
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/display/Chat.js +10 -7
  16. package/out-tsc/src/display/Chat.js.map +1 -1
  17. package/out-tsc/src/display/Dropdown.js +3 -1
  18. package/out-tsc/src/display/Dropdown.js.map +1 -1
  19. package/out-tsc/src/display/FloatingTab.js +25 -32
  20. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  21. package/out-tsc/src/display/Thumbnail.js +163 -5
  22. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  23. package/out-tsc/src/flow/CanvasMenu.js +5 -3
  24. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  25. package/out-tsc/src/flow/CanvasNode.js +70 -29
  26. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  27. package/out-tsc/src/flow/Editor.js +290 -239
  28. package/out-tsc/src/flow/Editor.js.map +1 -1
  29. package/out-tsc/src/flow/NodeEditor.js +118 -10
  30. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  31. package/out-tsc/src/flow/Plumber.js +757 -403
  32. package/out-tsc/src/flow/Plumber.js.map +1 -1
  33. package/out-tsc/src/flow/StickyNote.js +13 -4
  34. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  35. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  36. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  37. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  38. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  39. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  40. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  41. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  42. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  43. package/out-tsc/src/flow/config.js +11 -3
  44. package/out-tsc/src/flow/config.js.map +1 -1
  45. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  46. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  47. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  48. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  49. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  50. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  51. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  52. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  53. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  54. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  55. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  56. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  57. package/out-tsc/src/flow/operators.js +21 -5
  58. package/out-tsc/src/flow/operators.js.map +1 -1
  59. package/out-tsc/src/flow/types.js.map +1 -1
  60. package/out-tsc/src/flow/utils.js +213 -65
  61. package/out-tsc/src/flow/utils.js.map +1 -1
  62. package/out-tsc/src/form/ArrayEditor.js +4 -2
  63. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  64. package/out-tsc/src/form/FieldRenderer.js +49 -0
  65. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  66. package/out-tsc/src/interfaces.js +2 -0
  67. package/out-tsc/src/interfaces.js.map +1 -1
  68. package/out-tsc/src/layout/Dialog.js +52 -7
  69. package/out-tsc/src/layout/Dialog.js.map +1 -1
  70. package/out-tsc/src/list/TicketList.js +4 -1
  71. package/out-tsc/src/list/TicketList.js.map +1 -1
  72. package/out-tsc/src/live/TembaChart.js.map +1 -1
  73. package/out-tsc/src/locales/es.js +5 -5
  74. package/out-tsc/src/locales/es.js.map +1 -1
  75. package/out-tsc/src/locales/fr.js +5 -5
  76. package/out-tsc/src/locales/fr.js.map +1 -1
  77. package/out-tsc/src/locales/locale-codes.js +2 -11
  78. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  79. package/out-tsc/src/locales/pt.js +5 -5
  80. package/out-tsc/src/locales/pt.js.map +1 -1
  81. package/out-tsc/src/simulator/Simulator.js +10 -3
  82. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  83. package/out-tsc/src/store/AppState.js +89 -3
  84. package/out-tsc/src/store/AppState.js.map +1 -1
  85. package/out-tsc/test/actions/play_audio.test.js +118 -0
  86. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  87. package/out-tsc/test/actions/say_msg.test.js +158 -0
  88. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  89. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  90. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  91. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  92. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  93. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  94. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  95. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  96. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  97. package/out-tsc/test/temba-floating-tab.test.js +4 -6
  98. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  99. package/out-tsc/test/temba-flow-collision.test.js +473 -220
  100. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  101. package/out-tsc/test/temba-flow-editor.test.js +0 -2
  102. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  103. package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
  104. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  105. package/out-tsc/test/temba-flow-plumber.test.js +102 -93
  106. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  107. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  108. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  109. package/package.json +1 -1
  110. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  111. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  112. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  113. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  114. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  115. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  116. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  117. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  118. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  119. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  120. package/screenshots/truth/editor/router.png +0 -0
  121. package/screenshots/truth/editor/wait.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  124. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  125. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  126. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  127. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  128. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  129. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  141. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  142. package/src/display/Chat.ts +13 -7
  143. package/src/display/Dropdown.ts +3 -1
  144. package/src/display/FloatingTab.ts +24 -33
  145. package/src/display/Thumbnail.ts +162 -2
  146. package/src/flow/CanvasMenu.ts +8 -3
  147. package/src/flow/CanvasNode.ts +75 -30
  148. package/src/flow/Editor.ts +336 -288
  149. package/src/flow/NodeEditor.ts +137 -9
  150. package/src/flow/Plumber.ts +1011 -457
  151. package/src/flow/StickyNote.ts +14 -4
  152. package/src/flow/actions/audio-player.ts +127 -0
  153. package/src/flow/actions/enter_flow.ts +44 -0
  154. package/src/flow/actions/play_audio.ts +64 -5
  155. package/src/flow/actions/say_msg.ts +94 -4
  156. package/src/flow/config.ts +11 -3
  157. package/src/flow/nodes/shared-rules.ts +1 -1
  158. package/src/flow/nodes/terminal.ts +9 -0
  159. package/src/flow/nodes/wait_for_audio.ts +88 -0
  160. package/src/flow/nodes/wait_for_dial.ts +176 -0
  161. package/src/flow/nodes/wait_for_digits.ts +86 -2
  162. package/src/flow/nodes/wait_for_menu.ts +209 -3
  163. package/src/flow/operators.ts +23 -5
  164. package/src/flow/types.ts +23 -1
  165. package/src/flow/utils.ts +238 -81
  166. package/src/form/ArrayEditor.ts +4 -2
  167. package/src/form/FieldRenderer.ts +64 -1
  168. package/src/interfaces.ts +3 -1
  169. package/src/layout/Dialog.ts +53 -7
  170. package/src/list/TicketList.ts +4 -1
  171. package/src/live/TembaChart.ts +1 -1
  172. package/src/locales/es.ts +13 -18
  173. package/src/locales/fr.ts +13 -18
  174. package/src/locales/locale-codes.ts +2 -11
  175. package/src/locales/pt.ts +13 -18
  176. package/src/simulator/Simulator.ts +13 -3
  177. package/src/store/AppState.ts +105 -1
  178. package/src/store/flow-definition.d.ts +2 -0
  179. package/test/actions/play_audio.test.ts +155 -0
  180. package/test/actions/say_msg.test.ts +196 -0
  181. package/test/nodes/wait_for_audio.test.ts +182 -0
  182. package/test/nodes/wait_for_dial.test.ts +382 -0
  183. package/test/nodes/wait_for_digits.test.ts +233 -109
  184. package/test/nodes/wait_for_menu.test.ts +383 -0
  185. package/test/temba-floating-tab.test.ts +4 -6
  186. package/test/temba-flow-collision.test.ts +495 -293
  187. package/test/temba-flow-editor.test.ts +0 -2
  188. package/test/temba-flow-plumber-connections.test.ts +97 -97
  189. package/test/temba-flow-plumber.test.ts +116 -103
  190. package/test/temba-node-type-selector.test.ts +6 -6
  191. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -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);
package/src/flow/types.ts CHANGED
@@ -114,6 +114,8 @@ export interface NodeConfig extends FormConfig {
114
114
  rules?: {
115
115
  type:
116
116
  | 'has_number_between'
117
+ | 'has_number_eq'
118
+ | 'has_only_text'
117
119
  | 'has_string'
118
120
  | 'has_value'
119
121
  | 'has_not_value'
@@ -253,6 +255,12 @@ export interface MessageEditorFieldConfig extends BaseFieldConfig {
253
255
  disableCompletion?: boolean;
254
256
  }
255
257
 
258
+ export interface MediaFieldConfig extends BaseFieldConfig {
259
+ type: 'media';
260
+ accept?: string; // MIME filter, e.g. 'audio/*'
261
+ endpoint?: string; // upload endpoint, defaults to DEFAULT_MEDIA_ENDPOINT
262
+ }
263
+
256
264
  export type FieldConfig =
257
265
  | TextFieldConfig
258
266
  | TextareaFieldConfig
@@ -260,7 +268,8 @@ export type FieldConfig =
260
268
  | KeyValueFieldConfig
261
269
  | ArrayFieldConfig
262
270
  | CheckboxFieldConfig
263
- | MessageEditorFieldConfig;
271
+ | MessageEditorFieldConfig
272
+ | MediaFieldConfig;
264
273
 
265
274
  // Layout configurations for better form organization
266
275
  // Recursive layout system - any layout item can contain other layout items
@@ -276,6 +285,8 @@ export interface RowLayoutConfig {
276
285
  gap?: string; // CSS gap value, defaults to '1rem'
277
286
  label?: string; // optional label for the entire row
278
287
  helpText?: string; // optional help text for the entire row
288
+ inlineLabels?: Record<string, string>; // map of field name to inline label text
289
+ marginBottom?: string; // CSS margin-bottom for spacing below the row
279
290
  }
280
291
 
281
292
  export interface GroupLayoutConfig {
@@ -288,10 +299,21 @@ export interface GroupLayoutConfig {
288
299
  getGroupValueCount?: (formData: FormData) => number; // optional function to get count for bubble display
289
300
  }
290
301
 
302
+ export interface SpacerLayoutConfig {
303
+ type: 'spacer';
304
+ }
305
+
306
+ export interface TextLayoutConfig {
307
+ type: 'text';
308
+ text: string;
309
+ }
310
+
291
311
  export type LayoutItem =
292
312
  | FieldItemConfig
293
313
  | RowLayoutConfig
294
314
  | GroupLayoutConfig
315
+ | SpacerLayoutConfig
316
+ | TextLayoutConfig
295
317
  | string; // string is shorthand for field
296
318
 
297
319
  /**