@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
@@ -305,7 +305,7 @@ export class StickyNote extends RapidElement {
305
305
 
306
306
  private handleBodyBlur(event: FocusEvent): void {
307
307
  const target = event.target as HTMLElement;
308
- const newBody = target.textContent || '';
308
+ const newBody = target.innerText || '';
309
309
 
310
310
  if (this.data && newBody !== this.data.body) {
311
311
  getStore()
@@ -328,7 +328,17 @@ export class StickyNote extends RapidElement {
328
328
  event.stopPropagation();
329
329
  }
330
330
 
331
- private handleKeyDown(event: KeyboardEvent): void {
331
+ private handleTitleKeyDown(event: KeyboardEvent): void {
332
+ if (event.key === 'Enter') {
333
+ event.preventDefault();
334
+ (event.target as HTMLElement).blur();
335
+ }
336
+ if (event.key === 'Escape') {
337
+ (event.target as HTMLElement).blur();
338
+ }
339
+ }
340
+
341
+ private handleBodyKeyDown(event: KeyboardEvent): void {
332
342
  if (event.key === 'Enter' && !event.shiftKey) {
333
343
  event.preventDefault();
334
344
  (event.target as HTMLElement).blur();
@@ -386,7 +396,7 @@ export class StickyNote extends RapidElement {
386
396
  class="sticky-title"
387
397
  contenteditable="${!this.isTranslating}"
388
398
  @blur="${this.handleTitleBlur}"
389
- @keydown="${this.handleKeyDown}"
399
+ @keydown="${this.handleTitleKeyDown}"
390
400
  @mousedown="${this.handleContentMouseDown}"
391
401
  .textContent="${this.data.title}"
392
402
  ></div>
@@ -396,7 +406,7 @@ export class StickyNote extends RapidElement {
396
406
  class="sticky-body"
397
407
  contenteditable="${!this.isTranslating}"
398
408
  @blur="${this.handleBodyBlur}"
399
- @keydown="${this.handleKeyDown}"
409
+ @keydown="${this.handleBodyKeyDown}"
400
410
  @mousedown="${this.handleContentMouseDown}"
401
411
  .textContent="${this.data.body}"
402
412
  ></div>
@@ -0,0 +1,127 @@
1
+ import { html, TemplateResult } from 'lit-html';
2
+
3
+ // SVG paths for play and pause icons
4
+ const PLAY_SVG = html`<svg
5
+ viewBox="0 0 24 24"
6
+ width="16"
7
+ height="16"
8
+ fill="currentColor"
9
+ >
10
+ <polygon points="6,3 20,12 6,21" />
11
+ </svg>`;
12
+
13
+ // Track active audio so only one plays at a time
14
+ let activeAudio: HTMLAudioElement | null = null;
15
+ let activeContainer: HTMLElement | null = null;
16
+
17
+ function stopActive() {
18
+ if (activeAudio) {
19
+ activeAudio.pause();
20
+ activeAudio.currentTime = 0;
21
+ if (activeContainer) {
22
+ resetPlayer(activeContainer);
23
+ }
24
+ activeAudio = null;
25
+ activeContainer = null;
26
+ }
27
+ }
28
+
29
+ function resetPlayer(container: HTMLElement) {
30
+ const btn = container.querySelector('.audio-play-btn') as HTMLElement;
31
+ const progress = container.querySelector('.audio-progress') as HTMLElement;
32
+ if (btn)
33
+ btn.innerHTML =
34
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>';
35
+ if (progress) progress.style.width = '0%';
36
+ }
37
+
38
+ function handlePlayClick(e: MouseEvent) {
39
+ e.stopPropagation();
40
+ e.preventDefault();
41
+
42
+ const container = (e.currentTarget as HTMLElement).closest(
43
+ '.audio-player'
44
+ ) as HTMLElement;
45
+ if (!container) return;
46
+
47
+ const url = container.dataset.url;
48
+ if (!url) return;
49
+
50
+ const btn = container.querySelector('.audio-play-btn') as HTMLElement;
51
+ const progress = container.querySelector('.audio-progress') as HTMLElement;
52
+
53
+ // If this is already playing, pause it
54
+ if (activeAudio && activeContainer === container && !activeAudio.paused) {
55
+ activeAudio.pause();
56
+ btn.innerHTML =
57
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>';
58
+ return;
59
+ }
60
+
61
+ // Stop any other playing audio
62
+ stopActive();
63
+
64
+ const audio = new Audio(url);
65
+ activeAudio = audio;
66
+ activeContainer = container;
67
+
68
+ btn.innerHTML =
69
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg>';
70
+
71
+ audio.addEventListener('timeupdate', () => {
72
+ if (audio.duration && progress) {
73
+ const pct = (audio.currentTime / audio.duration) * 100;
74
+ progress.style.width = `${pct}%`;
75
+ }
76
+ });
77
+
78
+ audio.addEventListener('ended', () => {
79
+ resetPlayer(container);
80
+ activeAudio = null;
81
+ activeContainer = null;
82
+ });
83
+
84
+ audio.addEventListener('error', () => {
85
+ resetPlayer(container);
86
+ activeAudio = null;
87
+ activeContainer = null;
88
+ });
89
+
90
+ audio.play().catch(() => {
91
+ resetPlayer(container);
92
+ activeAudio = null;
93
+ activeContainer = null;
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Renders an inline audio player with play/pause button and progress bar.
99
+ * Used on canvas nodes for play_audio and say_msg actions.
100
+ */
101
+ export function renderAudioPlayer(audioUrl: string): TemplateResult {
102
+ return html`
103
+ <div
104
+ class="audio-player"
105
+ data-url="${audioUrl}"
106
+ style="display: flex; align-items: center; gap: 0.4em; cursor: default;"
107
+ @mousedown=${(e: MouseEvent) => e.stopPropagation()}
108
+ @mouseup=${(e: MouseEvent) => e.stopPropagation()}
109
+ >
110
+ <div
111
+ class="audio-play-btn"
112
+ @click=${handlePlayClick}
113
+ style="cursor: pointer; color: #666; display: flex; align-items: center; flex-shrink: 0;"
114
+ >
115
+ ${PLAY_SVG}
116
+ </div>
117
+ <div
118
+ style="flex: 1; height: 4px; background: #e0e0e0; border-radius: 2px; overflow: hidden; min-width: 40px;"
119
+ >
120
+ <div
121
+ class="audio-progress"
122
+ style="width: 0%; height: 100%; background: var(--color-primary, #2387ca); border-radius: 2px; transition: width 0.2s linear;"
123
+ ></div>
124
+ </div>
125
+ </div>
126
+ `;
127
+ }
@@ -0,0 +1,44 @@
1
+ import { html } from 'lit-html';
2
+ import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
3
+ import { Node, EnterFlow } from '../../store/flow-definition';
4
+ import { renderNamedObjects } from '../utils';
5
+
6
+ export const enter_flow: ActionConfig = {
7
+ name: 'Enter a Flow',
8
+ group: ACTION_GROUPS.trigger,
9
+ hideFromActions: true,
10
+ flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
11
+ render: (_node: Node, action: EnterFlow) => {
12
+ return html`${renderNamedObjects([action.flow], 'flow')}`;
13
+ },
14
+ toFormData: (action: EnterFlow) => {
15
+ return {
16
+ uuid: action.uuid,
17
+ flow: action.flow ? [action.flow] : []
18
+ };
19
+ },
20
+ form: {
21
+ flow: {
22
+ type: 'select',
23
+ required: true,
24
+ placeholder: 'Select a flow...',
25
+ helpText: 'The contact will enter this flow and not return',
26
+ endpoint: '/api/v2/flows.json',
27
+ valueKey: 'uuid',
28
+ nameKey: 'name'
29
+ }
30
+ },
31
+ layout: ['flow'],
32
+ fromFormData: (formData: any): EnterFlow => {
33
+ const selected = formData.flow[0];
34
+ return {
35
+ uuid: formData.uuid,
36
+ type: 'enter_flow',
37
+ terminal: true,
38
+ flow: {
39
+ uuid: selected.uuid || selected.value,
40
+ name: selected.name
41
+ }
42
+ };
43
+ }
44
+ };
@@ -1,13 +1,72 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
2
+ import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
3
3
  import { Node, PlayAudio } from '../../store/flow-definition';
4
4
 
5
5
  export const play_audio: ActionConfig = {
6
- name: 'Play Audio',
6
+ name: 'Play Recording',
7
7
  group: ACTION_GROUPS.send,
8
8
  flowTypes: [FlowTypes.VOICE],
9
- render: (_node: Node, _action: PlayAudio) => {
10
- // This will need to be implemented based on the actual render logic
11
- return html`<div>Play Audio</div>`;
9
+ render: (_node: Node, action: PlayAudio) => {
10
+ return html`
11
+ <div style="display: flex; align-items: center; gap: 0.3em;">
12
+ <temba-icon name="recording" size="1"></temba-icon>
13
+ <div
14
+ style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;"
15
+ title="${action.audio_url || ''}"
16
+ >
17
+ ${action.audio_url || ''}
18
+ </div>
19
+ </div>
20
+ `;
21
+ },
22
+ form: {
23
+ audio_url: {
24
+ type: 'text',
25
+ label: 'Recording URL',
26
+ required: true,
27
+ evaluated: true
28
+ }
29
+ },
30
+ layout: ['audio_url'],
31
+ toFormData: (action: PlayAudio) => {
32
+ return {
33
+ uuid: action.uuid,
34
+ audio_url: action.audio_url || ''
35
+ };
36
+ },
37
+ fromFormData: (data: FormData) => {
38
+ return {
39
+ uuid: data.uuid,
40
+ type: 'play_audio',
41
+ audio_url: (data.audio_url || '').trim()
42
+ } as PlayAudio;
43
+ },
44
+ localizable: ['audio_url'],
45
+ toLocalizationFormData: (
46
+ action: PlayAudio,
47
+ localization: Record<string, any>
48
+ ) => {
49
+ const formData: FormData = {
50
+ uuid: action.uuid
51
+ };
52
+
53
+ if (localization.audio_url && Array.isArray(localization.audio_url)) {
54
+ formData.audio_url = localization.audio_url[0] || '';
55
+ } else {
56
+ formData.audio_url = '';
57
+ }
58
+
59
+ return formData;
60
+ },
61
+ fromLocalizationFormData: (formData: FormData, action: PlayAudio) => {
62
+ const localization: Record<string, any> = {};
63
+
64
+ if (formData.audio_url && formData.audio_url.trim() !== '') {
65
+ if (formData.audio_url !== action.audio_url) {
66
+ localization.audio_url = [formData.audio_url];
67
+ }
68
+ }
69
+
70
+ return localization;
12
71
  }
13
72
  };
@@ -1,13 +1,103 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
2
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
+ import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
3
4
  import { Node, SayMsg } from '../../store/flow-definition';
5
+ import { renderAudioPlayer } from './audio-player';
4
6
 
5
7
  export const say_msg: ActionConfig = {
6
8
  name: 'Say Message',
7
9
  group: ACTION_GROUPS.send,
8
10
  flowTypes: [FlowTypes.VOICE],
9
- render: (_node: Node, _action: SayMsg) => {
10
- // This will need to be implemented based on the actual render logic
11
- return html`<div>Say Message</div>`;
11
+ render: (_node: Node, action: SayMsg) => {
12
+ const text = (action.text || '').replace(/\n/g, '<br>');
13
+ return html`
14
+ ${unsafeHTML(text)}
15
+ ${action.audio_url
16
+ ? html`<div style="margin-top: 0.5em;">
17
+ ${renderAudioPlayer(action.audio_url)}
18
+ </div>`
19
+ : null}
20
+ `;
21
+ },
22
+ form: {
23
+ text: {
24
+ type: 'textarea',
25
+ label: 'Message',
26
+ required: true,
27
+ evaluated: true,
28
+ placeholder: 'Enter message to speak...',
29
+ minHeight: 80
30
+ },
31
+ audio_url: {
32
+ type: 'media',
33
+ label: 'Recording',
34
+ required: false,
35
+ accept: 'audio/*',
36
+ optionalLink: 'Add a recording'
37
+ }
38
+ },
39
+ layout: ['text', 'audio_url'],
40
+ toFormData: (action: SayMsg) => {
41
+ return {
42
+ uuid: action.uuid,
43
+ text: action.text || '',
44
+ audio_url: action.audio_url || ''
45
+ };
46
+ },
47
+ fromFormData: (data: FormData) => {
48
+ const result: any = {
49
+ uuid: data.uuid,
50
+ type: 'say_msg',
51
+ text: data.text || ''
52
+ };
53
+ if (data.audio_url && data.audio_url.trim() !== '') {
54
+ result.audio_url = data.audio_url.trim();
55
+ }
56
+ return result as SayMsg;
57
+ },
58
+ sanitize: (formData: FormData): void => {
59
+ if (formData.text && typeof formData.text === 'string') {
60
+ formData.text = formData.text.trim();
61
+ }
62
+ },
63
+ localizable: ['text', 'audio_url'],
64
+ toLocalizationFormData: (
65
+ action: SayMsg,
66
+ localization: Record<string, any>
67
+ ) => {
68
+ const formData: FormData = {
69
+ uuid: action.uuid
70
+ };
71
+
72
+ if (localization.text && Array.isArray(localization.text)) {
73
+ formData.text = localization.text[0] || '';
74
+ } else {
75
+ formData.text = '';
76
+ }
77
+
78
+ if (localization.audio_url && Array.isArray(localization.audio_url)) {
79
+ formData.audio_url = localization.audio_url[0] || '';
80
+ } else {
81
+ formData.audio_url = '';
82
+ }
83
+
84
+ return formData;
85
+ },
86
+ fromLocalizationFormData: (formData: FormData, action: SayMsg) => {
87
+ const localization: Record<string, any> = {};
88
+
89
+ if (formData.text && formData.text.trim() !== '') {
90
+ if (formData.text !== action.text) {
91
+ localization.text = [formData.text];
92
+ }
93
+ }
94
+
95
+ if (formData.audio_url && formData.audio_url.trim() !== '') {
96
+ if (formData.audio_url !== action.audio_url) {
97
+ localization.audio_url = [formData.audio_url];
98
+ }
99
+ }
100
+
101
+ return localization;
12
102
  }
13
103
  };
@@ -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
+ };