@nyaruka/temba-components 0.131.3 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/demo/components/flow/example.html +1 -0
  3. package/demo/static/css/tailwind.css +30019 -0
  4. package/dist/temba-components.js +449 -417
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/display/Chat.js +26 -6
  7. package/out-tsc/src/display/Chat.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +4 -4
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/events.js.map +1 -1
  11. package/out-tsc/src/flow/CanvasNode.js +124 -58
  12. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  13. package/out-tsc/src/flow/Editor.js +89 -40
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/flow/NodeTypeSelector.js +8 -2
  16. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  17. package/out-tsc/src/flow/config.js +17 -4
  18. package/out-tsc/src/flow/config.js.map +1 -1
  19. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +2 -2
  20. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  21. package/out-tsc/src/flow/nodes/split_by_run_result.js +6 -0
  22. package/out-tsc/src/flow/nodes/split_by_run_result.js.map +1 -1
  23. package/out-tsc/src/flow/types.js.map +1 -1
  24. package/out-tsc/src/layout/FloatingWindow.js +1 -2
  25. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  26. package/out-tsc/src/list/ContentMenu.js +1 -0
  27. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  28. package/out-tsc/src/list/SortableList.js +3 -2
  29. package/out-tsc/src/list/SortableList.js.map +1 -1
  30. package/out-tsc/src/live/ContactChat.js +105 -69
  31. package/out-tsc/src/live/ContactChat.js.map +1 -1
  32. package/out-tsc/src/store/AppState.js +39 -1
  33. package/out-tsc/src/store/AppState.js.map +1 -1
  34. package/out-tsc/src/utils.js +3 -3
  35. package/out-tsc/src/utils.js.map +1 -1
  36. package/out-tsc/test/ActionHelper.js +6 -5
  37. package/out-tsc/test/ActionHelper.js.map +1 -1
  38. package/out-tsc/test/actions/send_broadcast.test.js +1 -1
  39. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  40. package/out-tsc/test/nodes/split_by_run_result.test.js +83 -0
  41. package/out-tsc/test/nodes/split_by_run_result.test.js.map +1 -1
  42. package/out-tsc/test/temba-backwards-compatibility.test.js +30 -0
  43. package/out-tsc/test/temba-backwards-compatibility.test.js.map +1 -0
  44. package/out-tsc/test/temba-contact-chat.test.js +28 -13
  45. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  46. package/out-tsc/test/temba-floating-window.test.js +0 -2
  47. package/out-tsc/test/temba-floating-window.test.js.map +1 -1
  48. package/out-tsc/test/temba-flow-editor-node.test.js +109 -0
  49. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  50. package/out-tsc/test/temba-localization.test.js +24 -5
  51. package/out-tsc/test/temba-localization.test.js.map +1 -1
  52. package/out-tsc/test/temba-node-type-selector.test.js +70 -3
  53. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  54. package/out-tsc/test/temba-utils-uuid.test.js +45 -1
  55. package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
  56. package/out-tsc/test/utils.test.js +3 -3
  57. package/out-tsc/test/utils.test.js.map +1 -1
  58. package/package.json +1 -1
  59. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  61. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  62. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  63. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  65. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  66. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  67. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  68. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  69. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  70. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  71. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  72. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  73. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  74. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  75. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  76. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  77. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  78. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  79. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  80. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  81. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  82. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  83. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  84. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  86. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  87. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  88. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  89. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  90. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  91. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  92. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  93. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  94. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  95. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  96. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  97. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  98. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  99. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  100. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  101. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  102. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  103. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  104. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  105. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  106. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  107. package/screenshots/truth/contacts/chat-failure.png +0 -0
  108. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  109. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  110. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  111. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  112. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  113. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  114. package/screenshots/truth/floating-tab/default.png +0 -0
  115. package/screenshots/truth/floating-tab/gray.png +0 -0
  116. package/screenshots/truth/floating-tab/green.png +0 -0
  117. package/screenshots/truth/floating-tab/hover.png +0 -0
  118. package/screenshots/truth/floating-tab/purple.png +0 -0
  119. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  120. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  121. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  122. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  123. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  124. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  125. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  126. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  127. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  128. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  129. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  130. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  131. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  132. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  141. package/src/display/Chat.ts +29 -7
  142. package/src/display/FloatingTab.ts +4 -4
  143. package/src/events.ts +1 -4
  144. package/src/flow/CanvasNode.ts +130 -57
  145. package/src/flow/Editor.ts +107 -40
  146. package/src/flow/NodeTypeSelector.ts +7 -1
  147. package/src/flow/config.ts +22 -4
  148. package/src/flow/nodes/split_by_llm_categorize.ts +2 -8
  149. package/src/flow/nodes/split_by_run_result.ts +7 -0
  150. package/src/flow/types.ts +2 -0
  151. package/src/layout/FloatingWindow.ts +1 -3
  152. package/src/list/ContentMenu.ts +1 -0
  153. package/src/list/SortableList.ts +3 -2
  154. package/src/live/ContactChat.ts +112 -78
  155. package/src/store/AppState.ts +53 -1
  156. package/src/utils.ts +3 -3
  157. package/test/ActionHelper.ts +13 -5
  158. package/test/actions/send_broadcast.test.ts +2 -1
  159. package/test/nodes/split_by_run_result.test.ts +99 -0
  160. package/test/temba-backwards-compatibility.test.ts +37 -0
  161. package/test/temba-contact-chat.test.ts +28 -13
  162. package/test/temba-floating-window.test.ts +0 -2
  163. package/test/temba-flow-editor-node.test.ts +129 -0
  164. package/test/temba-localization.test.ts +29 -5
  165. package/test/temba-node-type-selector.test.ts +89 -3
  166. package/test/temba-utils-uuid.test.ts +61 -1
  167. package/test/utils.test.ts +8 -3
  168. package/test-assets/contacts/history.json +22 -9
  169. package/web-test-runner.config.mjs +3 -3
