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