@nyaruka/temba-components 0.129.3 → 0.129.5

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 (304) hide show
  1. package/.eslintrc.js +1 -0
  2. package/.github/workflows/build.yml +135 -3
  3. package/CHANGELOG.md +19 -0
  4. package/demo/data/flows/sample-flow.json +110 -87
  5. package/demo/field-config-demo.html +135 -0
  6. package/dist/temba-components.js +1257 -675
  7. package/dist/temba-components.js.map +1 -1
  8. package/docs/ActionEditor-Migration.md +118 -0
  9. package/out-tsc/src/events.js.map +1 -1
  10. package/out-tsc/src/flow/{EditorNode.js → CanvasNode.js} +345 -42
  11. package/out-tsc/src/flow/CanvasNode.js.map +1 -0
  12. package/out-tsc/src/flow/Editor.js +107 -3
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/NodeEditor.js +1211 -0
  15. package/out-tsc/src/flow/NodeEditor.js.map +1 -0
  16. package/out-tsc/src/flow/Plumber.js +0 -6
  17. package/out-tsc/src/flow/Plumber.js.map +1 -1
  18. package/out-tsc/src/flow/actions/add_contact_groups.js +40 -0
  19. package/out-tsc/src/flow/actions/add_contact_groups.js.map +1 -0
  20. package/out-tsc/src/flow/actions/add_contact_urn.js +16 -0
  21. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -0
  22. package/out-tsc/src/flow/actions/add_input_labels.js +11 -0
  23. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -0
  24. package/out-tsc/src/flow/actions/call_classifier.js +11 -0
  25. package/out-tsc/src/flow/actions/call_classifier.js.map +1 -0
  26. package/out-tsc/src/flow/actions/call_llm.js +11 -0
  27. package/out-tsc/src/flow/actions/call_llm.js.map +1 -0
  28. package/out-tsc/src/flow/actions/call_resthook.js +11 -0
  29. package/out-tsc/src/flow/actions/call_resthook.js.map +1 -0
  30. package/out-tsc/src/flow/actions/call_webhook.js +122 -0
  31. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -0
  32. package/out-tsc/src/flow/actions/enter_flow.js +14 -0
  33. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  34. package/out-tsc/src/flow/actions/open_ticket.js +11 -0
  35. package/out-tsc/src/flow/actions/open_ticket.js.map +1 -0
  36. package/out-tsc/src/flow/actions/play_audio.js +11 -0
  37. package/out-tsc/src/flow/actions/play_audio.js.map +1 -0
  38. package/out-tsc/src/flow/actions/remove_contact_groups.js +62 -0
  39. package/out-tsc/src/flow/actions/remove_contact_groups.js.map +1 -0
  40. package/out-tsc/src/flow/actions/request_optin.js +11 -0
  41. package/out-tsc/src/flow/actions/request_optin.js.map +1 -0
  42. package/out-tsc/src/flow/actions/say_msg.js +11 -0
  43. package/out-tsc/src/flow/actions/say_msg.js.map +1 -0
  44. package/out-tsc/src/flow/actions/send_broadcast.js +33 -0
  45. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -0
  46. package/out-tsc/src/flow/actions/send_email.js +56 -0
  47. package/out-tsc/src/flow/actions/send_email.js.map +1 -0
  48. package/out-tsc/src/flow/actions/send_msg.js +55 -0
  49. package/out-tsc/src/flow/actions/send_msg.js.map +1 -0
  50. package/out-tsc/src/flow/actions/set_contact_channel.js +12 -0
  51. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -0
  52. package/out-tsc/src/flow/actions/set_contact_field.js +12 -0
  53. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -0
  54. package/out-tsc/src/flow/actions/set_contact_language.js +10 -0
  55. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -0
  56. package/out-tsc/src/flow/actions/set_contact_name.js +10 -0
  57. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -0
  58. package/out-tsc/src/flow/actions/set_contact_status.js +10 -0
  59. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -0
  60. package/out-tsc/src/flow/actions/set_run_result.js +10 -0
  61. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -0
  62. package/out-tsc/src/flow/actions/split_by_expression_example.js +77 -0
  63. package/out-tsc/src/flow/actions/split_by_expression_example.js.map +1 -0
  64. package/out-tsc/src/flow/actions/start_session.js +11 -0
  65. package/out-tsc/src/flow/actions/start_session.js.map +1 -0
  66. package/out-tsc/src/flow/actions/transfer_airtime.js +11 -0
  67. package/out-tsc/src/flow/actions/transfer_airtime.js.map +1 -0
  68. package/out-tsc/src/flow/config.js +88 -193
  69. package/out-tsc/src/flow/config.js.map +1 -1
  70. package/out-tsc/src/flow/nodes/execute_actions.js +4 -0
  71. package/out-tsc/src/flow/nodes/execute_actions.js.map +1 -0
  72. package/out-tsc/src/flow/nodes/split_by_airtime.js +9 -0
  73. package/out-tsc/src/flow/nodes/split_by_airtime.js.map +1 -0
  74. package/out-tsc/src/flow/nodes/split_by_contact_field.js +7 -0
  75. package/out-tsc/src/flow/nodes/split_by_contact_field.js.map +1 -0
  76. package/out-tsc/src/flow/nodes/split_by_expression.js +7 -0
  77. package/out-tsc/src/flow/nodes/split_by_expression.js.map +1 -0
  78. package/out-tsc/src/flow/nodes/split_by_groups.js +7 -0
  79. package/out-tsc/src/flow/nodes/split_by_groups.js.map +1 -0
  80. package/out-tsc/src/flow/nodes/split_by_random.js +10 -0
  81. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -0
  82. package/out-tsc/src/flow/nodes/split_by_run_result.js +7 -0
  83. package/out-tsc/src/flow/nodes/split_by_run_result.js.map +1 -0
  84. package/out-tsc/src/flow/nodes/split_by_scheme.js +7 -0
  85. package/out-tsc/src/flow/nodes/split_by_scheme.js.map +1 -0
  86. package/out-tsc/src/flow/nodes/split_by_subflow.js +9 -0
  87. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -0
  88. package/out-tsc/src/flow/nodes/split_by_webhook.js +18 -0
  89. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -0
  90. package/out-tsc/src/flow/nodes/wait_for_audio.js +7 -0
  91. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  92. package/out-tsc/src/flow/nodes/wait_for_digits.js +7 -0
  93. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -0
  94. package/out-tsc/src/flow/nodes/wait_for_image.js +7 -0
  95. package/out-tsc/src/flow/nodes/wait_for_image.js.map +1 -0
  96. package/out-tsc/src/flow/nodes/wait_for_location.js +7 -0
  97. package/out-tsc/src/flow/nodes/wait_for_location.js.map +1 -0
  98. package/out-tsc/src/flow/nodes/wait_for_menu.js +7 -0
  99. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -0
  100. package/out-tsc/src/flow/nodes/wait_for_response.js +7 -0
  101. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -0
  102. package/out-tsc/src/flow/nodes/wait_for_video.js +7 -0
  103. package/out-tsc/src/flow/nodes/wait_for_video.js.map +1 -0
  104. package/out-tsc/src/flow/types.js +79 -0
  105. package/out-tsc/src/flow/types.js.map +1 -0
  106. package/out-tsc/src/flow/utils.js +65 -0
  107. package/out-tsc/src/flow/utils.js.map +1 -0
  108. package/out-tsc/src/form/ArrayEditor.js +199 -0
  109. package/out-tsc/src/form/ArrayEditor.js.map +1 -0
  110. package/out-tsc/src/form/BaseListEditor.js +128 -0
  111. package/out-tsc/src/form/BaseListEditor.js.map +1 -0
  112. package/out-tsc/src/form/Checkbox.js +17 -2
  113. package/out-tsc/src/form/Checkbox.js.map +1 -1
  114. package/out-tsc/src/form/Completion.js +6 -0
  115. package/out-tsc/src/form/Completion.js.map +1 -1
  116. package/out-tsc/src/form/FormField.js +110 -11
  117. package/out-tsc/src/form/FormField.js.map +1 -1
  118. package/out-tsc/src/form/KeyValueEditor.js +223 -0
  119. package/out-tsc/src/form/KeyValueEditor.js.map +1 -0
  120. package/out-tsc/src/form/select/Select.js +92 -32
  121. package/out-tsc/src/form/select/Select.js.map +1 -1
  122. package/out-tsc/src/interfaces.js +6 -0
  123. package/out-tsc/src/interfaces.js.map +1 -1
  124. package/out-tsc/src/live/ContactChat.js +2 -76
  125. package/out-tsc/src/live/ContactChat.js.map +1 -1
  126. package/out-tsc/temba-modules.js +9 -2
  127. package/out-tsc/temba-modules.js.map +1 -1
  128. package/out-tsc/test/ActionHelper.js +116 -0
  129. package/out-tsc/test/ActionHelper.js.map +1 -0
  130. package/out-tsc/test/actions/add_contact_groups.test.js +66 -0
  131. package/out-tsc/test/actions/add_contact_groups.test.js.map +1 -0
  132. package/out-tsc/test/actions/remove_contact_groups.test.js +226 -0
  133. package/out-tsc/test/actions/remove_contact_groups.test.js.map +1 -0
  134. package/out-tsc/test/actions/send_email.test.js +160 -0
  135. package/out-tsc/test/actions/send_email.test.js.map +1 -0
  136. package/out-tsc/test/actions/send_msg.test.js +95 -0
  137. package/out-tsc/test/actions/send_msg.test.js.map +1 -0
  138. package/out-tsc/test/temba-action-editing-integration.test.js +183 -0
  139. package/out-tsc/test/temba-action-editing-integration.test.js.map +1 -0
  140. package/out-tsc/test/temba-checkbox.test.js +1 -1
  141. package/out-tsc/test/temba-checkbox.test.js.map +1 -1
  142. package/out-tsc/test/temba-field-config.test.js +133 -0
  143. package/out-tsc/test/temba-field-config.test.js.map +1 -0
  144. package/out-tsc/test/temba-flow-editor-node.test.js +14 -14
  145. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  146. package/out-tsc/test/temba-node-editor.test.js +283 -0
  147. package/out-tsc/test/temba-node-editor.test.js.map +1 -0
  148. package/out-tsc/test/temba-select.test.js +158 -0
  149. package/out-tsc/test/temba-select.test.js.map +1 -1
  150. package/package.json +1 -1
  151. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  152. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  153. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  154. package/screenshots/truth/actions/add_contact_groups/editor/multiple-groups.png +0 -0
  155. package/screenshots/truth/actions/add_contact_groups/editor/single-group.png +0 -0
  156. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  157. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  158. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  159. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  160. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  161. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  162. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  163. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  164. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  165. package/screenshots/truth/actions/remove_contact_groups/editor/remove-from-all-groups.png +0 -0
  166. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  167. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  168. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  169. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  170. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  171. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  172. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  173. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  174. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  175. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  176. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  177. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  178. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  179. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  180. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  181. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  182. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  183. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  184. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  185. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  186. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  187. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  188. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  189. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  190. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  191. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  192. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  193. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  194. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  195. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  196. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  197. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  198. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  199. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  200. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  201. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  202. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  203. package/screenshots/truth/editor/router.png +0 -0
  204. package/screenshots/truth/editor/send_msg.png +0 -0
  205. package/screenshots/truth/editor/set_contact_language.png +0 -0
  206. package/screenshots/truth/editor/set_contact_name.png +0 -0
  207. package/screenshots/truth/editor/set_run_result.png +0 -0
  208. package/screenshots/truth/editor/wait.png +0 -0
  209. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  210. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  211. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  212. package/screenshots/truth/integration/checkbox-markdown-errors.png +0 -0
  213. package/src/events.ts +1 -40
  214. package/src/flow/{EditorNode.ts → CanvasNode.ts} +424 -48
  215. package/src/flow/Editor.ts +140 -4
  216. package/src/flow/NodeEditor.ts +1454 -0
  217. package/src/flow/Plumber.ts +0 -9
  218. package/src/flow/actions/add_contact_groups.ts +42 -0
  219. package/src/flow/actions/add_contact_urn.ts +17 -0
  220. package/src/flow/actions/add_input_labels.ts +12 -0
  221. package/src/flow/actions/call_classifier.ts +12 -0
  222. package/src/flow/actions/call_llm.ts +12 -0
  223. package/src/flow/actions/call_resthook.ts +12 -0
  224. package/src/flow/actions/call_webhook.ts +133 -0
  225. package/src/flow/actions/enter_flow.ts +15 -0
  226. package/src/flow/actions/open_ticket.ts +12 -0
  227. package/src/flow/actions/play_audio.ts +12 -0
  228. package/src/flow/actions/remove_contact_groups.ts +66 -0
  229. package/src/flow/actions/request_optin.ts +12 -0
  230. package/src/flow/actions/say_msg.ts +12 -0
  231. package/src/flow/actions/send_broadcast.ts +35 -0
  232. package/src/flow/actions/send_email.ts +60 -0
  233. package/src/flow/actions/send_msg.ts +58 -0
  234. package/src/flow/actions/set_contact_channel.ts +13 -0
  235. package/src/flow/actions/set_contact_field.ts +13 -0
  236. package/src/flow/actions/set_contact_language.ts +11 -0
  237. package/src/flow/actions/set_contact_name.ts +11 -0
  238. package/src/flow/actions/set_contact_status.ts +11 -0
  239. package/src/flow/actions/set_run_result.ts +11 -0
  240. package/src/flow/actions/split_by_expression_example.ts +88 -0
  241. package/src/flow/actions/start_session.ts +12 -0
  242. package/src/flow/actions/transfer_airtime.ts +12 -0
  243. package/src/flow/config.ts +93 -232
  244. package/src/flow/nodes/execute_actions.ts +5 -0
  245. package/src/flow/nodes/split_by_airtime.ts +9 -0
  246. package/src/flow/nodes/split_by_contact_field.ts +7 -0
  247. package/src/flow/nodes/split_by_expression.ts +7 -0
  248. package/src/flow/nodes/split_by_groups.ts +7 -0
  249. package/src/flow/nodes/split_by_random.ts +10 -0
  250. package/src/flow/nodes/split_by_run_result.ts +7 -0
  251. package/src/flow/nodes/split_by_scheme.ts +7 -0
  252. package/src/flow/nodes/split_by_subflow.ts +9 -0
  253. package/src/flow/nodes/split_by_webhook.ts +19 -0
  254. package/src/flow/nodes/wait_for_audio.ts +7 -0
  255. package/src/flow/nodes/wait_for_digits.ts +7 -0
  256. package/src/flow/nodes/wait_for_image.ts +7 -0
  257. package/src/flow/nodes/wait_for_location.ts +7 -0
  258. package/src/flow/nodes/wait_for_menu.ts +7 -0
  259. package/src/flow/nodes/wait_for_response.ts +7 -0
  260. package/src/flow/nodes/wait_for_video.ts +7 -0
  261. package/src/flow/types.ts +352 -0
  262. package/src/flow/utils.ts +76 -0
  263. package/src/form/ArrayEditor.ts +240 -0
  264. package/src/form/BaseListEditor.ts +177 -0
  265. package/src/form/Checkbox.ts +22 -3
  266. package/src/form/Completion.ts +6 -0
  267. package/src/form/FormField.ts +115 -11
  268. package/src/form/KeyValueEditor.ts +251 -0
  269. package/src/form/select/Select.ts +105 -32
  270. package/src/interfaces.ts +7 -2
  271. package/src/live/ContactChat.ts +3 -97
  272. package/src/store/flow-definition.d.ts +6 -1
  273. package/static/api/contacts.json +30 -0
  274. package/static/api/groups.json +4 -426
  275. package/static/api/locations.json +24 -0
  276. package/static/api/media.json +5 -0
  277. package/static/api/optins.json +16 -0
  278. package/static/api/orgs.json +13 -0
  279. package/static/api/topics.json +21 -0
  280. package/static/api/users.json +26 -0
  281. package/static/css/temba-components.css +3 -6
  282. package/temba-modules.ts +9 -2
  283. package/test/ActionHelper.ts +142 -0
  284. package/test/actions/add_contact_groups.test.ts +89 -0
  285. package/test/actions/remove_contact_groups.test.ts +265 -0
  286. package/test/actions/send_email.test.ts +214 -0
  287. package/test/actions/send_msg.test.ts +130 -0
  288. package/test/temba-action-editing-integration.test.ts +240 -0
  289. package/test/temba-checkbox.test.ts +1 -1
  290. package/test/temba-field-config.test.ts +152 -0
  291. package/test/temba-flow-editor-node.test.ts +18 -18
  292. package/test/temba-node-editor.test.ts +353 -0
  293. package/test/temba-select.test.ts +234 -0
  294. package/test-assets/contacts/history.json +11 -33
  295. package/web-dev-server.config.mjs +34 -0
  296. package/.github/workflows/coverage.yml +0 -80
  297. package/demo/sticky-note-demo.html +0 -155
  298. package/out-tsc/src/flow/EditorNode.js.map +0 -1
  299. package/out-tsc/src/flow/render.js +0 -358
  300. package/out-tsc/src/flow/render.js.map +0 -1
  301. package/out-tsc/test/temba-flow-render.test.js +0 -794
  302. package/out-tsc/test/temba-flow-render.test.js.map +0 -1
  303. package/src/flow/render.ts +0 -443
  304. package/test/temba-flow-render.test.ts +0 -1003
