@nyaruka/temba-components 0.139.0 → 0.141.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 (385) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/.lintstagedrc.js +10 -0
  4. package/CHANGELOG.md +32 -0
  5. package/demo/data/flows/sample-flow.json +24 -0
  6. package/dist/locales/es.js +5 -5
  7. package/dist/locales/es.js.map +1 -1
  8. package/dist/locales/fr.js +5 -5
  9. package/dist/locales/fr.js.map +1 -1
  10. package/dist/locales/locale-codes.js +11 -2
  11. package/dist/locales/locale-codes.js.map +1 -1
  12. package/dist/locales/pt.js +5 -5
  13. package/dist/locales/pt.js.map +1 -1
  14. package/dist/temba-components.js +702 -338
  15. package/dist/temba-components.js.map +1 -1
  16. package/out-tsc/src/display/Chat.js +10 -7
  17. package/out-tsc/src/display/Chat.js.map +1 -1
  18. package/out-tsc/src/display/Dropdown.js +3 -1
  19. package/out-tsc/src/display/Dropdown.js.map +1 -1
  20. package/out-tsc/src/display/FloatingTab.js +4 -4
  21. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  22. package/out-tsc/src/display/Thumbnail.js +163 -5
  23. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  24. package/out-tsc/src/flow/CanvasNode.js +65 -23
  25. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  26. package/out-tsc/src/flow/Editor.js +369 -49
  27. package/out-tsc/src/flow/Editor.js.map +1 -1
  28. package/out-tsc/src/flow/NodeEditor.js +118 -10
  29. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  30. package/out-tsc/src/flow/Plumber.js +61 -14
  31. package/out-tsc/src/flow/Plumber.js.map +1 -1
  32. package/out-tsc/src/flow/StickyNote.js +13 -4
  33. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  34. package/out-tsc/src/flow/actions/add_contact_groups.js +4 -1
  35. package/out-tsc/src/flow/actions/add_contact_groups.js.map +1 -1
  36. package/out-tsc/src/flow/actions/add_input_labels.js +4 -1
  37. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  38. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  39. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  40. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  41. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  42. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  43. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  44. package/out-tsc/src/flow/actions/remove_contact_groups.js +6 -1
  45. package/out-tsc/src/flow/actions/remove_contact_groups.js.map +1 -1
  46. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  47. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  48. package/out-tsc/src/flow/actions/send_broadcast.js +6 -2
  49. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -1
  50. package/out-tsc/src/flow/actions/set_contact_channel.js +13 -0
  51. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  52. package/out-tsc/src/flow/actions/set_contact_status.js +7 -5
  53. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  54. package/out-tsc/src/flow/actions/start_session.js +10 -3
  55. package/out-tsc/src/flow/actions/start_session.js.map +1 -1
  56. package/out-tsc/src/flow/config.js +11 -3
  57. package/out-tsc/src/flow/config.js.map +1 -1
  58. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  59. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  60. package/out-tsc/src/flow/nodes/split_by_contact_field.js +18 -5
  61. package/out-tsc/src/flow/nodes/split_by_contact_field.js.map +1 -1
  62. package/out-tsc/src/flow/nodes/split_by_expression.js +1 -1
  63. package/out-tsc/src/flow/nodes/split_by_expression.js.map +1 -1
  64. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +0 -1
  65. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  66. package/out-tsc/src/flow/nodes/split_by_random.js +0 -1
  67. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  68. package/out-tsc/src/flow/nodes/split_by_run_result.js +10 -4
  69. package/out-tsc/src/flow/nodes/split_by_run_result.js.map +1 -1
  70. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  71. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  72. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  73. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  74. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  75. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  76. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  77. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  78. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  79. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  80. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -1
  81. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  82. package/out-tsc/src/flow/operators.js +21 -5
  83. package/out-tsc/src/flow/operators.js.map +1 -1
  84. package/out-tsc/src/flow/types.js.map +1 -1
  85. package/out-tsc/src/flow/utils.js +79 -3
  86. package/out-tsc/src/flow/utils.js.map +1 -1
  87. package/out-tsc/src/form/ArrayEditor.js +4 -2
  88. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  89. package/out-tsc/src/form/FieldRenderer.js +56 -0
  90. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  91. package/out-tsc/src/interfaces.js +1 -0
  92. package/out-tsc/src/interfaces.js.map +1 -1
  93. package/out-tsc/src/layout/Dialog.js +51 -7
  94. package/out-tsc/src/layout/Dialog.js.map +1 -1
  95. package/out-tsc/src/layout/Modax.js +20 -2
  96. package/out-tsc/src/layout/Modax.js.map +1 -1
  97. package/out-tsc/src/list/ContentMenu.js +14 -1
  98. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  99. package/out-tsc/src/locales/es.js +5 -5
  100. package/out-tsc/src/locales/es.js.map +1 -1
  101. package/out-tsc/src/locales/fr.js +5 -5
  102. package/out-tsc/src/locales/fr.js.map +1 -1
  103. package/out-tsc/src/locales/locale-codes.js +11 -2
  104. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  105. package/out-tsc/src/locales/pt.js +5 -5
  106. package/out-tsc/src/locales/pt.js.map +1 -1
  107. package/out-tsc/src/simulator/Simulator.js +21 -4
  108. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  109. package/out-tsc/src/store/AppState.js +102 -3
  110. package/out-tsc/src/store/AppState.js.map +1 -1
  111. package/out-tsc/test/actions/add_contact_groups.test.js +35 -0
  112. package/out-tsc/test/actions/add_contact_groups.test.js.map +1 -1
  113. package/out-tsc/test/actions/add_input_labels.test.js +53 -0
  114. package/out-tsc/test/actions/add_input_labels.test.js.map +1 -0
  115. package/out-tsc/test/actions/enter_flow.test.js +71 -0
  116. package/out-tsc/test/actions/enter_flow.test.js.map +1 -0
  117. package/out-tsc/test/actions/play_audio.test.js +118 -0
  118. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  119. package/out-tsc/test/actions/remove_contact_groups.test.js +24 -0
  120. package/out-tsc/test/actions/remove_contact_groups.test.js.map +1 -1
  121. package/out-tsc/test/actions/say_msg.test.js +158 -0
  122. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  123. package/out-tsc/test/actions/send_broadcast.test.js +41 -0
  124. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  125. package/out-tsc/test/actions/set_contact_channel.test.js +67 -0
  126. package/out-tsc/test/actions/set_contact_channel.test.js.map +1 -0
  127. package/out-tsc/test/actions/set_contact_field.test.js +52 -0
  128. package/out-tsc/test/actions/set_contact_field.test.js.map +1 -0
  129. package/out-tsc/test/actions/set_contact_language.test.js +39 -0
  130. package/out-tsc/test/actions/set_contact_language.test.js.map +1 -0
  131. package/out-tsc/test/actions/set_contact_name.test.js +28 -0
  132. package/out-tsc/test/actions/set_contact_name.test.js.map +1 -0
  133. package/out-tsc/test/actions/set_contact_status.test.js +44 -0
  134. package/out-tsc/test/actions/set_contact_status.test.js.map +1 -0
  135. package/out-tsc/test/actions/set_run_result.test.js +47 -0
  136. package/out-tsc/test/actions/set_run_result.test.js.map +1 -0
  137. package/out-tsc/test/actions/start_session.test.js +76 -0
  138. package/out-tsc/test/actions/start_session.test.js.map +1 -1
  139. package/out-tsc/test/nodes/split_by_contact_field.test.js +50 -0
  140. package/out-tsc/test/nodes/split_by_contact_field.test.js.map +1 -1
  141. package/out-tsc/test/nodes/split_by_run_result.test.js +82 -0
  142. package/out-tsc/test/nodes/split_by_run_result.test.js.map +1 -1
  143. package/out-tsc/test/nodes/split_by_ticket.test.js +139 -0
  144. package/out-tsc/test/nodes/split_by_ticket.test.js.map +1 -0
  145. package/out-tsc/test/nodes/split_by_webhook.test.js +111 -0
  146. package/out-tsc/test/nodes/split_by_webhook.test.js.map +1 -0
  147. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  148. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  149. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  150. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  151. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  152. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  153. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  154. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  155. package/out-tsc/test/temba-flow-collision.test.js +261 -6
  156. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  157. package/out-tsc/test/temba-flow-editor.test.js +187 -0
  158. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  159. package/out-tsc/test/temba-flow-plumber.test.js +19 -0
  160. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  161. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  162. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  163. package/out-tsc/test/temba-select.test.js +4 -1
  164. package/out-tsc/test/temba-select.test.js.map +1 -1
  165. package/out-tsc/test/utils.test.js +4 -2
  166. package/out-tsc/test/utils.test.js.map +1 -1
  167. package/package.json +3 -9
  168. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  169. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  170. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  171. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  172. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  173. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  174. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  175. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  176. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  177. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  178. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  179. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  180. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  181. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  182. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  183. package/screenshots/truth/actions/add_input_labels/editor/multiple-labels.png +0 -0
  184. package/screenshots/truth/actions/add_input_labels/editor/single-label.png +0 -0
  185. package/screenshots/truth/actions/add_input_labels/render/multiple-labels.png +0 -0
  186. package/screenshots/truth/actions/add_input_labels/render/single-label.png +0 -0
  187. package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
  188. package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
  189. package/screenshots/truth/actions/enter_flow/render/basic-flow.png +0 -0
  190. package/screenshots/truth/actions/enter_flow/render/long-flow-name.png +0 -0
  191. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  192. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  193. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  194. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  195. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  196. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  197. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  198. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  199. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  200. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  201. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  202. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  203. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  204. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  205. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  206. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  207. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  208. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  209. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  210. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  211. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  212. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  213. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  214. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  215. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  216. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  217. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  218. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  219. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  220. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  221. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  222. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  223. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  224. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  225. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  226. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  227. package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
  228. package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
  229. package/screenshots/truth/actions/set_contact_channel/render/sms-channel.png +0 -0
  230. package/screenshots/truth/actions/set_contact_channel/render/whatsapp-channel.png +0 -0
  231. package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
  232. package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
  233. package/screenshots/truth/actions/set_contact_field/render/clear-value.png +0 -0
  234. package/screenshots/truth/actions/set_contact_field/render/set-value.png +0 -0
  235. package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
  236. package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
  237. package/screenshots/truth/actions/set_contact_language/render/english.png +0 -0
  238. package/screenshots/truth/actions/set_contact_language/render/french.png +0 -0
  239. package/screenshots/truth/actions/set_contact_name/editor/expression-name.png +0 -0
  240. package/screenshots/truth/actions/set_contact_name/editor/static-name.png +0 -0
  241. package/screenshots/truth/actions/set_contact_name/render/expression-name.png +0 -0
  242. package/screenshots/truth/actions/set_contact_name/render/static-name.png +0 -0
  243. package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
  244. package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
  245. package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
  246. package/screenshots/truth/actions/set_contact_status/render/active.png +0 -0
  247. package/screenshots/truth/actions/set_contact_status/render/archived.png +0 -0
  248. package/screenshots/truth/actions/set_contact_status/render/blocked.png +0 -0
  249. package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
  250. package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
  251. package/screenshots/truth/actions/set_run_result/render/expression-value.png +0 -0
  252. package/screenshots/truth/actions/set_run_result/render/with-category.png +0 -0
  253. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  254. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  255. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  256. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  257. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  258. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  259. package/screenshots/truth/editor/router.png +0 -0
  260. package/screenshots/truth/editor/wait.png +0 -0
  261. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  262. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  263. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  264. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  265. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  266. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  267. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  268. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  269. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  270. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  271. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  272. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  273. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  274. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  275. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  276. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  277. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  278. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  279. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  280. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  281. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  282. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  283. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  284. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  285. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  286. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  287. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  288. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  289. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  290. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  291. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  292. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  293. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  294. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  295. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  296. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  297. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  298. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  299. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  300. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  301. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  302. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  303. package/src/display/Chat.ts +13 -7
  304. package/src/display/Dropdown.ts +3 -1
  305. package/src/display/FloatingTab.ts +4 -4
  306. package/src/display/Thumbnail.ts +162 -2
  307. package/src/flow/CanvasNode.ts +70 -24
  308. package/src/flow/Editor.ts +440 -99
  309. package/src/flow/NodeEditor.ts +137 -9
  310. package/src/flow/Plumber.ts +89 -14
  311. package/src/flow/StickyNote.ts +14 -4
  312. package/src/flow/actions/add_contact_groups.ts +4 -1
  313. package/src/flow/actions/add_input_labels.ts +4 -1
  314. package/src/flow/actions/audio-player.ts +127 -0
  315. package/src/flow/actions/enter_flow.ts +44 -0
  316. package/src/flow/actions/play_audio.ts +64 -5
  317. package/src/flow/actions/remove_contact_groups.ts +6 -1
  318. package/src/flow/actions/say_msg.ts +94 -4
  319. package/src/flow/actions/send_broadcast.ts +6 -2
  320. package/src/flow/actions/set_contact_channel.ts +13 -1
  321. package/src/flow/actions/set_contact_status.ts +7 -5
  322. package/src/flow/actions/start_session.ts +10 -3
  323. package/src/flow/config.ts +11 -3
  324. package/src/flow/nodes/shared-rules.ts +1 -1
  325. package/src/flow/nodes/split_by_contact_field.ts +16 -5
  326. package/src/flow/nodes/split_by_expression.ts +1 -1
  327. package/src/flow/nodes/split_by_llm_categorize.ts +0 -1
  328. package/src/flow/nodes/split_by_random.ts +0 -1
  329. package/src/flow/nodes/split_by_run_result.ts +10 -4
  330. package/src/flow/nodes/terminal.ts +9 -0
  331. package/src/flow/nodes/wait_for_audio.ts +88 -0
  332. package/src/flow/nodes/wait_for_dial.ts +176 -0
  333. package/src/flow/nodes/wait_for_digits.ts +87 -2
  334. package/src/flow/nodes/wait_for_menu.ts +209 -3
  335. package/src/flow/nodes/wait_for_response.ts +1 -1
  336. package/src/flow/operators.ts +23 -5
  337. package/src/flow/types.ts +23 -1
  338. package/src/flow/utils.ts +82 -3
  339. package/src/form/ArrayEditor.ts +4 -2
  340. package/src/form/FieldRenderer.ts +71 -1
  341. package/src/interfaces.ts +2 -1
  342. package/src/layout/Dialog.ts +52 -7
  343. package/src/layout/Modax.ts +19 -2
  344. package/src/list/ContentMenu.ts +15 -1
  345. package/src/locales/es.ts +18 -13
  346. package/src/locales/fr.ts +18 -13
  347. package/src/locales/locale-codes.ts +11 -2
  348. package/src/locales/pt.ts +18 -13
  349. package/src/simulator/Simulator.ts +25 -4
  350. package/src/store/AppState.ts +120 -1
  351. package/src/store/flow-definition.d.ts +2 -0
  352. package/test/actions/add_contact_groups.test.ts +38 -0
  353. package/test/actions/add_input_labels.test.ts +67 -0
  354. package/test/actions/enter_flow.test.ts +88 -0
  355. package/test/actions/play_audio.test.ts +155 -0
  356. package/test/actions/remove_contact_groups.test.ts +29 -0
  357. package/test/actions/say_msg.test.ts +196 -0
  358. package/test/actions/send_broadcast.test.ts +44 -0
  359. package/test/actions/set_contact_channel.test.ts +88 -0
  360. package/test/actions/set_contact_field.test.ts +68 -0
  361. package/test/actions/set_contact_language.test.ts +55 -0
  362. package/test/actions/set_contact_name.test.ts +39 -0
  363. package/test/actions/set_contact_status.test.ts +64 -0
  364. package/test/actions/set_run_result.test.ts +61 -0
  365. package/test/actions/start_session.test.ts +82 -0
  366. package/test/nodes/split_by_contact_field.test.ts +59 -0
  367. package/test/nodes/split_by_run_result.test.ts +100 -0
  368. package/test/nodes/split_by_ticket.test.ts +157 -0
  369. package/test/nodes/split_by_webhook.test.ts +131 -0
  370. package/test/nodes/wait_for_audio.test.ts +182 -0
  371. package/test/nodes/wait_for_dial.test.ts +382 -0
  372. package/test/nodes/wait_for_digits.test.ts +233 -109
  373. package/test/nodes/wait_for_menu.test.ts +383 -0
  374. package/test/temba-flow-collision.test.ts +286 -6
  375. package/test/temba-flow-editor.test.ts +240 -0
  376. package/test/temba-flow-plumber.test.ts +62 -0
  377. package/test/temba-node-type-selector.test.ts +6 -6
  378. package/test/temba-select.test.ts +6 -1
  379. package/test/utils.test.ts +4 -2
  380. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  381. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  382. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  383. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  384. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  385. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -0,0 +1,383 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { wait_for_menu } from '../../src/flow/nodes/wait_for_menu';
