@nyaruka/temba-components 0.138.6 → 0.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +26 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/locales/es.js +5 -5
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +5 -5
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/locale-codes.js +2 -11
  10. package/dist/locales/locale-codes.js.map +1 -1
  11. package/dist/locales/pt.js +5 -5
  12. package/dist/locales/pt.js.map +1 -1
  13. package/dist/temba-components.js +1112 -882
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/display/Chat.js +10 -7
  16. package/out-tsc/src/display/Chat.js.map +1 -1
  17. package/out-tsc/src/display/Dropdown.js +3 -1
  18. package/out-tsc/src/display/Dropdown.js.map +1 -1
  19. package/out-tsc/src/display/FloatingTab.js +25 -32
  20. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  21. package/out-tsc/src/display/Thumbnail.js +163 -5
  22. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  23. package/out-tsc/src/flow/CanvasMenu.js +5 -3
  24. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  25. package/out-tsc/src/flow/CanvasNode.js +70 -29
  26. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  27. package/out-tsc/src/flow/Editor.js +290 -239
  28. package/out-tsc/src/flow/Editor.js.map +1 -1
  29. package/out-tsc/src/flow/NodeEditor.js +118 -10
  30. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  31. package/out-tsc/src/flow/Plumber.js +757 -403
  32. package/out-tsc/src/flow/Plumber.js.map +1 -1
  33. package/out-tsc/src/flow/StickyNote.js +13 -4
  34. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  35. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  36. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  37. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  38. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  39. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  40. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  41. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  42. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  43. package/out-tsc/src/flow/config.js +11 -3
  44. package/out-tsc/src/flow/config.js.map +1 -1
  45. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  46. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  47. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  48. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  49. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  50. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  51. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  52. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  53. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  54. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  55. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  56. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  57. package/out-tsc/src/flow/operators.js +21 -5
  58. package/out-tsc/src/flow/operators.js.map +1 -1
  59. package/out-tsc/src/flow/types.js.map +1 -1
  60. package/out-tsc/src/flow/utils.js +213 -65
  61. package/out-tsc/src/flow/utils.js.map +1 -1
  62. package/out-tsc/src/form/ArrayEditor.js +4 -2
  63. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  64. package/out-tsc/src/form/FieldRenderer.js +49 -0
  65. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  66. package/out-tsc/src/interfaces.js +2 -0
  67. package/out-tsc/src/interfaces.js.map +1 -1
  68. package/out-tsc/src/layout/Dialog.js +52 -7
  69. package/out-tsc/src/layout/Dialog.js.map +1 -1
  70. package/out-tsc/src/list/TicketList.js +4 -1
  71. package/out-tsc/src/list/TicketList.js.map +1 -1
  72. package/out-tsc/src/live/TembaChart.js.map +1 -1
  73. package/out-tsc/src/locales/es.js +5 -5
  74. package/out-tsc/src/locales/es.js.map +1 -1
  75. package/out-tsc/src/locales/fr.js +5 -5
  76. package/out-tsc/src/locales/fr.js.map +1 -1
  77. package/out-tsc/src/locales/locale-codes.js +2 -11
  78. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  79. package/out-tsc/src/locales/pt.js +5 -5
  80. package/out-tsc/src/locales/pt.js.map +1 -1
  81. package/out-tsc/src/simulator/Simulator.js +10 -3
  82. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  83. package/out-tsc/src/store/AppState.js +89 -3
  84. package/out-tsc/src/store/AppState.js.map +1 -1
  85. package/out-tsc/test/actions/play_audio.test.js +118 -0
  86. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  87. package/out-tsc/test/actions/say_msg.test.js +158 -0
  88. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  89. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  90. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  91. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  92. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  93. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  94. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  95. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  96. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  97. package/out-tsc/test/temba-floating-tab.test.js +4 -6
  98. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  99. package/out-tsc/test/temba-flow-collision.test.js +473 -220
  100. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  101. package/out-tsc/test/temba-flow-editor.test.js +0 -2
  102. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  103. package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
  104. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  105. package/out-tsc/test/temba-flow-plumber.test.js +102 -93
  106. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  107. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  108. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  109. package/package.json +1 -1
  110. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  111. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  112. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  113. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  114. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  115. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  116. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  117. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  118. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  119. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  120. package/screenshots/truth/editor/router.png +0 -0
  121. package/screenshots/truth/editor/wait.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  124. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  125. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  126. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  127. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  128. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  129. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  141. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  142. package/src/display/Chat.ts +13 -7
  143. package/src/display/Dropdown.ts +3 -1
  144. package/src/display/FloatingTab.ts +24 -33
  145. package/src/display/Thumbnail.ts +162 -2
  146. package/src/flow/CanvasMenu.ts +8 -3
  147. package/src/flow/CanvasNode.ts +75 -30
  148. package/src/flow/Editor.ts +336 -288
  149. package/src/flow/NodeEditor.ts +137 -9
  150. package/src/flow/Plumber.ts +1011 -457
  151. package/src/flow/StickyNote.ts +14 -4
  152. package/src/flow/actions/audio-player.ts +127 -0
  153. package/src/flow/actions/enter_flow.ts +44 -0
  154. package/src/flow/actions/play_audio.ts +64 -5
  155. package/src/flow/actions/say_msg.ts +94 -4
  156. package/src/flow/config.ts +11 -3
  157. package/src/flow/nodes/shared-rules.ts +1 -1
  158. package/src/flow/nodes/terminal.ts +9 -0
  159. package/src/flow/nodes/wait_for_audio.ts +88 -0
  160. package/src/flow/nodes/wait_for_dial.ts +176 -0
  161. package/src/flow/nodes/wait_for_digits.ts +86 -2
  162. package/src/flow/nodes/wait_for_menu.ts +209 -3
  163. package/src/flow/operators.ts +23 -5
  164. package/src/flow/types.ts +23 -1
  165. package/src/flow/utils.ts +238 -81
  166. package/src/form/ArrayEditor.ts +4 -2
  167. package/src/form/FieldRenderer.ts +64 -1
  168. package/src/interfaces.ts +3 -1
  169. package/src/layout/Dialog.ts +53 -7
  170. package/src/list/TicketList.ts +4 -1
  171. package/src/live/TembaChart.ts +1 -1
  172. package/src/locales/es.ts +13 -18
  173. package/src/locales/fr.ts +13 -18
  174. package/src/locales/locale-codes.ts +2 -11
  175. package/src/locales/pt.ts +13 -18
  176. package/src/simulator/Simulator.ts +13 -3
  177. package/src/store/AppState.ts +105 -1
  178. package/src/store/flow-definition.d.ts +2 -0
  179. package/test/actions/play_audio.test.ts +155 -0
  180. package/test/actions/say_msg.test.ts +196 -0
  181. package/test/nodes/wait_for_audio.test.ts +182 -0
  182. package/test/nodes/wait_for_dial.test.ts +382 -0
  183. package/test/nodes/wait_for_digits.test.ts +233 -109
  184. package/test/nodes/wait_for_menu.test.ts +383 -0
  185. package/test/temba-floating-tab.test.ts +4 -6
  186. package/test/temba-flow-collision.test.ts +495 -293
  187. package/test/temba-flow-editor.test.ts +0 -2
  188. package/test/temba-flow-plumber-connections.test.ts +97 -97
  189. package/test/temba-flow-plumber.test.ts +116 -103
  190. package/test/temba-node-type-selector.test.ts +6 -6
  191. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
