@nyaruka/temba-components 0.132.0 → 0.134.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 (181) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/demo/components/flow/example.html +1 -0
  3. package/demo/components/webchat/example.html +1 -1
  4. package/demo/static/css/tailwind.css +30019 -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 +555 -476
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/display/Chat.js +248 -95
  16. package/out-tsc/src/display/Chat.js.map +1 -1
  17. package/out-tsc/src/display/FloatingTab.js +4 -4
  18. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  19. package/out-tsc/src/display/TembaUser.js +3 -3
  20. package/out-tsc/src/display/TembaUser.js.map +1 -1
  21. package/out-tsc/src/events.js.map +1 -1
  22. package/out-tsc/src/flow/CanvasNode.js +132 -58
  23. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  24. package/out-tsc/src/flow/Editor.js +183 -58
  25. package/out-tsc/src/flow/Editor.js.map +1 -1
  26. package/out-tsc/src/flow/utils.js +141 -0
  27. package/out-tsc/src/flow/utils.js.map +1 -1
  28. package/out-tsc/src/interfaces.js.map +1 -1
  29. package/out-tsc/src/layout/FloatingWindow.js +1 -2
  30. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  31. package/out-tsc/src/list/ContentMenu.js +1 -0
  32. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  33. package/out-tsc/src/list/SortableList.js +3 -2
  34. package/out-tsc/src/list/SortableList.js.map +1 -1
  35. package/out-tsc/src/live/ContactChat.js +184 -205
  36. package/out-tsc/src/live/ContactChat.js.map +1 -1
  37. package/out-tsc/src/locales/es.js +5 -5
  38. package/out-tsc/src/locales/es.js.map +1 -1
  39. package/out-tsc/src/locales/fr.js +5 -5
  40. package/out-tsc/src/locales/fr.js.map +1 -1
  41. package/out-tsc/src/locales/locale-codes.js +2 -11
  42. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  43. package/out-tsc/src/locales/pt.js +5 -5
  44. package/out-tsc/src/locales/pt.js.map +1 -1
  45. package/out-tsc/src/store/AppState.js +34 -0
  46. package/out-tsc/src/store/AppState.js.map +1 -1
  47. package/out-tsc/src/store/Store.js +5 -5
  48. package/out-tsc/src/store/Store.js.map +1 -1
  49. package/out-tsc/src/utils.js +3 -3
  50. package/out-tsc/src/utils.js.map +1 -1
  51. package/out-tsc/src/webchat/WebChat.js +22 -9
  52. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  53. package/out-tsc/test/ActionHelper.js +6 -5
  54. package/out-tsc/test/ActionHelper.js.map +1 -1
  55. package/out-tsc/test/actions/send_broadcast.test.js +9 -4
  56. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  57. package/out-tsc/test/temba-contact-chat.test.js +1 -1
  58. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  59. package/out-tsc/test/temba-floating-window.test.js +0 -2
  60. package/out-tsc/test/temba-floating-window.test.js.map +1 -1
  61. package/out-tsc/test/temba-flow-collision.test.js +673 -0
  62. package/out-tsc/test/temba-flow-collision.test.js.map +1 -0
  63. package/out-tsc/test/temba-flow-editor-node.test.js +195 -0
  64. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  65. package/out-tsc/test/temba-utils-uuid.test.js +45 -1
  66. package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
  67. package/out-tsc/test/utils.test.js +2 -2
  68. package/out-tsc/test/utils.test.js.map +1 -1
  69. package/package.json +1 -1
  70. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  71. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  72. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  73. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  74. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  75. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  76. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  77. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  78. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  79. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  80. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  81. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  82. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  83. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  84. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  85. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  86. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  87. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  88. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  89. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  90. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  91. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  92. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  93. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  94. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  95. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  96. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  97. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  98. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  99. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  100. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  101. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  102. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  103. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  104. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  105. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  106. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  107. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  108. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  109. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  110. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  111. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  112. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  113. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  114. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  115. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  116. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  117. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  118. package/screenshots/truth/contacts/chat-failure.png +0 -0
  119. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  120. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  121. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  122. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  123. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  124. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  125. package/screenshots/truth/floating-tab/default.png +0 -0
  126. package/screenshots/truth/floating-tab/gray.png +0 -0
  127. package/screenshots/truth/floating-tab/green.png +0 -0
  128. package/screenshots/truth/floating-tab/hover.png +0 -0
  129. package/screenshots/truth/floating-tab/purple.png +0 -0
  130. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  131. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  132. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  133. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  134. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  135. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  136. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  137. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  138. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  139. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  140. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  141. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  142. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  143. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  144. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  145. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  146. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  147. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  148. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  149. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  150. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  152. package/src/display/Chat.ts +331 -135
  153. package/src/display/FloatingTab.ts +4 -4
  154. package/src/display/TembaUser.ts +3 -2
  155. package/src/events.ts +12 -12
  156. package/src/flow/CanvasNode.ts +140 -57
  157. package/src/flow/Editor.ts +240 -58
  158. package/src/flow/utils.ts +207 -1
  159. package/src/interfaces.ts +7 -0
  160. package/src/layout/FloatingWindow.ts +1 -3
  161. package/src/list/ContentMenu.ts +1 -0
  162. package/src/list/SortableList.ts +3 -2
  163. package/src/live/ContactChat.ts +195 -221
  164. package/src/locales/es.ts +13 -18
  165. package/src/locales/fr.ts +13 -18
  166. package/src/locales/locale-codes.ts +2 -11
  167. package/src/locales/pt.ts +13 -18
  168. package/src/store/AppState.ts +43 -0
  169. package/src/store/Store.ts +5 -5
  170. package/src/utils.ts +3 -3
  171. package/src/webchat/WebChat.ts +24 -10
  172. package/test/ActionHelper.ts +13 -5
  173. package/test/actions/send_broadcast.test.ts +4 -2
  174. package/test/temba-contact-chat.test.ts +1 -1
  175. package/test/temba-floating-window.test.ts +0 -2
  176. package/test/temba-flow-collision.test.ts +833 -0
  177. package/test/temba-flow-editor-node.test.ts +224 -0
  178. package/test/temba-utils-uuid.test.ts +61 -1
  179. package/test/utils.test.ts +7 -2
  180. package/test-assets/contacts/history.json +22 -9
  181. package/web-test-runner.config.mjs +3 -3
