@nyaruka/temba-components 0.132.0 → 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 (143) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/demo/components/flow/example.html +1 -0
  3. package/demo/static/css/tailwind.css +30019 -0
  4. package/dist/temba-components.js +434 -402
  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 +66 -30
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/layout/FloatingWindow.js +1 -2
  16. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  17. package/out-tsc/src/list/ContentMenu.js +1 -0
  18. package/out-tsc/src/list/ContentMenu.js.map +1 -1
  19. package/out-tsc/src/list/SortableList.js +3 -2
  20. package/out-tsc/src/list/SortableList.js.map +1 -1
  21. package/out-tsc/src/live/ContactChat.js +63 -35
  22. package/out-tsc/src/live/ContactChat.js.map +1 -1
  23. package/out-tsc/src/store/AppState.js +31 -0
  24. package/out-tsc/src/store/AppState.js.map +1 -1
  25. package/out-tsc/src/utils.js +3 -3
  26. package/out-tsc/src/utils.js.map +1 -1
  27. package/out-tsc/test/ActionHelper.js +6 -5
  28. package/out-tsc/test/ActionHelper.js.map +1 -1
  29. package/out-tsc/test/actions/send_broadcast.test.js +1 -1
  30. package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
  31. package/out-tsc/test/temba-contact-chat.test.js +1 -1
  32. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  33. package/out-tsc/test/temba-floating-window.test.js +0 -2
  34. package/out-tsc/test/temba-floating-window.test.js.map +1 -1
  35. package/out-tsc/test/temba-flow-editor-node.test.js +109 -0
  36. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  37. package/out-tsc/test/temba-utils-uuid.test.js +45 -1
  38. package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
  39. package/out-tsc/test/utils.test.js +2 -2
  40. package/out-tsc/test/utils.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  43. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  47. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  57. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  63. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/contacts/chat-failure.png +0 -0
  91. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  92. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  93. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  94. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  95. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  96. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  97. package/screenshots/truth/floating-tab/default.png +0 -0
  98. package/screenshots/truth/floating-tab/gray.png +0 -0
  99. package/screenshots/truth/floating-tab/green.png +0 -0
  100. package/screenshots/truth/floating-tab/hover.png +0 -0
  101. package/screenshots/truth/floating-tab/purple.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  103. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  104. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  105. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  106. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  107. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  108. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  109. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  110. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  111. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  112. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  113. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  114. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  115. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  116. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  117. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  118. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  119. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  120. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  121. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  124. package/src/display/Chat.ts +29 -7
  125. package/src/display/FloatingTab.ts +4 -4
  126. package/src/events.ts +1 -4
  127. package/src/flow/CanvasNode.ts +130 -57
  128. package/src/flow/Editor.ts +84 -30
  129. package/src/layout/FloatingWindow.ts +1 -3
  130. package/src/list/ContentMenu.ts +1 -0
  131. package/src/list/SortableList.ts +3 -2
  132. package/src/live/ContactChat.ts +68 -42
  133. package/src/store/AppState.ts +41 -0
  134. package/src/utils.ts +3 -3
  135. package/test/ActionHelper.ts +13 -5
  136. package/test/actions/send_broadcast.test.ts +2 -1
  137. package/test/temba-contact-chat.test.ts +1 -1
  138. package/test/temba-floating-window.test.ts +0 -2
  139. package/test/temba-flow-editor-node.test.ts +129 -0
  140. package/test/temba-utils-uuid.test.ts +61 -1
  141. package/test/utils.test.ts +7 -2
  142. package/test-assets/contacts/history.json +22 -9
  143. 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">
@@ -292,8 +292,6 @@ export class Editor extends RapidElement {
292
292
  );
293
293
  background-size: 20px 20px;
294
294
  background-position: 10px 10px;
295
- box-shadow: inset -5px 0 10px rgba(0, 0, 0, 0.05);
296
- border-top: 1px solid #e0e0e0;
297
295
  width: 100%;
