@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.
- package/.github/workflows/cla.yml +1 -1
- package/.github/workflows/copilot-setup-steps.yml +6 -1
- package/CHANGELOG.md +26 -0
- package/demo/data/flows/sample-flow.json +24 -0
- package/dist/locales/es.js +5 -5
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +5 -5
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/locale-codes.js +2 -11
- package/dist/locales/locale-codes.js.map +1 -1
- package/dist/locales/pt.js +5 -5
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +1112 -882
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/display/Chat.js +10 -7
- package/out-tsc/src/display/Chat.js.map +1 -1
- package/out-tsc/src/display/Dropdown.js +3 -1
- package/out-tsc/src/display/Dropdown.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js +25 -32
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/display/Thumbnail.js +163 -5
- package/out-tsc/src/display/Thumbnail.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +5 -3
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +70 -29
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +290 -239
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +118 -10
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +757 -403
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/StickyNote.js +13 -4
- package/out-tsc/src/flow/StickyNote.js.map +1 -1
- package/out-tsc/src/flow/actions/audio-player.js +112 -0
- package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
- package/out-tsc/src/flow/actions/enter_flow.js +43 -0
- package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
- package/out-tsc/src/flow/actions/play_audio.js +57 -4
- package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
- package/out-tsc/src/flow/actions/say_msg.js +86 -3
- package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
- package/out-tsc/src/flow/config.js +11 -3
- package/out-tsc/src/flow/config.js.map +1 -1
- package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
- package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
- package/out-tsc/src/flow/nodes/terminal.js +7 -0
- package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
- package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
- package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
- package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
- package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
- package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
- package/out-tsc/src/flow/operators.js +21 -5
- package/out-tsc/src/flow/operators.js.map +1 -1
- package/out-tsc/src/flow/types.js.map +1 -1
- package/out-tsc/src/flow/utils.js +213 -65
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/form/ArrayEditor.js +4 -2
- package/out-tsc/src/form/ArrayEditor.js.map +1 -1
- package/out-tsc/src/form/FieldRenderer.js +49 -0
- package/out-tsc/src/form/FieldRenderer.js.map +1 -1
- package/out-tsc/src/interfaces.js +2 -0
- package/out-tsc/src/interfaces.js.map +1 -1
- package/out-tsc/src/layout/Dialog.js +52 -7
- package/out-tsc/src/layout/Dialog.js.map +1 -1
- package/out-tsc/src/list/TicketList.js +4 -1
- package/out-tsc/src/list/TicketList.js.map +1 -1
- package/out-tsc/src/live/TembaChart.js.map +1 -1
- package/out-tsc/src/locales/es.js +5 -5
- package/out-tsc/src/locales/es.js.map +1 -1
- package/out-tsc/src/locales/fr.js +5 -5
- package/out-tsc/src/locales/fr.js.map +1 -1
- package/out-tsc/src/locales/locale-codes.js +2 -11
- package/out-tsc/src/locales/locale-codes.js.map +1 -1
- package/out-tsc/src/locales/pt.js +5 -5
- package/out-tsc/src/locales/pt.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +10 -3
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/out-tsc/src/store/AppState.js +89 -3
- package/out-tsc/src/store/AppState.js.map +1 -1
- package/out-tsc/test/actions/play_audio.test.js +118 -0
- package/out-tsc/test/actions/play_audio.test.js.map +1 -0
- package/out-tsc/test/actions/say_msg.test.js +158 -0
- package/out-tsc/test/actions/say_msg.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
- package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
- package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
- package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
- package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
- package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
- package/out-tsc/test/temba-floating-tab.test.js +4 -6
- package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
- package/out-tsc/test/temba-flow-collision.test.js +473 -220
- package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
- package/out-tsc/test/temba-flow-editor.test.js +0 -2
- package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
- package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber.test.js +102 -93
- package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
- package/out-tsc/test/temba-node-type-selector.test.js +6 -6
- package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
- package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
- package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
- package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
- package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
- package/screenshots/truth/editor/router.png +0 -0
- package/screenshots/truth/editor/wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
- package/src/display/Chat.ts +13 -7
- package/src/display/Dropdown.ts +3 -1
- package/src/display/FloatingTab.ts +24 -33
- package/src/display/Thumbnail.ts +162 -2
- package/src/flow/CanvasMenu.ts +8 -3
- package/src/flow/CanvasNode.ts +75 -30
- package/src/flow/Editor.ts +336 -288
- package/src/flow/NodeEditor.ts +137 -9
- package/src/flow/Plumber.ts +1011 -457
- package/src/flow/StickyNote.ts +14 -4
- package/src/flow/actions/audio-player.ts +127 -0
- package/src/flow/actions/enter_flow.ts +44 -0
- package/src/flow/actions/play_audio.ts +64 -5
- package/src/flow/actions/say_msg.ts +94 -4
- package/src/flow/config.ts +11 -3
- package/src/flow/nodes/shared-rules.ts +1 -1
- package/src/flow/nodes/terminal.ts +9 -0
- package/src/flow/nodes/wait_for_audio.ts +88 -0
- package/src/flow/nodes/wait_for_dial.ts +176 -0
- package/src/flow/nodes/wait_for_digits.ts +86 -2
- package/src/flow/nodes/wait_for_menu.ts +209 -3
- package/src/flow/operators.ts +23 -5
- package/src/flow/types.ts +23 -1
- package/src/flow/utils.ts +238 -81
- package/src/form/ArrayEditor.ts +4 -2
- package/src/form/FieldRenderer.ts +64 -1
- package/src/interfaces.ts +3 -1
- package/src/layout/Dialog.ts +53 -7
- package/src/list/TicketList.ts +4 -1
- package/src/live/TembaChart.ts +1 -1
- package/src/locales/es.ts +13 -18
- package/src/locales/fr.ts +13 -18
- package/src/locales/locale-codes.ts +2 -11
- package/src/locales/pt.ts +13 -18
- package/src/simulator/Simulator.ts +13 -3
- package/src/store/AppState.ts +105 -1
- package/src/store/flow-definition.d.ts +2 -0
- package/test/actions/play_audio.test.ts +155 -0
- package/test/actions/say_msg.test.ts +196 -0
- package/test/nodes/wait_for_audio.test.ts +182 -0
- package/test/nodes/wait_for_dial.test.ts +382 -0
- package/test/nodes/wait_for_digits.test.ts +233 -109
- package/test/nodes/wait_for_menu.test.ts +383 -0
- package/test/temba-floating-tab.test.ts +4 -6
- package/test/temba-flow-collision.test.ts +495 -293
- package/test/temba-flow-editor.test.ts +0 -2
- package/test/temba-flow-plumber-connections.test.ts +97 -97
- package/test/temba-flow-plumber.test.ts +116 -103
- package/test/temba-node-type-selector.test.ts +6 -6
- package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
package/src/flow/StickyNote.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
6
|
+
name: 'Play Recording',
|
|
7
7
|
group: ACTION_GROUPS.send,
|
|
8
8
|
flowTypes: [FlowTypes.VOICE],
|
|
9
|
-
render: (_node: Node,
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
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,
|
|
10
|
-
|
|
11
|
-
return html
|
|
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
|
};
|
package/src/flow/config.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -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
|
+
};
|