@nyaruka/temba-components 0.130.3 → 0.130.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/demo/sortable-rules-demo.html +155 -0
  3. package/dist/temba-components.js +133 -143
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/events.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasNode.js +13 -7
  7. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  8. package/out-tsc/src/flow/actions/send_msg.js +1 -0
  9. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  10. package/out-tsc/src/flow/nodes/split_by_groups.js +149 -1
  11. package/out-tsc/src/flow/nodes/split_by_groups.js.map +1 -1
  12. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +1 -0
  13. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -1
  14. package/out-tsc/src/flow/nodes/wait_for_response.js +81 -75
  15. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  16. package/out-tsc/src/form/ArrayEditor.js +106 -28
  17. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  18. package/out-tsc/src/form/select/Select.js +21 -25
  19. package/out-tsc/src/form/select/Select.js.map +1 -1
  20. package/out-tsc/src/list/SortableList.js +214 -140
  21. package/out-tsc/src/list/SortableList.js.map +1 -1
  22. package/out-tsc/src/live/ContactChat.js +18 -13
  23. package/out-tsc/src/live/ContactChat.js.map +1 -1
  24. package/out-tsc/test/nodes/split_by_groups.test.js +130 -0
  25. package/out-tsc/test/nodes/split_by_groups.test.js.map +1 -0
  26. package/out-tsc/test/nodes/wait_for_response.test.js +149 -0
  27. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
  28. package/out-tsc/test/temba-field-config.test.js +56 -0
  29. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  30. package/package.json +1 -1
  31. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  32. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  33. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  34. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  35. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  36. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  37. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  38. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  39. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  40. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  41. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  42. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  43. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  44. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  45. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  46. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  47. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  48. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  49. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  50. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  51. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  52. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  53. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  54. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  55. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  56. package/screenshots/truth/contacts/chat-failure.png +0 -0
  57. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  58. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  59. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  60. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  61. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  62. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  63. package/screenshots/truth/editor/wait.png +0 -0
  64. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  65. package/screenshots/truth/list/fields-dragging.png +0 -0
  66. package/screenshots/truth/list/sortable-dragging.png +0 -0
  67. package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
  68. package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
  69. package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
  70. package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
  71. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  72. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  73. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  74. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  75. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  76. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  77. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  78. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  79. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  80. package/screenshots/truth/select/search-enabled.png +0 -0
  81. package/screenshots/truth/select/search-selected-focus.png +0 -0
  82. package/screenshots/truth/select/search-selected.png +0 -0
  83. package/screenshots/truth/templates/default.png +0 -0
  84. package/screenshots/truth/templates/unapproved.png +0 -0
  85. package/src/events.ts +6 -6
  86. package/src/flow/CanvasNode.ts +15 -13
  87. package/src/flow/actions/send_msg.ts +1 -0
  88. package/src/flow/nodes/split_by_groups.ts +190 -1
  89. package/src/flow/nodes/split_by_llm_categorize.ts +1 -0
  90. package/src/flow/nodes/wait_for_response.ts +98 -74
  91. package/src/form/ArrayEditor.ts +112 -28
  92. package/src/form/select/Select.ts +24 -25
  93. package/src/list/SortableList.ts +250 -149
  94. package/src/live/ContactChat.ts +20 -13
  95. package/test/nodes/split_by_groups.test.ts +165 -0
  96. package/test/nodes/wait_for_response.test.ts +182 -0
  97. package/test/temba-field-config.test.ts +69 -0
  98. package/test-assets/contacts/history.json +37 -35
  99. package/screenshots/truth/contacts/chat-for-active-contact.png +0 -0
