@nyaruka/temba-components 0.139.0 → 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/cla.yml +1 -1
- package/.github/workflows/copilot-setup-steps.yml +6 -1
- package/CHANGELOG.md +17 -0
- package/demo/data/flows/sample-flow.json +24 -0
- package/dist/temba-components.js +562 -296
- 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 +3 -3
- 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/CanvasNode.js +64 -22
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +142 -8
- 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/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 +79 -3
- 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 +1 -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/live/TembaChart.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +10 -4
- 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-flow-collision.test.js +261 -6
- package/out-tsc/test/temba-flow-collision.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 +3 -3
- package/src/display/Thumbnail.ts +162 -2
- package/src/flow/CanvasNode.ts +69 -23
- package/src/flow/Editor.ts +156 -13
- package/src/flow/NodeEditor.ts +137 -9
- 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 +82 -3
- package/src/form/ArrayEditor.ts +4 -2
- package/src/form/FieldRenderer.ts +64 -1
- package/src/interfaces.ts +2 -1
- package/src/layout/Dialog.ts +53 -7
- package/src/live/TembaChart.ts +1 -1
- package/src/simulator/Simulator.ts +13 -4
- 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-flow-collision.test.ts +286 -6
- 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/NodeEditor.ts
CHANGED
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
} from './types';
|
|
21
21
|
import { CustomEventType } from '../interfaces';
|
|
22
22
|
import { generateUUID } from '../utils';
|
|
23
|
+
import { formatIssueMessage } from './utils';
|
|
23
24
|
import { FieldRenderer } from '../form/FieldRenderer';
|
|
24
25
|
import { renderMarkdownInline } from '../markdown';
|
|
25
|
-
import { AppState, fromStore, zustand } from '../store/AppState';
|
|
26
|
+
import { AppState, FlowIssue, fromStore, zustand } from '../store/AppState';
|
|
26
27
|
import { getStore } from '../store/Store';
|
|
27
28
|
|
|
28
29
|
export class NodeEditor extends RapidElement {
|
|
@@ -55,6 +56,19 @@ export class NodeEditor extends RapidElement {
|
|
|
55
56
|
margin-top: 15px;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
.issue-warning {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 8px;
|
|
63
|
+
color: var(--color-error, tomato);
|
|
64
|
+
font-size: 13px;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.issue-warning:hover .issue-text {
|
|
69
|
+
text-decoration: underline;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
.form-actions {
|
|
59
73
|
display: flex;
|
|
60
74
|
gap: 10px;
|
|
@@ -112,12 +126,29 @@ export class NodeEditor extends RapidElement {
|
|
|
112
126
|
color: var(--color-label, #777);
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
.form-row-inline-label {
|
|
130
|
+
font-size: 20px;
|
|
131
|
+
font-weight: 300;
|
|
132
|
+
color: #ccc;
|
|
133
|
+
min-width: 20px;
|
|
134
|
+
text-align: center;
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
justify-content: center;
|
|
138
|
+
user-select: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
.form-row-help {
|
|
116
142
|
font-size: 12px;
|
|
117
143
|
color: #666;
|
|
118
144
|
margin-top: 6px;
|
|
119
145
|
}
|
|
120
146
|
|
|
147
|
+
.form-text {
|
|
148
|
+
color: #666;
|
|
149
|
+
font-size: 13px;
|
|
150
|
+
}
|
|
151
|
+
|
|
121
152
|
.form-group {
|
|
122
153
|
border: 1px solid #e0e0e0;
|
|
123
154
|
border-radius: 6px;
|
|
@@ -381,6 +412,9 @@ export class NodeEditor extends RapidElement {
|
|
|
381
412
|
@property({ type: Object })
|
|
382
413
|
nodeUI?: NodeUI;
|
|
383
414
|
|
|
415
|
+
@property({ attribute: false })
|
|
416
|
+
dialogOrigin?: { x: number; y: number };
|
|
417
|
+
|
|
384
418
|
@property({ type: Boolean })
|
|
385
419
|
isOpen: boolean = false;
|
|
386
420
|
|
|
@@ -411,6 +445,12 @@ export class NodeEditor extends RapidElement {
|
|
|
411
445
|
@fromStore(zustand, (state: AppState) => state.flowDefinition)
|
|
412
446
|
private flowDefinition!: FlowDefinition;
|
|
413
447
|
|
|
448
|
+
@fromStore(zustand, (state: AppState) => state.issuesByNode)
|
|
449
|
+
private issuesByNode!: Map<string, FlowIssue[]>;
|
|
450
|
+
|
|
451
|
+
@fromStore(zustand, (state: AppState) => state.issuesByAction)
|
|
452
|
+
private issuesByAction!: Map<string, FlowIssue[]>;
|
|
453
|
+
|
|
414
454
|
connectedCallback(): void {
|
|
415
455
|
super.connectedCallback();
|
|
416
456
|
this.initializeFormData();
|
|
@@ -447,7 +487,13 @@ export class NodeEditor extends RapidElement {
|
|
|
447
487
|
private initializeFormData(): void {
|
|
448
488
|
const nodeConfig = this.getNodeConfig();
|
|
449
489
|
|
|
450
|
-
|
|
490
|
+
// Temporary: terminal nodes defer to action configs, same as execute_actions
|
|
491
|
+
if (
|
|
492
|
+
(!nodeConfig ||
|
|
493
|
+
nodeConfig.type === 'execute_actions' ||
|
|
494
|
+
nodeConfig.type === 'terminal') &&
|
|
495
|
+
this.action
|
|
496
|
+
) {
|
|
451
497
|
// Action editing mode - use action config
|
|
452
498
|
const actionConfig = ACTION_CONFIG[this.action.type];
|
|
453
499
|
|
|
@@ -597,8 +643,13 @@ export class NodeEditor extends RapidElement {
|
|
|
597
643
|
if (this.node && this.nodeUI) {
|
|
598
644
|
const nodeConfig = this.getNodeConfig();
|
|
599
645
|
|
|
600
|
-
//
|
|
601
|
-
|
|
646
|
+
// Temporary: terminal nodes defer to action configs for editing, same as execute_actions
|
|
647
|
+
// For execute_actions/terminal nodes, defer to action editing if an action is selected
|
|
648
|
+
if (
|
|
649
|
+
(this.nodeUI.type === 'execute_actions' ||
|
|
650
|
+
this.nodeUI.type === ('terminal' as any)) &&
|
|
651
|
+
this.action
|
|
652
|
+
) {
|
|
602
653
|
return ACTION_CONFIG[this.action.type] || null;
|
|
603
654
|
}
|
|
604
655
|
|
|
@@ -1462,6 +1513,11 @@ export class NodeEditor extends RapidElement {
|
|
|
1462
1513
|
} else if (fieldName && config.type === 'message-editor') {
|
|
1463
1514
|
// Special handling for message editor
|
|
1464
1515
|
this.handleMessageEditorChange(fieldName, e);
|
|
1516
|
+
} else if (fieldName && config.type === 'media') {
|
|
1517
|
+
// Extract URL from media picker's attachment
|
|
1518
|
+
const picker = e.target as any;
|
|
1519
|
+
const url = picker.attachments?.[0]?.url || '';
|
|
1520
|
+
this.handleNewFieldChange(fieldName, url);
|
|
1465
1521
|
} else {
|
|
1466
1522
|
// Default handling for most field types
|
|
1467
1523
|
this.handleFormFieldChange(fieldName, e);
|
|
@@ -1575,6 +1631,12 @@ export class NodeEditor extends RapidElement {
|
|
|
1575
1631
|
case 'group':
|
|
1576
1632
|
return this.renderGroup(item, config, renderedFields);
|
|
1577
1633
|
|
|
1634
|
+
case 'spacer':
|
|
1635
|
+
return html``;
|
|
1636
|
+
|
|
1637
|
+
case 'text':
|
|
1638
|
+
return html`<div class="form-text">${item.text}</div>`;
|
|
1639
|
+
|
|
1578
1640
|
default:
|
|
1579
1641
|
return html``;
|
|
1580
1642
|
}
|
|
@@ -1585,7 +1647,14 @@ export class NodeEditor extends RapidElement {
|
|
|
1585
1647
|
config: ActionConfig | NodeConfig,
|
|
1586
1648
|
renderedFields: Set<string>
|
|
1587
1649
|
): TemplateResult {
|
|
1588
|
-
const {
|
|
1650
|
+
const {
|
|
1651
|
+
items,
|
|
1652
|
+
gap = '1rem',
|
|
1653
|
+
label,
|
|
1654
|
+
helpText,
|
|
1655
|
+
inlineLabels,
|
|
1656
|
+
marginBottom
|
|
1657
|
+
} = rowConfig;
|
|
1589
1658
|
|
|
1590
1659
|
// Collect all fields from this row for width calculations
|
|
1591
1660
|
const fieldsInRow = this.collectFieldsFromItems(items);
|
|
@@ -1619,8 +1688,18 @@ export class NodeEditor extends RapidElement {
|
|
|
1619
1688
|
});
|
|
1620
1689
|
|
|
1621
1690
|
const rowContent = html`
|
|
1622
|
-
<div
|
|
1691
|
+
<div
|
|
1692
|
+
class="form-row"
|
|
1693
|
+
style="display: flex; gap: ${gap};${marginBottom
|
|
1694
|
+
? ` margin-bottom: ${marginBottom};`
|
|
1695
|
+
: ''}"
|
|
1696
|
+
>
|
|
1623
1697
|
${items.map((item) => {
|
|
1698
|
+
// Spacer items render as empty flex children
|
|
1699
|
+
if (typeof item !== 'string' && item.type === 'spacer') {
|
|
1700
|
+
return html`<div style="flex: 1 1 0;"></div>`;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1624
1703
|
// Get the field name from the item
|
|
1625
1704
|
const fieldName =
|
|
1626
1705
|
typeof item === 'string'
|
|
@@ -1641,10 +1720,22 @@ export class NodeEditor extends RapidElement {
|
|
|
1641
1720
|
renderedFields
|
|
1642
1721
|
);
|
|
1643
1722
|
|
|
1723
|
+
// When inlineLabels is provided, render the label inline to the left
|
|
1724
|
+
const inlineLabel =
|
|
1725
|
+
inlineLabels && fieldName ? inlineLabels[fieldName] : null;
|
|
1726
|
+
|
|
1644
1727
|
// Wrap in a div with flex style if we have a flex style
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1728
|
+
if (flexStyle) {
|
|
1729
|
+
return inlineLabel
|
|
1730
|
+
? html`<div
|
|
1731
|
+
style="${flexStyle} display: flex; align-items: center; gap: 0.35rem;"
|
|
1732
|
+
>
|
|
1733
|
+
<span class="form-row-inline-label">${inlineLabel}</span>
|
|
1734
|
+
<div style="flex: 1 1 0; min-width: 0;">${itemContent}</div>
|
|
1735
|
+
</div>`
|
|
1736
|
+
: html`<div style="${flexStyle}">${itemContent}</div>`;
|
|
1737
|
+
}
|
|
1738
|
+
return itemContent;
|
|
1648
1739
|
})}
|
|
1649
1740
|
</div>
|
|
1650
1741
|
`;
|
|
@@ -2038,6 +2129,40 @@ export class NodeEditor extends RapidElement {
|
|
|
2038
2129
|
`;
|
|
2039
2130
|
}
|
|
2040
2131
|
|
|
2132
|
+
private handleIssueClick(issue: FlowIssue): void {
|
|
2133
|
+
this.fireCustomEvent(CustomEventType.ShowIssue, { issue });
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
private renderIssueWarnings(): TemplateResult | string {
|
|
2137
|
+
const issues: FlowIssue[] = [];
|
|
2138
|
+
|
|
2139
|
+
// Check for action-level issues
|
|
2140
|
+
if (this.action && this.issuesByAction?.has(this.action.uuid)) {
|
|
2141
|
+
issues.push(...this.issuesByAction.get(this.action.uuid));
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Check for node-level issues (issues without action_uuid)
|
|
2145
|
+
if (this.node && this.issuesByNode?.has(this.node.uuid)) {
|
|
2146
|
+
issues.push(...this.issuesByNode.get(this.node.uuid));
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (issues.length === 0) return '';
|
|
2150
|
+
|
|
2151
|
+
return html`
|
|
2152
|
+
${issues.map(
|
|
2153
|
+
(issue) => html`
|
|
2154
|
+
<div
|
|
2155
|
+
class="issue-warning"
|
|
2156
|
+
@click=${() => this.handleIssueClick(issue)}
|
|
2157
|
+
>
|
|
2158
|
+
<temba-icon name="alert_warning" size="1.2"></temba-icon>
|
|
2159
|
+
<span class="issue-text">${formatIssueMessage(issue)}</span>
|
|
2160
|
+
</div>
|
|
2161
|
+
`
|
|
2162
|
+
)}
|
|
2163
|
+
`;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2041
2166
|
render(): TemplateResult {
|
|
2042
2167
|
if (!this.isOpen) {
|
|
2043
2168
|
return html``;
|
|
@@ -2061,6 +2186,8 @@ export class NodeEditor extends RapidElement {
|
|
|
2061
2186
|
<temba-dialog
|
|
2062
2187
|
header="${header}"
|
|
2063
2188
|
.open="${this.isOpen}"
|
|
2189
|
+
.originX=${this.dialogOrigin?.x ?? null}
|
|
2190
|
+
.originY=${this.dialogOrigin?.y ?? null}
|
|
2064
2191
|
@temba-button-clicked=${this.handleDialogButtonClick}
|
|
2065
2192
|
primaryButtonName="Save"
|
|
2066
2193
|
cancelButtonName="Cancel"
|
|
@@ -2072,6 +2199,7 @@ export class NodeEditor extends RapidElement {
|
|
|
2072
2199
|
${this.getNodeConfig()?.router?.configurable
|
|
2073
2200
|
? this.renderRouterSection()
|
|
2074
2201
|
: null}
|
|
2202
|
+
${this.renderIssueWarnings()}
|
|
2075
2203
|
</div>
|
|
2076
2204
|
|
|
2077
2205
|
<div slot="gutter">${this.renderGutter()}</div>
|
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
|
};
|