@nyaruka/temba-components 0.133.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 (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/demo/components/webchat/example.html +1 -1
  3. package/dist/locales/es.js +5 -5
  4. package/dist/locales/es.js.map +1 -1
  5. package/dist/locales/fr.js +5 -5
  6. package/dist/locales/fr.js.map +1 -1
  7. package/dist/locales/locale-codes.js +2 -11
  8. package/dist/locales/locale-codes.js.map +1 -1
  9. package/dist/locales/pt.js +5 -5
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/temba-components.js +306 -259
  12. package/dist/temba-components.js.map +1 -1
  13. package/out-tsc/src/display/Chat.js +223 -90
  14. package/out-tsc/src/display/Chat.js.map +1 -1
  15. package/out-tsc/src/display/TembaUser.js +3 -3
  16. package/out-tsc/src/display/TembaUser.js.map +1 -1
  17. package/out-tsc/src/events.js.map +1 -1
  18. package/out-tsc/src/flow/CanvasNode.js +8 -0
  19. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  20. package/out-tsc/src/flow/Editor.js +117 -28
  21. package/out-tsc/src/flow/Editor.js.map +1 -1
  22. package/out-tsc/src/flow/utils.js +141 -0
  23. package/out-tsc/src/flow/utils.js.map +1 -1
  24. package/out-tsc/src/interfaces.js.map +1 -1
  25. package/out-tsc/src/live/ContactChat.js +121 -170
  26. package/out-tsc/src/live/ContactChat.js.map +1 -1
  27. package/out-tsc/src/locales/es.js +5 -5
  28. package/out-tsc/src/locales/es.js.map +1 -1
  29. package/out-tsc/src/locales/fr.js +5 -5
  30. package/out-tsc/src/locales/fr.js.map +1 -1
  31. package/out-tsc/src/locales/locale-codes.js +2 -11
  32. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  33. package/out-tsc/src/locales/pt.js +5 -5
  34. package/out-tsc/src/locales/pt.js.map +1 -1
  35. package/out-tsc/src/store/AppState.js +3 -0
  36. package/out-tsc/src/store/AppState.js.map +1 -1
  37. package/out-tsc/src/store/Store.js +5 -5
  38. package/out-tsc/src/store/Store.js.map +1 -1
  39. package/out-tsc/src/webchat/WebChat.js +22 -9
  40. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  41. package/out-tsc/test/actions/send_broadcast.test.js +9 -4
  42. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  43. package/out-tsc/test/temba-flow-collision.test.js +673 -0
  44. package/out-tsc/test/temba-flow-collision.test.js.map +1 -0
  45. package/out-tsc/test/temba-flow-editor-node.test.js +128 -42
  46. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/screenshots/truth/contacts/chat-failure.png +0 -0
  49. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  50. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  51. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  52. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  53. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  54. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  55. package/src/display/Chat.ts +303 -129
  56. package/src/display/TembaUser.ts +3 -2
  57. package/src/events.ts +11 -8
  58. package/src/flow/CanvasNode.ts +10 -0
  59. package/src/flow/Editor.ts +156 -28
  60. package/src/flow/utils.ts +207 -1
  61. package/src/interfaces.ts +7 -0
  62. package/src/live/ContactChat.ts +128 -180
  63. package/src/locales/es.ts +13 -18
  64. package/src/locales/fr.ts +13 -18
  65. package/src/locales/locale-codes.ts +2 -11
  66. package/src/locales/pt.ts +13 -18
  67. package/src/store/AppState.ts +2 -0
  68. package/src/store/Store.ts +5 -5
  69. package/src/webchat/WebChat.ts +24 -10
  70. package/test/actions/send_broadcast.test.ts +2 -1
  71. package/test/temba-flow-collision.test.ts +833 -0
  72. package/test/temba-flow-editor-node.test.ts +142 -47
@@ -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;
@@ -310,6 +316,7 @@ export class Editor extends RapidElement {
310
316
 
311
317
  #canvas > .dragging {
312
318
  z-index: 99999 !important;
319
+ transition: none !important;
313
320
  }
314
321
 
315
322
  body .jtk-endpoint {
@@ -1186,6 +1193,76 @@ export class Editor extends RapidElement {
1186
1193
  </div>`;
1187
1194
  }
1188
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
+
1189
1266
  private handleMouseMove(event: MouseEvent): void {
1190
1267
  // Handle selection box drawing
1191
1268
  if (this.canvasMouseDown && !this.isMouseDown) {
@@ -1333,9 +1410,65 @@ export class Editor extends RapidElement {
1333
1410
  if (Object.keys(newPositions).length > 0) {
1334
1411
  getStore().getState().updateCanvasPositions(newPositions);
1335
1412
 
1336
- setTimeout(() => {
1337
- this.plumber.repaintEverything();
1338
- }, 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
+ }
1339
1472
  }
1340
1473
 
1341
1474
  this.selectedItems.clear();
@@ -1657,23 +1790,18 @@ export class Editor extends RapidElement {
1657
1790
  this.isCreatingNewNode = false;
1658
1791
  this.pendingNodePosition = null;
1659
1792
 
1660
- // Repaint jsplumb connections
1661
- if (this.plumber) {
1662
- requestAnimationFrame(() => {
1663
- this.plumber.repaintEverything();
1664
- });
1665
- }
1793
+ // Check for collisions and reflow
1794
+ requestAnimationFrame(() => {
1795
+ this.checkCollisionsAndReflow([updatedNode.uuid]);
1796
+ });
1666
1797
  } else {
1667
1798
  // Update existing node in the store
1668
1799
  getStore()?.getState().updateNode(this.editingNode.uuid, updatedNode);
1669
1800
 
1670
- // Repaint jsplumb connections in case node size changed
1671
- if (this.plumber) {
1672
- // Use requestAnimationFrame to ensure DOM has been updated first
1673
- requestAnimationFrame(() => {
1674
- this.plumber.repaintEverything();
1675
- });
1676
- }
1801
+ // Check for collisions and reflow in case node size changed
1802
+ requestAnimationFrame(() => {
1803
+ this.checkCollisionsAndReflow([this.editingNode.uuid]);
1804
+ });
1677
1805
  }
1678
1806
  }
1679
1807
  this.closeNodeEditor();
@@ -1715,6 +1843,11 @@ export class Editor extends RapidElement {
1715
1843
  // Reset the creation flags
1716
1844
  this.isCreatingNewNode = false;
1717
1845
  this.pendingNodePosition = null;
1846
+
1847
+ // Check for collisions and reflow
1848
+ requestAnimationFrame(() => {
1849
+ this.checkCollisionsAndReflow([updatedNode.uuid]);
1850
+ });
1718
1851
  } else {
1719
1852
  // This is an existing node - update it
1720
1853
  // Clean up jsPlumb connections for removed exits before updating the node
@@ -1743,13 +1876,10 @@ export class Editor extends RapidElement {
1743
1876
  if (uiConfig) {
1744
1877
  getStore()?.getState().updateNodeUIConfig(updatedNode.uuid, uiConfig);
1745
1878
  }
1746
- }
1747
1879
 
1748
- // Repaint jsplumb connections in case node size changed
1749
- if (this.plumber) {
1750
- // Use requestAnimationFrame to ensure DOM has been updated first
1880
+ // Check for collisions and reflow in case node size changed
1751
1881
  requestAnimationFrame(() => {
1752
- this.plumber.repaintEverything();
1882
+ this.checkCollisionsAndReflow([this.editingNode.uuid]);
1753
1883
  });
1754
1884
  }
1755
1885
  }
@@ -2077,12 +2207,10 @@ export class Editor extends RapidElement {
2077
2207
  this.canvasDropPreview = null;
2078
2208
  this.actionDragTargetNodeUuid = null;
2079
2209
 
2080
- // repaint connections
2081
- if (this.plumber) {
2082
- requestAnimationFrame(() => {
2083
- this.plumber.repaintEverything();
2084
- });
2085
- }
2210
+ // Check for collisions and reflow after adding new node
2211
+ requestAnimationFrame(() => {
2212
+ this.checkCollisionsAndReflow([newNode.uuid]);
2213
+ });
2086
2214
  }
2087
2215
 
2088
2216
  private getLocalizationLanguages(): Array<{ code: string; name: string }> {
@@ -2841,7 +2969,7 @@ export class Editor extends RapidElement {
2841
2969
  @mousedown=${this.handleMouseDown.bind(this)}
2842
2970
  uuid=${node.uuid}
2843
2971
  data-node-uuid=${node.uuid}
2844
- 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;"
2845
2973
  .plumber=${this.plumber}
2846
2974
  .node=${node}
2847
2975
  .ui=${this.definition._ui.nodes[node.uuid]}
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 {