@@ -76,6 +76,10 @@ export class CanvasNode extends RapidElement {
76
76
  actionHeight: number;
77
77
  } | null = null;
78
78
 
79
+ // Track if we're showing a placeholder for our own last action being dragged out
80
+ private showLastActionPlaceholder = false;
81
+ private lastActionPlaceholderHeight = 60;
82
+
79
83
  static get styles() {
80
84
  return css`
81
85
 
@@ -119,6 +123,7 @@ export class CanvasNode extends RapidElement {
119
123
 
120
124
  .action .cn-title:hover .remove-button,
121
125
  .router:hover .remove-button {
126
+ visibility: visible;
122
127
  opacity: 0.7;
123
128
  }
124
129
 
@@ -135,7 +140,7 @@ export class CanvasNode extends RapidElement {
135
140
  .remove-button {
136
141
  background: transparent;
137
142
  color: white;
138
- opacity: 0;
143
+ visibility: hidden;
139
144
  cursor: pointer;
140
145
  font-size: 1em;
141
146
  font-weight: 600;
@@ -143,16 +148,22 @@ export class CanvasNode extends RapidElement {
143
148
  z-index: 10;
144
149
  transition: all 100ms ease-in-out;
145
150
  align-self: center;
146
- padding:0.25em;
151
+ margin-right:0.15em;
147
152
  border: 0px solid red;
148
153
  width: 1em;
149
154
  pointer-events: auto; /* Ensure remove button can receive events */
150
155
  }
151
156
 
152
157
  .remove-button:hover {
158
+ visibility: visible;
153
159
  opacity: 1;
154
160
  }
155
161
 
162
+ .translating-hidden {
163
+ visibility: hidden !important;
164
+ pointer-events: none !important;
165
+ }
166
+
156
167
  .action.sortable {
157
168
  display: flex;
158
169
  align-items: stretch;
@@ -164,6 +175,7 @@ export class CanvasNode extends RapidElement {
164
175
  flex-direction: column;
165
176
  min-width: 0; /* Allow flex item to shrink below its content size */
166
177
  overflow: hidden;
178
+ background: #fff;
167
179
  }
168
180
 
169
181
  .action .body {
@@ -194,7 +206,7 @@ export class CanvasNode extends RapidElement {
194
206
  }
195
207
 
196
208
  .action .drag-handle {
197
- opacity: 0;
209
+ visibility: hidden;
198
210
  transition: all 200ms ease-in-out;
199
211
  cursor: move;
200
212
  background: rgba(0, 0, 0, 0.02);
@@ -209,6 +221,7 @@ export class CanvasNode extends RapidElement {
209
221
  }
210
222
 
211
223
  .action:hover .drag-handle {
224
+ visibility: visible;
212
225
  opacity: 0.7;
213
226
 
214
227
 
@@ -219,6 +232,7 @@ export class CanvasNode extends RapidElement {
219
232
  }
220
233
 
221
234
  .action .drag-handle:hover {
235
+ visibility: visible;
222
236
  opacity: 1;
223
237
 
224
238
  }
@@ -422,6 +436,18 @@ export class CanvasNode extends RapidElement {
422
436
  opacity: 1 !important;
423
437
  transform: scale(1.1);
424
438
  }
439
+
440
+ .empty-node-placeholder {
441
+ height: 60px;
442
+ background: #f3f4f6;
443
+ border: 2px dashed #d1d5db;
444
+ border-radius: var(--curvature);
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ color: #9ca3af;
449
+ font-size: 0.9em;
450
+ }
425
451
  }`;
426
452
  }