package/src/flow/utils.ts CHANGED
@@ -1,5 +1,21 @@
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
+ }
12
+
13
+ const GRID_SIZE = 20;
14
+
15
+ export function snapToGrid(value: number): number {
16
+ const snapped = Math.round(value / GRID_SIZE) * GRID_SIZE;
17
+ return Math.max(snapped, 0);
18
+ }
3
19
 
4
20
  /**
5
21
  * Renders a single line item with optional icon
@@ -264,110 +280,251 @@ export const detectCollisions = (
264
280
  );
265
281
  };
266
282
 
283
+ type Direction = 'down' | 'up' | 'right' | 'left';
284
+
285
+ const DIRECTIONS: Direction[] = ['down', 'up', 'right', 'left'];
286
+
287
+ /**
288
+ * Creates a new NodeBounds at a different position
289
+ */
290
+ const makeBoundsAt = (
291
+ original: NodeBounds,
292
+ left: number,
293
+ top: number
294
+ ): NodeBounds => ({
295
+ ...original,
296
+ left,
297
+ top,
298
+ right: left + original.width,
299
+ bottom: top + original.height
300
+ });
301
+
302
+ /**
303
+ * Computes the minimum position needed to clear all fixed nodes in a given direction.
304
+ * Returns null if the direction is not viable (e.g., would require negative coordinates
305
+ * and still overlap).
306
+ */
307
+ const computeDirectionalClearance = (
308
+ collider: NodeBounds,
309
+ fixedNodes: NodeBounds[],
310
+ direction: Direction
311
+ ): { left: number; top: number } | null => {
312
+ switch (direction) {
313
+ case 'down': {
314
+ const maxBottom = Math.max(...fixedNodes.map((f) => f.bottom));
315
+ const newTop = snapToGrid(maxBottom + MIN_NODE_SPACING);
316
+ return { left: collider.left, top: newTop };
317
+ }
318
+ case 'up': {
319
+ const minTop = Math.min(...fixedNodes.map((f) => f.top));
320
+ const newTop = snapToGrid(minTop - collider.height - MIN_NODE_SPACING);
321
+ if (newTop < 0) return { left: collider.left, top: 0 };
322
+ return { left: collider.left, top: newTop };
323
+ }
324
+ case 'right': {
325
+ const maxRight = Math.max(...fixedNodes.map((f) => f.right));
326
+ const newLeft = snapToGrid(maxRight + MIN_NODE_SPACING);
327
+ return { left: newLeft, top: collider.top };
328
+ }
329
+ case 'left': {
330
+ const minLeft = Math.min(...fixedNodes.map((f) => f.left));
331
+ const newLeft = snapToGrid(minLeft - collider.width - MIN_NODE_SPACING);
332
+ if (newLeft < 0) return { left: 0, top: collider.top };
333
+ return { left: newLeft, top: collider.top };
334
+ }
335
+ }
336
+ };
337
+
267
338
  /**
268
- * Calculates the new positions needed to resolve all collisions
269
- * Nodes are only moved downward, never up, left, or right
270
- * Returns a map of node UUIDs to their new positions
339
+ * Calculates new positions to resolve all collisions using multi-directional reflow.
340
+ *
341
+ * Sacred nodes (the ones just dropped/created) keep their positions. All other
342
+ * colliding nodes are moved in whichever direction requires the least displacement
343
+ * and causes the fewest cascading collisions.
271
344
  */
