@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.
Files changed (155) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +17 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/temba-components.js +562 -296
  6. package/dist/temba-components.js.map +1 -1
  7. package/out-tsc/src/display/Chat.js +10 -7
  8. package/out-tsc/src/display/Chat.js.map +1 -1
  9. package/out-tsc/src/display/Dropdown.js +3 -1
  10. package/out-tsc/src/display/Dropdown.js.map +1 -1
  11. package/out-tsc/src/display/FloatingTab.js +3 -3
  12. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  13. package/out-tsc/src/display/Thumbnail.js +163 -5
  14. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  15. package/out-tsc/src/flow/CanvasNode.js +64 -22
  16. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  17. package/out-tsc/src/flow/Editor.js +142 -8
  18. package/out-tsc/src/flow/Editor.js.map +1 -1
  19. package/out-tsc/src/flow/NodeEditor.js +118 -10
  20. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  21. package/out-tsc/src/flow/StickyNote.js +13 -4
  22. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  23. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  24. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  25. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  26. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  27. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  28. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  29. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  30. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  31. package/out-tsc/src/flow/config.js +11 -3
  32. package/out-tsc/src/flow/config.js.map +1 -1
  33. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  34. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  35. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  36. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  37. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  38. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  39. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  40. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  41. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  42. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  43. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  44. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  45. package/out-tsc/src/flow/operators.js +21 -5
  46. package/out-tsc/src/flow/operators.js.map +1 -1
  47. package/out-tsc/src/flow/types.js.map +1 -1
  48. package/out-tsc/src/flow/utils.js +79 -3
  49. package/out-tsc/src/flow/utils.js.map +1 -1
  50. package/out-tsc/src/form/ArrayEditor.js +4 -2
  51. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  52. package/out-tsc/src/form/FieldRenderer.js +49 -0
  53. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  54. package/out-tsc/src/interfaces.js +1 -0
  55. package/out-tsc/src/interfaces.js.map +1 -1
  56. package/out-tsc/src/layout/Dialog.js +52 -7
  57. package/out-tsc/src/layout/Dialog.js.map +1 -1
  58. package/out-tsc/src/live/TembaChart.js.map +1 -1
  59. package/out-tsc/src/simulator/Simulator.js +10 -4
  60. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  61. package/out-tsc/src/store/AppState.js +89 -3
  62. package/out-tsc/src/store/AppState.js.map +1 -1
  63. package/out-tsc/test/actions/play_audio.test.js +118 -0
  64. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  65. package/out-tsc/test/actions/say_msg.test.js +158 -0
  66. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  67. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  68. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  69. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  70. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  71. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  72. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  73. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  74. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  75. package/out-tsc/test/temba-flow-collision.test.js +261 -6
  76. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  77. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  78. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  79. package/package.json +1 -1
  80. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  81. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  82. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  83. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  84. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  86. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  87. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  88. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  89. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  90. package/screenshots/truth/editor/router.png +0 -0
  91. package/screenshots/truth/editor/wait.png +0 -0
  92. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  93. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  94. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  95. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  96. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  97. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  98. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  99. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  100. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  101. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  102. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  103. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  104. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  105. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  112. package/src/display/Chat.ts +13 -7
  113. package/src/display/Dropdown.ts +3 -1
  114. package/src/display/FloatingTab.ts +3 -3
  115. package/src/display/Thumbnail.ts +162 -2
  116. package/src/flow/CanvasNode.ts +69 -23
  117. package/src/flow/Editor.ts +156 -13
  118. package/src/flow/NodeEditor.ts +137 -9
  119. package/src/flow/StickyNote.ts +14 -4
  120. package/src/flow/actions/audio-player.ts +127 -0
  121. package/src/flow/actions/enter_flow.ts +44 -0
  122. package/src/flow/actions/play_audio.ts +64 -5
  123. package/src/flow/actions/say_msg.ts +94 -4
  124. package/src/flow/config.ts +11 -3
  125. package/src/flow/nodes/shared-rules.ts +1 -1
  126. package/src/flow/nodes/terminal.ts +9 -0
  127. package/src/flow/nodes/wait_for_audio.ts +88 -0
  128. package/src/flow/nodes/wait_for_dial.ts +176 -0
  129. package/src/flow/nodes/wait_for_digits.ts +86 -2
  130. package/src/flow/nodes/wait_for_menu.ts +209 -3
  131. package/src/flow/operators.ts +23 -5
  132. package/src/flow/types.ts +23 -1
  133. package/src/flow/utils.ts +82 -3
  134. package/src/form/ArrayEditor.ts +4 -2
  135. package/src/form/FieldRenderer.ts +64 -1
  136. package/src/interfaces.ts +2 -1
  137. package/src/layout/Dialog.ts +53 -7
  138. package/src/live/TembaChart.ts +1 -1
  139. package/src/simulator/Simulator.ts +13 -4
  140. package/src/store/AppState.ts +105 -1
  141. package/src/store/flow-definition.d.ts +2 -0
  142. package/test/actions/play_audio.test.ts +155 -0
  143. package/test/actions/say_msg.test.ts +196 -0
  144. package/test/nodes/wait_for_audio.test.ts +182 -0
  145. package/test/nodes/wait_for_dial.test.ts +382 -0
  146. package/test/nodes/wait_for_digits.test.ts +233 -109
  147. package/test/nodes/wait_for_menu.test.ts +383 -0
  148. package/test/temba-flow-collision.test.ts +286 -6
  149. package/test/temba-node-type-selector.test.ts +6 -6
  150. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  152. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  153. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  154. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  155. 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
