@nyaruka/temba-components 0.127.0 → 0.128.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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/demo/data/flows/sample-flow.json +127 -100
  3. package/demo/sticky-note-demo.html +152 -0
  4. package/dist/locales/es.js +5 -5
  5. package/dist/locales/es.js.map +1 -1
  6. package/dist/locales/fr.js +5 -5
  7. package/dist/locales/fr.js.map +1 -1
  8. package/dist/locales/locale-codes.js +11 -2
  9. package/dist/locales/locale-codes.js.map +1 -1
  10. package/dist/locales/pt.js +5 -5
  11. package/dist/locales/pt.js.map +1 -1
  12. package/dist/temba-components.js +346 -86
  13. package/dist/temba-components.js.map +1 -1
  14. package/out-tsc/src/chart/TembaChart.js +20 -3
  15. package/out-tsc/src/chart/TembaChart.js.map +1 -1
  16. package/out-tsc/src/flow/Editor.js +210 -1
  17. package/out-tsc/src/flow/Editor.js.map +1 -1
  18. package/out-tsc/src/flow/EditorNode.js +98 -139
  19. package/out-tsc/src/flow/EditorNode.js.map +1 -1
  20. package/out-tsc/src/flow/StickyNote.js +272 -0
  21. package/out-tsc/src/flow/StickyNote.js.map +1 -0
  22. package/out-tsc/src/list/RunList.js +2 -1
  23. package/out-tsc/src/list/RunList.js.map +1 -1
  24. package/out-tsc/src/list/SortableList.js +9 -0
  25. package/out-tsc/src/list/SortableList.js.map +1 -1
  26. package/out-tsc/src/locales/es.js +5 -5
  27. package/out-tsc/src/locales/es.js.map +1 -1
  28. package/out-tsc/src/locales/fr.js +5 -5
  29. package/out-tsc/src/locales/fr.js.map +1 -1
  30. package/out-tsc/src/locales/locale-codes.js +11 -2
  31. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  32. package/out-tsc/src/locales/pt.js +5 -5
  33. package/out-tsc/src/locales/pt.js.map +1 -1
  34. package/out-tsc/src/store/AppState.js +33 -0
  35. package/out-tsc/src/store/AppState.js.map +1 -1
  36. package/out-tsc/src/vectoricon/index.js +2 -1
  37. package/out-tsc/src/vectoricon/index.js.map +1 -1
  38. package/out-tsc/temba-modules.js +2 -0
  39. package/out-tsc/temba-modules.js.map +1 -1
  40. package/out-tsc/test/temba-flow-editor-node.test.js +249 -5
  41. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  42. package/out-tsc/test/temba-select.test.js +11 -17
  43. package/out-tsc/test/temba-select.test.js.map +1 -1
  44. package/out-tsc/test/utils.test.js +62 -0
  45. package/out-tsc/test/utils.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/screenshots/truth/sticky-note/blue.png +0 -0
  48. package/screenshots/truth/sticky-note/gray.png +0 -0
  49. package/screenshots/truth/sticky-note/green.png +0 -0
  50. package/screenshots/truth/sticky-note/pink.png +0 -0
  51. package/screenshots/truth/sticky-note/yellow.png +0 -0
  52. package/src/chart/TembaChart.ts +20 -3
  53. package/src/flow/Editor.ts +252 -2
  54. package/src/flow/EditorNode.ts +98 -156
  55. package/src/flow/StickyNote.ts +284 -0
  56. package/src/list/RunList.ts +2 -1
  57. package/src/list/SortableList.ts +11 -0
  58. package/src/locales/es.ts +18 -13
  59. package/src/locales/fr.ts +18 -13
  60. package/src/locales/locale-codes.ts +11 -2
  61. package/src/locales/pt.ts +18 -13
  62. package/src/store/AppState.ts +51 -1
  63. package/src/store/flow-definition.d.ts +8 -0
  64. package/src/vectoricon/index.ts +2 -1
  65. package/static/svg/index.pdf +137 -0
  66. package/temba-modules.ts +2 -0
  67. package/test/temba-flow-editor-node.test.ts +322 -6
  68. package/test/temba-select.test.ts +12 -20
  69. package/test/utils.test.ts +98 -0
  70. package/web-dev-server.config.mjs +30 -22
