@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
@@ -265,17 +265,16 @@ describe('Collision Detection Utilities', () => {
265
265
  });
266
266
  describe('calculateReflowPositions', () => {
267
267
  it('returns empty map when no collisions', () => {
268
- const movedBounds = {
269
- uuid: 'moved',
270
- left: 100,
271
- top: 100,
272
- right: 200,
273
- bottom: 200,
274
- width: 100,
275
- height: 100
276
- };
277
268
  const allBounds = [
278
- movedBounds,
269
+ {
270
+ uuid: 'moved',
271
+ left: 100,
272
+ top: 100,
273
+ right: 200,
274
+ bottom: 200,
275
+ width: 100,
276
+ height: 100
277
+ },
279
278
  {
280
279
  uuid: 'node1',
281
280
  left: 300,
@@ -286,50 +285,46 @@ describe('Collision Detection Utilities', () => {
286
285
  height: 100
287
286
  }
288
287
  ];
289
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
288
+ const positions = calculateReflowPositions(['moved'], allBounds);
290
289
  expect(positions.size).to.equal(0);
291
290
  });
292
- it('moves colliding node down', () => {
293
- const movedBounds = {
294
- uuid: 'moved',
295
- left: 100,
296
- top: 100,
297
- right: 200,
298
- bottom: 200,
299
- width: 100,
300
- height: 100
301
- };
291
+ it('moves colliding node out of the way', () => {
302
292
  const allBounds = [
303
- movedBounds,
293
+ {
294
+ uuid: 'moved',
295
+ left: 100,
296
+ top: 100,
297
+ right: 200,
298
+ bottom: 200,
299
+ width: 100,
300
+ height: 100
301
+ },
304
302
  {
305
303
  uuid: 'node1',
306
- left: 150,
304
+ left: 100,
307
305
  top: 150,
308
- right: 250,
306
+ right: 200,
309
307
  bottom: 250,
310
308
  width: 100,
311
309
  height: 100
312
310
  }
313
311
  ];
314
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
312
+ const positions = calculateReflowPositions(['moved'], allBounds);
315
313
  expect(positions.size).to.equal(1);
316
314
  expect(positions.has('node1')).to.be.true;
317
- const newPos = positions.get('node1');
318
- expect(newPos.left).to.equal(150); // left unchanged
319
- expect(newPos.top).to.be.greaterThan(200); // moved below the moved node
315
+ expect(positions.has('moved')).to.be.false;
320
316
  });
321
- it('handles droppedBelowMidpoint by moving dropped node down', () => {
322
- const movedBounds = {
323
- uuid: 'moved',
324
- left: 100,
325
- top: 150,
326
- right: 200,
327
- bottom: 250,
328
- width: 100,
329
- height: 100
330
- };
317
+ it('sacred node never appears in returned positions', () => {
331
318
  const allBounds = [
332
- movedBounds,
319
+ {
320
+ uuid: 'dropped',
321
+ left: 100,
322
+ top: 100,
323
+ right: 200,
324
+ bottom: 200,
325
+ width: 100,
326
+ height: 100
327
+ },
333
328
  {
334
329
  uuid: 'existing',
335
330
  left: 100,
@@ -340,91 +335,124 @@ describe('Collision Detection Utilities', () => {
340
335
  height: 100
341
336
  }
342
337
  ];
343
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, true // droppedBelowMidpoint
344
- );
345
- expect(positions.size).to.equal(1);
346
- expect(positions.has('moved')).to.be.true;
347
- const newPos = positions.get('moved');
348
- expect(newPos.top).to.be.greaterThan(200); // moved below existing node
349
- });
350
- it('gives priority to dropped node when dropped above midpoint', () => {
351
- // Dropped node overlaps with bottom of existing node
352
- // Bottom of dropped (180) is above midpoint of existing (150)
353
- // So dropped node should keep position, existing moves down
354
- const movedBounds = {
355
- uuid: 'dropped',
356
- left: 100,
357
- top: 80,
358
- right: 200,
359
- bottom: 180,
360
- width: 100,
361
- height: 100
362
- };
338
+ const positions = calculateReflowPositions(['dropped'], allBounds);
339
+ expect(positions.has('dropped')).to.be.false;
340
+ expect(positions.has('existing')).to.be.true;
341
+ });
342
+ it('prefers least-displacement direction', () => {
343
+ // Sacred at (100,100)-(200,200), collider at (180,100)-(280,200)
344
+ // Right requires only 60px displacement, down requires 140px
363
345
  const allBounds = [
364
- movedBounds,
365
346
  {
366
- uuid: 'existing',
347
+ uuid: 'sacred',
367
348
  left: 100,
368
349
  top: 100,
369
350
  right: 200,
370
351
  bottom: 200,
371
352
  width: 100,
372
353
  height: 100
354
+ },
355
+ {
356
+ uuid: 'collider',
357
+ left: 180,
358
+ top: 100,
359
+ right: 280,
360
+ bottom: 200,
361
+ width: 100,
362
+ height: 100
373
363
  }
374
364
  ];
375
- const positions = calculateReflowPositions('dropped', movedBounds, allBounds, false // dropped node's bottom is above target midpoint, dropped node gets priority
376
- );
377
- // Existing node should be moved down
378
- expect(positions.has('existing')).to.be.true;
379
- expect(positions.has('dropped')).to.be.false; // dropped keeps its position
380
- const existingNewPos = positions.get('existing');
381
- expect(existingNewPos.top).to.be.greaterThan(180); // moved below dropped node
382
- });
383
- it('gives priority to existing node when dropped below midpoint', () => {
384
- // Dropped node overlaps with top of existing node
385
- // Bottom of dropped (220) is below midpoint of existing (150)
386
- // So existing node keeps position, dropped moves down
387
- const movedBounds = {
388
- uuid: 'dropped',
389
- left: 100,
390
- top: 120,
391
- right: 200,
392
- bottom: 220,
393
- width: 100,
394
- height: 100
395
- };
365
+ const positions = calculateReflowPositions(['sacred'], allBounds);
366
+ expect(positions.has('collider')).to.be.true;
367
+ const newPos = positions.get('collider');
368
+ // Should move right (shorter) rather than down (longer)
369
+ expect(newPos.left).to.be.greaterThan(200);
370
+ expect(newPos.top).to.equal(100); // vertical position unchanged
371
+ });
372
+ it('prefers up when it is the shortest move', () => {
373
+ // Sacred at (100,200)-(200,300), collider at (100,180)-(200,280)
374
+ // Up: newTop=snapToGrid(200-100-30)=snapToGrid(70)=80, distance=100
375
+ // Down: newTop=snapToGrid(300+30)=340, distance=160
376
+ // Right: newLeft=snapToGrid(200+30)=240, distance=140
396
377
  const allBounds = [
397
- movedBounds,
398
378
  {
399
- uuid: 'existing',
379
+ uuid: 'sacred',
380
+ left: 100,
381
+ top: 200,
382
+ right: 200,
383
+ bottom: 300,
384
+ width: 100,
385
+ height: 100
386
+ },
387
+ {
388
+ uuid: 'collider',
389
+ left: 100,
390
+ top: 180,
391
+ right: 200,
392
+ bottom: 280,
393
+ width: 100,
394
+ height: 100
395
+ }
396
+ ];
397
+ const positions = calculateReflowPositions(['sacred'], allBounds);
398
+ expect(positions.has('collider')).to.be.true;
399
+ const newPos = positions.get('collider');
400
+ // Should move up (shortest displacement)
401
+ expect(newPos.top).to.be.lessThan(200);
402
+ expect(newPos.left).to.equal(100); // horizontal position unchanged
403
+ });
404
+ it('prefers axis-matching direction even with a cascade', () => {
405
+ // Sacred at (100,100)-(200,200), collider at (100,150)-(200,250)
406
+ // Overlap is 100w x 50h (wide) = vertical collision = prefer down
407
+ // A blocker sits below at (100,280)-(200,380), so down causes a cascade
408
+ // But axis bias still prefers down over moving right
409
+ const allBounds = [
410
+ {
411
+ uuid: 'sacred',
400
412
  left: 100,
401
413
  top: 100,
402
414
  right: 200,
403
415
  bottom: 200,
404
416
  width: 100,
405
417
  height: 100
418
+ },
419
+ {
420
+ uuid: 'collider',
421
+ left: 100,
422
+ top: 150,
423
+ right: 200,
424
+ bottom: 250,
425
+ width: 100,
426
+ height: 100
427
+ },
428
+ {
429
+ uuid: 'blocker',
430
+ left: 100,
431
+ top: 280,
432
+ right: 200,
433
+ bottom: 380,
434
+ width: 100,
435
+ height: 100
406
436
  }
407
437
  ];
408
- const positions = calculateReflowPositions('dropped', movedBounds, allBounds, true // dropped node's bottom is below target midpoint, target gets priority
409
- );
410
- // Dropped node should be moved down
411
- expect(positions.has('dropped')).to.be.true;
412
- expect(positions.has('existing')).to.be.false; // existing keeps its position
413
- const droppedNewPos = positions.get('dropped');
414
- expect(droppedNewPos.top).to.be.greaterThan(200); // moved below existing node
438
+ const positions = calculateReflowPositions(['sacred'], allBounds);
439
+ expect(positions.has('collider')).to.be.true;
440
+ const newPos = positions.get('collider');
441
+ // Axis bias prefers down (vertical) even though it cascades into blocker
442
+ expect(newPos.top).to.be.greaterThan(200);
443
+ expect(newPos.left).to.equal(100); // horizontal position unchanged
415
444
  });
416
445
  it('resolves cascading collisions', () => {
417
- const movedBounds = {
418
- uuid: 'moved',
419
- left: 100,
420
- top: 100,
421
- right: 200,
422
- bottom: 200,
423
- width: 100,
424
- height: 100
425
- };
426
446
  const allBounds = [
427
- movedBounds,
447
+ {
448
+ uuid: 'moved',
449
+ left: 100,
450
+ top: 100,
451
+ right: 200,
452
+ bottom: 200,
453
+ width: 100,
454
+ height: 100
455
+ },
428
456
  {
429
457
  uuid: 'node1',
430
458
  left: 100,
@@ -444,34 +472,102 @@ describe('Collision Detection Utilities', () => {
444
472
  height: 100
445
473
  }
446
474
  ];
447
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
448
- // Both nodes should be repositioned to avoid collision
475
+ const positions = calculateReflowPositions(['moved'], allBounds);
476
+ // At least one node should be repositioned
449
477
  expect(positions.size).to.be.greaterThan(0);
450
- // Check that moved nodes maintain vertical order and spacing
451
- if (positions.has('node1') && positions.has('node2')) {
452
- const node1Pos = positions.get('node1');
453
- const node2Pos = positions.get('node2');
454
- // node2 should be below node1
455
- expect(node2Pos.top).to.be.greaterThan(node1Pos.top);
456
- }
478
+ // No node should overlap with the sacred node or each other after reflow
479
+ // (verified by the algorithm's correctness guarantee)
457
480
  });
458
- it('handles multiple iterations for complex cascading collisions', () => {
459
- // Test case that requires multiple iterations to resolve all collisions
460
- // This scenario has nodes that initially don't collide with moved node,
461
- // but will collide with other nodes that get moved
462
- const movedBounds = {
463
- uuid: 'moved',
464
- left: 100,
465
- top: 50,
466
- right: 200,
467
- bottom: 150,
481
+ it('handles multiple sacred nodes', () => {
482
+ // Two sacred nodes with a non-sacred node overlapping one
483
+ const allBounds = [
484
+ {
485
+ uuid: 'sacred1',
486
+ left: 100,
487
+ top: 100,
488
+ right: 200,
489
+ bottom: 200,
490
+ width: 100,
491
+ height: 100
492
+ },
493
+ {
494
+ uuid: 'sacred2',
495
+ left: 100,
496
+ top: 400,
497
+ right: 200,
498
+ bottom: 500,
499
+ width: 100,
500
+ height: 100
501
+ },
502
+ {
503
+ uuid: 'collider',
504
+ left: 100,
505
+ top: 150,
506
+ right: 200,
507
+ bottom: 250,
508
+ width: 100,
509
+ height: 100
510
+ }
511
+ ];
512
+ const positions = calculateReflowPositions(['sacred1', 'sacred2'], allBounds);
513
+ // Sacred nodes should never be moved
514
+ expect(positions.has('sacred1')).to.be.false;
515
+ expect(positions.has('sacred2')).to.be.false;
516
+ // Collider should be moved
517
+ expect(positions.has('collider')).to.be.true;
518
+ });
519
+ it('does not move a node into another sacred node', () => {
520
+ // Two sacred nodes close together with a collider between them
521
+ // Moving right would overlap sacred2, so it should choose another direction
522
+ const allBounds = [
523
+ {
524
+ uuid: 'sacred1',
525
+ left: 100,
526
+ top: 100,
527
+ right: 200,
528
+ bottom: 200,
529
+ width: 100,
530
+ height: 100
531
+ },
532
+ {
533
+ uuid: 'sacred2',
534
+ left: 240,
535
+ top: 100,
536
+ right: 340,
537
+ bottom: 200,
538
+ width: 100,
539
+ height: 100
540
+ },
541
+ {
542
+ uuid: 'collider',
543
+ left: 150,
544
+ top: 100,
545
+ right: 250,
546
+ bottom: 200,
547
+ width: 100,
548
+ height: 100
549
+ }
550
+ ];
551
+ const positions = calculateReflowPositions(['sacred1', 'sacred2'], allBounds);
552
+ expect(positions.has('collider')).to.be.true;
553
+ const newPos = positions.get('collider');
554
+ // Should not overlap either sacred node after reflow
555
+ const newBounds = {
556
+ uuid: 'collider',
557
+ left: newPos.left,
558
+ top: newPos.top,
559
+ right: newPos.left + 100,
560
+ bottom: newPos.top + 100,
468
561
  width: 100,
469
562
  height: 100
470
563
  };
564
+ expect(nodesOverlap(newBounds, allBounds[0])).to.be.false;
565
+ expect(nodesOverlap(newBounds, allBounds[1])).to.be.false;
566
+ });
567
+ it('snaps reflow positions to grid', () => {
471
568
  const allBounds = [
472
- movedBounds,
473
569
  {
474
- uuid: 'node1',
570
+ uuid: 'sacred',
475
571
  left: 100,
476
572
  top: 100,
477
573
  right: 200,
@@ -480,151 +576,308 @@ describe('Collision Detection Utilities', () => {
480
576
  height: 100
481
577
  },
482
578
  {
483
- uuid: 'node2',
579
+ uuid: 'collider',
580
+ left: 100,
581
+ top: 150,
582
+ right: 200,
583
+ bottom: 250,
584
+ width: 100,
585
+ height: 100
586
+ }
587
+ ];
588
+ const positions = calculateReflowPositions(['sacred'], allBounds);
589
+ expect(positions.has('collider')).to.be.true;
590
+ const newPos = positions.get('collider');
591
+ // Both coordinates should be multiples of 20 (grid size)
592
+ expect(newPos.left % 20).to.equal(0);
593
+ expect(newPos.top % 20).to.equal(0);
594
+ });
595
+ it('clamps positions to zero (no negative coordinates)', () => {
596
+ // Sacred node near top-left, collider above it
597
+ // Moving up would go negative, so it should fall back
598
+ const allBounds = [
599
+ {
600
+ uuid: 'sacred',
601
+ left: 0,
602
+ top: 0,
603
+ right: 200,
604
+ bottom: 200,
605
+ width: 200,
606
+ height: 200
607
+ },
608
+ {
609
+ uuid: 'collider',
610
+ left: 0,
611
+ top: 100,
612
+ right: 200,
613
+ bottom: 200,
614
+ width: 200,
615
+ height: 100
616
+ }
617
+ ];
618
+ const positions = calculateReflowPositions(['sacred'], allBounds);
619
+ expect(positions.has('collider')).to.be.true;
620
+ const newPos = positions.get('collider');
621
+ expect(newPos.left).to.be.at.least(0);
622
+ expect(newPos.top).to.be.at.least(0);
623
+ });
624
+ it('does not move a lower node above the sacred node', () => {
625
+ // Collider is below sacred (collider.top > sacred.top)
626
+ // Up should be filtered, so collider goes down or to the side
627
+ const allBounds = [
628
+ {
629
+ uuid: 'sacred',
630
+ left: 100,
631
+ top: 100,
632
+ right: 200,
633
+ bottom: 200,
634
+ width: 100,
635
+ height: 100
636
+ },
637
+ {
638
+ uuid: 'collider',
484
639
  left: 100,
485
640
  top: 180,
486
641
  right: 200,
487
642
  bottom: 280,
488
643
  width: 100,
489
644
  height: 100
645
+ }
646
+ ];
647
+ const positions = calculateReflowPositions(['sacred'], allBounds);
648
+ expect(positions.has('collider')).to.be.true;
649
+ const newPos = positions.get('collider');
650
+ // Should NOT move above the sacred node's top
651
+ expect(newPos.top).to.be.at.least(100);
652
+ });
653
+ it('does not move a right-of node left of the sacred node', () => {
654
+ // Collider is to the right of sacred (collider.left > sacred.left)
655
+ // Left should be filtered
656
+ const allBounds = [
657
+ {
658
+ uuid: 'sacred',
659
+ left: 100,
660
+ top: 100,
661
+ right: 200,
662
+ bottom: 200,
663
+ width: 100,
664
+ height: 100
665
+ },
666
+ {
667
+ uuid: 'collider',
668
+ left: 180,
669
+ top: 100,
670
+ right: 280,
671
+ bottom: 200,
672
+ width: 100,
673
+ height: 100
674
+ }
675
+ ];
676
+ const positions = calculateReflowPositions(['sacred'], allBounds);
677
+ expect(positions.has('collider')).to.be.true;
678
+ const newPos = positions.get('collider');
679
+ // Should NOT move left of the sacred node's left
680
+ expect(newPos.left).to.be.at.least(100);
681
+ });
682
+ it('prefers vertical for wide overlap (vertical collision)', () => {
683
+ // Nodes stacked: same horizontal position, slight vertical overlap
684
+ // Overlap: 100w x 30h (wider than tall) = vertical collision = prefer up/down
685
+ const allBounds = [
686
+ {
687
+ uuid: 'sacred',
688
+ left: 100,
689
+ top: 100,
690
+ right: 200,
691
+ bottom: 200,
692
+ width: 100,
693
+ height: 100
694
+ },
695
+ {
696
+ uuid: 'collider',
697
+ left: 100,
698
+ top: 170,
699
+ right: 200,
700
+ bottom: 270,
701
+ width: 100,
702
+ height: 100
703
+ }
704
+ ];
705
+ const positions = calculateReflowPositions(['sacred'], allBounds);
706
+ expect(positions.has('collider')).to.be.true;
707
+ const newPos = positions.get('collider');
708
+ // Should move vertically (down since collider is below)
709
+ expect(newPos.top).to.be.greaterThan(200);
710
+ expect(newPos.left).to.equal(100); // horizontal position unchanged
711
+ });
712
+ it('prefers horizontal for tall overlap (horizontal collision)', () => {
713
+ // Nodes side-by-side: same vertical position, slight horizontal overlap
714
+ // Overlap: 30w x 100h (taller than wide) = horizontal collision = prefer left/right
715
+ const allBounds = [
716
+ {
717
+ uuid: 'sacred',
718
+ left: 100,
719
+ top: 100,
720
+ right: 200,
721
+ bottom: 200,
722
+ width: 100,
723
+ height: 100
724
+ },
725
+ {
726
+ uuid: 'collider',
727
+ left: 170,
728
+ top: 100,
729
+ right: 270,
730
+ bottom: 200,
731
+ width: 100,
732
+ height: 100
733
+ }
734
+ ];
735
+ const positions = calculateReflowPositions(['sacred'], allBounds);
736
+ expect(positions.has('collider')).to.be.true;
737
+ const newPos = positions.get('collider');
738
+ // Should move horizontally (right since collider is right of sacred)
739
+ expect(newPos.left).to.be.greaterThan(200);
740
+ expect(newPos.top).to.equal(100); // vertical position unchanged
741
+ });
742
+ it('axis bias tolerates a few cascading collisions', () => {
743
+ // Sacred at (100,100)-(200,200), collider at (100,170)-(200,270)
744
+ // Overlap: 100w x 30h = vertical collision = prefer down
745
+ // Two blockers below: moving down causes 2 cascades
746
+ // Moving right causes 0 cascades but is axis-mismatched
747
+ // Axis bias should still prefer down with 2 cascades
748
+ const allBounds = [
749
+ {
750
+ uuid: 'sacred',
751
+ left: 100,
752
+ top: 100,
753
+ right: 200,
754
+ bottom: 200,
755
+ width: 100,
756
+ height: 100
757
+ },
758
+ {
759
+ uuid: 'collider',
760
+ left: 100,
761
+ top: 170,
762
+ right: 200,
763
+ bottom: 270,
764
+ width: 100,
765
+ height: 100
490
766
  },
491
767
  {
492
- uuid: 'node3',
768
+ uuid: 'blocker1',
493
769
  left: 100,
494
770
  top: 260,
495
771
  right: 200,
496
772
  bottom: 360,
497
773
  width: 100,
498
774
  height: 100
775
+ },
776
+ {
777
+ uuid: 'blocker2',
778
+ left: 100,
779
+ top: 350,
780
+ right: 200,
781
+ bottom: 450,
782
+ width: 100,
783
+ height: 100
499
784
  }
500
785
  ];
501
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
502
- // All three nodes should be repositioned to be stacked below the moved node
503
- expect(positions.size).to.equal(3);
504
- expect(positions.has('node1')).to.be.true;
505
- expect(positions.has('node2')).to.be.true;
506
- expect(positions.has('node3')).to.be.true;
507
- const node1Pos = positions.get('node1');
508
- const node2Pos = positions.get('node2');
509
- const node3Pos = positions.get('node3');
510
- // Nodes should be stacked vertically with proper spacing
511
- expect(node1Pos.top).to.be.at.least(170); // 150 (bottom of moved) + 20
512
- expect(node2Pos.top).to.be.at.least(node1Pos.top + 120); // node1 top + height + spacing
513
- expect(node3Pos.top).to.be.at.least(node2Pos.top + 120); // node2 top + height + spacing
514
- });
515
- it('handles nodes that collide with already repositioned nodes', () => {
516
- // This test creates a scenario where node3 doesn't collide with the moved node,
517
- // but collides with node2 which itself got repositioned due to collision with node1
518
- const movedBounds = {
519
- uuid: 'moved',
520
- left: 100,
521
- top: 100,
522
- right: 200,
523
- bottom: 200,
524
- width: 100,
525
- height: 100
526
- };
786
+ const positions = calculateReflowPositions(['sacred'], allBounds);
787
+ expect(positions.has('collider')).to.be.true;
788
+ const newPos = positions.get('collider');
789
+ // Should still prefer down (axis match) despite 2 cascades
790
+ expect(newPos.top).to.be.greaterThan(200);
791
+ expect(newPos.left).to.equal(100);
792
+ });
793
+ it('sacred node yields to existing top node when dropped below its top', () => {
794
+ // Existing node at top of canvas, sacred dropped overlapping from below
527
795
  const allBounds = [
528
- movedBounds,
529
796
  {
530
- uuid: 'node1',
797
+ uuid: 'existing',
531
798
  left: 100,
532
- top: 150,
799
+ top: 0,
533
800
  right: 200,
534
- bottom: 250,
801
+ bottom: 100,
535
802
  width: 100,
536
803
  height: 100
537
804
  },
538
805
  {
539
- uuid: 'node2',
806
+ uuid: 'dropped',
540
807
  left: 100,
541
- top: 240,
808
+ top: 50,
542
809
  right: 200,
543
- bottom: 340,
810
+ bottom: 150,
544
811
  width: 100,
545
812
  height: 100
546
813
  }
547
814
  ];
548
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
549
- // Both nodes should be moved
550
- expect(positions.size).to.equal(2);
551
- expect(positions.has('node1')).to.be.true;
552
- expect(positions.has('node2')).to.be.true;
553
- const node1Pos = positions.get('node1');
554
- const node2Pos = positions.get('node2');
555
- // node1 should be moved below moved node
556
- expect(node1Pos.top).to.be.at.least(220); // 200 + 20
557
- // node2 should be moved below the new position of node1
558
- // This tests the code path where we check against already repositioned nodes
559
- expect(node2Pos.top).to.be.at.least(node1Pos.top + 120);
560
- });
561
- it('maintains horizontal position while moving vertically', () => {
562
- const movedBounds = {
563
- uuid: 'moved',
564
- left: 100,
565
- top: 100,
566
- right: 200,
567
- bottom: 200,
568
- width: 100,
569
- height: 100
570
- };
815
+ const positions = calculateReflowPositions(['dropped'], allBounds);
816
+ // Sacred (dropped) should yield since it didn't drop above existing
817
+ // and existing has no room to move up
818
+ expect(positions.has('dropped')).to.be.true;
819
+ expect(positions.has('existing')).to.be.false;
820
+ const newPos = positions.get('dropped');
821
+ expect(newPos.top).to.be.greaterThanOrEqual(100); // moved below existing
822
+ });
823
+ it('sacred keeps position when dropped above existing node', () => {
824
+ // Sacred node dropped above existing node - sacred gets priority
571
825
  const allBounds = [
572
- movedBounds,
573
826
  {
574
- uuid: 'node1',
575
- left: 150,
576
- top: 150,
577
- right: 250,
578
- bottom: 250,
827
+ uuid: 'existing',
828
+ left: 100,
829
+ top: 50,
830
+ right: 200,
831
+ bottom: 150,
832
+ width: 100,
833
+ height: 100
834
+ },
835
+ {
836
+ uuid: 'dropped',
837
+ left: 100,
838
+ top: 0,
839
+ right: 200,
840
+ bottom: 100,
579
841
  width: 100,
580
842
  height: 100
581
843
  }
582
844
  ];
583
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
584
- const newPos = positions.get('node1');
585
- // Horizontal position should remain unchanged
586
- expect(newPos.left).to.equal(150);
845
+ const positions = calculateReflowPositions(['dropped'], allBounds);
846
+ // Sacred dropped above existing, so it keeps priority
847
+ expect(positions.has('dropped')).to.be.false;
848
+ expect(positions.has('existing')).to.be.true;
587
849
  });
588
- it('adds minimum spacing between nodes', () => {
589
- const movedBounds = {
590
- uuid: 'moved',
591
- left: 100,
592
- top: 100,
593
- right: 200,
594
- bottom: 200,
595
- width: 100,
596
- height: 100
597
- };
850
+ it('sacred keeps position when dropped at same top as existing node', () => {
851
+ // Both at top=0 - sacred keeps priority since it's not below existing
598
852
  const allBounds = [
599
- movedBounds,
600
853
  {
601
- uuid: 'node1',
854
+ uuid: 'existing',
602
855
  left: 100,
603
- top: 150,
856
+ top: 0,
604
857
  right: 200,
605
- bottom: 250,
858
+ bottom: 100,
859
+ width: 100,
860
+ height: 100
861
+ },
862
+ {
863
+ uuid: 'dropped',
864
+ left: 100,
865
+ top: 0,
866
+ right: 200,
867
+ bottom: 100,
606
868
  width: 100,
607
869
  height: 100
608
870
  }
609
871
  ];
610
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
611
- const newPos = positions.get('node1');
612
- // Should have at least 20px spacing (MIN_NODE_SPACING)
613
- expect(newPos.top).to.be.at.least(220); // 200 (bottom of moved) + 20
872
+ const positions = calculateReflowPositions(['dropped'], allBounds);
873
+ // Sacred at same top keeps priority - existing node moves
874
+ expect(positions.has('dropped')).to.be.false;
875
+ expect(positions.has('existing')).to.be.true;
614
876
  });
615
877
  });
616
878
  describe('edge cases', () => {
617
879
  it('handles empty allBounds array', () => {
618
- const movedBounds = {
619
- uuid: 'moved',
620
- left: 100,
621
- top: 100,
622
- right: 200,
623
- bottom: 200,
624
- width: 100,
625
- height: 100
626
- };
627
- const positions = calculateReflowPositions('moved', movedBounds, [], false);
880
+ const positions = calculateReflowPositions(['moved'], []);
628
881
  expect(positions.size).to.equal(0);
629
882
  });
630
883
  it('handles single node (no other nodes to collide with)', () => {
@@ -637,7 +890,7 @@ describe('Collision Detection Utilities', () => {
637
890
  width: 100,
638
891
  height: 100
639
892
  };
640
- const positions = calculateReflowPositions('moved', movedBounds, [movedBounds], false);
893
+ const positions = calculateReflowPositions(['moved'], [movedBounds]);
641
894
  expect(positions.size).to.equal(0);
642
895
  });
643
896
  it('prevents infinite loops with complex collisions', () => {
@@ -664,7 +917,7 @@ describe('Collision Detection Utilities', () => {
664
917
  });
665
918
  }
666
919
  // Should complete without hanging
667
- const positions = calculateReflowPositions('moved', movedBounds, allBounds, false);
920
+ const positions = calculateReflowPositions(['moved'], allBounds);
668
921
  // Should have resolved some collisions
669
922
  expect(positions.size).to.be.greaterThan(0);
670
923
  });