272
345
  export const calculateReflowPositions = (
273
- movedNodeUuid: string,
274
- movedNodeBounds: NodeBounds,
275
- allBounds: NodeBounds[],
276
- droppedBelowMidpoint: boolean = false
346
+ sacredNodeUuids: string[],
347
+ allBounds: NodeBounds[]
277
348
  ): Map<string, FlowPosition> => {
278
349
  const newPositions = new Map<string, FlowPosition>();
350
+ const sacredSet = new Set(sacredNodeUuids);
279
351
 
280
- // If dropped below midpoint, the moved node should move down instead
281
- if (droppedBelowMidpoint) {
282
- // Find all nodes that collide with the moved node
283
- const collisions = detectCollisions(movedNodeBounds, allBounds);
284
-
285
- if (collisions.length > 0) {
286
- // Find the highest bottom position of all colliding nodes
287
- const maxBottom = Math.max(...collisions.map((b) => b.bottom));
288
-
289
- // Move the dropped node below all colliding nodes
290
- const newTop = maxBottom + MIN_NODE_SPACING;
291
- newPositions.set(movedNodeUuid, {
292
- left: movedNodeBounds.left,
293
- top: newTop
294
- });
295
-
296
- // Update the moved node bounds for further collision checks
297
- movedNodeBounds = {
298
- ...movedNodeBounds,
299
- top: newTop,
300
- bottom: newTop + movedNodeBounds.height
301
- };
352
+ // Mutable map of current bounds, updated as collisions are resolved
353
+ const currentBounds = new Map<string, NodeBounds>();
354
+ for (const b of allBounds) {
355
+ currentBounds.set(b.uuid, { ...b });
356
+ }
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
+ }
302
374
  }
303
375
  }
304
376
 
305
- // Now check for any remaining collisions and move other nodes down
306
- const processedNodes = new Set<string>();
307
- processedNodes.add(movedNodeUuid);
377
+ // Seed the queue with non-sacred nodes that overlap any sacred node
378
+ const queue: string[] = [];
379
+ const inQueue = new Set<string>();
380
+
381
+ for (const sacredUuid of sacredSet) {
382
+ const sacred = currentBounds.get(sacredUuid);
383
+ if (!sacred) continue;
384
+ for (const [uuid, bounds] of currentBounds) {
385
+ if (sacredSet.has(uuid) || inQueue.has(uuid)) continue;
386
+ if (nodesOverlap(sacred, bounds)) {
387
+ queue.push(uuid);
388
+ inQueue.add(uuid);
389
+ }
390
+ }
391
+ }
308
392
 
309
- // Keep checking for collisions until none remain
310
- let hasCollisions = true;
393
+ const resolved = new Set<string>();
311
394
  let iterations = 0;
