@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
@@ -10,18 +10,9 @@ export const sourceLocale = `en`;
10
10
  * The other locale codes that this application is localized into. Sorted
11
11
  * lexicographically.
12
12
  */
13
- export const targetLocales = [
14
- `es`,
15
- `fr`,
16
- `pt`,
17
- ] as const;
13
+ export const targetLocales = [`es`, `fr`, `pt`] as const;
18
14
 
19
15
  /**
20
16
  * All valid project locale codes. Sorted lexicographically.
21
17
  */
22
- export const allLocales = [
23
- `en`,
24
- `es`,
25
- `fr`,
26
- `pt`,
27
- ] as const;
18
+ export const allLocales = [`en`, `es`, `fr`, `pt`] as const;
package/src/locales/pt.ts CHANGED
@@ -1,18 +1,13 @@
1
-
2
- // Do not modify this file by hand!
3
- // Re-generate this file by running lit-localize
4
-
5
-
6
-
7
-
8
- /* eslint-disable no-irregular-whitespace */
9
- /* eslint-disable @typescript-eslint/no-explicit-any */
10
-
11
- export const templates = {
12
- 's73b4d70c02f4b4e0': `No options`,
13
- 'scf1453991c986b25': `Tab to complete, enter to select`,
14
- 's8f02e3a18ffc083a': `Are not currently in a flow`,
15
- 's638236250662c6b3': `Have sent a message in the last`,
16
- 's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
17
- };
18
-
1
+ // Do not modify this file by hand!
2
+ // Re-generate this file by running lit-localize
3
+
4
+ /* eslint-disable no-irregular-whitespace */
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+
7
+ export const templates = {
8
+ s73b4d70c02f4b4e0: `No options`,
9
+ scf1453991c986b25: `Tab to complete, enter to select`,
10
+ s8f02e3a18ffc083a: `Are not currently in a flow`,
11
+ s638236250662c6b3: `Have sent a message in the last`,
12
+ s4788ee206c4570c7: `Have not started this flow in the last 90 days`
13
+ };
@@ -148,6 +148,10 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
148
148
  export class Simulator extends RapidElement {
149
149
  static get styles() {
150
150
  return css`
151
+ temba-floating-tab {
152
+ --floating-tab-right: 15px;
153
+ }
154
+
151
155
  :host {
152
156
  /* size-specific dimensions are set dynamically via inline styles */
153
157
  --phone-width: 300px;
@@ -1117,8 +1121,12 @@ export class Simulator extends RapidElement {
1117
1121
  continue;
1118
1122
  }
1119
1123
 
1120
- // skip msg_created events without a proper msg property
1121
- if (rawEvent.type === 'msg_created' && !(rawEvent as any).msg) {
1124
+ // skip msg_created/ivr_created events without a proper msg property
1125
+ if (
1126
+ (rawEvent.type === 'msg_created' ||
1127
+ rawEvent.type === 'ivr_created') &&
1128
+ !(rawEvent as any).msg
1129
+ ) {
1122
1130
  continue;
1123
1131
  }
1124
1132
 
@@ -1140,7 +1148,8 @@ export class Simulator extends RapidElement {
1140
1148
  this.currentQuickReplies = (event as any).msg.quick_replies;
1141
1149
  }
1142
1150
 
1143
- const isMessage = event.type === 'msg_created';
1151
+ const isMessage =
1152
+ event.type === 'msg_created' || event.type === 'ivr_created';
1144
1153
  const msg = (event as any).msg;
1145
1154
 
1146
1155
  // Check if the event should be displayed.
@@ -1934,6 +1943,7 @@ export class Simulator extends RapidElement {
1934
1943
  icon="simulator"
1935
1944
  label="Phone Simulator"
1936
1945
  color="#10b981"
1946
+ order="4"
1937
1947
  .hidden=${this.isVisible}
1938
1948
  @temba-button-clicked=${this.handleShow}
1939
1949
  ></temba-floating-tab>
@@ -18,6 +18,59 @@ import { produce } from 'immer';
18
18
  export const FLOW_SPEC_VERSION = '14.3';
19
19
  const CANVAS_PADDING = 800;
20
20
 
21
+ /**
22
+ * Temporary: Reclassify nodes based on whether they contain terminal actions.
23
+ * - execute_actions nodes with a terminal action become "terminal"
24
+ * - terminal nodes that no longer have a terminal action become "execute_actions"
25
+ * This can be removed once we stop supporting terminal nodes.
26
+ */
27
+ function reclassifyTerminalNodes(definition: FlowDefinition): void {
28
+ if (!definition?.nodes || !definition?._ui?.nodes) return;
29
+
30
+ for (const node of definition.nodes) {
31
+ const nodeUI = definition._ui.nodes[node.uuid];
32
+ if (!nodeUI) continue;
33
+
34
+ const hasTerminalAction = node.actions?.some(
35
+ (action) => (action as any).terminal === true
36
+ );
37
+
38
+ if (nodeUI.type === 'execute_actions' && hasTerminalAction) {
39
+ nodeUI.type = 'terminal' as any;
40
+ } else if (nodeUI.type === ('terminal' as any) && !hasTerminalAction) {
41
+ nodeUI.type = 'execute_actions';
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Reclassify wait_for_response nodes that are actually voice-specific wait types.
48
+ * The server stores all voice waits as wait_for_response, but we detect the specific
49
+ * type from the router's wait hint:
50
+ * - hint.type === 'digits' && hint.count === 1 → wait_for_menu
51
+ * - hint.type === 'digits' (no count) → wait_for_digits
52
+ * - hint.type === 'audio' → wait_for_audio
53
+ */
54
+ function reclassifyVoiceWaitNodes(definition: FlowDefinition): void {
55
+ if (!definition?.nodes || !definition?._ui?.nodes) return;
56
+
57
+ for (const node of definition.nodes) {
58
+ const nodeUI = definition._ui.nodes[node.uuid];
59
+ if (!nodeUI || nodeUI.type !== 'wait_for_response') continue;
60
+
61
+ const hint = node.router?.wait?.hint;
62
+ if (!hint) continue;
63
+
64
+ if (hint.type === 'digits' && hint.count === 1) {
65
+ nodeUI.type = 'wait_for_menu' as any;
66
+ } else if (hint.type === 'digits') {
67
+ nodeUI.type = 'wait_for_digits' as any;
68
+ } else if (hint.type === 'audio') {
69
+ nodeUI.type = 'wait_for_audio' as any;
70
+ }
71
+ }
72
+ }
73
+
21
74
  /**
22
75
  * Sorts nodes by their position - first by y (top), then by x (left)
23
76
  */
@@ -65,11 +118,44 @@ export interface Language {
65
118
  name: string;
66
119
  }
67
120
 
121
+ export interface FlowIssue {
122
+ type: string;
123
+ node_uuid: string;
124
+ action_uuid?: string;
125
+ description: string;
126
+ dependency?: {
127
+ key: string;
128
+ name: string;
129
+ type: string;
130
+ };
131
+ }
132
+
68
133
  export interface FlowInfo {
69
134
  results: InfoResult[];
70
135
  dependencies: TypedObjectRef[];
71
136
  counts: { nodes: number; languages: number };
72
137
  locals: string[];
138
+ issues?: FlowIssue[];
139
+ }
140
+
141
+ function buildIssueMaps(issues: FlowIssue[] = []): {
142
+ byNode: Map<string, FlowIssue[]>;
143
+ byAction: Map<string, FlowIssue[]>;
144
+ } {
145
+ const byNode = new Map<string, FlowIssue[]>();
146
+ const byAction = new Map<string, FlowIssue[]>();
147
+ for (const issue of issues) {
148
+ if (issue.action_uuid) {
149
+ const actionList = byAction.get(issue.action_uuid) || [];
150
+ actionList.push(issue);
151
+ byAction.set(issue.action_uuid, actionList);
152
+ } else {
153
+ const nodeList = byNode.get(issue.node_uuid) || [];
154
+ nodeList.push(issue);
155
+ byNode.set(issue.node_uuid, nodeList);
156
+ }
157
+ }
158
+ return { byNode, byAction };
73
159
  }
74
160
 
75
161
  export interface FlowContents {
@@ -100,6 +186,8 @@ export interface Activity {
100
186
  export interface AppState {
101
187
  flowDefinition: FlowDefinition;
102
188
  flowInfo: FlowInfo;
189
+ issuesByNode: Map<string, FlowIssue[]>;
190
+ issuesByAction: Map<string, FlowIssue[]>;
103
191
 
104
192
  languageCode: string;
105
193
  languageNames: { [code: string]: Language };
@@ -174,6 +262,8 @@ export const zustand = createStore<AppState>()(
174
262
  workspace: null,
175
263
  flowDefinition: null,
176
264
  flowInfo: null,
265
+ issuesByNode: new Map(),
266
+ issuesByAction: new Map(),
177
267
  isTranslating: false,
178
268
  viewingRevision: false,
179
269
  dirtyDate: null,
@@ -201,10 +291,15 @@ export const zustand = createStore<AppState>()(
201
291
  throw new Error('Network response was not ok');
202
292
  }
203
293
  const data = (await response.json()) as FlowContents;
294
+ reclassifyTerminalNodes(data.definition);
295
+ reclassifyVoiceWaitNodes(data.definition);
296
+ const issueMaps = buildIssueMaps(data.info?.issues);
204
297
  set({
205
298
  flowInfo: data.info,
206
299
  flowDefinition: data.definition,
207
- viewingRevision
300
+ viewingRevision,
301
+ issuesByNode: issueMaps.byNode,
302
+ issuesByAction: issueMaps.byAction
208
303
  });
209
304
  },
210
305
 
@@ -295,10 +390,16 @@ export const zustand = createStore<AppState>()(
295
390
  nodes: [...(flow.definition.nodes || [])]
296
391
  };
297
392
  state.flowInfo = flow.info;
393
+ const issueMaps = buildIssueMaps(flow.info?.issues);
394
+ state.issuesByNode = issueMaps.byNode;
395
+ state.issuesByAction = issueMaps.byAction;
298
396
  // Reset to the flow's default language when loading a new flow
299
397
  state.languageCode = flowLang;
300
398
  state.isTranslating = false;
301
399
 
400
+ reclassifyTerminalNodes(state.flowDefinition);
401
+ reclassifyVoiceWaitNodes(state.flowDefinition);
402
+
302
403
  // Sort nodes by position when loading flow
303
404
  if (state.flowDefinition?.nodes && state.flowDefinition?._ui?.nodes) {
304
405
  sortNodesByPosition(
@@ -312,6 +413,9 @@ export const zustand = createStore<AppState>()(
312
413
  setFlowInfo: (info: FlowInfo) => {
313
414
  set((state: AppState) => {
314
415
  state.flowInfo = info;
416
+ const issueMaps = buildIssueMaps(info?.issues);
417
+ state.issuesByNode = issueMaps.byNode;
418
+ state.issuesByAction = issueMaps.byAction;
315
419
  });
316
420
  },
317
421
 
@@ -28,6 +28,7 @@ export type ActionType =
28
28
  | 'send_email'
29
29
  | 'send_broadcast'
30
30
  | 'enter_flow'
31
+ | 'terminal'
31
32
  | 'start_session'
32
33
  | 'transfer_airtime'
33
34
  | 'split_by_airtime'
@@ -152,6 +153,7 @@ export interface SendBroadcast extends Action {
152
153
 
153
154
  export interface EnterFlow extends Action {
154
155
  flow: NamedObject;
156
+ terminal?: boolean;
155
157
  }
156
158
 
157
159
  export interface StartSession extends Action {
@@ -0,0 +1,155 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { play_audio } from '../../src/flow/actions/play_audio';
3
+ import { PlayAudio } from '../../src/store/flow-definition';
4
+ import { ActionTest } from '../ActionHelper';
5
+
6
+ /**
7
+ * Test suite for the play_audio action configuration.
8
+ */
9
+ describe('play_audio action config', () => {
10
+ const helper = new ActionTest(play_audio, 'play_audio');
11
+
12
+ describe('basic properties', () => {
13
+ helper.testBasicProperties();
14
+
15
+ it('has correct name', () => {
16
+ expect(play_audio.name).to.equal('Play Recording');
17
+ });
18
+
19
+ it('is voice-only', () => {
20
+ expect(play_audio.flowTypes).to.deep.equal(['voice']);
21
+ });
22
+ });
23
+
24
+ describe('action scenarios', () => {
25
+ helper.testAction(
26
+ {
27
+ uuid: 'test-play-1',
28
+ type: 'play_audio',
29
+ audio_url: '@results.voicemail'
30
+ } as PlayAudio,
31
+ 'expression-url'
32
+ );
33
+
34
+ helper.testAction(
35
+ {
36
+ uuid: 'test-play-2',
37
+ type: 'play_audio',
38
+ audio_url: 'https://example.com/greeting.mp3'
39
+ } as PlayAudio,
40
+ 'static-url'
41
+ );
42
+ });
43
+
44
+ describe('data transformation', () => {
45
+ it('converts action to form data', () => {
46
+ const action: PlayAudio = {
47
+ uuid: 'test-action',
48
+ type: 'play_audio',
49
+ audio_url: '@results.voicemail'
50
+ };
51
+
52
+ const formData = play_audio.toFormData!(action);
53
+ expect(formData.uuid).to.equal('test-action');
54
+ expect(formData.audio_url).to.equal('@results.voicemail');
55
+ });
56
+
57
+ it('handles missing audio_url', () => {
58
+ const action = {
59
+ uuid: 'test-action',
60
+ type: 'play_audio'
61
+ } as PlayAudio;
62
+
63
+ const formData = play_audio.toFormData!(action);
64
+ expect(formData.audio_url).to.equal('');
65
+ });
66
+
67
+ it('converts form data to action', () => {
68
+ const formData = {
69
+ uuid: 'test-action',
70
+ audio_url: '@results.voicemail'
71
+ };
72
+
73
+ const action = play_audio.fromFormData!(formData) as PlayAudio;
74
+ expect(action.uuid).to.equal('test-action');
75
+ expect(action.type).to.equal('play_audio');
76
+ expect(action.audio_url).to.equal('@results.voicemail');
77
+ });
78
+
79
+ it('trims whitespace from audio_url', () => {
80
+ const formData = {
81
+ uuid: 'test-action',
82
+ audio_url: ' @results.voicemail '
83
+ };
84
+
85
+ const action = play_audio.fromFormData!(formData) as PlayAudio;
86
+ expect(action.audio_url).to.equal('@results.voicemail');
87
+ });
88
+ });
89
+
90
+ describe('localization', () => {
91
+ it('converts localization to form data', () => {
92
+ const action: PlayAudio = {
93
+ uuid: 'test-action',
94
+ type: 'play_audio',
95
+ audio_url: '@results.voicemail'
96
+ };
97
+
98
+ const localization = {
99
+ audio_url: ['@results.voicemail_es']
100
+ };
101
+
102
+ const formData = play_audio.toLocalizationFormData!(action, localization);
103
+ expect(formData.audio_url).to.equal('@results.voicemail_es');
104
+ });
105
+
106
+ it('handles missing localization', () => {
107
+ const action: PlayAudio = {
108
+ uuid: 'test-action',
109
+ type: 'play_audio',
110
+ audio_url: '@results.voicemail'
111
+ };
112
+
113
+ const formData = play_audio.toLocalizationFormData!(action, {});
114
+ expect(formData.audio_url).to.equal('');
115
+ });
116
+
117
+ it('converts form data to localization', () => {
118
+ const action: PlayAudio = {
119
+ uuid: 'test-action',
120
+ type: 'play_audio',
121
+ audio_url: '@results.voicemail'
122
+ };
123
+
124
+ const formData = {
125
+ uuid: 'test-action',
126
+ audio_url: '@results.voicemail_es'
127
+ };
128
+
129
+ const localization = play_audio.fromLocalizationFormData!(
130
+ formData,
131
+ action
132
+ );
133
+ expect(localization.audio_url).to.deep.equal(['@results.voicemail_es']);
134
+ });
135
+
136
+ it('omits unchanged localization', () => {
137
+ const action: PlayAudio = {
138
+ uuid: 'test-action',
139
+ type: 'play_audio',
140
+ audio_url: '@results.voicemail'
141
+ };
142
+
143
+ const formData = {
144
+ uuid: 'test-action',
145
+ audio_url: '@results.voicemail' // same as original
146
+ };
147
+
148
+ const localization = play_audio.fromLocalizationFormData!(
149
+ formData,
150
+ action
151
+ );
152
+ expect(localization.audio_url).to.be.undefined;
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,196 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { say_msg } from '../../src/flow/actions/say_msg';
3
+ import { SayMsg } from '../../src/store/flow-definition';
4
+ import { ActionTest } from '../ActionHelper';
5
+
6
+ /**
7
+ * Test suite for the say_msg action configuration.
8
+ */
9
+ describe('say_msg action config', () => {
10
+ const helper = new ActionTest(say_msg, 'say_msg');
11
+
12
+ describe('basic properties', () => {
13
+ helper.testBasicProperties();
14
+
15
+ it('has correct name', () => {
16
+ expect(say_msg.name).to.equal('Say Message');
17
+ });
18
+
19
+ it('is voice-only', () => {
20
+ expect(say_msg.flowTypes).to.deep.equal(['voice']);
21
+ });
22
+ });
23
+
24
+ describe('action scenarios', () => {
25
+ helper.testAction(
26
+ {
27
+ uuid: 'test-say-1',
28
+ type: 'say_msg',
29
+ text: 'Hello, welcome to our service.'
30
+ } as SayMsg,
31
+ 'simple-text'
32
+ );
33
+
34
+ helper.testAction(
35
+ {
36
+ uuid: 'test-say-2',
37
+ type: 'say_msg',
38
+ text: 'Press 1 for sales.\nPress 2 for support.\nPress 3 to leave a message.'
39
+ } as SayMsg,
40
+ 'multiline-text'
41
+ );
42
+
43
+ helper.testAction(
44
+ {
45
+ uuid: 'test-say-3',
46
+ type: 'say_msg',
47
+ text: 'Please listen to the following recording.',
48
+ audio_url: 'https://example.com/greeting.mp3'
49
+ } as SayMsg,
50
+ 'text-with-audio-url'
51
+ );
52
+ });
53
+
54
+ describe('data transformation', () => {
55
+ it('converts action to form data', () => {
56
+ const action: SayMsg = {
57
+ uuid: 'test-action',
58
+ type: 'say_msg',
59
+ text: 'Hello world',
60
+ audio_url: 'https://example.com/audio.mp3'
61
+ };
62
+
63
+ const formData = say_msg.toFormData!(action);
64
+ expect(formData.uuid).to.equal('test-action');
65
+ expect(formData.text).to.equal('Hello world');
66
+ expect(formData.audio_url).to.equal('https://example.com/audio.mp3');
67
+ });
68
+
69
+ it('handles missing audio_url in toFormData', () => {
70
+ const action: SayMsg = {
71
+ uuid: 'test-action',
72
+ type: 'say_msg',
73
+ text: 'Hello'
74
+ } as SayMsg;
75
+
76
+ const formData = say_msg.toFormData!(action);
77
+ expect(formData.audio_url).to.equal('');
78
+ });
79
+
80
+ it('converts form data to action', () => {
81
+ const formData = {
82
+ uuid: 'test-action',
83
+ text: 'Hello world',
84
+ audio_url: 'https://example.com/audio.mp3'
85
+ };
86
+
87
+ const action = say_msg.fromFormData!(formData) as SayMsg;
88
+ expect(action.uuid).to.equal('test-action');
89
+ expect(action.type).to.equal('say_msg');
90
+ expect(action.text).to.equal('Hello world');
91
+ expect(action.audio_url).to.equal('https://example.com/audio.mp3');
92
+ });
93
+
94
+ it('omits empty audio_url in fromFormData', () => {
95
+ const formData = {
96
+ uuid: 'test-action',
97
+ text: 'Hello world',
98
+ audio_url: ''
99
+ };
100
+
101
+ const action = say_msg.fromFormData!(formData) as SayMsg;
102
+ expect(action.audio_url).to.be.undefined;
103
+ });
104
+
105
+ it('trims whitespace from audio_url', () => {
106
+ const formData = {
107
+ uuid: 'test-action',
108
+ text: 'Hello',
109
+ audio_url: ' https://example.com/audio.mp3 '
110
+ };
111
+
112
+ const action = say_msg.fromFormData!(formData) as SayMsg;
113
+ expect(action.audio_url).to.equal('https://example.com/audio.mp3');
114
+ });
115
+ });
116
+
117
+ describe('sanitize', () => {
118
+ it('trims text whitespace', () => {
119
+ const formData = { text: ' Hello world ' };
120
+ say_msg.sanitize!(formData);
121
+ expect(formData.text).to.equal('Hello world');
122
+ });
123
+ });
124
+
125
+ describe('localization', () => {
126
+ it('converts localization to form data', () => {
127
+ const action: SayMsg = {
128
+ uuid: 'test-action',
129
+ type: 'say_msg',
130
+ text: 'Hello',
131
+ audio_url: 'https://example.com/en.mp3'
132
+ };
133
+
134
+ const localization = {
135
+ text: ['Hola'],
136
+ audio_url: ['https://example.com/es.mp3']
137
+ };
138
+
139
+ const formData = say_msg.toLocalizationFormData!(action, localization);
140
+ expect(formData.text).to.equal('Hola');
141
+ expect(formData.audio_url).to.equal('https://example.com/es.mp3');
142
+ });
143
+
144
+ it('handles missing localization fields', () => {
145
+ const action: SayMsg = {
146
+ uuid: 'test-action',
147
+ type: 'say_msg',
148
+ text: 'Hello'
149
+ } as SayMsg;
150
+
151
+ const formData = say_msg.toLocalizationFormData!(action, {});
152
+ expect(formData.text).to.equal('');
153
+ expect(formData.audio_url).to.equal('');
154
+ });
155
+
156
+ it('converts form data to localization', () => {
157
+ const action: SayMsg = {
158
+ uuid: 'test-action',
159
+ type: 'say_msg',
160
+ text: 'Hello',
161
+ audio_url: 'https://example.com/en.mp3'
162
+ };
163
+
164
+ const formData = {
165
+ uuid: 'test-action',
166
+ text: 'Hola',
167
+ audio_url: 'https://example.com/es.mp3'
168
+ };
169
+
170
+ const localization = say_msg.fromLocalizationFormData!(formData, action);
171
+ expect(localization.text).to.deep.equal(['Hola']);
172
+ expect(localization.audio_url).to.deep.equal([
173
+ 'https://example.com/es.mp3'
174
+ ]);
175
+ });
176
+
177
+ it('omits unchanged localization fields', () => {
178
+ const action: SayMsg = {
179
+ uuid: 'test-action',
180
+ type: 'say_msg',
181
+ text: 'Hello',
182
+ audio_url: 'https://example.com/en.mp3'
183
+ };
184
+
185
+ const formData = {
186
+ uuid: 'test-action',
187
+ text: 'Hello', // same as original
188
+ audio_url: 'https://example.com/en.mp3' // same as original
189
+ };
190
+
191
+ const localization = say_msg.fromLocalizationFormData!(formData, action);
192
+ expect(localization.text).to.be.undefined;
193
+ expect(localization.audio_url).to.be.undefined;
194
+ });
195
+ });
196
+ });