3
+ import { Node } from '../../src/store/flow-definition';
4
+ import { NodeTest } from '../NodeHelper';
5
+
6
+ /**
7
+ * Test suite for the wait_for_menu node configuration.
8
+ */
9
+ describe('wait_for_menu node config', () => {
10
+ const helper = new NodeTest(wait_for_menu, 'wait_for_menu');
11
+
12
+ describe('basic properties', () => {
13
+ helper.testBasicProperties();
14
+
15
+ it('has correct name', () => {
16
+ expect(wait_for_menu.name).to.equal('Wait for Menu');
17
+ });
18
+
19
+ it('has correct type', () => {
20
+ expect(wait_for_menu.type).to.equal('wait_for_menu');
21
+ });
22
+
23
+ it('is voice-only', () => {
24
+ expect(wait_for_menu.flowTypes).to.deep.equal(['voice']);
25
+ });
26
+
27
+ it('has form with 10 digit fields plus result_name', () => {
28
+ expect(wait_for_menu.form).to.exist;
29
+ for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) {
30
+ expect(wait_for_menu.form![`digit_${d}`]).to.exist;
31
+ }
32
+ expect(wait_for_menu.form!.result_name).to.exist;
33
+ });
34
+ });
35
+
36
+ describe('node scenarios', () => {
37
+ it('renders menu with filled digits', async () => {
38
+ await helper.testNode(
39
+ {
40
+ uuid: 'test-menu-node-1',
41
+ actions: [],
42
+ router: {
43
+ type: 'switch',
44
+ operand: '@input.text',
45
+ wait: {
46
+ type: 'msg',
47
+ hint: { type: 'digits', count: 1 }
48
+ },
49
+ result_name: 'menu_choice',
50
+ default_category_uuid: 'other-cat',
51
+ cases: [
52
+ {
53
+ uuid: 'case-1',
54
+ type: 'has_number_eq',
55
+ arguments: ['1'],
56
+ category_uuid: 'sales-cat'
57
+ },
58
+ {
59
+ uuid: 'case-2',
60
+ type: 'has_number_eq',
61
+ arguments: ['2'],
62
+ category_uuid: 'support-cat'
63
+ },
64
+ {
65
+ uuid: 'case-0',
66
+ type: 'has_number_eq',
67
+ arguments: ['0'],
68
+ category_uuid: 'operator-cat'
69
+ }
70
+ ],
71
+ categories: [
72
+ {
73
+ uuid: 'sales-cat',
74
+ name: 'Sales',
75
+ exit_uuid: 'sales-exit'
76
+ },
77
+ {
78
+ uuid: 'support-cat',
79
+ name: 'Support',
80
+ exit_uuid: 'support-exit'
81
+ },
82
+ {
83
+ uuid: 'operator-cat',
84
+ name: 'Operator',
85
+ exit_uuid: 'operator-exit'
86
+ },
87
+ {
88
+ uuid: 'other-cat',
89
+ name: 'Other',
90
+ exit_uuid: 'other-exit'
91
+ }
92
+ ]
93
+ },
94
+ exits: [
95
+ { uuid: 'sales-exit', destination_uuid: null },
96
+ { uuid: 'support-exit', destination_uuid: null },
97
+ { uuid: 'operator-exit', destination_uuid: null },
98
+ { uuid: 'other-exit', destination_uuid: null }
99
+ ]
100
+ } as Node,
101
+ { type: 'wait_for_menu' },
102
+ 'menu-with-digits'
103
+ );
104
+ });
105
+ });
106
+
107
+ describe('data transformation', () => {
108
+ it('converts node to form data', () => {
109
+ const node: Node = {
110
+ uuid: 'test-node',
111
+ actions: [],
112
+ router: {
113
+ type: 'switch',
114
+ result_name: 'menu',
115
+ categories: [
116
+ {
117
+ uuid: 'sales-cat',
118
+ name: 'Sales',
119
+ exit_uuid: 'sales-exit'
120
+ },
121
+ {
122
+ uuid: 'support-cat',
123
+ name: 'Support',
124
+ exit_uuid: 'support-exit'
125
+ },
126
+ {
127
+ uuid: 'other-cat',
128
+ name: 'Other',
129
+ exit_uuid: 'other-exit'
130
+ }
131
+ ],
132
+ cases: [
133
+ {
134
+ uuid: 'case-1',
135
+ type: 'has_number_eq',
136
+ arguments: ['1'],
137
+ category_uuid: 'sales-cat'
138
+ },
139
+ {
140
+ uuid: 'case-2',
141
+ type: 'has_number_eq',
142
+ arguments: ['2'],
143
+ category_uuid: 'support-cat'
144
+ }
145
+ ]
146
+ },
147
+ exits: [
148
+ { uuid: 'sales-exit', destination_uuid: null },
149
+ { uuid: 'support-exit', destination_uuid: null },
150
+ { uuid: 'other-exit', destination_uuid: null }
151
+ ]
152
+ };
153
+
154
+ const formData = wait_for_menu.toFormData!(node);
155
+
156
+ expect(formData.uuid).to.equal('test-node');
157
+ expect(formData.digit_1).to.equal('Sales');
158
+ expect(formData.digit_2).to.equal('Support');
159
+ expect(formData.digit_3).to.equal('');
160
+ expect(formData.digit_0).to.equal('');
161
+ expect(formData.result_name).to.equal('menu');
162
+ });
163
+
164
+ it('handles empty menu', () => {
165
+ const node: Node = {
166
+ uuid: 'test-node',
167
+ actions: [],
168
+ router: {
169
+ type: 'switch',
170
+ categories: []
171
+ },
172
+ exits: []
173
+ };
174
+
175
+ const formData = wait_for_menu.toFormData!(node);
176
+
177
+ for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) {
178
+ expect(formData[`digit_${d}`]).to.equal('');
179
+ }
180
+ });
181
+
182
+ it('creates node from form data with filled digits', () => {
183
+ const formData = {
184
+ uuid: 'test-node',
185
+ digit_1: 'Sales',
186
+ digit_2: 'Support',
187
+ digit_3: '',
188
+ digit_4: '',
189
+ digit_5: '',
190
+ digit_6: '',
191
+ digit_7: '',
192
+ digit_8: '',
193
+ digit_9: '',
194
+ digit_0: 'Operator',
195
+ result_name: 'menu_choice'
196
+ };
197
+
198
+ const originalNode: Node = {
199
+ uuid: 'test-node',
200
+ actions: [],
201
+ exits: [],
202
+ router: { type: 'switch', categories: [] }
203
+ };
204
+
205
+ const result = wait_for_menu.fromFormData!(formData, originalNode);
206
+
207
+ // Should have 4 categories: Sales, Support, Operator, Other
208
+ expect(result.router?.categories).to.have.length(4);
209
+ const names = result.router!.categories.map((c) => c.name);
210
+ expect(names).to.deep.equal(['Sales', 'Support', 'Operator', 'Other']);
211
+
212
+ // Should have 3 cases
213
+ expect(result.router?.cases).to.have.length(3);
214
+ expect(result.router!.cases[0].arguments).to.deep.equal(['1']);
215
+ expect(result.router!.cases[1].arguments).to.deep.equal(['2']);
216
+ expect(result.router!.cases[2].arguments).to.deep.equal(['0']);
217
+
218
+ // Check wait config
219
+ expect(result.router?.wait?.type).to.equal('msg');
220
+ expect(result.router?.wait?.hint?.type).to.equal('digits');
221
+ expect(result.router?.wait?.hint?.count).to.equal(1);
222
+
223
+ // Check result name
224
+ expect(result.router?.result_name).to.equal('menu_choice');
225
+
226
+ // 4 exits
227
+ expect(result.exits).to.have.length(4);
228
+ });
229
+
230
+ it('merges duplicate category names', () => {
231
+ const formData = {
232
+ uuid: 'test-node',
233
+ digit_1: 'Sales',
234
+ digit_2: 'Sales', // same category name
235
+ digit_3: '',
236
+ digit_4: '',
237
+ digit_5: '',
238
+ digit_6: '',
239
+ digit_7: '',
240
+ digit_8: '',
241
+ digit_9: '',
242
+ digit_0: '',
243
+ result_name: ''
244
+ };
245
+
246
+ const originalNode: Node = {
247
+ uuid: 'test-node',
248
+ actions: [],
249
+ exits: [],
250
+ router: { type: 'switch', categories: [] }
251
+ };
252
+
253
+ const result = wait_for_menu.fromFormData!(formData, originalNode);
254
+
255
+ // Should have 2 categories: Sales and Other (not duplicate Sales)
256
+ expect(result.router?.categories).to.have.length(2);
257
+ expect(result.router!.categories[0].name).to.equal('Sales');
258
+ expect(result.router!.categories[1].name).to.equal('Other');
259
+
260
+ // Both cases should reference same category
261
+ expect(result.router?.cases).to.have.length(2);
262
+ expect(result.router!.cases[0].category_uuid).to.equal(
263
+ result.router!.cases[1].category_uuid
264
+ );
265
+
266
+ // Should have 2 exits (not 3)
267
+ expect(result.exits).to.have.length(2);
268
+ });
269
+
270
+ it('preserves existing category UUIDs', () => {
271
+ const formData = {
272
+ uuid: 'test-node',
273
+ digit_1: 'Sales',
274
+ digit_2: 'Support',
275
+ digit_3: '',
276
+ digit_4: '',
277
+ digit_5: '',
278
+ digit_6: '',
279
+ digit_7: '',
280
+ digit_8: '',
281
+ digit_9: '',
282
+ digit_0: '',
283
+ result_name: ''
284
+ };
285
+
286
+ const originalNode: Node = {
287
+ uuid: 'test-node',
288
+ actions: [],
289
+ router: {
290
+ type: 'switch',
291
+ categories: [
292
+ {
293
+ uuid: 'orig-sales',
294
+ name: 'Sales',
295
+ exit_uuid: 'orig-sales-exit'
296
+ },
297
+ {
298
+ uuid: 'orig-support',
299
+ name: 'Support',
300
+ exit_uuid: 'orig-support-exit'
301
+ },
302
+ {
303
+ uuid: 'orig-other',
304
+ name: 'Other',
305
+ exit_uuid: 'orig-other-exit'
306
+ }
307
+ ],
308
+ cases: [
309
+ {
310
+ uuid: 'orig-case-1',
311
+ type: 'has_number_eq',
312
+ arguments: ['1'],
313
+ category_uuid: 'orig-sales'
314
+ },
315
+ {
316
+ uuid: 'orig-case-2',
317
+ type: 'has_number_eq',
318
+ arguments: ['2'],
319
+ category_uuid: 'orig-support'
320
+ }
321
+ ]
322
+ },
323
+ exits: [
324
+ { uuid: 'orig-sales-exit', destination_uuid: 'dest-1' },
325
+ { uuid: 'orig-support-exit', destination_uuid: 'dest-2' },
326
+ { uuid: 'orig-other-exit', destination_uuid: null }
327
+ ]
328
+ };
329
+
330
+ const result = wait_for_menu.fromFormData!(formData, originalNode);
331
+
332
+ // Category UUIDs preserved
333
+ const sales = result.router!.categories.find((c) => c.name === 'Sales');
334
+ expect(sales?.uuid).to.equal('orig-sales');
335
+ expect(sales?.exit_uuid).to.equal('orig-sales-exit');
336
+
337
+ const other = result.router!.categories.find((c) => c.name === 'Other');
338
+ expect(other?.uuid).to.equal('orig-other');
339
+
340
+ // Case UUIDs preserved
341
+ const case1 = result.router!.cases.find(
342
+ (c: any) => c.arguments[0] === '1'
343
+ );
344
+ expect(case1?.uuid).to.equal('orig-case-1');
345
+
346
+ // Exit destinations preserved
347
+ const salesExit = result.exits.find((e) => e.uuid === 'orig-sales-exit');
348
+ expect(salesExit?.destination_uuid).to.equal('dest-1');
349
+ });
350
+
351
+ it('handles all empty digits', () => {
352
+ const formData = {
353
+ uuid: 'test-node',
354
+ digit_1: '',
355
+ digit_2: '',
356
+ digit_3: '',
357
+ digit_4: '',
358
+ digit_5: '',
359
+ digit_6: '',
360
+ digit_7: '',
361
+ digit_8: '',
362
+ digit_9: '',
363
+ digit_0: '',
364
+ result_name: ''
365
+ };
366
+
367
+ const originalNode: Node = {
368
+ uuid: 'test-node',
369
+ actions: [],
370
+ exits: [],
371
+ router: { type: 'switch', categories: [] }
372
+ };
373
+
374
+ const result = wait_for_menu.fromFormData!(formData, originalNode);
375
+
376
+ // Should still have Other category
377
+ expect(result.router?.categories).to.have.length(1);
378
+ expect(result.router!.categories[0].name).to.equal('Other');
379
+ expect(result.router?.cases).to.have.length(0);
380
+ expect(result.exits).to.have.length(1);
381
+ });
382
+ });
383
+ });
@@ -454,10 +454,11 @@ describe('Collision Detection Utilities', () => {
454
454
  expect(newPos.left).to.equal(100); // horizontal position unchanged
455
455
  });