312
- const maxIterations = 100; // Prevent infinite loops
395
+ const maxIterations = 200;
313
396
 
314
- while (hasCollisions && iterations < maxIterations) {
315
- hasCollisions = false;
397
+ while (queue.length > 0 && iterations < maxIterations) {
316
398
  iterations++;
399
+ const uuid = queue.shift()!;
317
400
 
318
- // Check all nodes for collisions
319
- for (const bounds of allBounds) {
320
- if (processedNodes.has(bounds.uuid)) {
321
- continue;
401
+ if (resolved.has(uuid)) continue;
402
+
403
+ const collider = currentBounds.get(uuid)!;
404
+
405
+ // Find all fixed nodes (sacred + already-resolved) that overlap this node
406
+ const fixedOverlaps: NodeBounds[] = [];
407
+ for (const [otherUuid, otherBounds] of currentBounds) {
408
+ if (otherUuid === uuid) continue;
409
+ if (sacredSet.has(otherUuid) || resolved.has(otherUuid)) {
410
+ if (nodesOverlap(collider, otherBounds)) {
411
+ fixedOverlaps.push(otherBounds);
412
+ }
322
413
  }
414
+ }
323
415
 
324
- // Use original bounds since we skip already processed nodes
325
- const currentBounds = bounds;
416
+ if (fixedOverlaps.length === 0) continue;
326
417
 
327
- // Check if this node collides with the moved node or any already repositioned nodes
328
- let collisionFound = false;
329
- let maxCollisionBottom = 0;
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;
330
422
 
331
- // Check against moved node
332
- if (nodesOverlap(currentBounds, movedNodeBounds)) {
333
- collisionFound = true;
334
- maxCollisionBottom = Math.max(
335
- maxCollisionBottom,
336
- movedNodeBounds.bottom
337
- );
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);
338
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
+ }
339
454
 
340
- // Check against other repositioned nodes
341
- for (const [otherUuid, otherPosition] of newPositions.entries()) {
342
- if (otherUuid === bounds.uuid) continue;
343
-
344
- const otherBounds = allBounds.find((b) => b.uuid === otherUuid);
345
- if (!otherBounds) continue;
346
-
347
- const otherUpdatedBounds = {
348
- ...otherBounds,
349
- top: otherPosition.top,
350
- bottom: otherPosition.top + otherBounds.height
351
- };
352
-
353
- if (nodesOverlap(currentBounds, otherUpdatedBounds)) {
354
- collisionFound = true;
355
- maxCollisionBottom = Math.max(
356
- maxCollisionBottom,
357
- otherUpdatedBounds.bottom
358
- );
455
+ // Try each allowed direction, pick the one with least disruption
456
+ let bestPos: { left: number; top: number } | null = null;
457
+ let bestScore = Infinity;
458
+
459
+ for (const dir of allowedDirections) {
460
+ const candidate = computeDirectionalClearance(
461
+ collider,
462
+ fixedOverlaps,
463
+ dir
464
+ );
465
+ if (!candidate) continue;
466
+
467
+ const candidateBounds = makeBoundsAt(
468
+ collider,
469
+ candidate.left,
470
+ candidate.top
471
+ );
472
+
473
+ // Verify no overlap with any sacred or resolved node
474
+ let stillOverlaps = false;
475
+ let cascadeCount = 0;
476
+ for (const [otherUuid, otherBounds] of currentBounds) {
477
+ if (otherUuid === uuid) continue;
478
+ if (!nodesOverlap(candidateBounds, otherBounds)) continue;
479
+
480
+ if (sacredSet.has(otherUuid) || resolved.has(otherUuid)) {
481
+ stillOverlaps = true;
482
+ break;
359
483
  }
484
+ cascadeCount++;
485
+ }
486
+ if (stillOverlaps) continue;
487
+
488
+ const distance =
489
+ Math.abs(candidate.left - collider.left) +
490
+ Math.abs(candidate.top - collider.top);
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;
360
505
  }
361
506
 
362
- if (collisionFound) {
363
- // Move this node down below the collision
364
- const newTop = maxCollisionBottom + MIN_NODE_SPACING;
365
- newPositions.set(bounds.uuid, {
366
- left: bounds.left,
367
- top: newTop
368
- });
369
- hasCollisions = true;
370
- processedNodes.add(bounds.uuid);
507
+ if (score < bestScore) {
508
+ bestScore = score;
509
+ bestPos = candidate;
510
+ }
511
+ }
512
+
513
+ if (bestPos) {
514
+ newPositions.set(uuid, { left: bestPos.left, top: bestPos.top });
515
+ const newBounds = makeBoundsAt(collider, bestPos.left, bestPos.top);
516
+ currentBounds.set(uuid, newBounds);
517
+ resolved.add(uuid);
518
+
519
+ // Enqueue any new cascading collisions
520
+ for (const [otherUuid, otherBounds] of currentBounds) {
521
+ if (otherUuid === uuid) continue;
522
+ if (sacredSet.has(otherUuid) || resolved.has(otherUuid)) continue;
523
+ if (inQueue.has(otherUuid)) continue;
524
+ if (nodesOverlap(newBounds, otherBounds)) {
525
+ queue.push(otherUuid);
526
+ inQueue.add(otherUuid);
527
+ }
371
528
  }
372
529
  }
373
530
  }
@@ -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
@@ -303,5 +303,7 @@ export enum CustomEventType {
303
303
  NodeEditRequested = 'temba-node-edit-requested',
304
304
  NodeSaved = 'temba-node-saved',
305
305
  NodeEditCancelled = 'temba-node-edit-cancelled',
306
- FollowSimulation = 'temba-follow-simulation'
306
+ FollowSimulation = 'temba-follow-simulation',
307
+ ContactClicked = 'temba-contact-clicked',
308
+ ShowIssue = 'temba-show-issue'
307
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';
@@ -13,7 +13,10 @@ export class TicketList extends TembaList {
13
13
  const lastActivity = this.items[0].ticket.last_activity_on;
14
14
  const separator = this.endpoint.includes('?') ? '&' : '?';
15
15
  return (
16
- this.endpoint + separator + 'after=' + new Date(lastActivity).getTime() * 1000
16
+ this.endpoint +
17
+ separator +
18
+ 'after=' +
19
+ new Date(lastActivity).getTime() * 1000
17
20
  );
18
21
  }
19
22
  return this.endpoint;
@@ -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)',
package/src/locales/es.ts CHANGED
@@ -1,18 +1,13 @@
1
-
2
- // Do not modify this file by hand!
3
- // Re-generate this file by running lit-localize
4
-
5
-
6
-
7
-
8
- /* eslint-disable no-irregular-whitespace */
9
- /* eslint-disable @typescript-eslint/no-explicit-any */
10
-
11
- export const templates = {
12
- 'scf1453991c986b25': `Tab para completar, enter para seleccionar`,
13
- 's73b4d70c02f4b4e0': `No options`,
14
- 's8f02e3a18ffc083a': `Are not currently in a flow`,
15
- 's638236250662c6b3': `Have sent a message in the last`,
16
- 's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
17
- };
18
-
1
+ // Do not modify this file by hand!
2
+ // Re-generate this file by running lit-localize
3
+
4
+ /* eslint-disable no-irregular-whitespace */
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+
7
+ export const templates = {
8
+ scf1453991c986b25: `Tab para completar, enter para seleccionar`,
9
+ s73b4d70c02f4b4e0: `No options`,
10
+ s8f02e3a18ffc083a: `Are not currently in a flow`,
11
+ s638236250662c6b3: `Have sent a message in the last`,
12
+ s4788ee206c4570c7: `Have not started this flow in the last 90 days`
13
+ };
package/src/locales/fr.ts CHANGED
@@ -1,18 +1,13 @@
1
-
2
- // Do not modify this file by hand!
3
- // Re-generate this file by running lit-localize
4
-
5
-
6
-
7
-
8
- /* eslint-disable no-irregular-whitespace */
9
- /* eslint-disable @typescript-eslint/no-explicit-any */
10
-
11
- export const templates = {
12
- 's73b4d70c02f4b4e0': `No options`,
13
- 'scf1453991c986b25': `Tab to complete, enter to select`,
14
- 's8f02e3a18ffc083a': `Are not currently in a flow`,
15
- 's638236250662c6b3': `Have sent a message in the last`,
16
- 's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
17
- };
18
-
1
+ // Do not modify this file by hand!
2
+ // Re-generate this file by running lit-localize
3
+
4
+ /* eslint-disable no-irregular-whitespace */
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+
7
+ export const templates = {
8
+ s73b4d70c02f4b4e0: `No options`,
9
+ scf1453991c986b25: `Tab to complete, enter to select`,
10
+ s8f02e3a18ffc083a: `Are not currently in a flow`,
11
+ s638236250662c6b3: `Have sent a message in the last`,
12
+ s4788ee206c4570c7: `Have not started this flow in the last 90 days`
13
+ };