@@ -1,7 +1,7 @@
1
1
  import { html, TemplateResult } from 'lit-html';
2
2
  import { css, PropertyValueMap, unsafeCSS } from 'lit';
3
- import { property } from 'lit/decorators.js';
4
- import { FlowDefinition } from '../store/flow-definition';
3
+ import { property, state } from 'lit/decorators.js';
4
+ import { FlowDefinition, FlowPosition } from '../store/flow-definition';
5
5
  import { getStore } from '../store/Store';
6
6
  import { AppState, fromStore, zustand } from '../store/AppState';
7
7
  import { RapidElement } from '../RapidElement';
@@ -9,8 +9,21 @@ import { RapidElement } from '../RapidElement';
9
9
  import { Plumber } from './Plumber';
10
10
  import { EditorNode } from './EditorNode';
11
11
 
12
+ export function snapToGrid(value: number): number {
13
+ return Math.round(value / 20) * 20;
14
+ }
15
+
12
16
  const SAVE_QUIET_TIME = 500;
13
17
 
18
+ export interface DraggableItem {
19
+ uuid: string;
20
+ position: FlowPosition;
21
+ element: HTMLElement;
22
+ type: 'node' | 'sticky';
23
+ }
24
+
25
+ const DRAG_THRESHOLD = 10;
26
+
14
27
  export class Editor extends RapidElement {
15
28
  // unfortunately, jsplumb requires that we be in light DOM
16
29
  createRenderRoot() {
@@ -38,6 +51,20 @@ export class Editor extends RapidElement {
38
51
  @fromStore(zustand, (state: AppState) => state.dirtyDate)
39
52
  private dirtyDate!: Date;
40
53
 
54
+ // Drag state
55
+ @state()
56
+ private isDragging = false;
57
+ private isMouseDown = false;
58
+ private dragStartPos = { x: 0, y: 0 };
59
+
60
+ @state()
61
+ private currentDragItem: DraggableItem | null = null;
62
+ private startPos = { left: 0, top: 0 };
63
+
64
+ // Bound event handlers to maintain proper 'this' context
65
+ private boundMouseMove = this.handleMouseMove.bind(this);
66
+ private boundMouseUp = this.handleMouseUp.bind(this);
67
+
41
68
  static get styles() {
42
69
  return css`
43
70
  #editor {
@@ -86,6 +113,15 @@ export class Editor extends RapidElement {
86
113
  margin: 20px;
87
114
  }
88
115
 
116
+ #canvas > .draggable {
117
+ position: absolute;
118
+ z-index: 100;
119
+ }
120
+
121
+ #canvas > .dragging {
122
+ z-index: 10000 !important;
123
+ }
124
+
89
125
  body .jtk-endpoint {
90
126
  width: initial;
91
127
  height: initial;
@@ -183,6 +219,7 @@ export class Editor extends RapidElement {
183
219
  ): void {
184
220
  super.firstUpdated(changes);
185
221
  this.plumber = new Plumber(this.querySelector('#canvas'));
222
+ this.setupGlobalEventListeners();
186
223
  if (changes.has('flow')) {
187
224
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
188
225
  }
@@ -196,6 +233,10 @@ export class Editor extends RapidElement {
196
233
  // console.log('Setting canvas size', this.canvasSize);
197
234
  }
198
235
 
236
+ if (changes.has('definition')) {
237
+ this.updateCanvasSize();
238
+ }
239
+
199
240
  if (changes.has('dirtyDate')) {
200
241
  if (this.dirtyDate) {
201
242
  this.debouncedSave();
@@ -234,6 +275,189 @@ export class Editor extends RapidElement {
234
275
  clearTimeout(this.saveTimer);
235
276
  this.saveTimer = null;
236
277
  }
278
+ document.removeEventListener('mousemove', this.boundMouseMove);
279
+ document.removeEventListener('mouseup', this.boundMouseUp);
280
+ }
281
+
282
+ private setupGlobalEventListeners(): void {
283
+ document.addEventListener('mousemove', this.boundMouseMove);
284
+ document.addEventListener('mouseup', this.boundMouseUp);
285
+ }
286
+
287
+ private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition {
288
+ if (type === 'node') {
289
+ return this.definition._ui.nodes[uuid]?.position;
290
+ } else {
291
+ return this.definition._ui.stickies?.[uuid]?.position;
292
+ }
293
+ }
294
+
295
+ private updatePosition(
296
+ uuid: string,
297
+ type: 'node' | 'sticky',
298
+ position: FlowPosition
299
+ ): void {
300
+ if (type === 'node') {
301
+ getStore().getState().updateNodePosition(uuid, position);
302
+ } else {
303
+ const currentSticky = this.definition._ui.stickies?.[uuid];
304
+ if (currentSticky) {
305
+ getStore()
306
+ .getState()
307
+ .updateStickyNote(uuid, {
308
+ ...currentSticky,
309
+ position
310
+ });
311
+ }
312
+ }
313
+ }
314
+
315
+ private handleMouseDown(event: MouseEvent): void {
316
+ const element = event.currentTarget as HTMLElement;
317
+ // Only start dragging if clicking on the element itself, not on exits or other interactive elements
318
+ const target = event.target as HTMLElement;
319
+ if (target.classList.contains('exit') || target.closest('.exit')) {
320
+ return;
321
+ }
322
+
323
+ const uuid = element.getAttribute('uuid');
324
+ const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
325
+
326
+ const position = this.getPosition(uuid, type);
327
+ if (!position) return;
328
+
329
+ // Set up potential drag state, but don't start dragging yet
330
+ this.isMouseDown = true;
331
+ this.dragStartPos = { x: event.clientX, y: event.clientY };
332
+ this.startPos = { left: position.left, top: position.top };
333
+ this.currentDragItem = {
334
+ uuid,
335
+ position,
336
+ element,
337
+ type
338
+ };
339
+
340
+ event.preventDefault();
341
+ event.stopPropagation();
342
+ }
343
+
344
+ private handleMouseMove(event: MouseEvent): void {
345
+ if (!this.isMouseDown || !this.currentDragItem) return;
346
+
347
+ const deltaX = event.clientX - this.dragStartPos.x;
348
+ const deltaY = event.clientY - this.dragStartPos.y;
349
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
350
+
351
+ // Only start dragging if we've moved beyond the threshold
352
+ if (!this.isDragging && distance > DRAG_THRESHOLD) {
353
+ this.isDragging = true;
354
+
355
+ // If this is a node, elevate connections
356
+ if (this.currentDragItem.type === 'node' && this.plumber) {
357
+ this.plumber.elevateNodeConnections(this.currentDragItem.uuid);
358
+ }
359
+ }
360
+
361
+ // If we're actually dragging, update positions
362
+ if (this.isDragging) {
363
+ const newLeft = this.startPos.left + deltaX;
364
+ const newTop = this.startPos.top + deltaY;
365
+
366
+ // Update the visual position during drag
367
+ this.currentDragItem.element.style.left = `${newLeft}px`;
368
+ this.currentDragItem.element.style.top = `${newTop}px`;
369
+
370
+ // Repaint connections if this is a node
371
+ if (this.currentDragItem.type === 'node' && this.plumber) {
372
+ this.plumber.repaintEverything();
373
+ }
374
+ }
375
+ }
376
+
377
+ private handleMouseUp(event: MouseEvent): void {
378
+ if (!this.isMouseDown || !this.currentDragItem) return;
379
+
380
+ // If we were actually dragging, handle the drag end
381
+ if (this.isDragging) {
382
+ // Restore normal z-index for node connections
383
+ if (this.currentDragItem.type === 'node' && this.plumber) {
384
+ this.plumber.restoreNodeConnections(this.currentDragItem.uuid);
385
+ }
386
+
387
+ const deltaX = event.clientX - this.dragStartPos.x;
388
+ const deltaY = event.clientY - this.dragStartPos.y;
389
+
390
+ const newLeft = this.startPos.left + deltaX;
391
+ const newTop = this.startPos.top + deltaY;
392
+
393
+ // Snap to 20px grid for final position
394
+ const snappedLeft = snapToGrid(newLeft);
395
+ const snappedTop = snapToGrid(newTop);
396
+
397
+ const newPosition = { left: snappedLeft, top: snappedTop };
398
+
399
+ // Update the store with the new snapped position
400
+ this.updatePosition(
401
+ this.currentDragItem.uuid,
402
+ this.currentDragItem.type,
403
+ newPosition
404
+ );
405
+
406
+ // Update canvas positions for nodes
407
+ if (this.currentDragItem.type === 'node') {
408
+ getStore()
409
+ .getState()
410
+ .updateCanvasPositions({
411
+ [this.currentDragItem.uuid]: newPosition
412
+ });
413
+ }
414
+
415
+ // Repaint connections if this is a node
416
+ if (this.currentDragItem.type === 'node' && this.plumber) {
417
+ this.plumber.repaintEverything();
418
+ }
419
+ }
420
+
421
+ // Reset all drag state
422
+ this.isDragging = false;
423
+ this.isMouseDown = false;
424
+ this.currentDragItem = null;
425
+ }
426
+
427
+ private updateCanvasSize(): void {
428
+ if (!this.definition) return;
429
+
430
+ const store = getStore();
431
+ if (!store) return;
432
+
433
+ // Calculate required canvas size based on all elements
434
+ let maxWidth = 0;
435
+ let maxHeight = 0;
436
+
437
+ // Check node positions
438
+ this.definition.nodes.forEach((node) => {
439
+ const ui = this.definition._ui.nodes[node.uuid];
440
+ if (ui && ui.position) {
441
+ const nodeElement = this.querySelector(`[id="${node.uuid}"]`);
442
+ if (nodeElement) {
443
+ const rect = nodeElement.getBoundingClientRect();
444
+ maxWidth = Math.max(maxWidth, ui.position.left + rect.width);
445
+ maxHeight = Math.max(maxHeight, ui.position.top + rect.height);
446
+ }
447
+ }
448
+ });
449
+
450
+ // Check sticky note positions
451
+ const stickies = this.definition._ui?.stickies || {};
452
+ Object.values(stickies).forEach((sticky) => {
453
+ if (sticky.position) {
454
+ maxWidth = Math.max(maxWidth, sticky.position.left + 200); // Sticky note width
455
+ maxHeight = Math.max(maxHeight, sticky.position.top + 100); // Sticky note height
456
+ }
457
+ });
458
+
459
+ // Update canvas size in store
460
+ store.getState().expandCanvas(maxWidth, maxHeight);
237
461
  }
238
462
 
239
463
  public render(): TemplateResult {
@@ -243,6 +467,8 @@ export class Editor extends RapidElement {
243
467
  ${unsafeCSS(EditorNode.styles.cssText)}
244
468
  </style>`;
245
469
 
470
+ const stickies = this.definition?._ui?.stickies || {};
471
+
246
472
  return html`${style}
247
473
  <div id="editor">
248
474
  <div
@@ -253,13 +479,37 @@ export class Editor extends RapidElement {
253
479
  <div id="canvas">
254
480
  ${this.definition
255
481
  ? this.definition.nodes.map((node) => {
482
+ const position =
483
+ this.definition._ui.nodes[node.uuid].position;
484
+
485
+ const dragging =
486
+ this.isDragging && this.currentDragItem?.uuid === node.uuid;
487
+
256
488
  return html`<temba-flow-node
489
+ class="draggable ${dragging ? 'dragging' : ''}"
490
+ @mousedown=${this.handleMouseDown.bind(this)}
491
+ uuid=${node.uuid}
492
+ style="left:${position.left}px; top:${position.top}px"
257
493
  .plumber=${this.plumber}
258
494
  .node=${node}
259
495
  .ui=${this.definition._ui.nodes[node.uuid]}
260
496
  ></temba-flow-node>`;
261
497
  })
262
498
  : html`<temba-loading></temba-loading>`}
499
+ ${Object.entries(stickies).map(([uuid, sticky]) => {
500
+ const position = sticky.position || { left: 0, top: 0 };
501
+ const dragging =
502
+ this.isDragging && this.currentDragItem?.uuid === uuid;
503
+ return html`<temba-sticky-note
504
+ class="draggable ${dragging ? 'dragging' : ''}"
505
+ @mousedown=${this.handleMouseDown.bind(this)}
506
+ style="left:${position.left}px; top:${position.top}px; z-index: ${1000 +
507
+ position.top}"
508
+ uuid=${uuid}
509
+ .data=${sticky}
510
+ .dragging=${dragging}
511
+ ></temba-sticky-note>`;
512
+ })}
263
513
  </div>
264
514
  </div>
265
515
  </div>`;
@@ -21,26 +21,10 @@ export class EditorNode extends RapidElement {
21
21
  @property({ type: Object })
22
22
  private ui: NodeUI;
23
23
 
24
- // Drag state properties
25
- private isDragging = false;
26
- private dragStartPos = { x: 0, y: 0 };
27
- private nodeStartPos = { left: 0, top: 0 };
28
-
29
- // Bound event handlers to maintain proper 'this' context
30
- private boundMouseMove = this.handleMouseMove.bind(this);
31
- private boundMouseUp = this.handleMouseUp.bind(this);
32
-
33
- /**
34
- * Snaps a coordinate value to the nearest 20px grid position
35
- */
36
- private snapToGrid(value: number): number {
37
- return Math.round(value / 20) * 20;
38
- }
39
-
40
24
  static get styles() {
41
25
  return css`
26
+
42
27
  .node {
43
- position: absolute;
44
28
  background-color: #fff;
45
29
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
46
30
  min-width: 200px;
@@ -49,7 +33,7 @@ export class EditorNode extends RapidElement {
49
33
  color: #333;
50
34
  cursor: move;
51
35
  user-select: none;
52
- z-index: 500;
36
+
53
37
  }
54
38
 
55
39
  .node:hover {
@@ -64,21 +48,59 @@ export class EditorNode extends RapidElement {
64
48
 
65
49
  .action {
66
50
  max-width: 200px;
51
+ position: relative;
52
+ }
53
+
54
+ .action.sortable {
55
+ display: flex;
56
+ align-items: stretch;
57
+ }
58
+
59
+ .action .action-content {
60
+ flex-grow: 1;
61
+ display: flex;
62
+ flex-direction: column;
67
63
  }
68
64
 
69
65
  .action .body {
70
66
  padding: 1em;
71
67
  }
72
68
 
69
+ .action .drag-handle {
70
+ opacity: 0;
71
+ transition: all 200ms ease-in-out;
72
+ cursor: move;
73
+ background: rgba(0, 0, 0, 0.02);
74
+ max-width:0px;
75
+ position: absolute;
76
+ }
77
+
78
+ .action:hover .drag-handle {
79
+ opacity: 0.5;
80
+ padding: 0.25em;
81
+ max-width: 20px;
82
+ }
83
+
84
+ .action .drag-handle:hover {
85
+ opacity: 1;
86
+
87
+ }
88
+
73
89
  .action .title,
74
90
  .router .title {
91
+ display: flex;
75
92
  color: #fff;
76
93
  padding: 5px 1px;
77
94
  text-align: center;
78
95
  font-size: 1em;
79
96
  font-weight: normal;
97
+
80
98
  }
81
99
 
100
+ .title .name {
101
+ flex-grow: 1;
102
+ }
103
+
82
104
  .quick-replies {
83
105
  margin-top: 0.5em;
84
106
  }
@@ -133,176 +155,89 @@ export class EditorNode extends RapidElement {
133
155
  }`;
134
156
  }
135
157
 
158
+ constructor() {
159
+ super();
160
+ this.handleActionOrderChanged = this.handleActionOrderChanged.bind(this);
161
+ }
162
+
136
163
  protected updated(
137
164
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
138
165
  ): void {
139
166
  super.updated(changes);
140
167
  if (changes.has('node')) {
141
- this.plumber.makeTarget(this.node.uuid);
142
-
143
- // our node was changed, see if we have new destinations
144
- for (const exit of this.node.exits) {
145
- if (!exit.destination_uuid) {
146
- this.plumber.makeSource(exit.uuid);
147
- } else {
148
- this.plumber.connectIds(exit.uuid, exit.destination_uuid);
168
+ // make our initial connections
169
+ if (changes.get('node') === undefined) {
170
+ // this.plumber.makeTarget(this.node.uuid);
171
+ for (const exit of this.node.exits) {
172
+ if (!exit.destination_uuid) {
173
+ this.plumber.makeSource(exit.uuid);
174
+ } else {
175
+ this.plumber.connectIds(exit.uuid, exit.destination_uuid);
176
+ }
149
177
  }
150
178
  }
151
179
 
152
- const ele = this.querySelector('.node');
180
+ const ele = this.parentElement;
153
181
  const rect = ele.getBoundingClientRect();
154
182
 
155
183
  getStore()
156
- .getState()
184
+ ?.getState()
157
185
  .expandCanvas(
158
186
  this.ui.position.left + rect.width,
159
187
  this.ui.position.top + rect.height
160
188
  );
161
-
162
- // Add drag event listeners to the node
163
- this.addDragEventListeners();
164
189
  }
165
190
  }
166
191
 
167
- private addDragEventListeners(): void {
168
- const nodeElement = this.querySelector('.node') as HTMLElement;
169
- if (!nodeElement) return;
170
-
171
- nodeElement.addEventListener('mousedown', this.handleMouseDown.bind(this));
172
- document.addEventListener('mousemove', this.boundMouseMove);
173
- document.addEventListener('mouseup', this.boundMouseUp);
174
- }
192
+ private handleActionOrderChanged(event: CustomEvent) {
193
+ const [fromIdx, toIdx] = event.detail.swap;
175
194
 
176
- private handleMouseDown(event: MouseEvent): void {
177
- // Only start dragging if clicking on the node itself, not on exits or other interactive elements
178
- const target = event.target as HTMLElement;
179
- if (target.classList.contains('exit') || target.closest('.exit')) {
180
- return;
181
- }
195
+ // swap our actions
196
+ const newActions = [...this.node.actions];
197
+ const movedAction = newActions.splice(fromIdx, 1)[0];
198
+ newActions.splice(toIdx, 0, movedAction);
182
199
 
183
- this.isDragging = true;
184
- this.dragStartPos = { x: event.clientX, y: event.clientY };
185
- this.nodeStartPos = {
186
- left: this.ui.position.left,
187
- top: this.ui.position.top
188
- };
189
-
190
- // Add dragging class for visual feedback
191
- const nodeElement = this.querySelector('.node') as HTMLElement;
192
- if (nodeElement) {
193
- nodeElement.classList.add('dragging');
194
- }
200
+ // udate our internal reprensentation, this isn't strictly necessary
201
+ // since the editor will update us from it's definition subscription
202
+ // but it makes testing a lot easier
203
+ this.node = { ...this.node, actions: newActions };
195
204
 
196
- // Elevate connections for this node during dragging
197
- if (this.plumber) {
198
- this.plumber.elevateNodeConnections(this.node.uuid);
199
- }
200
-
201
- event.preventDefault();
202
- event.stopPropagation();
203
- }
204
-
205
- private handleMouseMove(event: MouseEvent): void {
206
- if (!this.isDragging) return;
207
-
208
- const deltaX = event.clientX - this.dragStartPos.x;
209
- const deltaY = event.clientY - this.dragStartPos.y;
210
-
211
- const newLeft = this.nodeStartPos.left + deltaX;
212
- const newTop = this.nodeStartPos.top + deltaY;
213
-
214
- // Update the UI position temporarily (for visual feedback)
215
- const nodeElement = this.querySelector('.node') as HTMLElement;
216
- if (nodeElement) {
217
- nodeElement.style.left = `${newLeft}px`;
218
- nodeElement.style.top = `${newTop}px`;
219
- }
220
-
221
- // Repaint connections during dragging for smooth updates
222
- if (this.plumber) {
223
- this.plumber.repaintEverything();
224
- }
225
- }
226
-
227
- private handleMouseUp(event: MouseEvent): void {
228
- if (!this.isDragging) return;
229
-
230
- this.isDragging = false;
231
-
232
- // Remove dragging class
233
- const nodeElement = this.querySelector('.node') as HTMLElement;
234
- if (nodeElement) {
235
- nodeElement.classList.remove('dragging');
236
- }
237
-
238
- // Restore normal z-index for connections
239
- if (this.plumber) {
240
- this.plumber.restoreNodeConnections(this.node.uuid);
241
- }
242
-
243
- const deltaX = event.clientX - this.dragStartPos.x;
244
- const deltaY = event.clientY - this.dragStartPos.y;
245
-
246
- const newLeft = this.nodeStartPos.left + deltaX;
247
- const newTop = this.nodeStartPos.top + deltaY;
248
-
249
- // Snap to 20px grid for final position
250
- const snappedLeft = this.snapToGrid(newLeft);
251
- const snappedTop = this.snapToGrid(newTop);
252
-
253
- // Update the store with the new snapped position
254
- const newPosition = { left: snappedLeft, top: snappedTop };
255
205
  getStore()
256
- .getState()
257
- .updateCanvasPositions({
258
- [this.node.uuid]: newPosition
259
- });
260
-
261
- // Repaint connections if plumber is available
262
- if (this.plumber) {
263
- this.plumber.repaintEverything();
264
- }
265
-
266
- getStore().getState().updateNodePosition(this.node.uuid, newPosition);
267
-
268
- // Fire a custom event with the new coordinates
269
- /*this.fireCustomEvent(CustomEventType.Moved, {
270
- nodeId: this.node.uuid,
271
- position: newPosition,
272
- oldPosition: {
273
- left: this.nodeStartPos.left,
274
- top: this.nodeStartPos.top
275
- }
276
- });*/
277
- }
278
-
279
- disconnectedCallback(): void {
280
- super.disconnectedCallback();
281
- // Clean up event listeners
282
- document.removeEventListener('mousemove', this.boundMouseMove);
283
- document.removeEventListener('mouseup', this.boundMouseUp);
206
+ ?.getState()
207
+ .updateNode(this.node.uuid, { ...this.node, actions: newActions });
284
208
  }
285
209
 
286
210
  private renderTitle(config: UIConfig) {
287
211
  return html`<div class="title" style="background:${config.color}">
288
- ${config.name}
212
+ ${this.node?.actions?.length > 1
213
+ ? html`<temba-icon class="drag-handle" name="sort"></temba-icon>`
214
+ : null}
215
+
216
+ <div class="name">${config.name}</div>
289
217
  </div>`;
290
218
  }
291
219
 
292
- private renderAction(node: Node, action: Action) {
220
+ private renderAction(node: Node, action: Action, index: number) {
293
221
  const config = EDITOR_CONFIG[action.type];
294
222
 
295
223
  if (config) {
296
- return html`<div class="action ${action.type}">
297
- ${this.renderTitle(config)}
298
- <div class="body">
299
- ${config.render
300
- ? config.render(node, action)
301
- : html`<pre>${action.type}</pre>`}
224
+ return html`<div
225
+ class="action sortable ${action.type}"
226
+ id="action-${index}"
227
+ >
228
+ <div class="action-content">
229
+ ${this.renderTitle(config)}
230
+ <div class="body">
231
+ ${config.render
232
+ ? config.render(node, action)
233
+ : html`<pre>${action.type}</pre>`}
234
+ </div>
302
235
  </div>
303
236
  </div>`;
304
237
  }
305
- return html`<div>${action.type}</div>`;
238
+ return html`<div class="action sortable" id="action-${index}">
239
+ ${action.type}
240
+ </div>`;
306
241
  }
307
242
 
308
243
  private renderRouter(router: Router, ui: NodeUI) {
@@ -359,9 +294,16 @@ export class EditorNode extends RapidElement {
359
294
  class="node"
360
295
  style="left:${this.ui.position.left}px;top:${this.ui.position.top}px"
361
296
  >
362
- ${this.node.actions.map((actionSpec) => {
363
- return this.renderAction(this.node, actionSpec);
364
- })}
297
+ ${this.node.actions.length > 0
298
+ ? html`<temba-sortable-list
299
+ dragHandle="drag-handle"
300
+ @temba-order-changed="${this.handleActionOrderChanged}"
301
+ >
302
+ ${this.node.actions.map((actionSpec, index) => {
303
+ return this.renderAction(this.node, actionSpec, index);
304
+ })}
305
+ </temba-sortable-list>`
306
+ : ''}
365
307
  ${this.node.router
366
308
  ? html` ${this.renderRouter(this.node.router, this.ui)}
367
309
  ${this.renderCategories(this.node)}`