456
456
 
457
- it('prefers direction with fewer cascading collisions', () => {
457
+ it('prefers axis-matching direction even with a cascade', () => {
458
458
  // Sacred at (100,100)-(200,200), collider at (100,150)-(200,250)
459
- // A node sits below at (100,280)-(200,380) blocking the downward path
460
- // Down causes cascade, right does not
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
461
462
  const allBounds: NodeBounds[] = [
462
463
  {
463
464
  uuid: 'sacred',
@@ -491,9 +492,10 @@ describe('Collision Detection Utilities', () => {
491
492
  const positions = calculateReflowPositions(['sacred'], allBounds);
492
493
 
493
494
  expect(positions.has('collider')).to.be.true;
494
- // Should avoid moving down (would cascade into blocker)
495
- // blocker should not need to move
496
- expect(positions.has('blocker')).to.be.false;
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
497
499
  });
498
500
 
499
501
  it('resolves cascading collisions', () => {
@@ -696,6 +698,284 @@ describe('Collision Detection Utilities', () => {
696
698
  expect(newPos.left).to.be.at.least(0);
697
699
  expect(newPos.top).to.be.at.least(0);
698
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',
717
+ left: 100,
718
+ top: 180,
719
+ right: 200,
720
+ bottom: 280,
721
+ width: 100,
722
+ height: 100
723
+ }
724
+ ];
725
+
726
+ const positions = calculateReflowPositions(['sacred'], allBounds);
727
+
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
+ });
733
+
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
+ ];
757
+
758
+ const positions = calculateReflowPositions(['sacred'], allBounds);
759
+
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);
764
+ });
765
+
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
+ });
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
802
+ const allBounds: NodeBounds[] = [
803
+ {
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,
818
+ width: 100,
819
+ height: 100
820
+ }
821
+ ];
822
+
823
+ const positions = calculateReflowPositions(['sacred'], allBounds);
824
+
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
830
+ });
831
+
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
+ ];
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
888
+ const allBounds: NodeBounds[] = [
889
+ {
890
+ uuid: 'existing',
891
+ left: 100,
892
+ top: 0,
893
+ right: 200,
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,
904
+ width: 100,
905
+ height: 100
906
+ }
907
+ ];
908
+
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
+ ];
972
+
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;
978
+ });
699
979
  });
700
980
 
701
981
  describe('edge cases', () => {