@nyaruka/temba-components 0.129.3 → 0.129.4

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 +18 -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 +1200 -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 +77 -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 +85 -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 +1443 -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 +89 -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 +127 -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,1443 @@
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
+
281
+ private processFormDataForEditing(): void {
282
+ const processed = { ...this.formData };
283
+
284
+ // Convert Record objects to key-value arrays for key-value editors
285
+ Object.keys(processed).forEach((key) => {
286
+ const value = processed[key];
287
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
288
+ // Check if this field should be a key-value editor
289
+ const isKeyValueField = this.isKeyValueField(key);
290
+ if (isKeyValueField) {
291
+ // Convert Record to array format
292
+ processed[key] = Object.entries(value).map(([k, v]) => ({
293
+ key: k,
294
+ value: v
295
+ }));
296
+ }
297
+ }
298
+ });
299
+
300
+ this.formData = processed;
301
+ }
302
+
303
+ private applySmartSelectTransformations(actionConfig: ActionConfig): void {
304
+ if (!actionConfig) return;
305
+
306
+ const fields = actionConfig.form;
307
+ if (!fields) return;
308
+
309
+ Object.entries(fields).forEach(([fieldName, fieldConfig]) => {
310
+ if (this.shouldApplySmartSelectTransformation(fieldName, fieldConfig)) {
311
+ const value = this.formData[fieldName];
312
+ if (
313
+ Array.isArray(value) &&
314
+ value.length > 0 &&
315
+ typeof value[0] === 'string'
316
+ ) {
317
+ // Transform string array to select options format
318
+ this.formData[fieldName] = value.map((item: string) => ({
319
+ name: item,
320
+ value: item
321
+ }));
322
+ }
323
+ }
324
+ });
325
+ }
326
+
327
+ private shouldApplySmartSelectTransformation(
328
+ fieldName: string,
329
+ fieldConfig: any
330
+ ): boolean {
331
+ const selectConfig = fieldConfig as SelectFieldConfig;
332
+ return (
333
+ (fieldConfig.type === 'select' &&
334
+ (selectConfig.multi || selectConfig.tags) &&
335
+ // Don't transform if already has explicit transformations
336
+ !this.action) ||
337
+ !ACTION_CONFIG[this.action.type]?.toFormData
338
+ );
339
+ }
340
+
341
+ private isKeyValueField(fieldName: string): boolean {
342
+ // Check if this field is configured as a key-value type
343
+ if (this.action) {
344
+ const actionConfig = ACTION_CONFIG[this.action.type];
345
+ const fields = actionConfig?.form;
346
+ return fields?.[fieldName]?.type === 'key-value';
347
+ }
348
+ return false;
349
+ }
350
+
351
+ private getNodeConfig(): NodeConfig | null {
352
+ if (!this.nodeUI) return null;
353
+ // Get node config based on the nodeUI's type
354
+ return this.nodeUI.type ? NODE_CONFIG[this.nodeUI.type] : null;
355
+ }
356
+
357
+ private getHeaderColor(): string {
358
+ if (this.action) {
359
+ // Action editing mode
360
+ const actionConfig = ACTION_CONFIG[this.action.type];
361
+ return actionConfig?.color || '#666666';
362
+ } else if (this.node) {
363
+ // Node editing mode
364
+ const nodeConfig = this.getNodeConfig();
365
+ return nodeConfig?.color || '#666666';
366
+ }
367
+ return '#666666';
368
+ }
369
+
370
+ private handleDialogButtonClick(event: CustomEvent): void {
371
+ const button = event.detail.button;
372
+
373
+ if (button.name === 'Save') {
374
+ this.handleSave();
375
+ } else if (button.name === 'Cancel') {
376
+ this.handleCancel();
377
+ }
378
+ }
379
+
380
+ private handleSave(): void {
381
+ // Validate the form
382
+ const validation = this.validateForm();
383
+ if (!validation.valid) {
384
+ this.errors = validation.errors;
385
+
386
+ // Expand any groups that contain validation errors
387
+ this.expandGroupsWithErrors(validation.errors);
388
+
389
+ return;
390
+ }
391
+
392
+ // Process form data to convert key-value arrays to Records before saving
393
+ const processedFormData = this.processFormDataForSave();
394
+
395
+ // Determine whether to use node or action saving based on context
396
+ // If we have a node with a router, always use node saving (even if action is set)
397
+ // because router configuration is handled at the node level
398
+ if (this.node && this.node.router) {
399
+ // Node editing mode with router - use formDataToNode
400
+ const updatedNode = this.formDataToNode(processedFormData);
401
+ this.fireCustomEvent(CustomEventType.NodeSaved, {
402
+ node: updatedNode
403
+ });
404
+ } else if (this.action) {
405
+ // Pure action editing mode (no router)
406
+ const updatedAction = this.formDataToAction(processedFormData);
407
+ this.fireCustomEvent(CustomEventType.ActionSaved, {
408
+ action: updatedAction
409
+ });
410
+ } else if (this.node) {
411
+ // Node editing mode without router
412
+ const updatedNode = this.formDataToNode(processedFormData);
413
+ this.fireCustomEvent(CustomEventType.NodeSaved, {
414
+ node: updatedNode
415
+ });
416
+ }
417
+ }
418
+
419
+ private processFormDataForSave(): any {
420
+ const processed = { ...this.formData };
421
+
422
+ // Convert key-value arrays to Records
423
+ Object.keys(processed).forEach((key) => {
424
+ const value = processed[key];
425
+ if (
426
+ Array.isArray(value) &&
427
+ value.length > 0 &&
428
+ typeof value[0] === 'object' &&
429
+ 'key' in value[0] &&
430
+ 'value' in value[0]
431
+ ) {
432
+ // This is a key-value array, convert to Record
433
+ const record: Record<string, string> = {};
434
+ value.forEach(({ key: k, value: v }) => {
435
+ if (k.trim() !== '' || v.trim() !== '') {
436
+ record[k] = v;
437
+ }
438
+ });
439
+ processed[key] = record;
440
+ } else if (Array.isArray(value) && value.length === 0) {
441
+ // Empty key-value array should become empty object
442
+ const isKeyValueField = this.isKeyValueField(key);
443
+ if (isKeyValueField) {
444
+ processed[key] = {};
445
+ }
446
+ }
447
+ });
448
+
449
+ return processed;
450
+ }
451
+
452
+ private handleCancel(): void {
453
+ this.fireCustomEvent(CustomEventType.NodeEditCancelled, {});
454
+ }
455
+
456
+ private validateForm(): ValidationResult {
457
+ const errors: { [key: string]: string } = {};
458
+
459
+ if (this.action) {
460
+ // Action validation using fields configuration
461
+ const actionConfig = ACTION_CONFIG[this.action.type];
462
+
463
+ // Check if new field configuration system is available
464
+ if (actionConfig?.form) {
465
+ Object.entries(actionConfig?.form).forEach(
466
+ ([fieldName, fieldConfig]) => {
467
+ const value = this.formData[fieldName];
468
+
469
+ // Check required fields
470
+ if (
471
+ (fieldConfig as any).required &&
472
+ (!value || (Array.isArray(value) && value.length === 0))
473
+ ) {
474
+ errors[fieldName] = `${
475
+ (fieldConfig as any).label || fieldName
476
+ } is required`;
477
+ }
478
+
479
+ // Check minLength for text fields
480
+ if (
481
+ typeof value === 'string' &&
482
+ (fieldConfig as any).minLength &&
483
+ value.length < (fieldConfig as any).minLength
484
+ ) {
485
+ errors[fieldName] = `${
486
+ (fieldConfig as any).label || fieldName
487
+ } must be at least ${(fieldConfig as any).minLength} characters`;
488
+ }
489
+
490
+ // Check maxLength for text fields
491
+ if (
492
+ typeof value === 'string' &&
493
+ (fieldConfig as any).maxLength &&
494
+ value.length > (fieldConfig as any).maxLength
495
+ ) {
496
+ errors[fieldName] = `${
497
+ (fieldConfig as any).label || fieldName
498
+ } must be no more than ${
499
+ (fieldConfig as any).maxLength
500
+ } characters`;
501
+ }
502
+ }
503
+ );
504
+ }
505
+
506
+ // Run custom validation if available
507
+ if (actionConfig?.validate) {
508
+ // Convert form data back to action for validation
509
+ let actionForValidation: Action;
510
+ if (actionConfig.fromFormData) {
511
+ actionForValidation = actionConfig.fromFormData(this.formData);
512
+ } else {
513
+ actionForValidation = { ...this.action, ...this.formData } as Action;
514
+ }
515
+
516
+ const customValidation = actionConfig.validate(actionForValidation);
517
+ Object.assign(errors, customValidation.errors);
518
+ }
519
+ } else if (this.node) {
520
+ // Node validation
521
+ const nodeConfig = this.getNodeConfig();
522
+
523
+ // Check required fields from node properties
524
+ if (nodeConfig?.properties) {
525
+ Object.entries(nodeConfig.properties).forEach(
526
+ ([fieldName, fieldConfig]) => {
527
+ const value = this.formData[fieldName];
528
+
529
+ // Check required fields
530
+ if (
531
+ fieldConfig.required &&
532
+ (!value || (Array.isArray(value) && value.length === 0))
533
+ ) {
534
+ errors[fieldName] = `${
535
+ fieldConfig.label || fieldName
536
+ } is required`;
537
+ }
538
+ }
539
+ );
540
+ }
541
+ }
542
+
543
+ // Validate key-value fields for unique keys
544
+ this.validateKeyValueUniqueness(errors);
545
+
546
+ return {
547
+ valid: Object.keys(errors).length === 0,
548
+ errors
549
+ };
550
+ }
551
+
552
+ private validateKeyValueUniqueness(errors: { [key: string]: string }): void {
553
+ // The individual key-value editors will show validation errors on duplicate keys and empty keys with values
554
+ // We just need to prevent form submission when there are validation issues
555
+ Object.entries(this.formData).forEach(([fieldName, value]) => {
556
+ if (
557
+ Array.isArray(value) &&
558
+ value.length > 0 &&
559
+ typeof value[0] === 'object' &&
560
+ 'key' in value[0] &&
561
+ 'value' in value[0]
562
+ ) {
563
+ // This is a key-value array
564
+ let hasValidationErrors = false;
565
+
566
+ // Check for empty keys with values
567
+ value.forEach(({ key, value: itemValue }: any) => {
568
+ if (key.trim() === '' && itemValue.trim() !== '') {
569
+ hasValidationErrors = true;
570
+ }
571
+ });
572
+
573
+ // Check for duplicate keys (only non-empty ones)
574
+ const keys = value
575
+ .filter(({ key }: any) => key.trim() !== '') // Only check non-empty keys
576
+ .map(({ key }: any) => key.trim());
577
+
578
+ const uniqueKeys = new Set(keys);
579
+
580
+ if (keys.length !== uniqueKeys.size) {
581
+ hasValidationErrors = true;
582
+ }
583
+
584
+ if (hasValidationErrors) {
585
+ errors[fieldName] = `Please resolve validation errors to continue`;
586
+ }
587
+ }
588
+ });
589
+ }
590
+
591
+ private formDataToNode(formData: any = this.formData): Node {
592
+ if (!this.node) throw new Error('No node to update');
593
+ let updatedNode: Node = { ...this.node };
594
+
595
+ // Handle actions using action config transformations if available
596
+ if (this.node.actions && this.node.actions.length > 0) {
597
+ updatedNode.actions = this.node.actions.map((action) => {
598
+ // If we're editing a specific action, only transform that one
599
+ if (this.action && action.uuid === this.action.uuid) {
600
+ const actionConfig = ACTION_CONFIG[action.type];
601
+ if (actionConfig?.fromFormData) {
602
+ // Use action-specific form data transformation
603
+ return actionConfig.fromFormData(formData);
604
+ } else {
605
+ // Default transformation - merge form data with original action
606
+ return { ...action, ...formData };
607
+ }
608
+ } else {
609
+ // Keep other actions unchanged
610
+ return action;
611
+ }
612
+ });
613
+ }
614
+
615
+ // Handle router configuration using node config
616
+ if (this.node.router) {
617
+ const nodeConfig = this.getNodeConfig();
618
+
619
+ if (nodeConfig?.fromFormData) {
620
+ // Use node-specific form data transformation
621
+ updatedNode = nodeConfig.fromFormData(formData, updatedNode);
622
+ } else {
623
+ // Default router handling
624
+ updatedNode.router = { ...this.node.router };
625
+
626
+ // Apply form data to router fields if they exist
627
+ if (formData.result_name !== undefined) {
628
+ updatedNode.router.result_name = formData.result_name;
629
+ }
630
+
631
+ // Handle preconfigured rules from node config
632
+ if (nodeConfig?.router?.rules) {
633
+ // Build a complete new set of categories and exits based on node config
634
+ const existingCategories = updatedNode.router.categories || [];
635
+ const existingExits = updatedNode.exits || [];
636
+
637
+ const newCategories: any[] = [];
638
+ const newExits: any[] = [];
639
+
640
+ // Group rules by category name to handle multiple rules pointing to the same category
641
+ const categoryNameToRules = new Map<
642
+ string,
643
+ typeof nodeConfig.router.rules
644
+ >();
645
+ nodeConfig.router.rules.forEach((rule) => {
646
+ if (!categoryNameToRules.has(rule.categoryName)) {
647
+ categoryNameToRules.set(rule.categoryName, []);
648
+ }
649
+ categoryNameToRules.get(rule.categoryName)!.push(rule);
650
+ });
651
+
652
+ // Create categories for all unique category names
653
+ categoryNameToRules.forEach((rules, categoryName) => {
654
+ // Check if category already exists to preserve its UUID and exit_uuid
655
+ const existingCategory = existingCategories.find(
656
+ (cat) => cat.name === categoryName
657
+ );
658
+
659
+ if (existingCategory) {
660
+ // Preserve existing category and its associated exit
661
+ newCategories.push(existingCategory);
662
+ const associatedExit = existingExits.find(
663
+ (exit) => exit.uuid === existingCategory.exit_uuid
664
+ );
665
+ if (associatedExit) {
666
+ newExits.push(associatedExit);
667
+ }
668
+ } else {
669
+ // Create new category and exit
670
+ const categoryUuid = generateUUID();
671
+ const exitUuid = generateUUID();
672
+
673
+ newCategories.push({
674
+ uuid: categoryUuid,
675
+ name: categoryName,
676
+ exit_uuid: exitUuid
677
+ });
678
+
679
+ newExits.push({
680
+ uuid: exitUuid,
681
+ destination_uuid: null
682
+ });
683
+ }
684
+ });
685
+
686
+ // Add default category if specified
687
+ if (nodeConfig.router.defaultCategory) {
688
+ // Check if default category already exists in our new list
689
+ const existingDefault = newCategories.find(
690
+ (cat) => cat.name === nodeConfig.router.defaultCategory
691
+ );
692
+
693
+ if (!existingDefault) {
694
+ // Check if it exists in the original categories
695
+ const originalDefault = existingCategories.find(
696
+ (cat) => cat.name === nodeConfig.router.defaultCategory
697
+ );
698
+
699
+ if (originalDefault) {
700
+ // Preserve existing default category and its exit
701
+ newCategories.push(originalDefault);
702
+ const associatedExit = existingExits.find(
703
+ (exit) => exit.uuid === originalDefault.exit_uuid
704
+ );
705
+ if (associatedExit) {
706
+ newExits.push(associatedExit);
707
+ }
708
+ } else {
709
+ // Create new default category and exit
710
+ const categoryUuid = generateUUID();
711
+ const exitUuid = generateUUID();
712
+
713
+ newCategories.push({
714
+ uuid: categoryUuid,
715
+ name: nodeConfig.router.defaultCategory,
716
+ exit_uuid: exitUuid
717
+ });
718
+
719
+ newExits.push({
720
+ uuid: exitUuid,
721
+ destination_uuid: null
722
+ });
723
+ }
724
+ }
725
+ }
726
+
727
+ // Replace the entire categories and exits lists with our complete new sets
728
+ updatedNode.router.categories = newCategories;
729
+ updatedNode.exits = newExits;
730
+ }
731
+ }
732
+ } else {
733
+ // If no router, just apply form data to node properties
734
+ Object.keys(formData).forEach((key) => {
735
+ if (
736
+ key !== 'uuid' &&
737
+ key !== 'actions' &&
738
+ key !== 'exits' &&
739
+ key !== 'router'
740
+ ) {
741
+ (updatedNode as any)[key] = formData[key];
742
+ }
743
+ });
744
+ }
745
+
746
+ return updatedNode;
747
+ }
748
+
749
+ private formDataToAction(formData: any = this.formData): Action {
750
+ if (!this.action) throw new Error('No action to update');
751
+
752
+ // Use action config transformation if available
753
+ const actionConfig = ACTION_CONFIG[this.action.type];
754
+ if (actionConfig?.fromFormData) {
755
+ return actionConfig.fromFormData(formData);
756
+ } else {
757
+ // Apply smart select transformations in reverse and provide default 1:1 mapping
758
+ const processedFormData = this.reverseSmartSelectTransformations(
759
+ formData,
760
+ actionConfig
761
+ );
762
+ return { ...this.action, ...processedFormData };
763
+ }
764
+ }
765
+
766
+ private reverseSmartSelectTransformations(
767
+ formData: any,
768
+ actionConfig: ActionConfig
769
+ ): any {
770
+ if (!actionConfig || !actionConfig.form) return formData;
771
+ const processed = { ...formData };
772
+
773
+ Object.entries(actionConfig.form).forEach(([fieldName, fieldConfig]) => {
774
+ if (this.shouldApplySmartSelectTransformation(fieldName, fieldConfig)) {
775
+ const value = processed[fieldName];
776
+ if (
777
+ Array.isArray(value) &&
778
+ value.length > 0 &&
779
+ typeof value[0] === 'object' &&
780
+ 'value' in value[0]
781
+ ) {
782
+ // Transform select options format back to string array
783
+ processed[fieldName] = value.map(
784
+ (item: any) => item.value || item.name || item
785
+ );
786
+ }
787
+ }
788
+ });
789
+
790
+ return processed;
791
+ }
792
+
793
+ private handleFormFieldChange(propertyName: string, event: Event): void {
794
+ const target = event.target as any;
795
+ let value: any;
796
+
797
+ // Handle different component types like ActionEditor does
798
+ if (target.tagName === 'TEMBA-CHECKBOX') {
799
+ value = target.checked;
800
+ } else if (
801
+ target.tagName === 'TEMBA-SELECT' &&
802
+ (target.multi || target.emails || target.tags)
803
+ ) {
804
+ value = target.values || [];
805
+ } else if (target.values !== undefined) {
806
+ value = target.values;
807
+ } else {
808
+ value = target.value;
809
+ }
810
+
811
+ this.formData = {
812
+ ...this.formData,
813
+ [propertyName]: value
814
+ };
815
+
816
+ // Clear any existing error for this field
817
+ if (this.errors[propertyName]) {
818
+ const newErrors = { ...this.errors };
819
+ delete newErrors[propertyName];
820
+ this.errors = newErrors;
821
+ }
822
+
823
+ // Check for computed values in dependent fields
824
+ this.updateComputedFields(propertyName);
825
+
826
+ // Trigger re-render to handle conditional field visibility
827
+ this.requestUpdate();
828
+ }
829
+
830
+ private updateComputedFields(changedFieldName: string): void {
831
+ if (!this.action) return;
832
+
833
+ const config = ACTION_CONFIG[this.action.type];
834
+ if (!config?.form) return;
835
+
836
+ // Check all fields to see if any depend on the changed field
837
+ Object.entries(config.form).forEach(([fieldName, fieldConfig]) => {
838
+ if (fieldConfig.dependsOn?.includes(changedFieldName)) {
839
+ if (fieldConfig.computeValue) {
840
+ const currentValue = this.formData[fieldName];
841
+
842
+ const computedValue = fieldConfig.computeValue(
843
+ this.formData,
844
+ currentValue,
845
+ this.originalFormData
846
+ );
847
+
848
+ // Update the form data with the computed value
849
+ this.formData = {
850
+ ...this.formData,
851
+ [fieldName]: computedValue
852
+ };
853
+ }
854
+ }
855
+ });
856
+ }
857
+
858
+ private renderNewField(
859
+ fieldName: string,
860
+ config: FieldConfig,
861
+ value: any
862
+ ): TemplateResult {
863
+ // Check visibility condition
864
+ if (config.conditions?.visible) {
865
+ try {
866
+ const isVisible = config.conditions.visible(this.formData);
867
+ if (!isVisible) {
868
+ return html``;
869
+ }
870
+ } catch (error) {
871
+ console.error(`Error checking visibility for ${fieldName}:`, error);
872
+ // If there's an error, show the field by default
873
+ }
874
+ }
875
+
876
+ const errors = this.errors[fieldName] ? [this.errors[fieldName]] : [];
877
+
878
+ // Build container style with maxWidth if specified
879
+ const containerStyle = config.maxWidth
880
+ ? `max-width: ${config.maxWidth};`
881
+ : '';
882
+
883
+ const fieldContent = this.renderFieldContent(
884
+ fieldName,
885
+ config,
886
+ value,
887
+ errors
888
+ );
889
+
890
+ // Wrap in container with style if maxWidth is specified
891
+ if (containerStyle) {
892
+ return html`<div style="${containerStyle}">${fieldContent}</div>`;
893
+ }
894
+
895
+ return fieldContent;
896
+ }
897
+
898
+ private renderFieldContent(
899
+ fieldName: string,
900
+ config: FieldConfig,
901
+ value: any,
902
+ errors: string[]
903
+ ): TemplateResult {
904
+ switch (config.type) {
905
+ case 'text':
906
+ return html`<temba-textinput
907
+ name="${fieldName}"
908
+ label="${config.label}"
909
+ ?required="${config.required}"
910
+ .errors="${errors}"
911
+ .value="${value || ''}"
912
+ placeholder="${config.placeholder || ''}"
913
+ .helpText="${config.helpText || ''}"
914
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
915
+ ></temba-textinput>`;
916
+
917
+ case 'textarea': {
918
+ const textareaConfig = config as TextareaFieldConfig;
919
+ const minHeightStyle = textareaConfig.minHeight
920
+ ? `--textarea-min-height: ${textareaConfig.minHeight}px;`
921
+ : '';
922
+
923
+ if (config.evaluated) {
924
+ return html`<temba-completion
925
+ name="${fieldName}"
926
+ label="${config.label}"
927
+ ?required="${config.required}"
928
+ .errors="${errors}"
929
+ .value="${value || ''}"
930
+ placeholder="${config.placeholder || ''}"
931
+ textarea
932
+ expressions="session"
933
+ style="${minHeightStyle}"
934
+ .helpText="${config.helpText || ''}"
935
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
936
+ ></temba-completion>`;
937
+ } else {
938
+ return html`<temba-textinput
939
+ name="${fieldName}"
940
+ label="${config.label}"
941
+ ?required="${config.required}"
942
+ .errors="${errors}"
943
+ .value="${value || ''}"
944
+ placeholder="${config.placeholder || ''}"
945
+ textarea
946
+ .rows="${textareaConfig.rows || 3}"
947
+ style="${minHeightStyle}"
948
+ .helpText="${config.helpText || ''}"
949
+ @input="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
950
+ ></temba-textinput>`;
951
+ }
952
+ }
953
+
954
+ case 'select': {
955
+ const selectConfig = config as SelectFieldConfig;
956
+ return html`<temba-select
957
+ name="${fieldName}"
958
+ label="${config.label}"
959
+ ?required="${config.required}"
960
+ .errors="${errors}"
961
+ .values="${value || (selectConfig.multi ? [] : '')}"
962
+ ?multi="${selectConfig.multi}"
963
+ ?searchable="${selectConfig.searchable}"
964
+ ?tags="${selectConfig.tags}"
965
+ ?emails="${selectConfig.emails}"
966
+ placeholder="${selectConfig.placeholder || ''}"
967
+ maxItems="${selectConfig.maxItems || 0}"
968
+ valueKey="${selectConfig.valueKey || 'value'}"
969
+ nameKey="${selectConfig.nameKey || 'name'}"
970
+ endpoint="${selectConfig.endpoint || ''}"
971
+ .helpText="${config.helpText || ''}"
972
+ @change="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
973
+ >
974
+ ${selectConfig.options?.map((option: any) => {
975
+ if (typeof option === 'string') {
976
+ return html`<temba-option
977
+ name="${option}"
978
+ value="${option}"
979
+ ></temba-option>`;
980
+ } else {
981
+ return html`<temba-option
982
+ name="${option.label || option.name}"
983
+ value="${option.value}"
984
+ ></temba-option>`;
985
+ }
986
+ })}
987
+ </temba-select>`;
988
+ }
989
+
990
+ case 'key-value':
991
+ return html`<div class="form-field">
992
+ <label>${config.label}${config.required ? ' *' : ''}</label>
993
+ <temba-key-value-editor
994
+ name="${fieldName}"
995
+ .value="${value || []}"
996
+ .sortable="${config.sortable}"
997
+ .keyPlaceholder="${config.keyPlaceholder || 'Key'}"
998
+ .valuePlaceholder="${config.valuePlaceholder || 'Value'}"
999
+ .minRows="${config.minRows || 0}"
1000
+ @change="${(e: CustomEvent) => {
1001
+ if (e.detail) {
1002
+ this.handleNewFieldChange(fieldName, e.detail.value);
1003
+ }
1004
+ }}"
1005
+ ></temba-key-value-editor>
1006
+ ${errors.length
1007
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1008
+ : ''}
1009
+ </div>`;
1010
+
1011
+ case 'array':
1012
+ return html`<div class="form-field">
1013
+ <label>${config.label}${config.required ? ' *' : ''}</label>
1014
+ <temba-array-editor
1015
+ .value="${value || []}"
1016
+ .itemConfig="${config.itemConfig}"
1017
+ .sortable="${config.sortable}"
1018
+ .itemLabel="${config.itemLabel || 'Item'}"
1019
+ .minItems="${config.minItems || 0}"
1020
+ .onItemChange="${config.onItemChange}"
1021
+ @change="${(e: CustomEvent) =>
1022
+ this.handleNewFieldChange(fieldName, e.detail.value)}"
1023
+ ></temba-array-editor>
1024
+ ${errors.length
1025
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1026
+ : ''}
1027
+ </div>`;
1028
+
1029
+ case 'checkbox': {
1030
+ const checkboxConfig = config as CheckboxFieldConfig;
1031
+ return html`<div class="form-field">
1032
+ <temba-checkbox
1033
+ name="${fieldName}"
1034
+ label="${config.label}"
1035
+ .helpText="${config.helpText || ''}"
1036
+ ?required="${config.required}"
1037
+ .errors="${errors}"
1038
+ ?checked="${value || false}"
1039
+ size="${checkboxConfig.size || 1.2}"
1040
+ animateChange="${checkboxConfig.animateChange || 'pulse'}"
1041
+ @change="${(e: Event) => this.handleFormFieldChange(fieldName, e)}"
1042
+ ></temba-checkbox>
1043
+ ${errors.length
1044
+ ? html`<div class="field-errors">${errors.join(', ')}</div>`
1045
+ : ''}
1046
+ </div>`;
1047
+ }
1048
+
1049
+ default:
1050
+ return html`<div>Unsupported field type: ${(config as any).type}</div>`;
1051
+ }
1052
+ }
1053
+
1054
+ private handleGroupToggle(groupLabel: string): void {
1055
+ this.groupCollapseState = {
1056
+ ...this.groupCollapseState,
1057
+ [groupLabel]: !this.groupCollapseState[groupLabel]
1058
+ };
1059
+ }
1060
+
1061
+ private expandGroupsWithErrors(errors: { [key: string]: string }): void {
1062
+ if (!this.action) return;
1063
+
1064
+ const config = ACTION_CONFIG[this.action.type];
1065
+ if (!config?.layout) return;
1066
+
1067
+ const errorFields = new Set(Object.keys(errors));
1068
+ this.expandGroupsWithErrorsRecursive(config.layout, errorFields);
1069
+ }
1070
+
1071
+ private expandGroupsWithErrorsRecursive(
1072
+ items: LayoutItem[],
1073
+ errorFields: Set<string>
1074
+ ): void {
1075
+ items.forEach((item) => {
1076
+ if (typeof item === 'object' && item.type === 'group') {
1077
+ const fieldsInGroup = this.collectFieldsFromItems(item.items);
1078
+ const groupHasErrors = fieldsInGroup.some((fieldName) =>
1079
+ errorFields.has(fieldName)
1080
+ );
1081
+
1082
+ if (groupHasErrors) {
1083
+ // Expand this group
1084
+ this.groupCollapseState = {
1085
+ ...this.groupCollapseState,
1086
+ [item.label]: false
1087
+ };
1088
+ }
1089
+
1090
+ // Recursively check nested items
1091
+ this.expandGroupsWithErrorsRecursive(item.items, errorFields);
1092
+ } else if (typeof item === 'object' && item.type === 'row') {
1093
+ // Recursively check items in rows
1094
+ this.expandGroupsWithErrorsRecursive(item.items, errorFields);
1095
+ }
1096
+ });
1097
+ }
1098
+
1099
+ private renderLayoutItem(
1100
+ item: LayoutItem,
1101
+ config: ActionConfig,
1102
+ renderedFields: Set<string>
1103
+ ): TemplateResult {
1104
+ if (typeof item === 'string') {
1105
+ // String shorthand for field
1106
+ return this.renderLayoutItem(
1107
+ { type: 'field', field: item },
1108
+ config,
1109
+ renderedFields
1110
+ );
1111
+ }
1112
+
1113
+ switch (item.type) {
1114
+ case 'field':
1115
+ if (config.form![item.field] && !renderedFields.has(item.field)) {
1116
+ renderedFields.add(item.field);
1117
+ return this.renderNewField(
1118
+ item.field,
1119
+ config.form![item.field] as FieldConfig,
1120
+ this.formData[item.field]
1121
+ );
1122
+ }
1123
+ return html``;
1124
+
1125
+ case 'row':
1126
+ return this.renderRow(item, config, renderedFields);
1127
+
1128
+ case 'group':
1129
+ return this.renderGroup(item, config, renderedFields);
1130
+
1131
+ default:
1132
+ return html``;
1133
+ }
1134
+ }
1135
+
1136
+ private renderRow(
1137
+ rowConfig: RowLayoutConfig,
1138
+ config: ActionConfig,
1139
+ renderedFields: Set<string>
1140
+ ): TemplateResult {
1141
+ const { items, gap = '1rem' } = rowConfig;
1142
+
1143
+ // Collect all fields from this row for width calculations
1144
+ const fieldsInRow = this.collectFieldsFromItems(items);
1145
+ const validFields = fieldsInRow.filter(
1146
+ (fieldName) => config.form?.[fieldName]
1147
+ );
1148
+
1149
+ if (validFields.length === 0) {
1150
+ return html``;
1151
+ }
1152
+
1153
+ // Calculate grid template columns based on field maxWidth constraints
1154
+ const columns = validFields.map((fieldName) => {
1155
+ const fieldConfig = config.form![fieldName];
1156
+ return fieldConfig.maxWidth || '1fr';
1157
+ });
1158
+
1159
+ return html`
1160
+ <div
1161
+ class="form-row"
1162
+ style="display: grid; grid-template-columns: ${columns.join(
1163
+ ' '
1164
+ )}; gap: ${gap};"
1165
+ >
1166
+ ${items.map((item) =>
1167
+ this.renderLayoutItem(item, config, renderedFields)
1168
+ )}
1169
+ </div>
1170
+ `;
1171
+ }
1172
+
1173
+ private renderGroup(
1174
+ groupConfig: GroupLayoutConfig,
1175
+ config: ActionConfig,
1176
+ renderedFields: Set<string>
1177
+ ): TemplateResult {
1178
+ const {
1179
+ label,
1180
+ items,
1181
+ collapsible = false,
1182
+ collapsed = false,
1183
+ helpText
1184
+ } = groupConfig;
1185
+
1186
+ // Initialize collapse state if not set
1187
+ if (collapsible && !(label in this.groupCollapseState)) {
1188
+ this.groupCollapseState = {
1189
+ ...this.groupCollapseState,
1190
+ [label]: collapsed
1191
+ };
1192
+ }
1193
+
1194
+ const isCollapsed = collapsible
1195
+ ? this.groupCollapseState[label] ?? collapsed
1196
+ : false;
1197
+
1198
+ // Check if any field in this group has errors
1199
+ const fieldsInGroup = this.collectFieldsFromItems(items);
1200
+ const groupHasErrors = fieldsInGroup.some(
1201
+ (fieldName) => this.errors[fieldName]
1202
+ );
1203
+
1204
+ return html`
1205
+ <div
1206
+ class="form-group ${collapsible ? 'collapsible' : ''} ${groupHasErrors
1207
+ ? 'has-errors'
1208
+ : ''}"
1209
+ >
1210
+ <div
1211
+ class="form-group-header ${collapsible ? 'clickable' : ''}"
1212
+ @click=${collapsible
1213
+ ? () => this.handleGroupToggle(label)
1214
+ : undefined}
1215
+ >
1216
+ <div class="form-group-info">
1217
+ <div class="form-group-title">${label}</div>
1218
+ ${helpText
1219
+ ? html`<div class="form-group-help">${helpText}</div>`
1220
+ : ''}
1221
+ </div>
1222
+ ${groupHasErrors
1223
+ ? html`<temba-icon
1224
+ name="alert_warning"
1225
+ class="group-error-icon"
1226
+ size="1.5"
1227
+ ></temba-icon>`
1228
+ : ''}
1229
+ ${collapsible && !groupHasErrors
1230
+ ? html`<temba-icon
1231
+ name="arrow_right"
1232
+ size="1.5"
1233
+ class="group-toggle-icon ${isCollapsed
1234
+ ? 'collapsed'
1235
+ : 'expanded'}"
1236
+ ></temba-icon>`
1237
+ : ''}
1238
+ </div>
1239
+ <div
1240
+ class="form-group-content ${isCollapsed ? 'collapsed' : 'expanded'}"
1241
+ >
1242
+ ${items.map((item) =>
1243
+ this.renderLayoutItem(item, config, renderedFields)
1244
+ )}
1245
+ </div>
1246
+ </div>
1247
+ `;
1248
+ }
1249
+
1250
+ private collectFieldsFromItems(items: LayoutItem[]): string[] {
1251
+ const fields: string[] = [];
1252
+
1253
+ items.forEach((item) => {
1254
+ if (typeof item === 'string') {
1255
+ fields.push(item);
1256
+ } else if (item.type === 'field') {
1257
+ fields.push(item.field);
1258
+ } else if (item.type === 'row') {
1259
+ fields.push(...this.collectFieldsFromItems(item.items));
1260
+ } else if (item.type === 'group') {
1261
+ fields.push(...this.collectFieldsFromItems(item.items));
1262
+ }
1263
+ });
1264
+
1265
+ return fields;
1266
+ }
1267
+
1268
+ private renderFieldRow(
1269
+ rowConfig: RowLayoutConfig,
1270
+ config: ActionConfig
1271
+ ): TemplateResult {
1272
+ // This method is deprecated - use renderRow instead
1273
+ return this.renderRow(rowConfig, config, new Set());
1274
+ }
1275
+
1276
+ private renderFieldGroup(
1277
+ groupConfig: GroupLayoutConfig,
1278
+ config: ActionConfig
1279
+ ): TemplateResult {
1280
+ // This method is deprecated - use renderGroup instead
1281
+ return this.renderGroup(groupConfig, config, new Set());
1282
+ }
1283
+
1284
+ private handleNewFieldChange(fieldName: string, value: any) {
1285
+ this.formData = {
1286
+ ...this.formData,
1287
+ [fieldName]: value
1288
+ };
1289
+
1290
+ // Clear any existing error for this field
1291
+ if (this.errors[fieldName]) {
1292
+ const newErrors = { ...this.errors };
1293
+ delete newErrors[fieldName];
1294
+ this.errors = newErrors;
1295
+ }
1296
+
1297
+ // Trigger re-render
1298
+ this.requestUpdate();
1299
+ }
1300
+
1301
+ private renderFields(): TemplateResult {
1302
+ if (!this.action) {
1303
+ return html` <div>No action selected</div> `;
1304
+ }
1305
+
1306
+ const config = ACTION_CONFIG[this.action.type];
1307
+ if (!config) {
1308
+ return html` <div>No configuration available for this action</div> `;
1309
+ }
1310
+
1311
+ // Use the new fields configuration system
1312
+ if (config.form) {
1313
+ // If layout is specified, use it
1314
+ if (config.layout) {
1315
+ const renderedFields = new Set<string>();
1316
+
1317
+ return html`
1318
+ ${config.layout.map((item) =>
1319
+ this.renderLayoutItem(item, config, renderedFields)
1320
+ )}
1321
+ ${
1322
+ /* Render any fields not explicitly placed in layout */
1323
+ Object.entries(config.form).map(([fieldName, fieldConfig]) => {
1324
+ if (!renderedFields.has(fieldName)) {
1325
+ return this.renderNewField(
1326
+ fieldName,
1327
+ fieldConfig as FieldConfig,
1328
+ this.formData[fieldName]
1329
+ );
1330
+ }
1331
+ return html``;
1332
+ })
1333
+ }
1334
+ `;
1335
+ } else {
1336
+ // Default rendering without layout
1337
+ return html`
1338
+ ${Object.entries(config.form).map(([fieldName, fieldConfig]) =>
1339
+ this.renderNewField(
1340
+ fieldName,
1341
+ fieldConfig as FieldConfig,
1342
+ this.formData[fieldName]
1343
+ )
1344
+ )}
1345
+ `;
1346
+ }
1347
+ }
1348
+
1349
+ return html` <div>No form configuration available</div> `;
1350
+ }
1351
+
1352
+ private renderActionSection(): TemplateResult {
1353
+ if (!this.node || this.node.actions.length === 0) {
1354
+ return html``;
1355
+ }
1356
+
1357
+ const nodeConfig = this.getNodeConfig();
1358
+
1359
+ // If node has an action config, defer to ActionEditor
1360
+ if (nodeConfig?.action) {
1361
+ const action = this.node.actions[0]; // Assume single action for now
1362
+
1363
+ return html`
1364
+ <div class="action-section">
1365
+ <h3>Action Configuration</h3>
1366
+ <div class="action-preview">
1367
+ <p><strong>Type:</strong> ${action.type}</p>
1368
+ <p><em>Action details will be editable here</em></p>
1369
+ </div>
1370
+ </div>
1371
+ `;
1372
+ }
1373
+
1374
+ return html``;
1375
+ }
1376
+
1377
+ private renderRouterSection(): TemplateResult {
1378
+ if (!this.node?.router) {
1379
+ return html``;
1380
+ }
1381
+
1382
+ const nodeConfig = this.getNodeConfig();
1383
+
1384
+ return html`
1385
+ <div class="router-section">
1386
+ <h3>Router Configuration</h3>
1387
+ ${nodeConfig?.router
1388
+ ? this.renderRouterConfig()
1389
+ : html`<p>Basic router (no advanced configuration)</p>`}
1390
+ </div>
1391
+ `;
1392
+ }
1393
+
1394
+ private renderRouterConfig(): TemplateResult {
1395
+ const nodeConfig = this.getNodeConfig();
1396
+ if (!nodeConfig?.router) return html``;
1397
+
1398
+ // Render router configuration based on node config
1399
+ // This is where you'd render rule and category editors
1400
+ return html`
1401
+ <div class="router-config">
1402
+ <p><strong>Type:</strong> ${nodeConfig.router.type}</p>
1403
+ ${nodeConfig.router.rules
1404
+ ? html`
1405
+ <div class="rules-section">
1406
+ <h4>Rules</h4>
1407
+ <!-- Future: Render rule editor based on nodeConfig.router.rules -->
1408
+ <p><em>Rule editing will be implemented here</em></p>
1409
+ </div>
1410
+ `
1411
+ : ''}
1412
+ </div>
1413
+ `;
1414
+ }
1415
+
1416
+ render(): TemplateResult {
1417
+ if (!this.isOpen) {
1418
+ return html``;
1419
+ }
1420
+
1421
+ const headerColor = this.getHeaderColor();
1422
+ const nodeConfig = this.getNodeConfig();
1423
+ const actionConfig = ACTION_CONFIG[this.action?.type];
1424
+
1425
+ return html`
1426
+ <temba-dialog
1427
+ header="${actionConfig?.name || nodeConfig?.name || 'Edit'}"
1428
+ .open="${this.isOpen}"
1429
+ @temba-button-clicked=${this.handleDialogButtonClick}
1430
+ primaryButtonName="Save"
1431
+ cancelButtonName="Cancel"
1432
+ style="--header-bg: ${headerColor}"
1433
+ >
1434
+ <div class="node-editor-form">
1435
+ ${this.renderFields()}
1436
+ ${nodeConfig?.router?.configurable
1437
+ ? this.renderRouterSection()
1438
+ : null}
1439
+ </div>
1440
+ </temba-dialog>
1441
+ `;
1442
+ }
1443
+ }