298
296
  display: flex;
299
297
  }
@@ -741,11 +739,19 @@ export class Editor extends RapidElement {
741
739
  private saveChanges(): void {
742
740
  // post the flow definition to the server
743
741
  getStore()
744
- .postJSON(`/flow/revisions/${this.flow}`, this.definition)
742
+ .postJSON(`/flow/revisions/${this.flow}/`, this.definition)
745
743
  .then((response) => {
746
- // Update flow info with the response data
747
- if (response.json && response.json.info) {
748
- 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
+ }
749
755
  }
750
756
  })
751
757
  .catch((error) => {
@@ -812,6 +818,12 @@ export class Editor extends RapidElement {
812
818
  this.handleNodeEditRequested.bind(this)
813
819
  );
814
820
 
821
+ // Listen for node deletion events
822
+ this.addEventListener(
823
+ CustomEventType.NodeDeleted,
824
+ this.handleNodeDeleted.bind(this)
825
+ );
826
+
815
827
  // Listen for canvas menu selections
816
828
  this.addEventListener(CustomEventType.Selection, (event: CustomEvent) => {
817
829
  const target = event.target as HTMLElement;
@@ -1599,6 +1611,13 @@ export class Editor extends RapidElement {
1599
1611
  this.editingNodeUI = event.detail.nodeUI;
1600
1612
  }
1601
1613
 
1614
+ private handleNodeDeleted(event: CustomEvent): void {
1615
+ const nodeUuid = event.detail.uuid;
1616
+ if (nodeUuid) {
1617
+ this.deleteNodes([nodeUuid]);
1618
+ }
1619
+ }
1620
+
1602
1621
  private handleActionSaved(updatedAction: Action): void {
1603
1622
  if (this.editingNode && this.editingAction) {
1604
1623
  let updatedActions: Action[];
@@ -1801,7 +1820,8 @@ export class Editor extends RapidElement {
1801
1820
  actionIndex,
1802
1821
  mouseX,
1803
1822
  mouseY,
1804
- actionHeight = 60
1823
+ actionHeight = 60,
1824
+ isLastAction = false
1805
1825
  } = event.detail;
1806
1826
 
1807
1827
  // Check if mouse is over another execute_actions node
@@ -1896,29 +1916,47 @@ export class Editor extends RapidElement {
1896
1916
 
1897
1917
  this.actionDragTargetNodeUuid = null;
1898
1918
 
1899
- // Tell source node to hide ghost (we're not over a valid target)
1900
1919
  const sourceElement = this.querySelector(
1901
1920
  `temba-flow-node[data-node-uuid="${nodeUuid}"]`
1902
1921
  );
1903
- if (sourceElement) {
1904
- sourceElement.dispatchEvent(
1905
- new CustomEvent('action-hide-ghost', {
1906
- detail: {},
1907
- bubbles: false
1908
- })
1909
- );
1910
- }
1911
1922
 
1912
- // Don't snap to grid for preview - let it follow cursor smoothly
1913
- 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
+ }
1914
1935
 
1915
- this.canvasDropPreview = {
1916
- action,
1917
- nodeUuid,
1918
- actionIndex,
1919
- position,
1920
- actionHeight
1921
- };
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
+ }
1922
1960
 
1923
1961
  // Force re-render to update preview position
1924
1962
  this.requestUpdate();
@@ -1946,7 +1984,14 @@ export class Editor extends RapidElement {
1946
1984
  }
1947
1985
 
1948
1986
  private handleActionDropExternal(event: CustomEvent): void {
1949
- 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;
1950
1995
 
1951
1996
  // Check if we're dropping on an existing execute_actions node
1952
1997
  const targetNodeUuid = this.actionDragTargetNodeUuid;
@@ -1977,6 +2022,14 @@ export class Editor extends RapidElement {
1977
2022
  return;
1978
2023
  }
1979
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
+
1980
2033
  // Not dropping on another node, create a new one on canvas
1981
2034
  // Snap to grid for the final drop position
1982
2035
  const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
@@ -1991,7 +2044,8 @@ export class Editor extends RapidElement {
1991
2044
 
1992
2045
  // if no actions remain, delete the node
1993
2046
  if (updatedActions.length === 0) {
1994
- getStore()?.getState().removeNodes([nodeUuid]);
2047
+ // Use deleteNodes to properly clean up Plumber connections before removing
2048
+ this.deleteNodes([nodeUuid]);
1995
2049
  } else {
1996
2050
  // update the node
1997
2051
  const updatedNode = { ...originalNode, actions: updatedActions };
@@ -2573,7 +2627,7 @@ export class Editor extends RapidElement {
2573
2627
  header="Translations"
2574
2628
  .width=${360}
2575
2629
  .maxHeight=${600}
2576
- .top=${20}
2630
+ .top=${170}
2577
2631
  color="#6b7280"
2578
2632
  .hidden=${this.localizationWindowHidden}
2579
2633
  @temba-dialog-hidden=${this.handleLocalizationWindowClosed}
@@ -2738,6 +2792,7 @@ export class Editor extends RapidElement {
2738
2792
  icon="language"
2739
2793
  label="Translate Flow"
2740
2794
  color="#6b7280"
2795
+ top="180"
2741
2796
  .hidden=${!this.localizationWindowHidden}
2742
2797
  @temba-button-clicked=${this.handleLocalizationTabClick}
2743
2798
  ></temba-floating-tab>
@@ -2810,8 +2865,7 @@ export class Editor extends RapidElement {
2810
2865
  ? 'selected'
2811
2866
  : ''}"
2812
2867
  @mousedown=${this.handleMouseDown.bind(this)}
2813
- style="left:${position.left}px; top:${position.top}px; z-index: ${1000 +
2814
- position.top}"
2868
+ style="left:${position.left}px; top:${position.top}px;"
2815
2869
  uuid=${uuid}
2816
2870
  .data=${sticky}
2817
2871
  .dragging=${dragging}
@@ -18,7 +18,7 @@ export class FloatingWindow extends RapidElement {
18
18
  transition: transform var(--transition-duration, 300ms) ease-in-out,
19
19
  opacity var(--transition-duration, 300ms) ease-in-out;
20
20
  position: fixed;
21
- z-index: 9999;
21
+ z-index: 5000;
22
22
  top: 100px;
23
23
  background: white;
24
24
  border-radius: 8px;
@@ -193,8 +193,6 @@ export class FloatingWindow extends RapidElement {
193
193
  ): void {
194
194
  super.updated(changes);
195
195
  if (changes.has('hidden')) {
196
- this.classList.toggle('hidden', this.hidden);
197
-
198
196
  // when hiding, reset positioning behavior to original
199
197
  if (this.hidden && !changes.get('hidden')) {
200
198
  if (this.defaultLeft === -1) {
@@ -36,6 +36,7 @@ export class ContentMenu extends RapidElement {
36
36
  return css`
37
37
  :host {
38
38
  tabindex: 0;
39
+ z-index: 5000;
39
40
  }
40
41
  .container {
41
42
  --button-y: 0.4em;
@@ -577,8 +577,9 @@ export class SortableList extends RapidElement {
577
577
  const fromIdx = originalDragIdx;
578
578
  const toIdx = this.pendingDropIndex;
579
579
 
580
- // only fire if the position actually changed
581
- if (fromIdx !== toIdx) {
580
+ // only fire if the position actually changed AND this is not an external drag
581
+ // External drags are handled by external drop handlers
582
+ if (fromIdx !== toIdx && !this.isExternalDrag) {
582
583
  this.fireCustomEvent(CustomEventType.OrderChanged, {
583
584
  swap: [fromIdx, toIdx]
584
585
  });