@@ -1,4 +1,4 @@
1
- import { css, html, PropertyValueMap, TemplateResult } from 'lit';
1
+ import { css, html, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { CustomEventType } from '../interfaces';
4
4
  import { RapidElement } from '../RapidElement';
@@ -10,6 +10,7 @@ import { RapidElement } from '../RapidElement';
10
10
  // how far we have to drag before it starts
11
11
  const DRAG_THRESHOLD = 2;
12
12
  export class SortableList extends RapidElement {
13
+ originalDownDisplay: string;
13
14
  static get styles() {
14
15
  return css`
15
16
  :host {
@@ -19,6 +20,8 @@ export class SortableList extends RapidElement {
19
20
  .container {
20
21
  user-select: none;
21
22
  position: relative;
23
+ display: grid;
24
+ grid-template-columns: 1fr;
22
25
  }
23
26
 
24
27
  .container.horizontal {
@@ -31,58 +34,6 @@ export class SortableList extends RapidElement {
31
34
  background: var(--color-selection);
32
35
  }
33
36
 
34
- .dragged-item {
35
- opacity: 0;
36
- pointer-events: none;
37
- }
38
-
39
- .sortable {
40
- transition: all 300ms ease-in-out;
41
- display: flex;
42
- padding: 0.4em 0;
43
- }
44
-
45
- .container.horizontal .sortable {
46
- padding: 0;
47
- margin-right: 0.25em;
48
- margin-bottom: 0.25em;
49
- }
50
-
51
- .drop-indicator {
52
- position: absolute;
53
- background: var(--color-primary-dark, #1c7cd6);
54
- z-index: 1000;
55
- pointer-events: none;
56
- }
57
-
58
- .container.horizontal .drop-indicator {
59
- width: 2px;
60
- margin-top: -5px;
61
- padding-bottom: 10px;
62
- }
63
-
64
- .container:not(.horizontal) .drop-indicator {
65
- height: 2px;
66
- left: 0;
67
- }
68
-
69
- .sortable:hover temba-icon {
70
- opacity: 1;
71
- cursor: move;
72
- }
73
-
74
- .ghost {
75
- position: absolute;
76
- opacity: 0.7;
77
- transition: none;
78
- background: var(--color-background, white);
79
- border: 1px solid var(--color-primary, #1c7cd6);
80
- border-radius: 4px;
81
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
82
- pointer-events: none;
83
- z-index: 999;
84
- }
85
-
86
37
  .slot {
87
38
  flex-grow: 1;
88
39
  }
@@ -111,6 +62,9 @@ export class SortableList extends RapidElement {
111
62
  @property({ type: String })
112
63
  dragHandle: string;
113
64
 
65
+ @property({ type: String })
66
+ gap: string = '0em';
67
+
114
68
  /**
115
69
  * Optional callback to allow parent components to customize the ghost node.
116
70
  * Called after the ghost node is cloned but before it is appended to the DOM.
@@ -120,6 +74,8 @@ export class SortableList extends RapidElement {
120
74
 
121
75
  ghostElement: HTMLDivElement = null;
122
76
  downEle: HTMLDivElement = null;
77
+ originalElementRect: DOMRect = null; // Store original dimensions
78
+ originalDragIndex: number = -1; // Store original index before moving element
123
79
  xOffset = 0;
124
80
  yOffset = 0;
125
81
  yDown = 0;
@@ -127,7 +83,7 @@ export class SortableList extends RapidElement {
127
83
 
128
84
  draggingIdx = -1;
129
85
  draggingEle = null;
130
- dropIndicator: HTMLDivElement = null;
86
+ dropPlaceholder: HTMLDivElement = null;
131
87
  pendingDropIndex = -1;
132
88
  pendingTargetElement: HTMLElement = null;
133
89
 
@@ -140,17 +96,135 @@ export class SortableList extends RapidElement {
140
96
  this.handleMouseDown = this.handleMouseDown.bind(this);
141
97
  }
142
98
 
143
- protected firstUpdated(
144
- _changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
145
- ): void {
146
- super.firstUpdated(_changedProperties);
147
- }
148
-
149
99
  private getSortableElements(): Element[] {
150
- return this.shadowRoot
100
+ const eles = this.shadowRoot
151
101
  .querySelector('slot')
152
102
  .assignedElements()
153
- .filter((ele) => ele.classList.contains('sortable'));
103
+ .filter(
104
+ (ele) =>
105
+ ele.classList.contains('sortable') &&
106
+ !ele.classList.contains('drop-placeholder')
107
+ );
108
+ return eles;
109
+ }
110
+
111
+ private cloneElementWithState(element: HTMLElement): HTMLElement {
112
+ // First create a basic clone
113
+ const clone = element.cloneNode(true) as HTMLElement;
114
+
115
+ // Helper function to copy form element values recursively
116
+ const copyFormValues = (original: HTMLElement, cloned: HTMLElement) => {
117
+ try {
118
+ // Copy input values
119
+ const originalInputs = original.querySelectorAll(
120
+ 'input, textarea, select'
121
+ );
122
+ const clonedInputs = cloned.querySelectorAll('input, textarea, select');
123
+
124
+ originalInputs.forEach((originalInput, index) => {
125
+ const clonedInput = clonedInputs[index] as
126
+ | HTMLInputElement
127
+ | HTMLTextAreaElement
128
+ | HTMLSelectElement;
129
+ if (clonedInput) {
130
+ if (originalInput instanceof HTMLInputElement) {
131
+ const originalHtmlInput = originalInput as HTMLInputElement;
132
+ const clonedHtmlInput = clonedInput as HTMLInputElement;
133
+
134
+ if (
135
+ originalHtmlInput.type === 'checkbox' ||
136
+ originalHtmlInput.type === 'radio'
137
+ ) {
138
+ clonedHtmlInput.checked = originalHtmlInput.checked;
139
+ } else {
140
+ clonedHtmlInput.value = originalHtmlInput.value;
141
+ }
142
+ } else if (originalInput instanceof HTMLTextAreaElement) {
143
+ (clonedInput as HTMLTextAreaElement).value = originalInput.value;
144
+ } else if (originalInput instanceof HTMLSelectElement) {
145
+ (clonedInput as HTMLSelectElement).selectedIndex =
146
+ originalInput.selectedIndex;
147
+ }
148
+ }
149
+ });
150
+
151
+ // Copy properties from all custom elements that might have a value property
152
+ const allOriginalElements = Array.from(original.querySelectorAll('*'));
153
+ const allClonedElements = Array.from(cloned.querySelectorAll('*'));
154
+
155
+ allOriginalElements.forEach((originalEl, index) => {
156
+ const clonedEl = allClonedElements[index];
157
+ if (clonedEl && originalEl) {
158
+ // Special handling for temba components
159
+ if (
160
+ originalEl.tagName &&
161
+ originalEl.tagName.toLowerCase().startsWith('temba-')
162
+ ) {
163
+ try {
164
+ // Copy common temba component properties
165
+ const tembaProps = [
166
+ 'value',
167
+ 'values',
168
+ 'selectedValue',
169
+ 'checked',
170
+ 'selected',
171
+ 'textContent'
172
+ ];
173
+ tembaProps.forEach((prop) => {
174
+ if (
175
+ prop in originalEl &&
176
+ (originalEl as any)[prop] !== undefined
177
+ ) {
178
+ (clonedEl as any)[prop] = (originalEl as any)[prop];
179
+ }
180
+ });
181
+
182
+ // Copy all attributes for temba components to preserve state
183
+ Array.from(originalEl.attributes).forEach((attr) => {
184
+ clonedEl.setAttribute(attr.name, attr.value);
185
+ });
186
+ } catch (e) {
187
+ // Ignore errors when copying temba properties
188
+ }
189
+ } else {
190
+ // Try to copy value property for other elements
191
+ try {
192
+ if (
193
+ 'value' in originalEl &&
194
+ (originalEl as any).value !== undefined
195
+ ) {
196
+ (clonedEl as any).value = (originalEl as any).value;
197
+ }
198
+ } catch (e) {
199
+ // Ignore errors when copying properties
200
+ }
201
+
202
+ // Copy data attributes that might contain state
203
+ try {
204
+ Array.from(originalEl.attributes).forEach((attr) => {
205
+ if (
206
+ attr.name.startsWith('data-') ||
207
+ attr.name.startsWith('aria-')
208
+ ) {
209
+ clonedEl.setAttribute(attr.name, attr.value);
210
+ }
211
+ });
212
+ } catch (e) {
213
+ // Ignore errors when copying attributes
214
+ }
215
+ }
216
+ }
217
+ });
218
+ } catch (e) {
219
+ // If anything fails, just return the basic clone
220
+ console.warn('Failed to copy form values in cloneElementWithState:', e);
221
+ }
222
+ };
223
+
224
+ // Copy form values for the root element and all descendants
225
+ copyFormValues(element, clone);
226
+
227
+ return clone;
154
228
  }
155
229
 
156
230
  public getIds() {
@@ -208,59 +282,68 @@ export class SortableList extends RapidElement {
208
282
  }
209
283
  }
210
284
 
211
- private showDropIndicator(targetElement: HTMLElement, insertAfter: boolean) {
212
- this.hideDropIndicator();
285
+ private showDropPlaceholder(
286
+ targetElement: HTMLElement,
287
+ insertAfter: boolean
288
+ ) {
289
+ this.hideDropPlaceholder();
213
290
 
214
- if (!targetElement) return;
291
+ if (!targetElement || !this.draggingEle) return;
215
292
 
216
- const container = this.shadowRoot.querySelector('.container');
217
- this.dropIndicator = document.createElement('div');
218
- this.dropIndicator.className = 'drop-indicator';
293
+ // Don't show placeholder if we're targeting the dragging element itself
294
+ if (targetElement === this.draggingEle) return;
219
295
 
220
- const targetRect = targetElement.getBoundingClientRect();
221
- const containerRect = container.getBoundingClientRect();
296
+ this.dropPlaceholder = document.createElement('div');
222
297
 
223
- if (this.horizontal) {
224
- // For horizontal layout, show vertical line
225
- this.dropIndicator.style.height = targetRect.height + 'px';
226
- this.dropIndicator.style.top = targetRect.top - containerRect.top + 'px';
227
-
228
- if (insertAfter) {
229
- // Show line after target
230
- this.dropIndicator.style.left =
231
- targetRect.right - containerRect.left + 'px';
232
- } else {
233
- // Show line before target
234
- this.dropIndicator.style.left =
235
- targetRect.left - containerRect.left + 'px';
236
- }
237
- } else {
238
- // For vertical layout, show horizontal line
239
- this.dropIndicator.style.width = targetRect.width + 'px';
240
- this.dropIndicator.style.left =
241
- targetRect.left - containerRect.left + 'px';
242
-
243
- if (insertAfter) {
244
- // Show line after target
245
- this.dropIndicator.style.top =
246
- targetRect.bottom - containerRect.top + 'px';
247
- } else {
248
- // Show line before target
249
- this.dropIndicator.style.top =
250
- targetRect.top - containerRect.top + 'px';
251
- }
298
+ this.dropPlaceholder.className = 'drop-placeholder sortable';
299
+
300
+ // Copy dimensions from the original element (before it was hidden)
301
+ if (this.originalElementRect) {
302
+ const rect = this.originalElementRect;
303
+ this.dropPlaceholder.style.width = rect.width + 'px';
304
+ this.dropPlaceholder.style.height = rect.height + 'px';
305
+ this.dropPlaceholder.style.minHeight = rect.height + 'px';
306
+ this.dropPlaceholder.style.borderRadius = 'var(--curvature)';
307
+ this.dropPlaceholder.style.flexShrink = '0';
252
308
  }
253
309
 
254
- container.appendChild(this.dropIndicator);
310
+ // Insert the placeholder in the correct position in the DOM
311
+ if (insertAfter) {
312
+ targetElement.insertAdjacentElement('afterend', this.dropPlaceholder);
313
+ } else {
314
+ targetElement.insertAdjacentElement('beforebegin', this.dropPlaceholder);
315
+ }
255
316
  }
256
317
 
257
- private hideDropIndicator() {
258
- if (this.dropIndicator) {
259
- this.dropIndicator.remove();
260
- this.dropIndicator = null;
318
+ private hideDropPlaceholder() {
319
+ if (this.dropPlaceholder) {
320
+ this.dropPlaceholder.remove();
321
+ this.dropPlaceholder = null;
261
322
  }
262
323
  }
263
324
 
325
+ private showInitialPlaceholder() {
326
+ if (!this.downEle || !this.originalElementRect) return;
327
+
328
+ this.dropPlaceholder = document.createElement('div');
329
+ this.dropPlaceholder.className = 'drop-placeholder sortable';
330
+
331
+ // Copy dimensions from the original element
332
+ const rect = this.originalElementRect;
333
+ this.dropPlaceholder.style.width = rect.width + 'px';
334
+ this.dropPlaceholder.style.height = rect.height + 'px';
335
+ this.dropPlaceholder.style.minHeight = rect.height + 'px';
336
+ this.dropPlaceholder.style.borderRadius = 'var(--curvature)';
337
+ this.dropPlaceholder.style.flexShrink = '0';
338
+ this.dropPlaceholder.style.background =
339
+ 'rgba(var(--color-primary-rgb), 0.1)';
340
+ this.dropPlaceholder.style.border =
341
+ '2px dashed rgba(var(--color-primary-rgb), 0.3)';
342
+
343
+ // Insert the placeholder right after the hidden original element
344
+ this.downEle.insertAdjacentElement('afterend', this.dropPlaceholder);
345
+ }
346
+
264
347
  private handleMouseDown(event: MouseEvent) {
265
348
  let ele = event.target as HTMLDivElement;
266
349
 
@@ -275,14 +358,14 @@ export class SortableList extends RapidElement {
275
358
  if (ele) {
276
359
  event.preventDefault();
277
360
  event.stopPropagation();
278
-
279
361
  this.downEle = ele;
280
362
  this.draggingId = ele.id;
281
363
  this.draggingIdx = this.getRowIndex(ele.id);
282
364
  this.draggingEle = ele;
283
365
 
284
- // Use getBoundingClientRect for accurate offsets
366
+ // Use getBoundingClientRect for accurate offsets and store original dimensions
285
367
  const rect = ele.getBoundingClientRect();
368
+ this.originalElementRect = rect; // Store the original rect before hiding
286
369
  this.xOffset = event.clientX - rect.left;
287
370
  this.yOffset = event.clientY - rect.top;
288
371
  this.yDown = event.clientY;
@@ -304,38 +387,43 @@ export class SortableList extends RapidElement {
304
387
  id: this.downEle.id
305
388
  });
306
389
 
307
- this.ghostElement = this.downEle.cloneNode(true) as HTMLDivElement;
308
- this.ghostElement.classList.add('ghost');
390
+ // Capture the original index BEFORE hiding the element
391
+ this.originalDragIndex = this.getRowIndex(this.downEle.id);
309
392
 
310
- // dim the original element while dragging
311
- this.downEle.style.pointerEvents = 'none';
312
- this.downEle.style.opacity = '0.5';
393
+ // Create a clone of the element to use as the ghost
394
+ this.ghostElement = this.cloneElementWithState(
395
+ this.downEle
396
+ ) as HTMLDivElement;
313
397
 
314
- const rect = this.downEle.getBoundingClientRect();
315
- this.ghostElement.style.transition = 'transform 300ms linear';
398
+ // Hide the original element during dragging using inline styles
399
+ this.originalDownDisplay = this.downEle.style.display;
400
+ this.downEle.style.display = 'none';
401
+
402
+ // Style the clone as a ghost
403
+ this.ghostElement.classList.add('ghost');
404
+
405
+ // Use the stored original dimensions for positioning
406
+ const rect = this.originalElementRect;
316
407
 
317
- this.ghostElement.style.width = rect.width + 'px';
318
- this.ghostElement.style.height = rect.height + 'px';
319
408
  this.ghostElement.style.position = 'fixed';
320
409
  this.ghostElement.style.left = event.clientX - this.xOffset + 'px';
321
410
  this.ghostElement.style.top = event.clientY - this.yOffset + 'px';
322
- this.ghostElement.style.pointerEvents = 'none';
323
- this.ghostElement.style.border =
324
- '1px solid var(--color-primary, #1c7cd6)';
411
+ this.ghostElement.style.width = rect.width + 'px';
412
+ this.ghostElement.style.height = rect.height + 'px';
325
413
  this.ghostElement.style.zIndex = '99999';
326
- this.ghostElement.style.background = '#fff';
327
- this.ghostElement.style.opacity = '0.7';
328
- this.ghostElement.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)';
329
- this.ghostElement.style.borderRadius = 'var(--curvature)';
414
+ this.ghostElement.style.opacity = '0.8';
415
+ this.ghostElement.style.transform = 'scale(1.03)';
330
416
 
331
417
  // allow component to customize the ghost node
332
418
  if (this.prepareGhost) {
333
419
  this.prepareGhost(this.ghostElement);
334
420
  }
335
421
 
422
+ // Add the clone to document.body for dragging
336
423
  document.body.appendChild(this.ghostElement);
337
424
 
338
- // this.downEle = null;
425
+ // Show initial placeholder in the original position to maintain layout
426
+ this.showInitialPlaceholder();
339
427
 
340
428
  // Add global click blocker when drag starts
341
429
  if (!this.clickBlocker) {
@@ -356,17 +444,23 @@ export class SortableList extends RapidElement {
356
444
  if (targetInfo) {
357
445
  const { element: targetElement, insertAfter } = targetInfo;
358
446
  const targetIdx = this.getRowIndex(targetElement.id);
359
- const originalDragIdx = this.getRowIndex(this.draggingEle.id);
360
-
361
- // Calculate the intended drop index
362
- let dropIdx = targetIdx;
363
- if (insertAfter) {
364
- dropIdx += 1;
365
- }
366
447
 
367
- // Adjust dropIdx if dragging forward in the list
368
- if (originalDragIdx < dropIdx) {
369
- dropIdx -= 1;
448
+ // Use the original drag index we captured before moving the element
449
+ const originalDragIdx = this.originalDragIndex;
450
+
451
+ // Calculate where the dragged element will end up in the final array
452
+ // targetIdx is the position of target element in current DOM (missing dragged element)
453
+
454
+ let dropIdx;
455
+ if (targetIdx < originalDragIdx) {
456
+ // Target is before the original drag position - moving backward
457
+ dropIdx = insertAfter ? targetIdx + 1 : targetIdx;
458
+ } else {
459
+ // Target was originally after the drag position - moving forward
460
+ // When moving the dragged element forward (i.e., to a higher index), the targetIdx is based on the current DOM,
461
+ // which no longer includes the dragged element. This means all elements after the original position have shifted left by one,
462
+ // so we need to subtract 1 from targetIdx to get the correct insertion index. If inserting after the target, we use targetIdx as is.
463
+ dropIdx = insertAfter ? targetIdx : targetIdx - 1;
370
464
  }
371
465
 
372
466
  // Store pending drop info but don't fire event yet
@@ -374,10 +468,10 @@ export class SortableList extends RapidElement {
374
468
  this.pendingDropIndex = dropIdx;
375
469
  this.pendingTargetElement = targetElement;
376
470
 
377
- // Show drop indicator
378
- this.showDropIndicator(targetElement, insertAfter);
471
+ // Show drop placeholder
472
+ this.showDropPlaceholder(targetElement, insertAfter);
379
473
  } else {
380
- this.hideDropIndicator();
474
+ this.hideDropPlaceholder();
381
475
  this.dropTargetId = null;
382
476
  this.pendingDropIndex = -1;
383
477
  this.pendingTargetElement = null;
@@ -390,16 +484,23 @@ export class SortableList extends RapidElement {
390
484
  evt.preventDefault();
391
485
  evt.stopPropagation();
392
486
 
393
- // restore visibility of the dragged item
487
+ // Remove the ghost clone from document.body
488
+ if (this.ghostElement) {
489
+ this.ghostElement.remove();
490
+ }
394
491
 
492
+ // Restore visibility of the original element by clearing inline styles
395
493
  if (this.downEle) {
396
- this.downEle.style.pointerEvents = '';
397
- this.downEle.style.opacity = '1';
494
+ this.downEle.style.display = this.originalDownDisplay;
398
495
  }
399
496
 
497
+ // Clear visual effects before firing events
498
+ this.hideDropPlaceholder();
499
+
400
500
  // fire the order changed event only when dropped if we have a valid drop position
401
501
  if (this.pendingDropIndex >= 0 && this.pendingTargetElement) {
402
- const originalDragIdx = this.getRowIndex(this.draggingEle.id);
502
+ // Use the original drag index we captured before hiding the element
503
+ const originalDragIdx = this.originalDragIndex;
403
504
 
404
505
  // use swap-based logic - report which indexes need to be swapped
405
506
  const fromIdx = originalDragIdx;
@@ -420,18 +521,15 @@ export class SortableList extends RapidElement {
420
521
  this.draggingId = null;
421
522
  this.dropTargetId = null;
422
523
  this.downEle = null;
524
+ this.originalElementRect = null;
525
+ this.originalDragIndex = -1;
423
526
  this.pendingDropIndex = -1;
424
527
  this.pendingTargetElement = null;
425
528
 
426
- if (this.ghostElement) {
427
- // Remove from body if present
428
- if (this.ghostElement.parentNode) {
429
- this.ghostElement.parentNode.removeChild(this.ghostElement);
430
- }
431
- this.ghostElement = null;
432
- }
529
+ // Clear the ghost reference since we removed it
530
+ this.ghostElement = null;
433
531
 
434
- this.hideDropIndicator();
532
+ this.hideDropPlaceholder();
435
533
 
436
534
  // Keep the click blocker active for a short time after drop
437
535
  if (this.clickBlocker) {
@@ -451,7 +549,10 @@ export class SortableList extends RapidElement {
451
549
 
452
550
  public render(): TemplateResult {
453
551
  return html`
454
- <div class="container ${this.horizontal ? 'horizontal' : ''}">
552
+ <div
553
+ class="container ${this.horizontal ? 'horizontal' : ''}"
554
+ style="gap: ${this.gap}"
555
+ >
455
556
  <slot @mousedown=${this.handleMouseDown}></slot>
456
557
  </div>
457
558
  `;
@@ -789,16 +789,13 @@ export class ContactChat extends ContactStoreElement {
789
789
  text: renderChannelEvent(event as ChannelEvent)
790
790
  };
791
791
  break;
792
+ default:
793
+ console.error('Unknown event type', event);
792
794
  }
793
795
 
794
- if (message && event.created_on) {
796
+ if (message) {
797
+ message.id = event.uuid;
795
798
  message.date = new Date(event.created_on);
796
- } else {
797
- console.error('Unknown event type', event);
798
- }
799
-
800
- if (!message.id) {
801
- message.id = event.uuid || event.type + '@' + event.created_on;
802
799
  }
803
800
 
804
801
  return message;
@@ -855,9 +852,14 @@ export class ContactChat extends ContactStoreElement {
855
852
  } else if (
856
853
  event.type === 'msg_created' ||
857
854
  event.type === 'msg_received' ||
855
+ event.type === 'ivr_created' ||
858
856
  event.type === 'broadcast_created'
859
857
  ) {
860
858
  const msgEvent = event as MsgEvent;
859
+ const status = msgEvent.status || msgEvent._status;
860
+ const failedReason =
861
+ msgEvent.failed_reason_display || msgEvent._failed_reason;
862
+
861
863
  messages.push({
862
864
  id: event.uuid,
863
865
  type: msgEvent.type === 'msg_received' ? 'msg_in' : 'msg_out',
@@ -865,7 +867,7 @@ export class ContactChat extends ContactStoreElement {
865
867
  date: new Date(msgEvent.created_on),
866
868
  attachments: msgEvent.msg.attachments,
867
869
  text: msgEvent.msg.text,
868
- sendError: msgEvent.status === 'E' || msgEvent.status === 'F',
870
+ sendError: status === 'E' || status === 'F',
869
871
  popup: html`<div
870
872
  style="display: flex; flex-direction: row; align-items:center; justify-content: space-between;font-size:0.9em;line-height:1em;min-width:10em"
871
873
  >
@@ -880,25 +882,30 @@ export class ContactChat extends ContactStoreElement {
880
882
  ${msgEvent.optin.name}
881
883
  </div>`
882
884
  : null}
883
- ${msgEvent.failed_reason_display
885
+ ${failedReason
884
886
  ? html`
885
887
  <div
886
888
  style="margin-top:0.2em;margin-right: 0.5em;min-width:10em;max-width:15em;color:var(--color-error);font-size:0.9em"
887
889
  >
888
- ${msgEvent.failed_reason_display}
890
+ ${failedReason}
889
891
  </div>
890
892
  `
891
893
  : null}
892
894
  </div>
893
- ${msgEvent.logs_url
894
- ? html`<a style="margin-left:0.5em" href="${msgEvent.logs_url}"
895
+ ${msgEvent.logs_url || msgEvent._logs_url
896
+ ? html`<a
897
+ style="margin-left:0.5em"
898
+ href="${msgEvent.logs_url || msgEvent._logs_url}"
895
899
  ><temba-icon name="log"></temba-icon
896
900
  ></a>`
897
901
  : null}
898
902
  </div> `
899
903
  });
900
904
  } else {
901
- messages.push(this.getEventMessage(event));
905
+ const msg = this.getEventMessage(event);
906
+ if (msg) {
907
+ messages.push(msg);
908
+ }
902
909
  }
903
910
  });
904
911