@@ -24,6 +24,12 @@ import { Dialog } from '../layout/Dialog';
24
24
  import { Connection } from '@jsplumb/browser-ui';
25
25
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
26
26
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
27
+ import {
28
+ getNodeBounds,
29
+ calculateReflowPositions,
30
+ NodeBounds,
31
+ nodesOverlap
32
+ } from './utils';
27
33
 
28
34
  export function snapToGrid(value: number): number {
29
35
  const snapped = Math.round(value / 20) * 20;
@@ -292,8 +298,6 @@ export class Editor extends RapidElement {
292
298
  );
293
299
  background-size: 20px 20px;
294
300
  background-position: 10px 10px;
295
- box-shadow: inset -5px 0 10px rgba(0, 0, 0, 0.05);
296
- border-top: 1px solid #e0e0e0;
297
301
  width: 100%;
298
302
  display: flex;
299
303
  }
@@ -312,6 +316,7 @@ export class Editor extends RapidElement {
312
316
 
313
317
  #canvas > .dragging {
314
318
  z-index: 99999 !important;
319
+ transition: none !important;
315
320
  }
316
321
 
317
322
  body .jtk-endpoint {
@@ -741,11 +746,19 @@ export class Editor extends RapidElement {
741
746
  private saveChanges(): void {
742
747
  // post the flow definition to the server
743
748
  getStore()
744
- .postJSON(`/flow/revisions/${this.flow}`, this.definition)
749
+ .postJSON(`/flow/revisions/${this.flow}/`, this.definition)
745
750
  .then((response) => {
746
- // Update flow info with the response data
747
- if (response.json && response.json.info) {
748
- getStore().getState().setFlowInfo(response.json.info);
751
+ // Update flow info and revision with the response data
752
+ if (response.json) {
753
+ const state = getStore().getState();
754
+
755
+ if (response.json.info) {
756
+ state.setFlowInfo(response.json.info);
757
+ }
758
+
759
+ if (response.json.revision?.revision !== undefined) {
760
+ state.setRevision(response.json.revision.revision);
761
+ }
749
762
  }
750
763
  })
751
764
  .catch((error) => {
@@ -812,6 +825,12 @@ export class Editor extends RapidElement {
812
825
  this.handleNodeEditRequested.bind(this)
813
826
  );
814
827
 
828
+ // Listen for node deletion events
829
+ this.addEventListener(
830
+ CustomEventType.NodeDeleted,
831
+ this.handleNodeDeleted.bind(this)
832
+ );
833
+
815
834
  // Listen for canvas menu selections
816
835
  this.addEventListener(CustomEventType.Selection, (event: CustomEvent) => {
817
836
  const target = event.target as HTMLElement;
@@ -1174,6 +1193,76 @@ export class Editor extends RapidElement {
1174
1193
  </div>`;
1175
1194
  }
1176
1195
 
1196
+ /**
1197
+ * Checks for node collisions and reflows nodes as needed.
1198
+ * Nodes are only moved downward to resolve collisions.
1199
+ *
1200
+ * @param movedNodeUuids - UUIDs of nodes that were just moved/dropped
1201
+ * @param droppedNodeUuid - UUID of the specific node that was dropped (if applicable)
1202
+ * @param dropTargetBounds - Bounds of the node that was dropped onto (if applicable)
1203
+ */
1204
+ private checkCollisionsAndReflow(
1205
+ movedNodeUuids: string[],
1206
+ droppedNodeUuid: string | null = null,
1207
+ dropTargetBounds: NodeBounds | null = null
1208
+ ): void {
1209
+ if (!this.definition) return;
1210
+
1211
+ // Get all node bounds (only for actual nodes, not stickies)
1212
+ const allBounds: NodeBounds[] = [];
1213
+
1214
+ for (const node of this.definition.nodes) {
1215
+ const nodeUI = this.definition._ui?.nodes[node.uuid];
1216
+ if (!nodeUI?.position) continue;
1217
+
1218
+ const bounds = getNodeBounds(node.uuid, nodeUI.position);
1219
+ if (bounds) {
1220
+ allBounds.push(bounds);
1221
+ }
1222
+ }
1223
+
1224
+ // Check if we need to determine midpoint priority for a dropped node
1225
+ let targetHasPriority = false;
1226
+ if (droppedNodeUuid && dropTargetBounds) {
1227
+ const droppedBounds = allBounds.find((b) => b.uuid === droppedNodeUuid);
1228
+ if (droppedBounds) {
1229
+ // Check if the bottom of the dropped node is below the midpoint of the target
1230
+ // If bottom is above midpoint, dropped node gets preference (targetHasPriority = false)
1231
+ // If bottom is below midpoint, target gets preference (targetHasPriority = true)
1232
+ const droppedBottom = droppedBounds.bottom;
1233
+ const targetMidpoint =
1234
+ dropTargetBounds.top + dropTargetBounds.height / 2;
1235
+ targetHasPriority = droppedBottom > targetMidpoint;
1236
+ }
1237
+ }
1238
+
1239
+ // Calculate reflow positions for each moved node
1240
+ const allReflowPositions: { [uuid: string]: FlowPosition } = {};
1241
+
1242
+ for (const movedUuid of movedNodeUuids) {
1243
+ const movedBounds = allBounds.find((b) => b.uuid === movedUuid);
1244
+ if (!movedBounds) continue;
1245
+
1246
+ // Calculate reflow for this moved node
1247
+ const reflowPositions = calculateReflowPositions(
1248
+ movedUuid,
1249
+ movedBounds,
1250
+ allBounds,
1251
+ droppedNodeUuid === movedUuid ? targetHasPriority : false
1252
+ );
1253
+
1254
+ // Merge into all reflow positions
1255
+ for (const [uuid, position] of reflowPositions.entries()) {
1256
+ allReflowPositions[uuid] = position;
1257
+ }
1258
+ }
1259
+
1260
+ // If there are positions to update, apply them
1261
+ if (Object.keys(allReflowPositions).length > 0) {
1262
+ getStore().getState().updateCanvasPositions(allReflowPositions);
1263
+ }
1264
+ }
1265
+
1177
1266
  private handleMouseMove(event: MouseEvent): void {
1178
1267
  // Handle selection box drawing
1179
1268
  if (this.canvasMouseDown && !this.isMouseDown) {
@@ -1321,9 +1410,65 @@ export class Editor extends RapidElement {
1321
1410
  if (Object.keys(newPositions).length > 0) {
1322
1411
  getStore().getState().updateCanvasPositions(newPositions);
1323
1412
 
1324
- setTimeout(() => {
1325
- this.plumber.repaintEverything();
1326
- }, 0);
1413
+ // Check for collisions and reflow nodes after updating positions
1414
+ // Filter to only check nodes (not stickies)
1415
+ const nodeUuids = itemsToMove.filter((uuid) =>
1416
+ this.definition.nodes.find((node) => node.uuid === uuid)
1417
+ );
1418
+
1419
+ if (nodeUuids.length > 0) {
1420
+ // Allow DOM to update before checking collisions
1421
+ setTimeout(() => {
1422
+ // If only one node was moved, detect which node it might have been dropped onto
1423
+ let droppedNodeUuid: string | null = null;
1424
+ let dropTargetBounds: NodeBounds | null = null;
1425
+
1426
+ if (nodeUuids.length === 1) {
1427
+ droppedNodeUuid = nodeUuids[0];
1428
+ const droppedNodeUI = this.definition._ui?.nodes[droppedNodeUuid];
1429
+
1430
+ if (droppedNodeUI?.position) {
1431
+ const droppedBounds = getNodeBounds(
1432
+ droppedNodeUuid,
1433
+ droppedNodeUI.position
1434
+ );
1435
+
1436
+ if (droppedBounds) {
1437
+ // Find which node (if any) the dropped node overlaps with
1438
+ for (const node of this.definition.nodes) {
1439
+ if (node.uuid === droppedNodeUuid) continue;
1440
+
1441
+ const nodeUI = this.definition._ui?.nodes[node.uuid];
1442
+ if (!nodeUI?.position) continue;
1443
+
1444
+ const targetBounds = getNodeBounds(
1445
+ node.uuid,
1446
+ nodeUI.position
1447
+ );
1448
+ if (
1449
+ targetBounds &&
1450
+ nodesOverlap(droppedBounds, targetBounds)
1451
+ ) {
1452
+ dropTargetBounds = targetBounds;
1453
+ break; // Use the first overlapping node
1454
+ }
1455
+ }
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ this.checkCollisionsAndReflow(
1461
+ nodeUuids,
1462
+ droppedNodeUuid,
1463
+ dropTargetBounds
1464
+ );
1465
+ }, 0);
1466
+ } else {
1467
+ // No nodes moved, just repaint connections
1468
+ setTimeout(() => {
1469
+ this.plumber.repaintEverything();
1470
+ }, 0);
1471
+ }
1327
1472
  }
1328
1473
 
1329
1474
  this.selectedItems.clear();
@@ -1599,6 +1744,13 @@ export class Editor extends RapidElement {
1599
1744
  this.editingNodeUI = event.detail.nodeUI;
1600
1745
  }
1601
1746
 
1747
+ private handleNodeDeleted(event: CustomEvent): void {
1748
+ const nodeUuid = event.detail.uuid;
1749
+ if (nodeUuid) {
1750
+ this.deleteNodes([nodeUuid]);
1751
+ }
1752
+ }
1753
+
1602
1754
  private handleActionSaved(updatedAction: Action): void {
1603
1755
  if (this.editingNode && this.editingAction) {
1604
1756
  let updatedActions: Action[];
@@ -1638,23 +1790,18 @@ export class Editor extends RapidElement {
1638
1790
  this.isCreatingNewNode = false;
1639
1791
  this.pendingNodePosition = null;
1640
1792
 
1641
- // Repaint jsplumb connections
1642
- if (this.plumber) {
1643
- requestAnimationFrame(() => {
1644
- this.plumber.repaintEverything();
1645
- });
1646
- }
1793
+ // Check for collisions and reflow
1794
+ requestAnimationFrame(() => {
1795
+ this.checkCollisionsAndReflow([updatedNode.uuid]);
1796
+ });
1647
1797
  } else {
1648
1798
  // Update existing node in the store
1649
1799
  getStore()?.getState().updateNode(this.editingNode.uuid, updatedNode);
1650
1800
 
1651
- // Repaint jsplumb connections in case node size changed
1652
- if (this.plumber) {
1653
- // Use requestAnimationFrame to ensure DOM has been updated first
1654
- requestAnimationFrame(() => {
1655
- this.plumber.repaintEverything();
1656
- });
1657
- }
1801
+ // Check for collisions and reflow in case node size changed
1802
+ requestAnimationFrame(() => {
1803
+ this.checkCollisionsAndReflow([this.editingNode.uuid]);
1804
+ });
1658
1805
  }
1659
1806
  }
1660
1807
  this.closeNodeEditor();
@@ -1696,6 +1843,11 @@ export class Editor extends RapidElement {
1696
1843
  // Reset the creation flags
1697
1844
  this.isCreatingNewNode = false;
1698
1845
  this.pendingNodePosition = null;
1846
+
1847
+ // Check for collisions and reflow
1848
+ requestAnimationFrame(() => {
1849
+ this.checkCollisionsAndReflow([updatedNode.uuid]);
1850
+ });
1699
1851
  } else {
1700
1852
  // This is an existing node - update it
1701
1853
  // Clean up jsPlumb connections for removed exits before updating the node
@@ -1724,13 +1876,10 @@ export class Editor extends RapidElement {
1724
1876
  if (uiConfig) {
1725
1877
  getStore()?.getState().updateNodeUIConfig(updatedNode.uuid, uiConfig);
1726
1878
  }
1727
- }
1728
1879
 
1729
- // Repaint jsplumb connections in case node size changed
1730
- if (this.plumber) {
1731
- // Use requestAnimationFrame to ensure DOM has been updated first
1880
+ // Check for collisions and reflow in case node size changed
1732
1881
  requestAnimationFrame(() => {
1733
- this.plumber.repaintEverything();
1882
+ this.checkCollisionsAndReflow([this.editingNode.uuid]);
1734
1883
  });
1735
1884
  }
1736
1885
  }
@@ -1801,7 +1950,8 @@ export class Editor extends RapidElement {
1801
1950
  actionIndex,
1802
1951
  mouseX,
1803
1952
  mouseY,
1804
- actionHeight = 60
1953
+ actionHeight = 60,
1954
+ isLastAction = false
1805
1955
  } = event.detail;
1806
1956
 
1807
1957
  // Check if mouse is over another execute_actions node
@@ -1896,29 +2046,47 @@ export class Editor extends RapidElement {
1896
2046
 
1897
2047
  this.actionDragTargetNodeUuid = null;
1898
2048
 
1899
- // Tell source node to hide ghost (we're not over a valid target)
1900
2049
  const sourceElement = this.querySelector(
1901
2050
  `temba-flow-node[data-node-uuid="${nodeUuid}"]`
1902
2051
  );
1903
- if (sourceElement) {
1904
- sourceElement.dispatchEvent(
1905
- new CustomEvent('action-hide-ghost', {
1906
- detail: {},
1907
- bubbles: false
1908
- })
1909
- );
1910
- }
1911
2052
 
1912
- // Don't snap to grid for preview - let it follow cursor smoothly
1913
- const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
2053
+ // Show canvas drop preview only if this is NOT the last action
2054
+ // Last actions can only be dropped on other nodes, not on canvas
2055
+ if (!isLastAction) {
2056
+ // Hide ghost when showing canvas preview (for canvas drops)
2057
+ if (sourceElement) {
2058
+ sourceElement.dispatchEvent(
2059
+ new CustomEvent('action-hide-ghost', {
2060
+ detail: {},
2061
+ bubbles: false
2062
+ })
2063
+ );
2064
+ }
1914
2065
 
1915
- this.canvasDropPreview = {
1916
- action,
1917
- nodeUuid,
1918
- actionIndex,
1919
- position,
1920
- actionHeight
1921
- };
2066
+ // Don't snap to grid for preview - let it follow cursor smoothly
2067
+ const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
2068
+
2069
+ this.canvasDropPreview = {
2070
+ action,
2071
+ nodeUuid,
2072
+ actionIndex,
2073
+ position,
2074
+ actionHeight
2075
+ };
2076
+ } else {
2077
+ // For last action, keep ghost visible (can't drop on canvas)
2078
+ if (sourceElement) {
2079
+ sourceElement.dispatchEvent(
2080
+ new CustomEvent('action-show-ghost', {
2081
+ detail: {},
2082
+ bubbles: false
2083
+ })
2084
+ );
2085
+ }
2086
+
2087
+ // Clear any existing preview for last action
2088
+ this.canvasDropPreview = null;
2089
+ }
1922
2090
 
1923
2091
  // Force re-render to update preview position
1924
2092
  this.requestUpdate();
@@ -1946,7 +2114,14 @@ export class Editor extends RapidElement {
1946
2114
  }
1947
2115
 
1948
2116
  private handleActionDropExternal(event: CustomEvent): void {
1949
- const { action, nodeUuid, actionIndex, mouseX, mouseY } = event.detail;
2117
+ const {
2118
+ action,
2119
+ nodeUuid,
2120
+ actionIndex,
2121
+ mouseX,
2122
+ mouseY,
2123
+ isLastAction = false
2124
+ } = event.detail;
1950
2125
 
1951
2126
  // Check if we're dropping on an existing execute_actions node
1952
2127
  const targetNodeUuid = this.actionDragTargetNodeUuid;
@@ -1977,6 +2152,14 @@ export class Editor extends RapidElement {
1977
2152
  return;
1978
2153
  }
1979
2154
 
2155
+ // If this is the last action and we're not dropping on another node, do nothing
2156
+ // Last actions can only be moved to other nodes, not dropped on canvas
2157
+ if (isLastAction) {
2158
+ this.canvasDropPreview = null;
2159
+ this.actionDragTargetNodeUuid = null;
2160
+ return;
2161
+ }
2162
+
1980
2163
  // Not dropping on another node, create a new one on canvas
1981
2164
  // Snap to grid for the final drop position
1982
2165
  const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
@@ -1991,7 +2174,8 @@ export class Editor extends RapidElement {
1991
2174
 
1992
2175
  // if no actions remain, delete the node
1993
2176
  if (updatedActions.length === 0) {
1994
- getStore()?.getState().removeNodes([nodeUuid]);
2177
+ // Use deleteNodes to properly clean up Plumber connections before removing
2178
+ this.deleteNodes([nodeUuid]);
1995
2179
  } else {
1996
2180
  // update the node
1997
2181
  const updatedNode = { ...originalNode, actions: updatedActions };
@@ -2023,12 +2207,10 @@ export class Editor extends RapidElement {
2023
2207
  this.canvasDropPreview = null;
2024
2208
  this.actionDragTargetNodeUuid = null;
2025
2209
 
2026
- // repaint connections
2027
- if (this.plumber) {
2028
- requestAnimationFrame(() => {
2029
- this.plumber.repaintEverything();
2030
- });
2031
- }
2210
+ // Check for collisions and reflow after adding new node
2211
+ requestAnimationFrame(() => {
2212
+ this.checkCollisionsAndReflow([newNode.uuid]);
2213
+ });
2032
2214
  }
2033
2215
 
2034
2216
  private getLocalizationLanguages(): Array<{ code: string; name: string }> {
@@ -2573,7 +2755,7 @@ export class Editor extends RapidElement {
2573
2755
  header="Translations"
2574
2756
  .width=${360}
2575
2757
  .maxHeight=${600}
2576
- .top=${20}
2758
+ .top=${170}
2577
2759
  color="#6b7280"
2578
2760
  .hidden=${this.localizationWindowHidden}
2579
2761
  @temba-dialog-hidden=${this.handleLocalizationWindowClosed}
@@ -2738,6 +2920,7 @@ export class Editor extends RapidElement {
2738
2920
  icon="language"
2739
2921
  label="Translate Flow"
2740
2922
  color="#6b7280"
2923
+ top="180"
2741
2924
  .hidden=${!this.localizationWindowHidden}
2742
2925
  @temba-button-clicked=${this.handleLocalizationTabClick}
2743
2926
  ></temba-floating-tab>
@@ -2786,7 +2969,7 @@ export class Editor extends RapidElement {
2786
2969
  @mousedown=${this.handleMouseDown.bind(this)}
2787
2970
  uuid=${node.uuid}
2788
2971
  data-node-uuid=${node.uuid}
2789
- style="left:${position.left}px; top:${position.top}px"
2972
+ style="left:${position.left}px; top:${position.top}px;transition: all 0.2s ease-in-out;"
2790
2973
  .plumber=${this.plumber}
2791
2974
  .node=${node}
2792
2975
  .ui=${this.definition._ui.nodes[node.uuid]}
@@ -2810,8 +2993,7 @@ export class Editor extends RapidElement {
2810
2993
  ? 'selected'
2811
2994
  : ''}"
2812
2995
  @mousedown=${this.handleMouseDown.bind(this)}
2813
- style="left:${position.left}px; top:${position.top}px; z-index: ${1000 +
2814
- position.top}"
2996
+ style="left:${position.left}px; top:${position.top}px;"
2815
2997
  uuid=${uuid}
2816
2998
  .data=${sticky}
2817
2999
  .dragging=${dragging}
package/src/flow/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { html } from 'lit-html';
2
- import { NamedObject } from '../store/flow-definition';
2
+ import { NamedObject, FlowPosition } from '../store/flow-definition';
3
3
 
4
4
  /**
5
5
  * Renders a single line item with optional icon
@@ -168,3 +168,209 @@ export const SCHEMES: Scheme[] = [
168
168
  path: 'External ID'
169
169
  }
170
170
  ];
171
+
172
+ /**
173
+ * Represents the bounding box of a node on the canvas
174
+ */
175
+ export interface NodeBounds {
176
+ uuid: string;
177
+ left: number;
178
+ top: number;
179
+ right: number;
180
+ bottom: number;
181
+ width: number;
182
+ height: number;
183
+ }
184
+
185
+ /**
186
+ * Minimum vertical spacing between nodes (in pixels)
187
+ */
188
+ const MIN_NODE_SPACING = 30;
189
+
190
+ /**
191
+ * Small buffer to avoid floating point precision issues in overlap detection (in pixels)
192
+ * This prevents false positives when nodes are exactly adjacent (e.g., bottom of one node
193
+ * at exactly the same position as top of another)
194
+ */
195
+ const OVERLAP_BUFFER = 10;
196
+
197
+ /**
198
+ * Gets the bounding box for a node from the DOM
199
+ *
200
+ * @param nodeUuid - The UUID of the node
201
+ * @param position - The current position of the node
202
+ * @param element - Optional pre-fetched DOM element (recommended for performance when checking multiple nodes)
203
+ * @returns NodeBounds object or null if element not found
204
+ *
205
+ * Note: When element is not provided, performs a DOM query which may impact performance
206
+ * during bulk collision detection. Consider fetching elements beforehand when possible.
207
+ */
208
+ export const getNodeBounds = (
209
+ nodeUuid: string,
210
+ position: FlowPosition,
211
+ element?: HTMLElement
212
+ ): NodeBounds | null => {
213
+ // If element is provided, use it; otherwise try to find it in DOM
214
+ const nodeElement =
215
+ element || (document.querySelector(`[id="${nodeUuid}"]`) as HTMLElement);
216
+
217
+ if (!nodeElement) {
218
+ return null;
219
+ }
220
+
221
+ const rect = nodeElement.getBoundingClientRect();
222
+ const width = rect.width;
223
+ const height = rect.height;
224
+
225
+ return {
226
+ uuid: nodeUuid,
227
+ left: position.left,
228
+ top: position.top,
229
+ right: position.left + width,
230
+ bottom: position.top + height,
231
+ width,
232
+ height
233
+ };
234
+ };
235
+
236
+ /**
237
+ * Checks if two node bounding boxes overlap
238
+ */
239
+ export const nodesOverlap = (
240
+ bounds1: NodeBounds,
241
+ bounds2: NodeBounds
242
+ ): boolean => {
243
+ // Use a small buffer to avoid floating point precision issues
244
+ const buffer = OVERLAP_BUFFER;
245
+
246
+ return !(
247
+ bounds1.right <= bounds2.left - buffer ||
248
+ bounds1.left >= bounds2.right + buffer ||
249
+ bounds1.bottom <= bounds2.top - buffer ||
250
+ bounds1.top >= bounds2.bottom + buffer
251
+ );
252
+ };
253
+
254
+ /**
255
+ * Detects all collisions between a node and other nodes
256
+ */
257
+ export const detectCollisions = (
258
+ targetBounds: NodeBounds,
259
+ allBounds: NodeBounds[]
260
+ ): NodeBounds[] => {
261
+ return allBounds.filter(
262
+ (bounds) =>
263
+ bounds.uuid !== targetBounds.uuid && nodesOverlap(targetBounds, bounds)
264
+ );
265
+ };
266
+
267
+ /**
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
271
+ */
272
+ export const calculateReflowPositions = (
273
+ movedNodeUuid: string,
274
+ movedNodeBounds: NodeBounds,
275
+ allBounds: NodeBounds[],
276
+ droppedBelowMidpoint: boolean = false
277
+ ): Map<string, FlowPosition> => {
278
+ const newPositions = new Map<string, FlowPosition>();
279
+
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
+ };
302
+ }
303
+ }
304
+
305
+ // Now check for any remaining collisions and move other nodes down
306
+ const processedNodes = new Set<string>();
307
+ processedNodes.add(movedNodeUuid);
308
+
309
+ // Keep checking for collisions until none remain
310
+ let hasCollisions = true;
311
+ let iterations = 0;
312
+ const maxIterations = 100; // Prevent infinite loops
313
+
314
+ while (hasCollisions && iterations < maxIterations) {
315
+ hasCollisions = false;
316
+ iterations++;
317
+
318
+ // Check all nodes for collisions
319
+ for (const bounds of allBounds) {
320
+ if (processedNodes.has(bounds.uuid)) {
321
+ continue;
322
+ }
323
+
324
+ // Use original bounds since we skip already processed nodes
325
+ const currentBounds = bounds;
326
+
327
+ // Check if this node collides with the moved node or any already repositioned nodes
328
+ let collisionFound = false;
329
+ let maxCollisionBottom = 0;
330
+
331
+ // Check against moved node
332
+ if (nodesOverlap(currentBounds, movedNodeBounds)) {
333
+ collisionFound = true;
334
+ maxCollisionBottom = Math.max(
335
+ maxCollisionBottom,
336
+ movedNodeBounds.bottom
337
+ );
338
+ }
339
+
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
+ );
359
+ }
360
+ }
361
+
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);
371
+ }
372
+ }
373
+ }
374
+
375
+ return newPositions;
376
+ };
package/src/interfaces.ts CHANGED
@@ -103,6 +103,13 @@ export interface Msg {
103
103
  direction: string;
104
104
  type: string;
105
105
  attachments: string[];
106
+ unsendable_reason?:
107
+ | 'no_route'
108
+ | 'contact_blocked'
109
+ | 'contact_stopped'
110
+ | 'contact_archived'
111
+ | 'org_suspended'
112
+ | 'looping';
106
113
  }
107
114
 
108
115
  export interface ObjectReference {
@@ -18,7 +18,7 @@ export class FloatingWindow extends RapidElement {
18
18
  transition: transform var(--transition-duration, 300ms) ease-in-out,
19
19
  opacity var(--transition-duration, 300ms) ease-in-out;
20
20
  position: fixed;
21
- z-index: 9999;
21
+ z-index: 5000;
22
22
  top: 100px;
23
23
  background: white;
24
24
  border-radius: 8px;
@@ -193,8 +193,6 @@ export class FloatingWindow extends RapidElement {
193
193
  ): void {
194
194
  super.updated(changes);
195
195
  if (changes.has('hidden')) {
196
- this.classList.toggle('hidden', this.hidden);
197
-
198
196
  // when hiding, reset positioning behavior to original
199
197
  if (this.hidden && !changes.get('hidden')) {
200
198
  if (this.defaultLeft === -1) {