@@ -0,0 +1,1454 @@
1
+ import { html, TemplateResult, css } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { Node, NodeUI, Action } from '../store/flow-definition';
5
+ import {
6
+ ValidationResult,
7
+ NodeConfig,
8
+ NODE_CONFIG,
9
+ ACTION_CONFIG,
10
+ FieldConfig,
11
+ ActionConfig
12
+ } from './config';
13
+ import {
14
+ SelectFieldConfig,
15
+ CheckboxFieldConfig,
16
+ TextareaFieldConfig,
17
+ LayoutItem,
18
+ RowLayoutConfig,
19
+ GroupLayoutConfig
20
+ } from './types';
21
+ import { CustomEventType } from '../interfaces';
22
+ import { generateUUID } from '../utils';
23
+
24
+ export class NodeEditor extends RapidElement {
25
+ static get styles() {
26
+ return css`
27
+ .node-editor-form {
28
+ padding: 20px;
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 15px;
32
+ min-width: 400px;
33
+ padding-bottom: 40px;
34
+ }
35
+
36
+ .form-field {
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ .form-field label {
42
+ font-weight: 500;
43
+ margin-bottom: 6px;
44
+ color: #333;
45
+ font-size: 14px;
46
+ }
47
+
48
+ .field-errors {
49
+ color: var(--color-error, tomato);
50
+ font-size: 12px;
51
+ margin-left: 5px;
52
+ margin-top: 15px;
53
+ }
54
+
55
+ .form-actions {
56
+ display: flex;
57
+ gap: 10px;
58
+ justify-content: flex-end;
59
+ margin-top: 20px;
60
+ }
61
+
62
+ .action-section {
63
+ border: 1px solid #e0e0e0;
64
+ border-radius: 4px;
65
+ padding: 15px;
66
+ margin: 10px 0;
67
+ }
68
+
69
+ .action-section h3 {
70
+ margin: 0 0 10px 0;
71
+ color: #333;
72
+ font-size: 14px;
73
+ font-weight: 600;
74
+ }
75
+
76
+ .router-section {
77
+ border: 1px solid #e0e0e0;
78
+ border-radius: 4px;
79
+ padding: 15px;
80
+ margin: 10px 0;
81
+ }
82
+
83
+ .router-section h3 {
84
+ margin: 0 0 10px 0;
85
+ color: #333;
86
+ font-size: 14px;
87
+ font-weight: 600;
88
+ }
89
+
90
+ .form-row {
91
+ display: grid;
92
+ gap: 1rem;
93
+ align-items: end;
94
+ }
95
+
96
+ .form-group {
97
+ border: 1px solid #e0e0e0;
98
+ border-radius: 6px;
99
+ overflow: hidden;
100
+ }
101
+
102
+ .form-group.has-errors {
103
+ border-color: var(--color-error, tomato);
104
+ }
105
+
106
+ .form-group-header {
107
+ background: #f8f9fa;
108
+ padding: 12px 15px;
109
+ border-bottom: 1px solid #e0e0e0;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ cursor: pointer;
114
+ user-select: none;
115
+ }
116
+
117
+ .form-group-header.collapsible:hover {
118
+ background: #f1f3f4;
119
+ }
120
+
121
+ .form-group-info {
122
+ flex: 1;
123
+ }
124
+
125
+ .form-group-title {
126
+ font-weight: 500;
127
+ color: #333;
128
+ font-size: 14px;
129
+ display: flex;
130
+ }
131
+
132
+ .form-group-help {
133
+ font-size: 12px;
134
+ color: #666;
135
+ margin-top: 2px;
136
+ }
137
+
138
+ .form-group-toggle {
139
+ color: #666;
140
+ transition: transform 0.3s ease;
141
+ display: flex;
142
+ align-items: center;
143
+ }
144
+
145
+ .form-group-toggle.collapsed {
146
+ transform: rotate(-90deg);
147
+ }
148
+
149
+ .form-group-content {
150
+ padding: 15px;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: 15px;
154
+ overflow: hidden;
155
+ transition: all 0.3s ease;
156
+ max-height: 1000px; /* Large enough to accommodate most content */
157
+ opacity: 1;
158
+ }
159
+
160
+ .form-group-content.collapsed {
161
+ max-height: 0;
162
+ padding-top: 0;
163
+ padding-bottom: 0;
164
+ opacity: 0;
165
+ }
166
+
167
+ .group-toggle-icon {
168
+ color: #666;
169
+ transition: transform 0.3s ease;
170
+ cursor: pointer;
171
+ transform: rotate(0deg);
172
+ }
173
+
174
+ .group-toggle-icon.expanded {
175
+ transform: rotate(90deg);
176
+ }
177
+
178
+ .group-toggle-icon.collapsed {
179
+ transform: rotate(0deg);
180
+ }
181
+
182
+ .group-toggle-icon:hover {
183
+ color: #333;
184
+ }
185
+
186
+ .group-error-icon {
187
+ color: var(--color-error, tomato);
188
+ margin-right: 8px;
189
+ }
190
+ `;
191
+ }
192
+
193
+ @property({ type: Object })
194
+ action?: Action;
195
+
196
+ @property({ type: Object })
197
+ node?: Node;
198
+
199
+ @property({ type: Object })
200
+ nodeUI?: NodeUI;
201
+
202
+ @property({ type: Boolean })
203
+ isOpen: boolean = false;
204
+
205
+ @state()
206
+ private formData: any = {};
207
+
208
+ @state()
209
+ private originalFormData: any = {};
210
+
211
+ @state()
212
+ private errors: { [key: string]: string } = {};
213
+
214
+ @state()
215
+ private groupCollapseState: { [key: string]: boolean } = {};
216
+
217
+ connectedCallback(): void {
218
+ super.connectedCallback();
219
+ this.initializeFormData();
220
+ }
221
+
222
+ updated(changedProperties: Map<string | number | symbol, unknown>): void {
223
+ super.updated(changedProperties);
224
+ if (changedProperties.has('node') || changedProperties.has('action')) {
225
+ if (this.node || this.action) {
226
+ this.openDialog();
227
+ } else {
228
+ this.isOpen = false;
229
+ }
230
+ }
231
+ }
232
+
233
+ private openDialog(): void {
234
+ this.initializeFormData();
235
+ this.errors = {};
236
+ this.isOpen = true;
237
+ }
238
+
239
+ private closeDialog(): void {
240
+ this.isOpen = false;
241
+ this.formData = {};
242
+ this.errors = {};
243
+ this.groupCollapseState = {};
244
+ }
245
+
246
+ private initializeFormData(): void {
247
+ if (this.action) {
248
+ // Action editing mode - use action config
249
+ const actionConfig = ACTION_CONFIG[this.action.type];
250
+
251
+ if (actionConfig?.toFormData) {
252
+ this.formData = actionConfig.toFormData(this.action);
253
+ } else {
254
+ this.formData = { ...this.action };
255
+ // Apply smart transformations for select fields that expect {name, value} format
256
+ this.applySmartSelectTransformations(actionConfig);
257
+ }
258
+
259
+ // Convert Record objects to array format for key-value editors
260
+ this.processFormDataForEditing();
261
+
262
+ // Store a copy of the original form data for computed field comparisons
263
+ this.originalFormData = JSON.parse(JSON.stringify(this.formData));
264
+ } else if (this.node) {
265
+ // Node editing mode - use node config
266
+ const nodeConfig = this.getNodeConfig();
267
+ if (nodeConfig?.toFormData) {
268
+ this.formData = nodeConfig.toFormData(this.node);
269
+ } else {
270
+ this.formData = { ...this.node };
271
+ }
272
+
273
+ // Convert Record objects to array format for key-value editors
274
+ this.processFormDataForEditing();
275
+
276
+ // Store a copy of the original form data for computed field comparisons
277
+ this.originalFormData = JSON.parse(JSON.stringify(this.formData));
278
+ }
279
+
280
+ // enforce immutability of formData
281
+ Object.keys(this.formData).forEach((key) => {
282
+ const value = this.formData[key];
283
+ if (Array.isArray(value)) {
284
+ this.formData[key] = [...value];
285
+ } else if (value && typeof value === 'object') {
286
+ // If it's an object, ensure we don't mutate the original
287
+ this.formData[key] = { ...value };
288
+ }
289
+ });
290
+ }
291
+
292
+ private processFormDataForEditing(): void {
293
+ const processed = { ...this.formData };
294
+
295
+ // Convert Record objects to key-value arrays for key-value editors
296
+ Object.keys(processed).forEach((key) => {
297
+ const value = processed[key];
298
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
299
+ // Check if this field should be a key-value editor
300
+ const isKeyValueField = this.isKeyValueField(key);
301
+ if (isKeyValueField) {
302
+ // Convert Record to array format
303
+ processed[key] = Object.entries(value).map(([k, v]) => ({
304
+ key: k,
305
+ value: v
306
+ }));
307
+ }
308
+ }
309
+ });
310
+
311
+ this.formData = processed;
312
+ }
313
+
314
+ private applySmartSelectTransformations(actionConfig: ActionConfig): void {
315
+ if (!actionConfig) return;
316
+
317
+ const fields = actionConfig.form;
318
+ if (!fields) return;
319
+
320
+ Object.entries(fields).forEach(([fieldName, fieldConfig]) => {
321
+ if (this.shouldApplySmartSelectTransformation(fieldName, fieldConfig)) {
322
+ const value = this.formData[fieldName];
323
+ if (
324
+ Array.isArray(value) &&
325
+ value.length > 0 &&
326
+ typeof value[0] === 'string'
327
+ ) {
328
+ // Transform string array to select options format
329
+ this.formData[fieldName] = value.map((item: string) => ({
330
+ name: item,
331
+ value: item
332
+ }));
333
+ }
334
+ }
335
+ });
336
+ }
337
+
338
+ private shouldApplySmartSelectTransformation(
339
+ fieldName: string,
340
+ fieldConfig: any
341
+ ): boolean {
342
+ const selectConfig = fieldConfig as SelectFieldConfig;
343
+ return (
344
+ (fieldConfig.type === 'select' &&
345
+ (selectConfig.multi || selectConfig.tags) &&
346
+ // Don't transform if already has explicit transformations
347
+ !this.action) ||
348
+ !ACTION_CONFIG[this.action.type]?.toFormData
349
+ );
350
+ }
351
+
352
+ private isKeyValueField(fieldName: string): boolean {
353
+ // Check if this field is configured as a key-value type
354
+ if (this.action) {
355
+ const actionConfig = ACTION_CONFIG[this.action.type];
356
+ const fields = actionConfig?.form;
357
+ return fields?.[fieldName]?.type === 'key-value';
358
+ }
359
+ return false;
360
+ }
361
+
362
+ private getNodeConfig(): NodeConfig | null {
363
+ if (!this.nodeUI) return null;
364
+ // Get node config based on the nodeUI's type
365
+ return this.nodeUI.type ? NODE_CONFIG[this.nodeUI.type] : null;
366
+ }
367
+
368
+ private getHeaderColor(): string {
369
+ if (this.action) {
370
+ // Action editing mode
371
+ const actionConfig = ACTION_CONFIG[this.action.type];
372
+ return actionConfig?.color || '#666666';
373
+ } else if (this.node) {
374
+ // Node editing mode
375
+ const nodeConfig = this.getNodeConfig();
376
+ return nodeConfig?.color || '#666666';
377
+ }
378
+ return '#666666';
379
+ }
380
+
381
+ private handleDialogButtonClick(event: CustomEvent): void {
382
+ const button = event.detail.button;
383
+
384
+ if (button.name === 'Save') {
385
+ this.handleSave();
386
+ } else if (button.name === 'Cancel') {
387
+ this.handleCancel();
388
+ }
389
+ }
390
+
391
+ private handleSave(): void {
392
+ // Validate the form
393
+ const validation = this.validateForm();
394
+ if (!validation.valid) {
395
+ this.errors = validation.errors;
396
+
397
+ // Expand any groups that contain validation errors
398
+ this.expandGroupsWithErrors(validation.errors);
399
+
400
+ return;
401
+ }
402
+
403
+ // Process form data to convert key-value arrays to Records before saving
404
+ const processedFormData = this.processFormDataForSave();
405
+
406
+ // Determine whether to use node or action saving based on context
407
+ // If we have a node with a router, always use node saving (even if action is set)
408
+ // because router configuration is handled at the node level
409
+ if (this.node && this.node.router) {
410
+ // Node editing mode with router - use formDataToNode
411
+ const updatedNode = this.formDataToNode(processedFormData);
412
+ this.fireCustomEvent(CustomEventType.NodeSaved, {
413
+ node: updatedNode
414
+ });
415
+ } else if (this.action) {
416
+ // Pure action editing mode (no router)
417
+ const updatedAction = this.formDataToAction(processedFormData);
418
+ this.fireCustomEvent(CustomEventType.ActionSaved, {
419
+ action: updatedAction
420
+ });
421
+ } else if (this.node) {
422
+ // Node editing mode without router
423
+ const updatedNode = this.formDataToNode(processedFormData);
424
+ this.fireCustomEvent(CustomEventType.NodeSaved, {
425
+ node: updatedNode
426
+ });
427
+ }
428
+ }
429
+
430
+ private processFormDataForSave(): any {
431
+ const processed = { ...this.formData };
432
+
433
+ // Convert key-value arrays to Records
434
+ Object.keys(processed).forEach((key) => {
435
+ const value = processed[key];
436
+ if (
437
+ Array.isArray(value) &&
438
+ value.length > 0 &&
439
+ typeof value[0] === 'object' &&
440
+ 'key' in value[0] &&
441
+ 'value' in value[0]
442
+ ) {
443
+ // This is a key-value array, convert to Record
444
+ const record: Record<string, string> = {};
445
+ value.forEach(({ key: k, value: v }) => {
446
+ if (k.trim() !== '' || v.trim() !== '') {
447
+ record[k] = v;
448
+ }
449
+ });
450
+ processed[key] = record;
451
+ } else if (Array.isArray(value) && value.length === 0) {
452
+ // Empty key-value array should become empty object
453
+ const isKeyValueField = this.isKeyValueField(key);
454
+ if (isKeyValueField) {
455
+ processed[key] = {};
456
+ }
457
+ }
458
+ });
459
+
460
+ return processed;
461
+ }
462
+
463
+ private handleCancel(): void {
464
+ this.fireCustomEvent(CustomEventType.NodeEditCancelled, {});
465
+ }
466
+
467
+ private validateForm(): ValidationResult {
468
+ const errors: { [key: string]: string } = {};
469
+
470
+ if (this.action) {
471
+ // Action validation using fields configuration
472
+ const actionConfig = ACTION_CONFIG[this.action.type];
473
+
474
+ // Check if new field configuration system is available
475
+ if (actionConfig?.form) {
476
+ Object.entries(actionConfig?.form).forEach(
477
+ ([fieldName, fieldConfig]) => {
478
+ const value = this.formData[fieldName];
479
+
480
+ // Check required fields
481
+ if (
482
+ (fieldConfig as any).required &&
483
+ (!value || (Array.isArray(value) && value.length === 0))
484
+ ) {
485
+ errors[fieldName] = `${
486
+ (fieldConfig as any).label || fieldName
487
+ } is required`;
488
+ }
489
+
490
+ // Check minLength for text fields
491
+ if (
492
+ typeof value === 'string' &&
493
+ (fieldConfig as any).minLength &&
494
+ value.length < (fieldConfig as any).minLength
495
+ ) {
496
+ errors[fieldName] = `${
497
+ (fieldConfig as any).label || fieldName
498
+ } must be at least ${(fieldConfig as any).minLength} characters`;
499
+ }
500
+
501
+ // Check maxLength for text fields
502
+ if (
503
+ typeof value === 'string' &&
504
+ (fieldConfig as any).maxLength &&
505
+ value.length > (fieldConfig as any).maxLength
506
+ ) {
507
+ errors[fieldName] = `${
508
+ (fieldConfig as any).label || fieldName
509
+ } must be no more than ${
510
+ (fieldConfig as any).maxLength
511
+ } characters`;
512
+ }
513
+ }
514
+ );
515
+ }
516
+
517
+ // Run custom validation if available
518
+ if (actionConfig?.validate) {
519
+ // Convert form data back to action for validation
520
+ let actionForValidation: Action;
521
+ if (actionConfig.fromFormData) {
522
+ actionForValidation = actionConfig.fromFormData(this.formData);
523
+ } else {
524
+ actionForValidation = { ...this.action, ...this.formData } as Action;
525
+ }
526
+
527
+ const customValidation = actionConfig.validate(actionForValidation);
528
+ Object.assign(errors, customValidation.errors);
529
+ }
530
+ } else if (this.node) {
531
+ // Node validation
532
+ const nodeConfig = this.getNodeConfig();
533
+
534
+ // Check required fields from node properties
535
+ if (nodeConfig?.properties) {
536
+ Object.entries(nodeConfig.properties).forEach(
537
+ ([fieldName, fieldConfig]) => {
538
+ const value = this.formData[fieldName];
539
+
540
+ // Check required fields
541
+ if (
542
+ fieldConfig.required &&
543
+ (!value || (Array.isArray(value) && value.length === 0))
544
+ ) {
545
+ errors[fieldName] = `${
546
+ fieldConfig.label || fieldName
547
+ } is required`;
548
+ }
549
+ }
550
+ );
551
+ }
552
+ }
553
+
554
+ // Validate key-value fields for unique keys
555
+ this.validateKeyValueUniqueness(errors);
556
+
557
+ return {
558
+ valid: Object.keys(errors).length === 0,
559
+ errors
560
+ };
561
+ }
562
+
563
+ private validateKeyValueUniqueness(errors: { [key: string]: string }): void {
564
+ // The individual key-value editors will show validation errors on duplicate keys and empty keys with values
565
+ // We just need to prevent form submission when there are validation issues
566
+ Object.entries(this.formData).forEach(([fieldName, value]) => {
567
+ if (
568
+ Array.isArray(value) &&
569
+ value.length > 0 &&
570
+ typeof value[0] === 'object' &&
571
+ 'key' in value[0] &&
572
+ 'value' in value[0]
573
+ ) {
574
+ // This is a key-value array
575
+ let hasValidationErrors = false;
576
+
577
+ // Check for empty keys with values
578
+ value.forEach(({ key, value: itemValue }: any) => {
579
+ if (key.trim() === '' && itemValue.trim() !== '') {
580
+ hasValidationErrors = true;
581
+ }
582
+ });
583
+
584
+ // Check for duplicate keys (only non-empty ones)
585
+ const keys = value
586
+ .filter(({ key }: any) => key.trim() !== '') // Only check non-empty keys
587
+ .map(({ key }: any) => key.trim());
588
+
589
+ const uniqueKeys = new Set(keys);
590
+
591
+ if (keys.length !== uniqueKeys.size) {
592
+ hasValidationErrors = true;
593
+ }
594
+
595
+ if (hasValidationErrors) {
596
+ errors[fieldName] = `Please resolve validation errors to continue`;
597
+ }
598
+ }
599
+ });
600
+ }
601
+
602
+ private formDataToNode(formData: any = this.formData): Node {
603
+ if (!this.node) throw new Error('No node to update');
604
+ let updatedNode: Node = { ...this.node };
605
+
606
+ // Handle actions using action config transformations if available
607
+ if (this.node.actions && this.node.actions.length > 0) {
608
+ updatedNode.actions = this.node.actions.map((action) => {
609
+ // If we're editing a specific action, only transform that one
610
+ if (this.action && action.uuid === this.action.uuid) {
611
+ const actionConfig = ACTION_CONFIG[action.type];
612
+ if (actionConfig?.fromFormData) {
613
+ // Use action-specific form data transformation
614
+ return actionConfig.fromFormData(formData);
615
+ } else {
616
+ // Default transformation - merge form data with original action
617
+ return { ...action, ...formData };
618
+ }
619
+ } else {
620
+ // Keep other actions unchanged
621
+ return action;
622
+ }
623
+ });
624
+ }
625
+
626
+ // Handle router configuration using node config
627
+ if (this.node.router) {
628
+ const nodeConfig = this.getNodeConfig();
629
+
630
+ if (nodeConfig?.fromFormData) {
631
+ // Use node-specific form data transformation
632
+ updatedNode = nodeConfig.fromFormData(formData, updatedNode);
633
+ } else {
634
+ // Default router handling
635
+ updatedNode.router = { ...this.node.router };
636
+
637
+ // Apply form data to router fields if they exist
638
+ if (formData.result_name !== undefined) {
639
+ updatedNode.router.result_name = formData.result_name;
640
+ }
641
+
642
+ // Handle preconfigured rules from node config
643
+ if (nodeConfig?.router?.rules) {
644
+ // Build a complete new set of categories and exits based on node config
645
+ const existingCategories = updatedNode.router.categories || [];
646
+ const existingExits = updatedNode.exits || [];
647
+
648
+ const newCategories: any[] = [];
649
+ const newExits: any[] = [];
650
+
651
+ // Group rules by category name to handle multiple rules pointing to the same category
652
+ const categoryNameToRules = new Map<
653
+ string,
654
+ typeof nodeConfig.router.rules
655
+ >();
656
+ nodeConfig.router.rules.forEach((rule) => {
657
+ if (!categoryNameToRules.has(rule.categoryName)) {
658
+ categoryNameToRules.set(rule.categoryName, []);
659
+ }
660
+ categoryNameToRules.get(rule.categoryName)!.push(rule);
661
+ });
662
+
663
+ // Create categories for all unique category names
664
+ categoryNameToRules.forEach((rules, categoryName) => {
665
+ // Check if category already exists to preserve its UUID and exit_uuid
666
+ const existingCategory = existingCategories.find(
667
+ (cat) => cat.name === categoryName
668
+ );
669
+
670
+ if (existingCategory) {
671
+ // Preserve existing category and its associated exit
672
+ newCategories.push(existingCategory);
673
+ const associatedExit = existingExits.find(
674
+ (exit) => exit.uuid === existingCategory.exit_uuid
675
+ );
676
+ if (associatedExit) {
677
+ newExits.push(associatedExit);
678
+ }
679
+ } else {
680
+ // Create new category and exit
681
+ const categoryUuid = generateUUID();
682
+ const exitUuid = generateUUID();
683
+
684
+ newCategories.push({
685
+ uuid: categoryUuid,
686
+ name: categoryName,
687
+ exit_uuid: exitUuid
688
+ });
689
+
690
+ newExits.push({
691
+ uuid: exitUuid,
692
+ destination_uuid: null
693
+ });
694
+ }
695
+ });
696
+
697
+ // Add default category if specified
698
+ if (nodeConfig.router.defaultCategory) {
699
+ // Check if default category already exists in our new list
700
+ const existingDefault = newCategories.find(
701
+ (cat) => cat.name === nodeConfig.router.defaultCategory
702
+ );
703
+
704
+ if (!existingDefault) {
705
+ // Check if it exists in the original categories
706
+ const originalDefault = existingCategories.find(
707
+ (cat) => cat.name === nodeConfig.router.defaultCategory
708
+ );
709
+
710
+ if (originalDefault) {
711
+ // Preserve existing default category and its exit
712
+ newCategories.push(originalDefault);
713
+ const associatedExit = existingExits.find(
714
+ (exit) => exit.uuid === originalDefault.exit_uuid
715
+ );
716
+ if (associatedExit) {
717
+ newExits.push(associatedExit);
718
+ }
719
+ } else {
720
+ // Create new default category and exit
721
+ const categoryUuid = generateUUID();
722
+ const exitUuid = generateUUID();
723
+
724
+ newCategories.push({
725
+ uuid: categoryUuid,
726
+ name: nodeConfig.router.defaultCategory,
727
+ exit_uuid: exitUuid
728
+ });
729
+
730
+ newExits.push({
731
+ uuid: exitUuid,
732
+ destination_uuid: null
733
+ });
734
+ }
735
+ }
736
+ }
737
+
738
+ // Replace the entire categories and exits lists with our complete new sets
739
+ updatedNode.router.categories = newCategories;
740
+ updatedNode.exits = newExits;
741
+ }
742
+ }
743
+ } else {
744
+ // If no router, just apply form data to node properties
745
+ Object.keys(formData).forEach((key) => {
746
+ if (
747
+ key !== 'uuid' &&
748
+ key !== 'actions' &&
749
+ key !== 'exits' &&
750
+ key !== 'router'
751
+ ) {
752
+ (updatedNode as any)[key] = formData[key];
753
+ }
754
+ });
755
+ }
756
+
757
+ return updatedNode;
758
+ }
759
+
760
+ private formDataToAction(formData: any = this.formData): Action {
761
+ if (!this.action) throw new Error('No action to update');
762
+
763
+ // Use action config transformation if available
764
+ const actionConfig = ACTION_CONFIG[this.action.type];
765
+ if (actionConfig?.fromFormData) {
766
+ return actionConfig.fromFormData(formData);
767
+ } else {
768
+ // Apply smart select transformations in reverse and provide default 1:1 mapping
769
+ const processedFormData = this.reverseSmartSelectTransformations(
770
+ formData,
771
+ actionConfig
772
+ );
773
+ return { ...this.action, ...processedFormData };
774
+ }
775
+ }
776
+
777
+ private reverseSmartSelectTransformations(
778
+ formData: any,
779
+ actionConfig: ActionConfig
780
+ ): any {
781
+ if (!actionConfig || !actionConfig.form) return formData;
782
+ const processed = { ...formData };
783
+
784
+ Object.entries(actionConfig.form).forEach(([fieldName, fieldConfig]) => {
785
+ if (this.shouldApplySmartSelectTransformation(fieldName, fieldConfig)) {
786
+ const value = processed[fieldName];
787
+ if (
788
+ Array.isArray(value) &&
789
+ value.length > 0 &&
790
+ typeof value[0] === 'object' &&
791
+ 'value' in value[0]
792
+ ) {
793
+ // Transform select options format back to string array
794
+ processed[fieldName] = value.map(
795
+ (item: any) => item.value || item.name || item
796
+ );
797
+ }
798
+ }
799
+ });
800
+
801
+ return processed;
802
+ }
803
+
804
+ private handleFormFieldChange(propertyName: string, event: Event): void {
805
+ const target = event.target as any;
806
+ let value: any;
807
+
808
+ // Handle different component types like ActionEditor does
809
+ if (target.tagName === 'TEMBA-CHECKBOX') {
810
+ value = target.checked;
811
+ } else if (
812
+ target.tagName === 'TEMBA-SELECT' &&
813
+ (target.multi || target.emails || target.tags)
814
+ ) {
815
+ value = target.values || [];
816
+ } else if (target.values !== undefined) {
817
+ value = target.values;
818
+ } else {
819
+ value = target.value;
820
+ }
821
+
822
+ this.formData = {
823
+ ...this.formData,
824
+ [propertyName]: value
825
+ };
826
+
827
+ // Clear any existing error for this field
828
+ if (this.errors[propertyName]) {
829
+ const newErrors = { ...this.errors };
830
+ delete newErrors[propertyName];
831
+ this.errors = newErrors;
832
+ }
833
+
834
+ // Check for computed values in dependent fields
835
+ this.updateComputedFields(propertyName);
836
+
837
+ // Trigger re-render to handle conditional field visibility
838
+ this.requestUpdate();
839
+ }
840
+
841
+ private updateComputedFields(changedFieldName: string): void {
842
+ if (!this.action) return;
843
+
844
+ const config = ACTION_CONFIG[this.action.type];
845
+ if (!config?.form) return;
846
+
847
+ // Check all fields to see if any depend on the changed field
848
+ Object.entries(config.form).forEach(([fieldName, fieldConfig]) => {
849
+ if (fieldConfig.dependsOn?.includes(changedFieldName)) {
850
+ if (fieldConfig.computeValue) {
851
+ const currentValue = this.formData[fieldName];
852
+
853
+ const computedValue = fieldConfig.computeValue(
854
+ this.formData,
855
+ currentValue,
856
+ this.originalFormData
857
+ );
858
+
859
+ // Update the form data with the computed value
860
+ this.formData = {
861
+ ...this.formData,
862
+ [fieldName]: computedValue
863
+ };
864
+ }
865
+ }
866
+ });
867
+ }
868
+
869
+ private renderNewField(
870
+ fieldName: string,
871
+ config: FieldConfig,
872
+ value: any
873
+ ): TemplateResult {
874
+ // Check visibility condition
875
+ if (config.conditions?.visible) {
876
+ try {
877
+ const isVisible = config.conditions.visible(this.formData);
878
+ if (!isVisible) {
879
+ return html``;
880
+ }
881
+ } catch (error) {
882
+ console.error(`Error checking visibility for ${fieldName}:`, error);
883
+ // If there's an error, show the field by default
884
+ }
885
+ }
886
+
887
+ const errors = this.errors[fieldName] ? [this.errors[fieldName]] : [];
888
+
889
+ // Build container style with maxWidth if specified
890
+ const containerStyle = config.maxWidth
891
+ ? `max-width: ${config.maxWidth};`
892
+ : '';
893
+
894
+ const fieldContent = this.renderFieldContent(
895
+ fieldName,
896
+ config,
897
+ value,
898
+ errors
899
+ );
900
+
901
+ // Wrap in container with style if maxWidth is specified
902
+ if (containerStyle) {
903
+ return html`<div style="${containerStyle}">${fieldContent}</div>`;
904
+ }
905
+
906
+ return fieldContent;
907
+ }
908
+
909
+ private renderFieldContent(
910
+ fieldName: string,
911
+ config: FieldConfig,
912
+ value: any,
913
+ errors: string[]
914
+ ): TemplateResult {
915
+ switch (config.type) {
916
+ case 'text':
917
+ return html`<temba-textinput
918
+ name="${fieldName}"
919
+ label="${config.label}"
920
+ ?required="${config.required}"
921
+ .errors="${errors}"
922
+ .value="${value || ''}"
923
+ placeholder="${config.placeholder || ''}"
924
+ .helpText="${config.helpText || ''}"
925
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
926
+ ></temba-textinput>`;
927
+
928
+ case 'textarea': {
929
+ const textareaConfig = config as TextareaFieldConfig;
930
+ const minHeightStyle = textareaConfig.minHeight
931
+ ? `--textarea-min-height: ${textareaConfig.minHeight}px;`
932
+ : '';
933
+
934
+ if (config.evaluated) {
935
+ return html`<temba-completion
936
+ name="${fieldName}"
937
+ label="${config.label}"
938
+ ?required="${config.required}"
939
+ .errors="${errors}"
940
+ .value="${value || ''}"
941
+ placeholder="${config.placeholder || ''}"
942
+ textarea
943
+ expressions="session"
944
+ style="${minHeightStyle}"
945
+ .helpText="${config.helpText || ''}"
946
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
947
+ ></temba-completion>`;
948
+ } else {
949
+ return html`<temba-textinput
950
+ name="${fieldName}"
951
+ label="${config.label}"
952
+ ?required="${config.required}"
953
+ .errors="${errors}"
954
+ .value="${value || ''}"
955
+ placeholder="${config.placeholder || ''}"
956
+ textarea
957
+ .rows="${textareaConfig.rows || 3}"
958
+ style="${minHeightStyle}"
959
+ .helpText="${config.helpText || ''}"
960
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
961
+ ></temba-textinput>`;
962
+ }
963
+ }
964
+
965
+ case 'select': {
966
+ const selectConfig = config as SelectFieldConfig;
967
+ return html`<temba-select
968
+ name="${fieldName}"
969
+ label="${config.label}"
970
+ ?required="${config.required}"
971
+ .errors="${errors}"
972
+ .values="${value || (selectConfig.multi ? [] : '')}"
973
+ ?multi="${selectConfig.multi}"
974
+ ?searchable="${selectConfig.searchable}"
975
+ ?tags="${selectConfig.tags}"
976
+ ?emails="${selectConfig.emails}"
977
+ placeholder="${selectConfig.placeholder || ''}"
978
+ maxItems="${selectConfig.maxItems || 0}"
979
+ valueKey="${selectConfig.valueKey || 'value'}"
980
+ nameKey="${selectConfig.nameKey || 'name'}"
981
+ endpoint="${selectConfig.endpoint || ''}"
982
+ .helpText="${config.helpText || ''}"
983
+ @change="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
984
+ >
985
+ ${selectConfig.options?.map((option: any) => {
986
+ if (typeof option === 'string') {
987
+ return html`<temba-option
988
+ name="${option}"
989
+ value="${option}"
990
+ ></temba-option>`;
991
+ } else {
992
+ return html`<temba-option
993
+ name="${option.label || option.name}"
994
+ value="${option.value}"
995
+ ></temba-option>`;
996
+ }
997
+ })}
998
+ </temba-select>`;
999
+ }
1000
+
1001
+ case 'key-value':
1002
+ return html`<div class="form-field">
1003
+ <label>${config.label}${config.required ? ' *' : ''}</label>
1004
+ <temba-key-value-editor
1005
+ name="${fieldName}"
1006
+ .value="${value || []}"
1007
+ .sortable="${config.sortable}"
1008
+ .keyPlaceholder="${config.keyPlaceholder || 'Key'}"
1009
+ .valuePlaceholder="${config.valuePlaceholder || 'Value'}"
1010
+ .minRows="${config.minRows || 0}"
1011
+ @change="${(e: CustomEvent) => {
1012
+ if (e.detail) {
1013
+ this.handleNewFieldChange(fieldName, e.detail.value);
1014
+ }
1015
+ }}"
1016
+ ></temba-key-value-editor>
1017
+ ${errors.length
1018
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1019
+ : ''}
1020
+ </div>`;
1021
+
1022
+ case 'array':
1023
+ return html`<div class="form-field">
1024
+ <label>${config.label}${config.required ? ' *' : ''}</label>
1025
+ <temba-array-editor
1026
+ .value="${value || []}"
1027
+ .itemConfig="${config.itemConfig}"
1028
+ .sortable="${config.sortable}"
1029
+ .itemLabel="${config.itemLabel || 'Item'}"
1030
+ .minItems="${config.minItems || 0}"
1031
+ .onItemChange="${config.onItemChange}"
1032
+ @change="${(e: CustomEvent) =>
1033
+ this.handleNewFieldChange(fieldName, e.detail.value)}"
1034
+ ></temba-array-editor>
1035
+ ${errors.length
1036
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1037
+ : ''}
1038
+ </div>`;
1039
+
1040
+ case 'checkbox': {
1041
+ const checkboxConfig = config as CheckboxFieldConfig;
1042
+ return html`<div class="form-field">
1043
+ <temba-checkbox
1044
+ name="${fieldName}"
1045
+ label="${config.label}"
1046
+ .helpText="${config.helpText || ''}"
1047
+ ?required="${config.required}"
1048
+ .errors="${errors}"
1049
+ ?checked="${value || false}"
1050
+ size="${checkboxConfig.size || 1.2}"
1051
+ animateChange="${checkboxConfig.animateChange || 'pulse'}"
1052
+ @change="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
1053
+ ></temba-checkbox>
1054
+ ${errors.length
1055
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1056
+ : ''}
1057
+ </div>`;
1058
+ }
1059
+
1060
+ default:
1061
+ return html`<div>Unsupported field type: ${(config as any).type}</div>`;
1062
+ }
1063
+ }
1064
+
1065
+ private handleGroupToggle(groupLabel: string): void {
1066
+ this.groupCollapseState = {
1067
+ ...this.groupCollapseState,
1068
+ [groupLabel]: !this.groupCollapseState[groupLabel]
1069
+ };
1070
+ }
1071
+
1072
+ private expandGroupsWithErrors(errors: { [key: string]: string }): void {
1073
+ if (!this.action) return;
1074
+
1075
+ const config = ACTION_CONFIG[this.action.type];
1076
+ if (!config?.layout) return;
1077
+
1078
+ const errorFields = new Set(Object.keys(errors));
1079
+ this.expandGroupsWithErrorsRecursive(config.layout, errorFields);
1080
+ }
1081
+
1082
+ private expandGroupsWithErrorsRecursive(
1083
+ items: LayoutItem[],
1084
+ errorFields: Set<string>
1085
+ ): void {
1086
+ items.forEach((item) => {
1087
+ if (typeof item === 'object' && item.type === 'group') {
1088
+ const fieldsInGroup = this.collectFieldsFromItems(item.items);
1089
+ const groupHasErrors = fieldsInGroup.some((fieldName) =>
1090
+ errorFields.has(fieldName)
1091
+ );
1092
+
1093
+ if (groupHasErrors) {
1094
+ // Expand this group
1095
+ this.groupCollapseState = {
1096
+ ...this.groupCollapseState,
1097
+ [item.label]: false
1098
+ };
1099
+ }
1100
+
1101
+ // Recursively check nested items
1102
+ this.expandGroupsWithErrorsRecursive(item.items, errorFields);
1103
+ } else if (typeof item === 'object' && item.type === 'row') {
1104
+ // Recursively check items in rows
1105
+ this.expandGroupsWithErrorsRecursive(item.items, errorFields);
1106
+ }
1107
+ });
1108
+ }
1109
+
1110
+ private renderLayoutItem(
1111
+ item: LayoutItem,
1112
+ config: ActionConfig,
1113
+ renderedFields: Set<string>
1114
+ ): TemplateResult {
1115
+ if (typeof item === 'string') {
1116
+ // String shorthand for field
1117
+ return this.renderLayoutItem(
1118
+ { type: 'field', field: item },
1119
+ config,
1120
+ renderedFields
1121
+ );
1122
+ }
1123
+
1124
+ switch (item.type) {
1125
+ case 'field':
1126
+ if (config.form![item.field] && !renderedFields.has(item.field)) {
1127
+ renderedFields.add(item.field);
1128
+ return this.renderNewField(
1129
+ item.field,
1130
+ config.form![item.field] as FieldConfig,
1131
+ this.formData[item.field]
1132
+ );
1133
+ }
1134
+ return html``;
1135
+
1136
+ case 'row':
1137
+ return this.renderRow(item, config, renderedFields);
1138
+
1139
+ case 'group':
1140
+ return this.renderGroup(item, config, renderedFields);
1141
+
1142
+ default:
1143
+ return html``;
1144
+ }
1145
+ }
1146
+
1147
+ private renderRow(
1148
+ rowConfig: RowLayoutConfig,
1149
+ config: ActionConfig,
1150
+ renderedFields: Set<string>
1151
+ ): TemplateResult {
1152
+ const { items, gap = '1rem' } = rowConfig;
1153
+
1154
+ // Collect all fields from this row for width calculations
1155
+ const fieldsInRow = this.collectFieldsFromItems(items);
1156
+ const validFields = fieldsInRow.filter(
1157
+ (fieldName) => config.form?.[fieldName]
1158
+ );
1159
+
1160
+ if (validFields.length === 0) {
1161
+ return html``;
1162
+ }
1163
+
1164
+ // Calculate grid template columns based on field maxWidth constraints
1165
+ const columns = validFields.map((fieldName) => {
1166
+ const fieldConfig = config.form![fieldName];
1167
+ return fieldConfig.maxWidth || '1fr';
1168
+ });
1169
+
1170
+ return html`
1171
+ <div
1172
+ class="form-row"
1173
+ style="display: grid; grid-template-columns: ${columns.join(
1174
+ ' '
1175
+ )}; gap: ${gap};"
1176
+ >
1177
+ ${items.map((item) =>
1178
+ this.renderLayoutItem(item, config, renderedFields)
1179
+ )}
1180
+ </div>
1181
+ `;
1182
+ }
1183
+
1184
+ private renderGroup(
1185
+ groupConfig: GroupLayoutConfig,
1186
+ config: ActionConfig,
1187
+ renderedFields: Set<string>
1188
+ ): TemplateResult {
1189
+ const {
1190
+ label,
1191
+ items,
1192
+ collapsible = false,
1193
+ collapsed = false,
1194
+ helpText
1195
+ } = groupConfig;
1196
+
1197
+ // Initialize collapse state if not set
1198
+ if (collapsible && !(label in this.groupCollapseState)) {
1199
+ this.groupCollapseState = {
1200
+ ...this.groupCollapseState,
1201
+ [label]: collapsed
1202
+ };
1203
+ }
1204
+
1205
+ const isCollapsed = collapsible
1206
+ ? this.groupCollapseState[label] ?? collapsed
1207
+ : false;
1208
+
1209
+ // Check if any field in this group has errors
1210
+ const fieldsInGroup = this.collectFieldsFromItems(items);
1211
+ const groupHasErrors = fieldsInGroup.some(
1212
+ (fieldName) => this.errors[fieldName]
1213
+ );
1214
+
1215
+ return html`
1216
+ <div
1217
+ class="form-group ${collapsible ? 'collapsible' : ''} ${groupHasErrors
1218
+ ? 'has-errors'
1219
+ : ''}"
1220
+ >
1221
+ <div
1222
+ class="form-group-header ${collapsible ? 'clickable' : ''}"
1223
+ @click=${collapsible
1224
+ ? () => this.handleGroupToggle(label)
1225
+ : undefined}
1226
+ >
1227
+ <div class="form-group-info">
1228
+ <div class="form-group-title">${label}</div>
1229
+ ${helpText
1230
+ ? html`<div class="form-group-help">${helpText}</div>`
1231
+ : ''}
1232
+ </div>
1233
+ ${groupHasErrors
1234
+ ? html`<temba-icon
1235
+ name="alert_warning"
1236
+ class="group-error-icon"
1237
+ size="1.5"
1238
+ ></temba-icon>`
1239
+ : ''}
1240
+ ${collapsible && !groupHasErrors
1241
+ ? html`<temba-icon
1242
+ name="arrow_right"
1243
+ size="1.5"
1244
+ class="group-toggle-icon ${isCollapsed
1245
+ ? 'collapsed'
1246
+ : 'expanded'}"
1247
+ ></temba-icon>`
1248
+ : ''}
1249
+ </div>
1250
+ <div
1251
+ class="form-group-content ${isCollapsed ? 'collapsed' : 'expanded'}"
1252
+ >
1253
+ ${items.map((item) =>
1254
+ this.renderLayoutItem(item, config, renderedFields)
1255
+ )}
1256
+ </div>
1257
+ </div>
1258
+ `;
1259
+ }
1260
+
1261
+ private collectFieldsFromItems(items: LayoutItem[]): string[] {
1262
+ const fields: string[] = [];
1263
+
1264
+ items.forEach((item) => {
1265
+ if (typeof item === 'string') {
1266
+ fields.push(item);
1267
+ } else if (item.type === 'field') {
1268
+ fields.push(item.field);
1269
+ } else if (item.type === 'row') {
1270
+ fields.push(...this.collectFieldsFromItems(item.items));
1271
+ } else if (item.type === 'group') {
1272
+ fields.push(...this.collectFieldsFromItems(item.items));
1273
+ }
1274
+ });
1275
+
1276
+ return fields;
1277
+ }
1278
+
1279
+ private renderFieldRow(
1280
+ rowConfig: RowLayoutConfig,
1281
+ config: ActionConfig
1282
+ ): TemplateResult {
1283
+ // This method is deprecated - use renderRow instead
1284
+ return this.renderRow(rowConfig, config, new Set());
1285
+ }
1286
+
1287
+ private renderFieldGroup(
1288
+ groupConfig: GroupLayoutConfig,
1289
+ config: ActionConfig
1290
+ ): TemplateResult {
1291
+ // This method is deprecated - use renderGroup instead
1292
+ return this.renderGroup(groupConfig, config, new Set());
1293
+ }
1294
+
1295
+ private handleNewFieldChange(fieldName: string, value: any) {
1296
+ this.formData = {
1297
+ ...this.formData,
1298
+ [fieldName]: value
1299
+ };
1300
+
1301
+ // Clear any existing error for this field
1302
+ if (this.errors[fieldName]) {
1303
+ const newErrors = { ...this.errors };
1304
+ delete newErrors[fieldName];
1305
+ this.errors = newErrors;
1306
+ }
1307
+
1308
+ // Trigger re-render
1309
+ this.requestUpdate();
1310
+ }
1311
+
1312
+ private renderFields(): TemplateResult {
1313
+ if (!this.action) {
1314
+ return html` <div>No action selected</div> `;
1315
+ }
1316
+
1317
+ const config = ACTION_CONFIG[this.action.type];
1318
+ if (!config) {
1319
+ return html` <div>No configuration available for this action</div> `;
1320
+ }
1321
+
1322
+ // Use the new fields configuration system
1323
+ if (config.form) {
1324
+ // If layout is specified, use it
1325
+ if (config.layout) {
1326
+ const renderedFields = new Set<string>();
1327
+
1328
+ return html`
1329
+ ${config.layout.map((item) =>
1330
+ this.renderLayoutItem(item, config, renderedFields)
1331
+ )}
1332
+ ${
1333
+ /* Render any fields not explicitly placed in layout */
1334
+ Object.entries(config.form).map(([fieldName, fieldConfig]) => {
1335
+ if (!renderedFields.has(fieldName)) {
1336
+ return this.renderNewField(
1337
+ fieldName,
1338
+ fieldConfig as FieldConfig,
1339
+ this.formData[fieldName]
1340
+ );
1341
+ }
1342
+ return html``;
1343
+ })
1344
+ }
1345
+ `;
1346
+ } else {
1347
+ // Default rendering without layout
1348
+ return html`
1349
+ ${Object.entries(config.form).map(([fieldName, fieldConfig]) =>
1350
+ this.renderNewField(
1351
+ fieldName,
1352
+ fieldConfig as FieldConfig,
1353
+ this.formData[fieldName]
1354
+ )
1355
+ )}
1356
+ `;
1357
+ }
1358
+ }
1359
+
1360
+ return html` <div>No form configuration available</div> `;
1361
+ }
1362
+
1363
+ private renderActionSection(): TemplateResult {
1364
+ if (!this.node || this.node.actions.length === 0) {
1365
+ return html``;
1366
+ }
1367
+
1368
+ const nodeConfig = this.getNodeConfig();
1369
+
1370
+ // If node has an action config, defer to ActionEditor
1371
+ if (nodeConfig?.action) {
1372
+ const action = this.node.actions[0]; // Assume single action for now
1373
+
1374
+ return html`
1375
+ <div class="action-section">
1376
+ <h3>Action Configuration</h3>
1377
+ <div class="action-preview">
1378
+ <p><strong>Type:</strong> ${action.type}</p>
1379
+ <p><em>Action details will be editable here</em></p>
1380
+ </div>
1381
+ </div>
1382
+ `;
1383
+ }
1384
+
1385
+ return html``;
1386
+ }
1387
+
1388
+ private renderRouterSection(): TemplateResult {
1389
+ if (!this.node?.router) {
1390
+ return html``;
1391
+ }
1392
+
1393
+ const nodeConfig = this.getNodeConfig();
1394
+
1395
+ return html`
1396
+ <div class="router-section">
1397
+ <h3>Router Configuration</h3>
1398
+ ${nodeConfig?.router
1399
+ ? this.renderRouterConfig()
1400
+ : html`<p>Basic router (no advanced configuration)</p>`}
1401
+ </div>
1402
+ `;
1403
+ }
1404
+
1405
+ private renderRouterConfig(): TemplateResult {
1406
+ const nodeConfig = this.getNodeConfig();
1407
+ if (!nodeConfig?.router) return html``;
1408
+
1409
+ // Render router configuration based on node config
1410
+ // This is where you'd render rule and category editors
1411
+ return html`
1412
+ <div class="router-config">
1413
+ <p><strong>Type:</strong> ${nodeConfig.router.type}</p>
1414
+ ${nodeConfig.router.rules
1415
+ ? html`
1416
+ <div class="rules-section">
1417
+ <h4>Rules</h4>
1418
+ <!-- Future: Render rule editor based on nodeConfig.router.rules -->
1419
+ <p><em>Rule editing will be implemented here</em></p>
1420
+ </div>
1421
+ `
1422
+ : ''}
1423
+ </div>
1424
+ `;
1425
+ }
1426
+
1427
+ render(): TemplateResult {
1428
+ if (!this.isOpen) {
1429
+ return html``;
1430
+ }
1431
+
1432
+ const headerColor = this.getHeaderColor();
1433
+ const nodeConfig = this.getNodeConfig();
1434
+ const actionConfig = ACTION_CONFIG[this.action?.type];
1435
+
1436
+ return html`
1437
+ <temba-dialog
1438
+ header="${actionConfig?.name || nodeConfig?.name || 'Edit'}"
1439
+ .open="${this.isOpen}"
1440
+ @temba-button-clicked=${this.handleDialogButtonClick}
1441
+ primaryButtonName="Save"
1442
+ cancelButtonName="Cancel"
1443
+ style="--header-bg: ${headerColor}"
1444
+ >
1445
+ <div class="node-editor-form">
1446
+ ${this.renderFields()}
1447
+ ${nodeConfig?.router?.configurable
1448
+ ? this.renderRouterSection()
1449
+ : null}
1450
+ </div>
1451
+ </temba-dialog>
1452
+ `;
1453
+ }
1454
+ }