@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/types.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface NodeConfig extends FormConfig {
|
|
|
114
114
|
rules?: {
|
|
115
115
|
type:
|
|
116
116
|
| 'has_number_between'
|
|
117
|
+
| 'has_number_eq'
|
|
118
|
+
| 'has_only_text'
|
|
117
119
|
| 'has_string'
|
|
118
120
|
| 'has_value'
|
|
119
121
|
| 'has_not_value'
|
|
@@ -253,6 +255,12 @@ export interface MessageEditorFieldConfig extends BaseFieldConfig {
|
|
|
253
255
|
disableCompletion?: boolean;
|
|
254
256
|
}
|
|
255
257
|
|
|
258
|
+
export interface MediaFieldConfig extends BaseFieldConfig {
|
|
259
|
+
type: 'media';
|
|
260
|
+
accept?: string; // MIME filter, e.g. 'audio/*'
|
|
261
|
+
endpoint?: string; // upload endpoint, defaults to DEFAULT_MEDIA_ENDPOINT
|
|
262
|
+
}
|
|
263
|
+
|
|
256
264
|
export type FieldConfig =
|
|
257
265
|
| TextFieldConfig
|
|
258
266
|
| TextareaFieldConfig
|
|
@@ -260,7 +268,8 @@ export type FieldConfig =
|
|
|
260
268
|
| KeyValueFieldConfig
|
|
261
269
|
| ArrayFieldConfig
|
|
262
270
|
| CheckboxFieldConfig
|
|
263
|
-
| MessageEditorFieldConfig
|
|
271
|
+
| MessageEditorFieldConfig
|
|
272
|
+
| MediaFieldConfig;
|
|
264
273
|
|
|
265
274
|
// Layout configurations for better form organization
|
|
266
275
|
// Recursive layout system - any layout item can contain other layout items
|
|
@@ -276,6 +285,8 @@ export interface RowLayoutConfig {
|
|
|
276
285
|
gap?: string; // CSS gap value, defaults to '1rem'
|
|
277
286
|
label?: string; // optional label for the entire row
|
|
278
287
|
helpText?: string; // optional help text for the entire row
|
|
288
|
+
inlineLabels?: Record<string, string>; // map of field name to inline label text
|
|
289
|
+
marginBottom?: string; // CSS margin-bottom for spacing below the row
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
export interface GroupLayoutConfig {
|
|
@@ -288,10 +299,21 @@ export interface GroupLayoutConfig {
|
|
|
288
299
|
getGroupValueCount?: (formData: FormData) => number; // optional function to get count for bubble display
|
|
289
300
|
}
|
|
290
301
|
|
|
302
|
+
export interface SpacerLayoutConfig {
|
|
303
|
+
type: 'spacer';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface TextLayoutConfig {
|
|
307
|
+
type: 'text';
|
|
308
|
+
text: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
291
311
|
export type LayoutItem =
|
|
292
312
|
| FieldItemConfig
|
|
293
313
|
| RowLayoutConfig
|
|
294
314
|
| GroupLayoutConfig
|
|
315
|
+
| SpacerLayoutConfig
|
|
316
|
+
| TextLayoutConfig
|
|
295
317
|
| string; // string is shorthand for field
|
|
296
318
|
|
|
297
319
|
/**
|
package/src/flow/utils.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { html } from 'lit-html';
|
|
2
2
|
import { NamedObject, FlowPosition } from '../store/flow-definition';
|
|
3
|
+
import { FlowIssue } from '../store/AppState';
|
|
4
|
+
|
|
5
|
+
export function formatIssueMessage(issue: FlowIssue): string {
|
|
6
|
+
if (issue.dependency) {
|
|
7
|
+
const name = issue.dependency.name || issue.dependency.key;
|
|
8
|
+
return `Cannot find a ${issue.dependency.type} for ${name}`;
|
|
9
|
+
}
|
|
10
|
+
return issue.description;
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
const GRID_SIZE = 20;
|
|
5
14
|
|
|
@@ -346,6 +355,25 @@ export const calculateReflowPositions = (
|
|
|
346
355
|
currentBounds.set(b.uuid, { ...b });
|
|
347
356
|
}
|
|
348
357
|
|
|
358
|
+
// A sacred node yields to an existing node at the top of the canvas when
|
|
359
|
+
// the sacred wasn't dropped above it. The existing node keeps its top
|
|
360
|
+
// position and the sacred node moves below instead.
|
|
361
|
+
for (const sacredUuid of [...sacredSet]) {
|
|
362
|
+
const sacred = currentBounds.get(sacredUuid);
|
|
363
|
+
if (!sacred) continue;
|
|
364
|
+
|
|
365
|
+
for (const [uuid, bounds] of currentBounds) {
|
|
366
|
+
if (uuid === sacredUuid || sacredSet.has(uuid)) continue;
|
|
367
|
+
if (!nodesOverlap(sacred, bounds)) continue;
|
|
368
|
+
|
|
369
|
+
if (sacred.top > bounds.top && bounds.top < MIN_NODE_SPACING) {
|
|
370
|
+
sacredSet.delete(sacredUuid);
|
|
371
|
+
sacredSet.add(uuid);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
349
377
|
// Seed the queue with non-sacred nodes that overlap any sacred node
|
|
350
378
|
const queue: string[] = [];
|
|
351
379
|
const inQueue = new Set<string>();
|
|
@@ -387,11 +415,48 @@ export const calculateReflowPositions = (
|
|
|
387
415
|
|
|
388
416
|
if (fixedOverlaps.length === 0) continue;
|
|
389
417
|
|
|
390
|
-
//
|
|
418
|
+
// Determine direction constraints and axis bias from sacred node overlaps
|
|
419
|
+
const sacredOverlaps = fixedOverlaps.filter((f) => sacredSet.has(f.uuid));
|
|
420
|
+
const allowedDirections: Direction[] = [...DIRECTIONS];
|
|
421
|
+
let axisBias: 'vertical' | 'horizontal' | null = null;
|
|
422
|
+
|
|
423
|
+
if (sacredOverlaps.length > 0) {
|
|
424
|
+
// Rule 1: don't move a lower node above the sacred node
|
|
425
|
+
// Rule 2: don't move a right-of node to the left of the sacred node
|
|
426
|
+
for (const sacred of sacredOverlaps) {
|
|
427
|
+
if (collider.top > sacred.top) {
|
|
428
|
+
const idx = allowedDirections.indexOf('up');
|
|
429
|
+
if (idx !== -1) allowedDirections.splice(idx, 1);
|
|
430
|
+
}
|
|
431
|
+
if (collider.left > sacred.left) {
|
|
432
|
+
const idx = allowedDirections.indexOf('left');
|
|
433
|
+
if (idx !== -1) allowedDirections.splice(idx, 1);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Rule 3: bias direction based on overlap shape
|
|
438
|
+
let totalOverlapWidth = 0;
|
|
439
|
+
let totalOverlapHeight = 0;
|
|
440
|
+
for (const sacred of sacredOverlaps) {
|
|
441
|
+
totalOverlapWidth +=
|
|
442
|
+
Math.min(collider.right, sacred.right) -
|
|
443
|
+
Math.max(collider.left, sacred.left);
|
|
444
|
+
totalOverlapHeight +=
|
|
445
|
+
Math.min(collider.bottom, sacred.bottom) -
|
|
446
|
+
Math.max(collider.top, sacred.top);
|
|
447
|
+
}
|
|
448
|
+
if (totalOverlapWidth > totalOverlapHeight) {
|
|
449
|
+
axisBias = 'vertical'; // wide overlap = nodes stacked = prefer up/down
|
|
450
|
+
} else if (totalOverlapHeight > totalOverlapWidth) {
|
|
451
|
+
axisBias = 'horizontal'; // tall overlap = nodes side-by-side = prefer left/right
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Try each allowed direction, pick the one with least disruption
|
|
391
456
|
let bestPos: { left: number; top: number } | null = null;
|
|
392
457
|
let bestScore = Infinity;
|
|
393
458
|
|
|
394
|
-
for (const dir of
|
|
459
|
+
for (const dir of allowedDirections) {
|
|
395
460
|
const candidate = computeDirectionalClearance(
|
|
396
461
|
collider,
|
|
397
462
|
fixedOverlaps,
|
|
@@ -423,7 +488,21 @@ export const calculateReflowPositions = (
|
|
|
423
488
|
const distance =
|
|
424
489
|
Math.abs(candidate.left - collider.left) +
|
|
425
490
|
Math.abs(candidate.top - collider.top);
|
|
426
|
-
|
|
491
|
+
|
|
492
|
+
// When colliding with sacred nodes, use axis bias scoring;
|
|
493
|
+
// for cascading collisions (no sacred overlap), use original scoring
|
|
494
|
+
let score: number;
|
|
495
|
+
if (sacredOverlaps.length > 0) {
|
|
496
|
+
const isVerticalDir = dir === 'up' || dir === 'down';
|
|
497
|
+
const axisMatch =
|
|
498
|
+
axisBias === null ||
|
|
499
|
+
(axisBias === 'vertical' && isVerticalDir) ||
|
|
500
|
+
(axisBias === 'horizontal' && !isVerticalDir);
|
|
501
|
+
const axisPenalty = axisMatch ? 0 : 5000;
|
|
502
|
+
score = cascadeCount * 2000 + axisPenalty + distance;
|
|
503
|
+
} else {
|
|
504
|
+
score = cascadeCount * 10000 + distance;
|
|
505
|
+
}
|
|
427
506
|
|
|
428
507
|
if (score < bestScore) {
|
|
429
508
|
bestScore = score;
|
package/src/form/ArrayEditor.ts
CHANGED
|
@@ -648,13 +648,15 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
|
|
|
648
648
|
}
|
|
649
649
|
|
|
650
650
|
.removable .remove-btn {
|
|
651
|
-
|
|
651
|
+
opacity: 0.3;
|
|
652
652
|
cursor: default;
|
|
653
|
+
pointer-events: none;
|
|
653
654
|
}
|
|
654
655
|
|
|
655
656
|
.removable .drag-handle {
|
|
656
|
-
|
|
657
|
+
opacity: 0.3;
|
|
657
658
|
cursor: default;
|
|
659
|
+
pointer-events: none;
|
|
658
660
|
}
|
|
659
661
|
|
|
660
662
|
.drag-handle {
|
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
CheckboxFieldConfig,
|
|
9
9
|
MessageEditorFieldConfig,
|
|
10
10
|
KeyValueFieldConfig,
|
|
11
|
-
ArrayFieldConfig
|
|
11
|
+
ArrayFieldConfig,
|
|
12
|
+
MediaFieldConfig
|
|
12
13
|
} from '../flow/types';
|
|
14
|
+
import { Attachment } from '../interfaces';
|
|
15
|
+
import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* FieldRenderer provides a consistent way to render field configurations
|
|
@@ -90,6 +93,14 @@ export class FieldRenderer {
|
|
|
90
93
|
context
|
|
91
94
|
);
|
|
92
95
|
|
|
96
|
+
case 'media':
|
|
97
|
+
return FieldRenderer.renderMedia(
|
|
98
|
+
fieldName,
|
|
99
|
+
config as MediaFieldConfig,
|
|
100
|
+
value,
|
|
101
|
+
context
|
|
102
|
+
);
|
|
103
|
+
|
|
93
104
|
default:
|
|
94
105
|
return html`<div>Unsupported field type: ${(config as any).type}</div>`;
|
|
95
106
|
}
|
|
@@ -367,6 +378,58 @@ export class FieldRenderer {
|
|
|
367
378
|
</div>`;
|
|
368
379
|
}
|
|
369
380
|
|
|
381
|
+
private static urlToAttachments(url: string): Attachment[] {
|
|
382
|
+
if (!url || !url.trim()) return [];
|
|
383
|
+
const filename = url.split('/').pop() || 'recording';
|
|
384
|
+
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
385
|
+
const contentTypes: Record<string, string> = {
|
|
386
|
+
mp3: 'audio/mpeg',
|
|
387
|
+
wav: 'audio/wav',
|
|
388
|
+
ogg: 'audio/ogg',
|
|
389
|
+
m4a: 'audio/mp4'
|
|
390
|
+
};
|
|
391
|
+
return [
|
|
392
|
+
{
|
|
393
|
+
uuid: '',
|
|
394
|
+
content_type: contentTypes[ext] || 'audio/mpeg',
|
|
395
|
+
url,
|
|
396
|
+
filename,
|
|
397
|
+
size: 0,
|
|
398
|
+
error: ''
|
|
399
|
+
}
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private static renderMedia(
|
|
404
|
+
fieldName: string,
|
|
405
|
+
config: MediaFieldConfig,
|
|
406
|
+
value: any,
|
|
407
|
+
context: FieldRenderContext
|
|
408
|
+
): TemplateResult {
|
|
409
|
+
const { onChange, showLabel = true } = context;
|
|
410
|
+
const endpoint = config.endpoint || DEFAULT_MEDIA_ENDPOINT;
|
|
411
|
+
const attachments = FieldRenderer.urlToAttachments(value);
|
|
412
|
+
|
|
413
|
+
return html`
|
|
414
|
+
<div>
|
|
415
|
+
${showLabel && config.label
|
|
416
|
+
? html`<label
|
|
417
|
+
style="margin-bottom: 5px; margin-left: 4px; display: block; font-weight: 400; font-size: var(--label-size); letter-spacing: 0.05em; line-height: normal; color: var(--color-label, #777);"
|
|
418
|
+
>${config.label}</label
|
|
419
|
+
>`
|
|
420
|
+
: ''}
|
|
421
|
+
<temba-media-picker
|
|
422
|
+
name="${fieldName}"
|
|
423
|
+
accept="${config.accept || ''}"
|
|
424
|
+
endpoint="${endpoint}"
|
|
425
|
+
max="1"
|
|
426
|
+
.attachments="${attachments}"
|
|
427
|
+
@change="${onChange || (() => {})}"
|
|
428
|
+
></temba-media-picker>
|
|
429
|
+
</div>
|
|
430
|
+
`;
|
|
431
|
+
}
|
|
432
|
+
|
|
370
433
|
private static renderMessageEditor(
|
|
371
434
|
fieldName: string,
|
|
372
435
|
config: MessageEditorFieldConfig,
|
package/src/interfaces.ts
CHANGED
|
@@ -304,5 +304,6 @@ export enum CustomEventType {
|
|
|
304
304
|
NodeSaved = 'temba-node-saved',
|
|
305
305
|
NodeEditCancelled = 'temba-node-edit-cancelled',
|
|
306
306
|
FollowSimulation = 'temba-follow-simulation',
|
|
307
|
-
ContactClicked = 'temba-contact-clicked'
|
|
307
|
+
ContactClicked = 'temba-contact-clicked',
|
|
308
|
+
ShowIssue = 'temba-show-issue'
|
|
308
309
|
}
|
package/src/layout/Dialog.ts
CHANGED
|
@@ -95,8 +95,9 @@ export class Dialog extends ResizeElement {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
.dialog-mask .dialog-container {
|
|
98
|
+
|
|
98
99
|
position: relative;
|
|
99
|
-
transition: transform var(--transition-speed)
|
|
100
|
+
transition: transform var(--transition-speed) ease-in-out,
|
|
100
101
|
opacity ease-in-out calc(var(--transition-speed) - 50ms);
|
|
101
102
|
border-radius: var(--curvature);
|
|
102
103
|
box-shadow: 0px 0px 2px 4px rgba(0, 0, 0, 0.06);
|
|
@@ -111,6 +112,7 @@ export class Dialog extends ResizeElement {
|
|
|
111
112
|
.dialog-body {
|
|
112
113
|
background: #fff;
|
|
113
114
|
overflow-y: auto;
|
|
115
|
+
overflow-x: hidden;
|
|
114
116
|
flex-grow: 1;
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -124,7 +126,7 @@ export class Dialog extends ResizeElement {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
.dialog-mask.dialog-animation-end .dialog-container {
|
|
127
|
-
transform: scale(1) !important;
|
|
129
|
+
transform: scale(1) translate(0, 0) !important;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
.dialog-mask.dialog-ready .dialog-container {
|
|
@@ -253,6 +255,12 @@ export class Dialog extends ResizeElement {
|
|
|
253
255
|
@property({ attribute: false })
|
|
254
256
|
onButtonClicked: (button: Button) => void;
|
|
255
257
|
|
|
258
|
+
@property({ type: Number })
|
|
259
|
+
originX: number | null = null;
|
|
260
|
+
|
|
261
|
+
@property({ type: Number })
|
|
262
|
+
originY: number | null = null;
|
|
263
|
+
|
|
256
264
|
scrollOffset: any = 0;
|
|
257
265
|
|
|
258
266
|
public constructor() {
|
|
@@ -289,11 +297,49 @@ export class Dialog extends ResizeElement {
|
|
|
289
297
|
const body = document.querySelector('body');
|
|
290
298
|
|
|
291
299
|
if (this.open) {
|
|
292
|
-
this.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
if (this.originX != null && this.originY != null) {
|
|
301
|
+
// Spring-from-origin animation: measure final position, then
|
|
302
|
+
// set initial transform at click point and transition to center
|
|
303
|
+
const ox = this.originX;
|
|
304
|
+
const oy = this.originY;
|
|
305
|
+
this.originX = null;
|
|
306
|
+
this.originY = null;
|
|
307
|
+
|
|
308
|
+
requestAnimationFrame(() => {
|
|
309
|
+
const container = this.shadowRoot?.querySelector(
|
|
310
|
+
'.dialog-container'
|
|
311
|
+
) as HTMLElement;
|
|
312
|
+
if (container) {
|
|
313
|
+
const rect = container.getBoundingClientRect();
|
|
314
|
+
const cx = rect.left + rect.width / 2;
|
|
315
|
+
const cy = rect.top + rect.height / 2;
|
|
316
|
+
const dx = ox - cx;
|
|
317
|
+
const dy = oy - cy;
|
|
318
|
+
|
|
319
|
+
// Disable transition so we can set the start position instantly
|
|
320
|
+
container.style.transition = 'none';
|
|
321
|
+
container.style.transform = `translate(${dx}px, ${dy}px) scale(0.2)`;
|
|
322
|
+
// Force reflow to register the start position
|
|
323
|
+
container.getBoundingClientRect();
|
|
324
|
+
|
|
325
|
+
// Re-enable transition and trigger animation to final position
|
|
326
|
+
container.style.transition = '';
|
|
327
|
+
this.animationEnd = true;
|
|
328
|
+
window.setTimeout(() => {
|
|
329
|
+
this.ready = true;
|
|
330
|
+
this.animationEnd = false;
|
|
331
|
+
container.style.transform = '';
|
|
332
|
+
}, 400);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
// Default animation (no origin)
|
|
337
|
+
this.animationEnd = true;
|
|
338
|
+
window.setTimeout(() => {
|
|
339
|
+
this.ready = true;
|
|
340
|
+
this.animationEnd = false;
|
|
341
|
+
}, 400);
|
|
342
|
+
}
|
|
297
343
|
|
|
298
344
|
this.scrollOffset = -document.documentElement.scrollTop;
|
|
299
345
|
body.style.position = 'fixed';
|
package/src/live/TembaChart.ts
CHANGED
|
@@ -759,7 +759,7 @@ export class TembaChart extends RapidElement {
|
|
|
759
759
|
private getValueAxisConfig() {
|
|
760
760
|
return {
|
|
761
761
|
min: 0,
|
|
762
|
-
...(this.showPercent && { max:
|
|
762
|
+
...(this.showPercent && { max:this.getInflatedMax() }),
|
|
763
763
|
stacked: true,
|
|
764
764
|
grid: {
|
|
765
765
|
color: 'rgba(0,0,0,0.04)',
|
|
@@ -148,6 +148,10 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
|
|
|
148
148
|
export class Simulator extends RapidElement {
|
|
149
149
|
static get styles() {
|
|
150
150
|
return css`
|
|
151
|
+
temba-floating-tab {
|
|
152
|
+
--floating-tab-right: 15px;
|
|
153
|
+
}
|
|
154
|
+
|
|
151
155
|
:host {
|
|
152
156
|
/* size-specific dimensions are set dynamically via inline styles */
|
|
153
157
|
--phone-width: 300px;
|
|
@@ -1117,8 +1121,12 @@ export class Simulator extends RapidElement {
|
|
|
1117
1121
|
continue;
|
|
1118
1122
|
}
|
|
1119
1123
|
|
|
1120
|
-
// skip msg_created events without a proper msg property
|
|
1121
|
-
if (
|
|
1124
|
+
// skip msg_created/ivr_created events without a proper msg property
|
|
1125
|
+
if (
|
|
1126
|
+
(rawEvent.type === 'msg_created' ||
|
|
1127
|
+
rawEvent.type === 'ivr_created') &&
|
|
1128
|
+
!(rawEvent as any).msg
|
|
1129
|
+
) {
|
|
1122
1130
|
continue;
|
|
1123
1131
|
}
|
|
1124
1132
|
|
|
@@ -1140,7 +1148,8 @@ export class Simulator extends RapidElement {
|
|
|
1140
1148
|
this.currentQuickReplies = (event as any).msg.quick_replies;
|
|
1141
1149
|
}
|
|
1142
1150
|
|
|
1143
|
-
const isMessage =
|
|
1151
|
+
const isMessage =
|
|
1152
|
+
event.type === 'msg_created' || event.type === 'ivr_created';
|
|
1144
1153
|
const msg = (event as any).msg;
|
|
1145
1154
|
|
|
1146
1155
|
// Check if the event should be displayed.
|
|
@@ -1934,7 +1943,7 @@ export class Simulator extends RapidElement {
|
|
|
1934
1943
|
icon="simulator"
|
|
1935
1944
|
label="Phone Simulator"
|
|
1936
1945
|
color="#10b981"
|
|
1937
|
-
order="
|
|
1946
|
+
order="4"
|
|
1938
1947
|
.hidden=${this.isVisible}
|
|
1939
1948
|
@temba-button-clicked=${this.handleShow}
|
|
1940
1949
|
></temba-floating-tab>
|
package/src/store/AppState.ts
CHANGED
|
@@ -18,6 +18,59 @@ import { produce } from 'immer';
|
|
|
18
18
|
export const FLOW_SPEC_VERSION = '14.3';
|
|
19
19
|
const CANVAS_PADDING = 800;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Temporary: Reclassify nodes based on whether they contain terminal actions.
|
|
23
|
+
* - execute_actions nodes with a terminal action become "terminal"
|
|
24
|
+
* - terminal nodes that no longer have a terminal action become "execute_actions"
|
|
25
|
+
* This can be removed once we stop supporting terminal nodes.
|
|
26
|
+
*/
|
|
27
|
+
function reclassifyTerminalNodes(definition: FlowDefinition): void {
|
|
28
|
+
if (!definition?.nodes || !definition?._ui?.nodes) return;
|
|
29
|
+
|
|
30
|
+
for (const node of definition.nodes) {
|
|
31
|
+
const nodeUI = definition._ui.nodes[node.uuid];
|
|
32
|
+
if (!nodeUI) continue;
|
|
33
|
+
|
|
34
|
+
const hasTerminalAction = node.actions?.some(
|
|
35
|
+
(action) => (action as any).terminal === true
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (nodeUI.type === 'execute_actions' && hasTerminalAction) {
|
|
39
|
+
nodeUI.type = 'terminal' as any;
|
|
40
|
+
} else if (nodeUI.type === ('terminal' as any) && !hasTerminalAction) {
|
|
41
|
+
nodeUI.type = 'execute_actions';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reclassify wait_for_response nodes that are actually voice-specific wait types.
|
|
48
|
+
* The server stores all voice waits as wait_for_response, but we detect the specific
|
|
49
|
+
* type from the router's wait hint:
|
|
50
|
+
* - hint.type === 'digits' && hint.count === 1 → wait_for_menu
|
|
51
|
+
* - hint.type === 'digits' (no count) → wait_for_digits
|
|
52
|
+
* - hint.type === 'audio' → wait_for_audio
|
|
53
|
+
*/
|
|
54
|
+
function reclassifyVoiceWaitNodes(definition: FlowDefinition): void {
|
|
55
|
+
if (!definition?.nodes || !definition?._ui?.nodes) return;
|
|
56
|
+
|
|
57
|
+
for (const node of definition.nodes) {
|
|
58
|
+
const nodeUI = definition._ui.nodes[node.uuid];
|
|
59
|
+
if (!nodeUI || nodeUI.type !== 'wait_for_response') continue;
|
|
60
|
+
|
|
61
|
+
const hint = node.router?.wait?.hint;
|
|
62
|
+
if (!hint) continue;
|
|
63
|
+
|
|
64
|
+
if (hint.type === 'digits' && hint.count === 1) {
|
|
65
|
+
nodeUI.type = 'wait_for_menu' as any;
|
|
66
|
+
} else if (hint.type === 'digits') {
|
|
67
|
+
nodeUI.type = 'wait_for_digits' as any;
|
|
68
|
+
} else if (hint.type === 'audio') {
|
|
69
|
+
nodeUI.type = 'wait_for_audio' as any;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
/**
|
|
22
75
|
* Sorts nodes by their position - first by y (top), then by x (left)
|
|
23
76
|
*/
|
|
@@ -65,11 +118,44 @@ export interface Language {
|
|
|
65
118
|
name: string;
|
|
66
119
|
}
|
|
67
120
|
|
|
121
|
+
export interface FlowIssue {
|
|
122
|
+
type: string;
|
|
123
|
+
node_uuid: string;
|
|
124
|
+
action_uuid?: string;
|
|
125
|
+
description: string;
|
|
126
|
+
dependency?: {
|
|
127
|
+
key: string;
|
|
128
|
+
name: string;
|
|
129
|
+
type: string;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
68
133
|
export interface FlowInfo {
|
|
69
134
|
results: InfoResult[];
|
|
70
135
|
dependencies: TypedObjectRef[];
|
|
71
136
|
counts: { nodes: number; languages: number };
|
|
72
137
|
locals: string[];
|
|
138
|
+
issues?: FlowIssue[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildIssueMaps(issues: FlowIssue[] = []): {
|
|
142
|
+
byNode: Map<string, FlowIssue[]>;
|
|
143
|
+
byAction: Map<string, FlowIssue[]>;
|
|
144
|
+
} {
|
|
145
|
+
const byNode = new Map<string, FlowIssue[]>();
|
|
146
|
+
const byAction = new Map<string, FlowIssue[]>();
|
|
147
|
+
for (const issue of issues) {
|
|
148
|
+
if (issue.action_uuid) {
|
|
149
|
+
const actionList = byAction.get(issue.action_uuid) || [];
|
|
150
|
+
actionList.push(issue);
|
|
151
|
+
byAction.set(issue.action_uuid, actionList);
|
|
152
|
+
} else {
|
|
153
|
+
const nodeList = byNode.get(issue.node_uuid) || [];
|
|
154
|
+
nodeList.push(issue);
|
|
155
|
+
byNode.set(issue.node_uuid, nodeList);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { byNode, byAction };
|
|
73
159
|
}
|
|
74
160
|
|
|
75
161
|
export interface FlowContents {
|
|
@@ -100,6 +186,8 @@ export interface Activity {
|
|
|
100
186
|
export interface AppState {
|
|
101
187
|
flowDefinition: FlowDefinition;
|
|
102
188
|
flowInfo: FlowInfo;
|
|
189
|
+
issuesByNode: Map<string, FlowIssue[]>;
|
|
190
|
+
issuesByAction: Map<string, FlowIssue[]>;
|
|
103
191
|
|
|
104
192
|
languageCode: string;
|
|
105
193
|
languageNames: { [code: string]: Language };
|
|
@@ -174,6 +262,8 @@ export const zustand = createStore<AppState>()(
|
|
|
174
262
|
workspace: null,
|
|
175
263
|
flowDefinition: null,
|
|
176
264
|
flowInfo: null,
|
|
265
|
+
issuesByNode: new Map(),
|
|
266
|
+
issuesByAction: new Map(),
|
|
177
267
|
isTranslating: false,
|
|
178
268
|
viewingRevision: false,
|
|
179
269
|
dirtyDate: null,
|
|
@@ -201,10 +291,15 @@ export const zustand = createStore<AppState>()(
|
|
|
201
291
|
throw new Error('Network response was not ok');
|
|
202
292
|
}
|
|
203
293
|
const data = (await response.json()) as FlowContents;
|
|
294
|
+
reclassifyTerminalNodes(data.definition);
|
|
295
|
+
reclassifyVoiceWaitNodes(data.definition);
|
|
296
|
+
const issueMaps = buildIssueMaps(data.info?.issues);
|
|
204
297
|
set({
|
|
205
298
|
flowInfo: data.info,
|
|
206
299
|
flowDefinition: data.definition,
|
|
207
|
-
viewingRevision
|
|
300
|
+
viewingRevision,
|
|
301
|
+
issuesByNode: issueMaps.byNode,
|
|
302
|
+
issuesByAction: issueMaps.byAction
|
|
208
303
|
});
|
|
209
304
|
},
|
|
210
305
|
|
|
@@ -295,10 +390,16 @@ export const zustand = createStore<AppState>()(
|
|
|
295
390
|
nodes: [...(flow.definition.nodes || [])]
|
|
296
391
|
};
|
|
297
392
|
state.flowInfo = flow.info;
|
|
393
|
+
const issueMaps = buildIssueMaps(flow.info?.issues);
|
|
394
|
+
state.issuesByNode = issueMaps.byNode;
|
|
395
|
+
state.issuesByAction = issueMaps.byAction;
|
|
298
396
|
// Reset to the flow's default language when loading a new flow
|
|
299
397
|
state.languageCode = flowLang;
|
|
300
398
|
state.isTranslating = false;
|
|
301
399
|
|
|
400
|
+
reclassifyTerminalNodes(state.flowDefinition);
|
|
401
|
+
reclassifyVoiceWaitNodes(state.flowDefinition);
|
|
402
|
+
|
|
302
403
|
// Sort nodes by position when loading flow
|
|
303
404
|
if (state.flowDefinition?.nodes && state.flowDefinition?._ui?.nodes) {
|
|
304
405
|
sortNodesByPosition(
|
|
@@ -312,6 +413,9 @@ export const zustand = createStore<AppState>()(
|
|
|
312
413
|
setFlowInfo: (info: FlowInfo) => {
|
|
313
414
|
set((state: AppState) => {
|
|
314
415
|
state.flowInfo = info;
|
|
416
|
+
const issueMaps = buildIssueMaps(info?.issues);
|
|
417
|
+
state.issuesByNode = issueMaps.byNode;
|
|
418
|
+
state.issuesByAction = issueMaps.byAction;
|
|
315
419
|
});
|
|
316
420
|
},
|
|
317
421
|
|
|
@@ -28,6 +28,7 @@ export type ActionType =
|
|
|
28
28
|
| 'send_email'
|
|
29
29
|
| 'send_broadcast'
|
|
30
30
|
| 'enter_flow'
|
|
31
|
+
| 'terminal'
|
|
31
32
|
| 'start_session'
|
|
32
33
|
| 'transfer_airtime'
|
|
33
34
|
| 'split_by_airtime'
|
|
@@ -152,6 +153,7 @@ export interface SendBroadcast extends Action {
|
|
|
152
153
|
|
|
153
154
|
export interface EnterFlow extends Action {
|
|
154
155
|
flow: NamedObject;
|
|
156
|
+
terminal?: boolean;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
export interface StartSession extends Action {
|