- // Try each direction, pick the one with least disruption
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 DIRECTIONS) {
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
- const score = cascadeCount * 10000 + distance;
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;
@@ -648,13 +648,15 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
648
648
  }
649
649
 
650
650
  .removable .remove-btn {
651
- visibility: hidden;
651
+ opacity: 0.3;
652
652
  cursor: default;
653
+ pointer-events: none;
653
654
  }
654
655
 
655
656
  .removable .drag-handle {
656
- visibility: hidden;
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
  }
@@ -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) var(--bounce),
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.animationEnd = true;
293
- window.setTimeout(() => {
294
- this.ready = true;
295
- this.animationEnd = false;
296
- }, 400);
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';
@@ -759,7 +759,7 @@ export class TembaChart extends RapidElement {
759
759
  private getValueAxisConfig() {
760
760
  return {
761
761
  min: 0,
762
- ...(this.showPercent && { max: this.getInflatedMax() }),
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 (rawEvent.type === 'msg_created' && !(rawEvent as any).msg) {
1124
+ // skip msg_created/ivr_created events without a proper msg property
1125
+ if (
1126
+ (rawEvent.type === 'msg_created' ||
1127
+ rawEvent.type === 'ivr_created') &&
1128
+ !(rawEvent as any).msg
1129
+ ) {
1122
1130
  continue;
1123
1131
  }
1124
1132
 
@@ -1140,7 +1148,8 @@ export class Simulator extends RapidElement {
1140
1148
  this.currentQuickReplies = (event as any).msg.quick_replies;
1141
1149
  }
1142
1150
 
1143
- const isMessage = event.type === 'msg_created';
1151
+ const isMessage =
1152
+ event.type === 'msg_created' || event.type === 'ivr_created';
1144
1153
  const msg = (event as any).msg;
1145
1154
 
1146
1155
  // Check if the event should be displayed.
@@ -1934,7 +1943,7 @@ export class Simulator extends RapidElement {
1934
1943
  icon="simulator"
1935
1944
  label="Phone Simulator"
1936
1945
  color="#10b981"
1937
- order="3"
1946
+ order="4"
1938
1947
  .hidden=${this.isVisible}
1939
1948
  @temba-button-clicked=${this.handleShow}
1940
1949
  ></temba-floating-tab>
@@ -18,6 +18,59 @@ import { produce } from 'immer';
18
18
  export const FLOW_SPEC_VERSION = '14.3';
19
19
  const CANVAS_PADDING = 800;
20
20
 
21
+ /**
22
+ * Temporary: Reclassify nodes based on whether they contain terminal actions.
23
+ * - execute_actions nodes with a terminal action become "terminal"
24
+ * - terminal nodes that no longer have a terminal action become "execute_actions"
25
+ * This can be removed once we stop supporting terminal nodes.
26
+ */
27
+ function reclassifyTerminalNodes(definition: FlowDefinition): void {
28
+ if (!definition?.nodes || !definition?._ui?.nodes) return;
29
+
30
+ for (const node of definition.nodes) {
31
+ const nodeUI = definition._ui.nodes[node.uuid];
32
+ if (!nodeUI) continue;
33
+
34
+ const hasTerminalAction = node.actions?.some(
35
+ (action) => (action as any).terminal === true
36
+ );
37
+
38
+ if (nodeUI.type === 'execute_actions' && hasTerminalAction) {
39
+ nodeUI.type = 'terminal' as any;
40
+ } else if (nodeUI.type === ('terminal' as any) && !hasTerminalAction) {
41
+ nodeUI.type = 'execute_actions';
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Reclassify wait_for_response nodes that are actually voice-specific wait types.
48
+ * The server stores all voice waits as wait_for_response, but we detect the specific
49
+ * type from the router's wait hint:
50
+ * - hint.type === 'digits' && hint.count === 1 → wait_for_menu
51
+ * - hint.type === 'digits' (no count) → wait_for_digits
52
+ * - hint.type === 'audio' → wait_for_audio
53
+ */
54
+ function reclassifyVoiceWaitNodes(definition: FlowDefinition): void {
55
+ if (!definition?.nodes || !definition?._ui?.nodes) return;
56
+
57
+ for (const node of definition.nodes) {
58
+ const nodeUI = definition._ui.nodes[node.uuid];
59
+ if (!nodeUI || nodeUI.type !== 'wait_for_response') continue;
60
+
61
+ const hint = node.router?.wait?.hint;
62
+ if (!hint) continue;
63
+
64
+ if (hint.type === 'digits' && hint.count === 1) {
65
+ nodeUI.type = 'wait_for_menu' as any;
66
+ } else if (hint.type === 'digits') {
67
+ nodeUI.type = 'wait_for_digits' as any;
68
+ } else if (hint.type === 'audio') {
69
+ nodeUI.type = 'wait_for_audio' as any;
70
+ }
71
+ }
72
+ }
73
+
21
74
  /**
22
75
  * Sorts nodes by their position - first by y (top), then by x (left)
23
76
  */
@@ -65,11 +118,44 @@ export interface Language {
65
118
  name: string;
66
119
  }
67
120
 
121
+ export interface FlowIssue {
122
+ type: string;
123
+ node_uuid: string;
124
+ action_uuid?: string;
125
+ description: string;
126
+ dependency?: {
127
+ key: string;
128
+ name: string;
129
+ type: string;
130
+ };
131
+ }
132
+
68
133
  export interface FlowInfo {
69
134
  results: InfoResult[];
70
135
  dependencies: TypedObjectRef[];
71
136
  counts: { nodes: number; languages: number };
72
137
  locals: string[];
138
+ issues?: FlowIssue[];
139
+ }
140
+
141
+ function buildIssueMaps(issues: FlowIssue[] = []): {
142
+ byNode: Map<string, FlowIssue[]>;
143
+ byAction: Map<string, FlowIssue[]>;
144
+ } {
145
+ const byNode = new Map<string, FlowIssue[]>();
146
+ const byAction = new Map<string, FlowIssue[]>();
147
+ for (const issue of issues) {
148
+ if (issue.action_uuid) {
149
+ const actionList = byAction.get(issue.action_uuid) || [];
150
+ actionList.push(issue);
151
+ byAction.set(issue.action_uuid, actionList);
152
+ } else {
153
+ const nodeList = byNode.get(issue.node_uuid) || [];
154
+ nodeList.push(issue);
155
+ byNode.set(issue.node_uuid, nodeList);
156
+ }
157
+ }
158
+ return { byNode, byAction };
73
159
  }
74
160
 
75
161
  export interface FlowContents {
@@ -100,6 +186,8 @@ export interface Activity {
100
186
  export interface AppState {
101
187
  flowDefinition: FlowDefinition;
102
188
  flowInfo: FlowInfo;
189
+ issuesByNode: Map<string, FlowIssue[]>;
190
+ issuesByAction: Map<string, FlowIssue[]>;
103
191
 
104
192
  languageCode: string;
105
193
  languageNames: { [code: string]: Language };
@@ -174,6 +262,8 @@ export const zustand = createStore<AppState>()(
174
262
  workspace: null,
175
263
  flowDefinition: null,
176
264
  flowInfo: null,
265
+ issuesByNode: new Map(),
266
+ issuesByAction: new Map(),
177
267
  isTranslating: false,
178
268
  viewingRevision: false,
179
269
  dirtyDate: null,
@@ -201,10 +291,15 @@ export const zustand = createStore<AppState>()(
201
291
  throw new Error('Network response was not ok');
202
292
  }
203
293
  const data = (await response.json()) as FlowContents;
294
+ reclassifyTerminalNodes(data.definition);
295
+ reclassifyVoiceWaitNodes(data.definition);
296
+ const issueMaps = buildIssueMaps(data.info?.issues);
204
297
  set({
205
298
  flowInfo: data.info,
206
299
  flowDefinition: data.definition,
207
- viewingRevision
300
+ viewingRevision,
301
+ issuesByNode: issueMaps.byNode,
302
+ issuesByAction: issueMaps.byAction
208
303
  });
209
304
  },
210
305
 
@@ -295,10 +390,16 @@ export const zustand = createStore<AppState>()(
295
390
  nodes: [...(flow.definition.nodes || [])]
296
391
  };
297
392
  state.flowInfo = flow.info;
393
+ const issueMaps = buildIssueMaps(flow.info?.issues);
394
+ state.issuesByNode = issueMaps.byNode;
395
+ state.issuesByAction = issueMaps.byAction;
298
396
  // Reset to the flow's default language when loading a new flow
299
397
  state.languageCode = flowLang;
300
398
  state.isTranslating = false;
301
399
 
400
+ reclassifyTerminalNodes(state.flowDefinition);
401
+ reclassifyVoiceWaitNodes(state.flowDefinition);
402
+
302
403
  // Sort nodes by position when loading flow
303
404
  if (state.flowDefinition?.nodes && state.flowDefinition?._ui?.nodes) {
304
405
  sortNodesByPosition(
@@ -312,6 +413,9 @@ export const zustand = createStore<AppState>()(
312
413
  setFlowInfo: (info: FlowInfo) => {
313
414
  set((state: AppState) => {
314
415
  state.flowInfo = info;
416
+ const issueMaps = buildIssueMaps(info?.issues);
417
+ state.issuesByNode = issueMaps.byNode;
418
+ state.issuesByAction = issueMaps.byAction;
315
419
  });
316
420
  },
317
421
 
@@ -28,6 +28,7 @@ export type ActionType =
28
28
  | 'send_email'
29
29
  | 'send_broadcast'
30
30
  | 'enter_flow'
31
+ | 'terminal'
31
32
  | 'start_session'
32
33
  | 'transfer_airtime'
33
34
  | 'split_by_airtime'
@@ -152,6 +153,7 @@ export interface SendBroadcast extends Action {
152
153
 
153
154
  export interface EnterFlow extends Action {
154
155
  flow: NamedObject;
156
+ terminal?: boolean;
155
157
  }
156
158
 
157
159
  export interface StartSession extends Action {