427
453
 
@@ -734,6 +760,8 @@ export class CanvasNode extends RapidElement {
734
760
  }
735
761
 
736
762
  // Fire the node deleted event
763
+ // The Editor will handle cleanup (Plumber connections) and call store.removeNodes()
764
+ // The store's removeNodes method handles rerouting of connections
737
765
  this.fireCustomEvent(CustomEventType.NodeDeleted, {
738
766
  uuid: this.node.uuid
739
767
  });
@@ -742,6 +770,12 @@ export class CanvasNode extends RapidElement {
742
770
  private handleActionOrderChanged(event: CustomEvent) {
743
771
  const [fromIdx, toIdx] = event.detail.swap;
744
772
 
773
+ // If we have an external drag in progress, ignore internal order changes
774
+ // as they'll be handled by the external drop handler
775
+ if (this.externalDragInfo) {
776
+ return;
777
+ }
778
+
745
779
  // swap our actions
746
780
  const newActions = [...this.node.actions];
747
781
  const movedAction = newActions.splice(fromIdx, 1)[0];
@@ -769,6 +803,13 @@ export class CanvasNode extends RapidElement {
769
803
  // Fallback to a reasonable default
770
804
  this.draggedActionHeight = 60;
771
805
  }
806
+
807
+ // If this is the last action, show placeholder
808
+ if (this.node.actions.length === 1) {
809
+ this.showLastActionPlaceholder = true;
810
+ this.lastActionPlaceholderHeight = this.draggedActionHeight;
811
+ this.requestUpdate();
812
+ }
772
813
  }
773
814
 
774
815
  private handleActionDragExternal(event: CustomEvent) {
@@ -785,6 +826,9 @@ export class CanvasNode extends RapidElement {
785
826
  const actionIndex = parseInt(splitId[1], 10);
786
827
  const action = this.node.actions[actionIndex];
787
828
 
829
+ // Check if this is the last action
830
+ const isLastAction = this.node.actions.length === 1;
831
+
788
832
  // fire event to editor to show canvas drop preview, including the captured height
789
833
  this.fireCustomEvent(CustomEventType.DragExternal, {
790
834
  action,
@@ -792,7 +836,8 @@ export class CanvasNode extends RapidElement {
792
836
  actionIndex,
793
837
  mouseX: event.detail.mouseX,
794
838
  mouseY: event.detail.mouseY,
795
- actionHeight: this.draggedActionHeight
839
+ actionHeight: this.draggedActionHeight,
840
+ isLastAction
796
841
  });
797
842
  }
798
843
 
@@ -807,6 +852,9 @@ export class CanvasNode extends RapidElement {
807
852
  private handleActionDragStop(event: CustomEvent) {
808
853
  const isExternal = event.detail.isExternal;
809
854
 
855
+ // Clear last action placeholder when drag stops
856
+ this.showLastActionPlaceholder = false;
857
+
810
858
  if (isExternal) {
811
859
  // stop propagation of the original event from SortableList
812
860
  event.stopPropagation();
@@ -821,16 +869,23 @@ export class CanvasNode extends RapidElement {
821
869
  const actionIndex = parseInt(split[1], 10);
822
870
  const action = this.node.actions[actionIndex];
823
871
 
824
- // fire event to editor to create new node
872
+ // Check if this is the last action in the node
873
+ const isLastAction = this.node.actions.length === 1;
874
+
875
+ // Always fire the DragStop event so the Editor can handle drops on other nodes
876
+ // The Editor will decide whether to create a new node or drop on existing node
825
877
  this.fireCustomEvent(CustomEventType.DragStop, {
826
878
  action,
827
879
  nodeUuid: this.node.uuid,
828
880
  actionIndex,
829
881
  isExternal: true,
882
+ isLastAction,
830
883
  mouseX: event.detail.mouseX,
831
884
  mouseY: event.detail.mouseY
832
885
  });
833
886
  }
887
+
888
+ this.requestUpdate();
834
889
  }
835
890
 
836
891
  private handleActionMouseDown(event: MouseEvent, action: Action): void {
@@ -1141,7 +1196,6 @@ export class CanvasNode extends RapidElement {
1141
1196
  // Clear external drag state
1142
1197
  this.externalDragInfo = null;
1143
1198
 
1144
- // Remove the action from the source node
1145
1199
  const store = getStore();
1146
1200
  if (!store) return;
1147
1201
 
@@ -1152,33 +1206,36 @@ export class CanvasNode extends RapidElement {
1152
1206
  (n) => n.uuid === sourceNodeUuid
1153
1207
  );
1154
1208
 
1155
- if (sourceNode) {
1156
- const updatedSourceActions = sourceNode.actions.filter(
1157
- (_a, idx) => idx !== actionIndex
1158
- );
1159
-
1160
- // If source node has no actions left, remove it
1161
- if (updatedSourceActions.length === 0) {
1162
- this.fireCustomEvent(CustomEventType.NodeDeleted, {
1163
- uuid: sourceNodeUuid
1164
- });
1165
- } else {
1166
- // Update source node
1167
- const updatedSourceNode = {
1168
- ...sourceNode,
1169
- actions: updatedSourceActions
1170
- };
1171
- getStore()?.getState().updateNode(sourceNodeUuid, updatedSourceNode);
1172
- }
1173
- }
1209
+ if (!sourceNode) return;
1174
1210
 
1175
- // Add the action to this node at the calculated position
1211
+ // IMPORTANT: Add the action to this node FIRST, before removing from source
1212
+ // This ensures we don't lose the action if the source node gets deleted
1176
1213
  const newActions = [...this.node.actions];
1177
1214
  newActions.splice(dropIndex, 0, action);
1178
1215
 
1179
1216
  const updatedNode = { ...this.node, actions: newActions };
1180
1217
  getStore()?.getState().updateNode(this.node.uuid, updatedNode);
1181
1218
 
1219
+ // Now remove the action from the source node
1220
+ const updatedSourceActions = sourceNode.actions.filter(
1221
+ (_a, idx) => idx !== actionIndex
1222
+ );
1223
+
1224
+ // If source node has no actions left, remove it
1225
+ if (updatedSourceActions.length === 0) {
1226
+ // Fire event to Editor so it can clean up jsPlumb connections properly
1227
+ this.fireCustomEvent(CustomEventType.NodeDeleted, {
1228
+ uuid: sourceNodeUuid
1229
+ });
1230
+ } else {
1231
+ // Update source node
1232
+ const updatedSourceNode = {
1233
+ ...sourceNode,
1234
+ actions: updatedSourceActions
1235
+ };
1236
+ getStore()?.getState().updateNode(sourceNodeUuid, updatedSourceNode);
1237
+ }
1238
+
1182
1239
  // Request update
1183
1240
  this.requestUpdate();
1184
1241
  }
@@ -1193,21 +1250,31 @@ export class CanvasNode extends RapidElement {
1193
1250
  ? ACTION_GROUP_METADATA[config.group]?.color
1194
1251
  : '#aaaaaa';
1195
1252
  return html`<div class="cn-title" style="background:${color}">
1196
- ${!this.isTranslating && this.node?.actions?.length > 1
1197
- ? html`<temba-icon class="drag-handle" name="sort"></temba-icon>`
1253
+ ${this.ui?.type === 'execute_actions'
1254
+ ? html`<temba-icon
1255
+ class="drag-handle ${this.isTranslating
1256
+ ? 'translating-hidden'
1257
+ : ''}"
1258
+ name="sort"
1259
+ ></temba-icon>`
1260
+ : this.node?.actions?.length > 1
1261
+ ? html`<temba-icon
1262
+ class="drag-handle ${this.isTranslating
1263
+ ? 'translating-hidden'
1264
+ : ''}"
1265
+ name="sort"
1266
+ ></temba-icon>`
1198
1267
  : html`<div class="title-spacer"></div>`}
1199
1268
 
1200
1269
  <div class="name">${isRemoving ? 'Remove?' : config.name}</div>
1201
- ${!this.isTranslating
1202
- ? html`<div
1203
- class="remove-button"
1204
- @click=${(e: MouseEvent) =>
1205
- this.handleActionRemoveClick(e, action, index)}
1206
- title="Remove action"
1207
- >
1208
-
1209
- </div>`
1210
- : html`<div class="title-spacer"></div>`}
1270
+ <div
1271
+ class="remove-button ${this.isTranslating ? 'translating-hidden' : ''}"
1272
+ @click=${(e: MouseEvent) =>
1273
+ this.handleActionRemoveClick(e, action, index)}
1274
+ title="Remove action"
1275
+ >
1276
+
1277
+ </div>
1211
1278
  </div>`;
1212
1279
  }
1213
1280
 
@@ -1234,15 +1301,13 @@ export class CanvasNode extends RapidElement {
1234
1301
  ? config.renderTitle(node, ui)
1235
1302
  : html`${config.name}`}
1236
1303
  </div>
1237
- ${!this.isTranslating
1238
- ? html`<div
1239
- class="remove-button"
1240
- @click=${(e: MouseEvent) => this.handleNodeRemoveClick(e)}
1241
- title="Remove node"
1242
- >
1243
-
1244
- </div>`
1245
- : html`<div class="title-spacer"></div>`}
1304
+ <div
1305
+ class="remove-button ${this.isTranslating ? 'translating-hidden' : ''}"
1306
+ @click=${(e: MouseEvent) => this.handleNodeRemoveClick(e)}
1307
+ title="Remove node"
1308
+ >
1309
+
1310
+ </div>
1246
1311
  </div>`;
1247
1312
  }
1248
1313
 
@@ -1528,19 +1593,27 @@ export class CanvasNode extends RapidElement {
1528
1593
  : this.node.actions.length > 0
1529
1594
  ? this.ui.type === 'execute_actions'
1530
1595
  ? html`<temba-sortable-list
1531
- dragHandle="drag-handle"
1532
- externalDrag
1533
- @temba-order-changed="${this.handleActionOrderChanged}"
1534
- @temba-drag-start="${this.handleActionDragStart}"
1535
- @temba-drag-external="${this.handleActionDragExternal}"
1536
- @temba-drag-internal="${this.handleActionDragInternal}"
1537
- @temba-drag-stop="${this.handleActionDragStop}"
1538
- >
1539
- ${this.renderActionsWithPlaceholder()}
1540
- </temba-sortable-list>`
1596
+ dragHandle="drag-handle"
1597
+ externalDrag
1598
+ @temba-order-changed="${this.handleActionOrderChanged}"
1599
+ @temba-drag-start="${this.handleActionDragStart}"
1600
+ @temba-drag-external="${this.handleActionDragExternal}"
1601
+ @temba-drag-internal="${this.handleActionDragInternal}"
1602
+ @temba-drag-stop="${this.handleActionDragStop}"
1603
+ >
1604
+ ${this.renderActionsWithPlaceholder()}
1605
+ </temba-sortable-list>
1606
+ ${this.showLastActionPlaceholder
1607
+ ? html`<div
1608
+ class="empty-node-placeholder"
1609
+ style="height: ${this.lastActionPlaceholderHeight}px;"
1610
+ ></div>`
1611
+ : ''}`
1541
1612
  : html`${this.node.actions.map((action, index) =>
1542
1613
  this.renderAction(this.node, action, index)
1543
1614
  )}`
1615
+ : this.ui.type === 'execute_actions'
1616
+ ? html`<div class="empty-node-placeholder"></div>`
1544
1617
  : ''}
1545
1618
  ${this.node.router
1546
1619
  ? html`<div class="router-section">
@@ -12,7 +12,7 @@ import { getStore } from '../store/Store';
12
12
  import { AppState, fromStore, zustand } from '../store/AppState';
13
13
  import { RapidElement } from '../RapidElement';
14
14
  import { repeat } from 'lit-html/directives/repeat.js';
15
- import { CustomEventType } from '../interfaces';
15
+ import { CustomEventType, Workspace } from '../interfaces';
16
16
  import { generateUUID, postJSON } from '../utils';
17
17
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
18
18
  import { ACTION_GROUP_METADATA } from './types';
@@ -134,6 +134,9 @@ export class Editor extends RapidElement {
134
134
  @fromStore(zustand, (state: AppState) => state.isTranslating)
135
135
  private isTranslating!: boolean;
136
136
 
137
+ @fromStore(zustand, (state: AppState) => state.workspace)
138
+ private workspace!: Workspace;
139
+
137
140
  // Drag state
138
141
  @state()
139
142
  private isDragging = false;
@@ -233,15 +236,23 @@ export class Editor extends RapidElement {
233
236
 
234
237
  private canvasMouseDown = false;
235
238
 
236
- // Default languages if not specified in flow definition
237
- private readonly DEFAULT_LANGUAGES = [
238
- { code: 'eng', name: 'English' },
239
- { code: 'fra', name: 'French' },
240
- { code: 'esp', name: 'Spanish' }
241
- ];
242
-
243
239
  private getAvailableLanguages(): Array<{ code: string; name: string }> {
244
- // Use languages from flow definition if available, otherwise use defaults
240
+ // Use languages from workspace if available
241
+ if (this.workspace?.languages && this.workspace.languages.length > 0) {
242
+ const languageNames = new Intl.DisplayNames(['en'], { type: 'language' });
243
+ return this.workspace.languages
244
+ .map((code) => {
245
+ try {
246
+ const name = languageNames.of(code);
247
+ return name ? { code, name } : { code, name: code };
248
+ } catch {
249
+ return { code, name: code };
250
+ }
251
+ })
252
+ .filter((lang) => lang.code && lang.name);
253
+ }
254
+
255
+ // Fall back to flow definition languages if available
245
256
  if (
246
257
  this.definition?._ui?.languages &&
247
258
  this.definition._ui.languages.length > 0
@@ -251,7 +262,9 @@ export class Editor extends RapidElement {
251
262
  name: typeof lang === 'string' ? lang : lang.name
252
263
  }));
253
264
  }
254
- return this.DEFAULT_LANGUAGES;
265
+
266
+ // No languages available
267
+ return [];
255
268
  }
256
269
 
257
270
  // Bound event handlers to maintain proper 'this' context
@@ -279,8 +292,6 @@ export class Editor extends RapidElement {
279
292
  );
280
293
  background-size: 20px 20px;
281
294
  background-position: 10px 10px;
282
- box-shadow: inset -5px 0 10px rgba(0, 0, 0, 0.05);
283
- border-top: 1px solid #e0e0e0;
284
295
  width: 100%;
285
296
  display: flex;
286
297
  }
@@ -728,11 +739,19 @@ export class Editor extends RapidElement {
728
739
  private saveChanges(): void {
729
740
  // post the flow definition to the server
730
741
  getStore()
731
- .postJSON(`/flow/revisions/${this.flow}`, this.definition)
742
+ .postJSON(`/flow/revisions/${this.flow}/`, this.definition)
732
743
  .then((response) => {
733
- // Update flow info with the response data
734
- if (response.json && response.json.info) {
735
- getStore().getState().setFlowInfo(response.json.info);
744
+ // Update flow info and revision with the response data
745
+ if (response.json) {
746
+ const state = getStore().getState();
747
+
748
+ if (response.json.info) {
749
+ state.setFlowInfo(response.json.info);
750
+ }
751
+
752
+ if (response.json.revision?.revision !== undefined) {
753
+ state.setRevision(response.json.revision.revision);
754
+ }
736
755
  }
737
756
  })
738
757
  .catch((error) => {
@@ -799,6 +818,12 @@ export class Editor extends RapidElement {
799
818
  this.handleNodeEditRequested.bind(this)
800
819
  );
801
820
 
821
+ // Listen for node deletion events
822
+ this.addEventListener(
823
+ CustomEventType.NodeDeleted,
824
+ this.handleNodeDeleted.bind(this)
825
+ );
826
+
802
827
  // Listen for canvas menu selections
803
828
  this.addEventListener(CustomEventType.Selection, (event: CustomEvent) => {
804
829
  const target = event.target as HTMLElement;
@@ -1586,6 +1611,13 @@ export class Editor extends RapidElement {
1586
1611
  this.editingNodeUI = event.detail.nodeUI;
1587
1612
  }
1588
1613
 
1614
+ private handleNodeDeleted(event: CustomEvent): void {
1615
+ const nodeUuid = event.detail.uuid;
1616
+ if (nodeUuid) {
1617
+ this.deleteNodes([nodeUuid]);
1618
+ }
1619
+ }
1620
+
1589
1621
  private handleActionSaved(updatedAction: Action): void {
1590
1622
  if (this.editingNode && this.editingAction) {
1591
1623
  let updatedActions: Action[];
@@ -1788,7 +1820,8 @@ export class Editor extends RapidElement {
1788
1820
  actionIndex,
1789
1821
  mouseX,
1790
1822
  mouseY,
1791
- actionHeight = 60
1823
+ actionHeight = 60,
1824
+ isLastAction = false
1792
1825
  } = event.detail;
1793
1826
 
1794
1827
  // Check if mouse is over another execute_actions node
@@ -1883,29 +1916,47 @@ export class Editor extends RapidElement {
1883
1916
 
1884
1917
  this.actionDragTargetNodeUuid = null;
1885
1918
 
1886
- // Tell source node to hide ghost (we're not over a valid target)
1887
1919
  const sourceElement = this.querySelector(
1888
1920
  `temba-flow-node[data-node-uuid="${nodeUuid}"]`
1889
1921
  );
1890
- if (sourceElement) {
1891
- sourceElement.dispatchEvent(
1892
- new CustomEvent('action-hide-ghost', {
1893
- detail: {},
1894
- bubbles: false
1895
- })
1896
- );
1897
- }
1898
1922
 
1899
- // Don't snap to grid for preview - let it follow cursor smoothly
1900
- const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
1923
+ // Show canvas drop preview only if this is NOT the last action
1924
+ // Last actions can only be dropped on other nodes, not on canvas
1925
+ if (!isLastAction) {
1926
+ // Hide ghost when showing canvas preview (for canvas drops)
1927
+ if (sourceElement) {
1928
+ sourceElement.dispatchEvent(
1929
+ new CustomEvent('action-hide-ghost', {
1930
+ detail: {},
1931
+ bubbles: false
1932
+ })
1933
+ );
1934
+ }
1901
1935
 
1902
- this.canvasDropPreview = {
1903
- action,
1904
- nodeUuid,
1905
- actionIndex,
1906
- position,
1907
- actionHeight
1908
- };
1936
+ // Don't snap to grid for preview - let it follow cursor smoothly
1937
+ const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
1938
+
1939
+ this.canvasDropPreview = {
1940
+ action,
1941
+ nodeUuid,
1942
+ actionIndex,
1943
+ position,
1944
+ actionHeight
1945
+ };
1946
+ } else {
1947
+ // For last action, keep ghost visible (can't drop on canvas)
1948
+ if (sourceElement) {
1949
+ sourceElement.dispatchEvent(
1950
+ new CustomEvent('action-show-ghost', {
1951
+ detail: {},
1952
+ bubbles: false
1953
+ })
1954
+ );
1955
+ }
1956
+
1957
+ // Clear any existing preview for last action
1958
+ this.canvasDropPreview = null;
1959
+ }
1909
1960
 
1910
1961
  // Force re-render to update preview position
1911
1962
  this.requestUpdate();
@@ -1933,7 +1984,14 @@ export class Editor extends RapidElement {
1933
1984
  }
1934
1985
 
1935
1986
  private handleActionDropExternal(event: CustomEvent): void {
1936
- const { action, nodeUuid, actionIndex, mouseX, mouseY } = event.detail;
1987
+ const {
1988
+ action,
1989
+ nodeUuid,
1990
+ actionIndex,
1991
+ mouseX,
1992
+ mouseY,
1993
+ isLastAction = false
1994
+ } = event.detail;
1937
1995
 
1938
1996
  // Check if we're dropping on an existing execute_actions node
1939
1997
  const targetNodeUuid = this.actionDragTargetNodeUuid;
@@ -1964,6 +2022,14 @@ export class Editor extends RapidElement {
1964
2022
  return;
1965
2023
  }
1966
2024
 
2025
+ // If this is the last action and we're not dropping on another node, do nothing
2026
+ // Last actions can only be moved to other nodes, not dropped on canvas
2027
+ if (isLastAction) {
2028
+ this.canvasDropPreview = null;
2029
+ this.actionDragTargetNodeUuid = null;
2030
+ return;
2031
+ }
2032
+
1967
2033
  // Not dropping on another node, create a new one on canvas
1968
2034
  // Snap to grid for the final drop position
1969
2035
  const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
@@ -1978,7 +2044,8 @@ export class Editor extends RapidElement {
1978
2044
 
1979
2045
  // if no actions remain, delete the node
1980
2046
  if (updatedActions.length === 0) {
1981
- getStore()?.getState().removeNodes([nodeUuid]);
2047
+ // Use deleteNodes to properly clean up Plumber connections before removing
2048
+ this.deleteNodes([nodeUuid]);
1982
2049
  } else {
1983
2050
  // update the node
1984
2051
  const updatedNode = { ...originalNode, actions: updatedActions };
@@ -2560,7 +2627,7 @@ export class Editor extends RapidElement {
2560
2627
  header="Translations"
2561
2628
  .width=${360}
2562
2629
  .maxHeight=${600}
2563
- .top=${20}
2630
+ .top=${170}
2564
2631
  color="#6b7280"
2565
2632
  .hidden=${this.localizationWindowHidden}
2566
2633
  @temba-dialog-hidden=${this.handleLocalizationWindowClosed}
@@ -2725,6 +2792,7 @@ export class Editor extends RapidElement {
2725
2792
  icon="language"
2726
2793
  label="Translate Flow"
2727
2794
  color="#6b7280"
2795
+ top="180"
2728
2796
  .hidden=${!this.localizationWindowHidden}
2729
2797
  @temba-button-clicked=${this.handleLocalizationTabClick}
2730
2798
  ></temba-floating-tab>
@@ -2797,8 +2865,7 @@ export class Editor extends RapidElement {
2797
2865
  ? 'selected'
2798
2866
  : ''}"
2799
2867
  @mousedown=${this.handleMouseDown.bind(this)}
2800
- style="left:${position.left}px; top:${position.top}px; z-index: ${1000 +
2801
- position.top}"
2868
+ style="left:${position.left}px; top:${position.top}px;"
2802
2869
  uuid=${uuid}
2803
2870
  .data=${sticky}
2804
2871
  .dragging=${dragging}
@@ -318,8 +318,11 @@ export class NodeTypeSelector extends RapidElement {
318
318
 
319
319
  // Collect regular actions (from ACTION_CONFIG, unless hideFromActions is true)
320
320
  Object.entries(ACTION_CONFIG)
321
- .filter(([_, config]) => {
321
+ .filter(([type, config]) => {
322
+ // exclude aliases - if config has aliases, check if this type is an alias
323
+ const isAlias = config.aliases && config.aliases.includes(type);
322
324
  return (
325
+ !isAlias &&
323
326
  config.name &&
324
327
  !config.hideFromActions &&
325
328
  config.group &&
@@ -341,6 +344,7 @@ export class NodeTypeSelector extends RapidElement {
341
344
  .filter(([type, config]) => {
342
345
  return (
343
346
  type !== 'execute_actions' &&
347
+ type === config.type && // exclude aliases (type won't match config.type for aliases)
344
348
  config.name &&
345
349
  config.showAsAction &&
346
350
  config.group &&
@@ -440,8 +444,10 @@ export class NodeTypeSelector extends RapidElement {
440
444
  .filter(([type, config]) => {
441
445
  // exclude execute_actions (it's the default action-only node)
442
446
  // exclude nodes that have showAsAction=true (they appear in action mode)
447
+ // exclude aliases (type won't match config.type for aliases)
443
448
  return (
444
449
  type !== 'execute_actions' &&
450
+ type === config.type &&
445
451
  config.name &&
446
452
  !config.showAsAction &&
447
453
  this.isConfigAvailable(config)