@nyaruka/temba-components 0.125.0 → 0.126.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.
@@ -0,0 +1,257 @@
1
+ import { fixture, assert } from '@open-wc/testing';
2
+ import { html } from 'lit';
3
+ describe('temba-flow-node drag and drop functionality', () => {
4
+ it('should add drag styling and event listeners to elements', async () => {
5
+ // Create a simple div to test our drag functionality
6
+ const testElement = await fixture(html `
7
+ <div
8
+ class="node"
9
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
10
+ >
11
+ Test Node
12
+ <div class="exit" style="padding: 5px; background: red;">Exit</div>
13
+ </div>
14
+ `);
15
+ // Test that the node has the correct cursor style
16
+ const computedStyle = window.getComputedStyle(testElement);
17
+ assert.equal(computedStyle.cursor, 'move');
18
+ // Test drag event simulation
19
+ let dragStarted = false;
20
+ let dragEnded = false;
21
+ let newPosition = { left: 0, top: 0 };
22
+ // Simulate our drag implementation
23
+ let isDragging = false;
24
+ let dragStartPos = { x: 0, y: 0 };
25
+ let nodeStartPos = { left: 100, top: 100 };
26
+ const handleMouseDown = (event) => {
27
+ const target = event.target;
28
+ if (target.classList.contains('exit') || target.closest('.exit')) {
29
+ return;
30
+ }
31
+ isDragging = true;
32
+ dragStarted = true;
33
+ dragStartPos = { x: event.clientX, y: event.clientY };
34
+ nodeStartPos = { left: 100, top: 100 };
35
+ event.preventDefault();
36
+ event.stopPropagation();
37
+ };
38
+ const handleMouseMove = (event) => {
39
+ if (!isDragging)
40
+ return;
41
+ const deltaX = event.clientX - dragStartPos.x;
42
+ const deltaY = event.clientY - dragStartPos.y;
43
+ const newLeft = nodeStartPos.left + deltaX;
44
+ const newTop = nodeStartPos.top + deltaY;
45
+ // Update position
46
+ testElement.style.left = `${newLeft}px`;
47
+ testElement.style.top = `${newTop}px`;
48
+ };
49
+ const handleMouseUp = (event) => {
50
+ if (!isDragging)
51
+ return;
52
+ isDragging = false;
53
+ dragEnded = true;
54
+ const deltaX = event.clientX - dragStartPos.x;
55
+ const deltaY = event.clientY - dragStartPos.y;
56
+ newPosition = {
57
+ left: nodeStartPos.left + deltaX,
58
+ top: nodeStartPos.top + deltaY
59
+ };
60
+ };
61
+ // Add event listeners
62
+ testElement.addEventListener('mousedown', handleMouseDown);
63
+ document.addEventListener('mousemove', handleMouseMove);
64
+ document.addEventListener('mouseup', handleMouseUp);
65
+ // Test drag from node (should work)
66
+ const mouseDownEvent = new MouseEvent('mousedown', {
67
+ clientX: 150,
68
+ clientY: 150,
69
+ bubbles: true
70
+ });
71
+ testElement.dispatchEvent(mouseDownEvent);
72
+ assert.isTrue(dragStarted, 'Drag should start when clicking on node');
73
+ const mouseMoveEvent = new MouseEvent('mousemove', {
74
+ clientX: 200,
75
+ clientY: 200,
76
+ bubbles: true
77
+ });
78
+ document.dispatchEvent(mouseMoveEvent);
79
+ const mouseUpEvent = new MouseEvent('mouseup', {
80
+ clientX: 200,
81
+ clientY: 200,
82
+ bubbles: true
83
+ });
84
+ document.dispatchEvent(mouseUpEvent);
85
+ assert.isTrue(dragEnded, 'Drag should end on mouse up');
86
+ assert.equal(newPosition.left, 150, 'New left position should be calculated correctly');
87
+ assert.equal(newPosition.top, 150, 'New top position should be calculated correctly');
88
+ // Test that position was visually updated
89
+ assert.equal(testElement.style.left, '150px');
90
+ assert.equal(testElement.style.top, '150px');
91
+ // Reset for next test
92
+ dragStarted = false;
93
+ dragEnded = false;
94
+ // Test drag from exit element (should not work)
95
+ const exitElement = testElement.querySelector('.exit');
96
+ const exitMouseDownEvent = new MouseEvent('mousedown', {
97
+ clientX: 150,
98
+ clientY: 150,
99
+ bubbles: true
100
+ });
101
+ exitElement.dispatchEvent(exitMouseDownEvent);
102
+ assert.isFalse(dragStarted, 'Drag should not start when clicking on exit element');
103
+ // Clean up
104
+ document.removeEventListener('mousemove', handleMouseMove);
105
+ document.removeEventListener('mouseup', handleMouseUp);
106
+ });
107
+ it('should snap node positions to 20px grid during drag and drop', async () => {
108
+ // Create a test node element
109
+ const testElement = await fixture(html `
110
+ <div
111
+ class="node"
112
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
113
+ >
114
+ Test Node for Grid Snapping
115
+ </div>
116
+ `);
117
+ // Helper function to snap values to 20px grid (same as implementation)
118
+ const snapToGrid = (value) => {
119
+ return Math.round(value / 20) * 20;
120
+ };
121
+ // Simulate drag implementation with grid snapping
122
+ let isDragging = false;
123
+ let dragStartPos = { x: 0, y: 0 };
124
+ let nodeStartPos = { left: 100, top: 100 };
125
+ let finalPosition = { left: 0, top: 0 };
126
+ const handleMouseDown = (event) => {
127
+ isDragging = true;
128
+ dragStartPos = { x: event.clientX, y: event.clientY };
129
+ // Get current position from element style
130
+ const currentLeft = parseInt(testElement.style.left) || 0;
131
+ const currentTop = parseInt(testElement.style.top) || 0;
132
+ nodeStartPos = { left: currentLeft, top: currentTop };
133
+ event.preventDefault();
134
+ event.stopPropagation();
135
+ };
136
+ const handleMouseMove = (event) => {
137
+ if (!isDragging)
138
+ return;
139
+ const deltaX = event.clientX - dragStartPos.x;
140
+ const deltaY = event.clientY - dragStartPos.y;
141
+ const newLeft = nodeStartPos.left + deltaX;
142
+ const newTop = nodeStartPos.top + deltaY;
143
+ // Snap to 20px grid
144
+ const snappedLeft = snapToGrid(newLeft);
145
+ const snappedTop = snapToGrid(newTop);
146
+ // Update position with snapped values
147
+ testElement.style.left = `${snappedLeft}px`;
148
+ testElement.style.top = `${snappedTop}px`;
149
+ };
150
+ const handleMouseUp = (event) => {
151
+ if (!isDragging)
152
+ return;
153
+ isDragging = false;
154
+ const deltaX = event.clientX - dragStartPos.x;
155
+ const deltaY = event.clientY - dragStartPos.y;
156
+ const newLeft = nodeStartPos.left + deltaX;
157
+ const newTop = nodeStartPos.top + deltaY;
158
+ // Snap final position to grid
159
+ finalPosition = {
160
+ left: snapToGrid(newLeft),
161
+ top: snapToGrid(newTop)
162
+ };
163
+ };
164
+ // Add event listeners
165
+ testElement.addEventListener('mousedown', handleMouseDown);
166
+ document.addEventListener('mousemove', handleMouseMove);
167
+ document.addEventListener('mouseup', handleMouseUp);
168
+ // Test Case 1: Drag to position that should snap to grid
169
+ // Starting at (100, 100), drag by (33, 27) pixels
170
+ // Should result in (133, 127) -> snapped to (140, 120)
171
+ const mouseDownEvent1 = new MouseEvent('mousedown', {
172
+ clientX: 150,
173
+ clientY: 150,
174
+ bubbles: true
175
+ });
176
+ testElement.dispatchEvent(mouseDownEvent1);
177
+ const mouseMoveEvent1 = new MouseEvent('mousemove', {
178
+ clientX: 183, // 150 + 33
179
+ clientY: 177, // 150 + 27
180
+ bubbles: true
181
+ });
182
+ document.dispatchEvent(mouseMoveEvent1);
183
+ // Check that position is snapped during drag
184
+ assert.equal(testElement.style.left, '140px');
185
+ assert.equal(testElement.style.top, '120px');
186
+ const mouseUpEvent1 = new MouseEvent('mouseup', {
187
+ clientX: 183,
188
+ clientY: 177,
189
+ bubbles: true
190
+ });
191
+ document.dispatchEvent(mouseUpEvent1);
192
+ // Check final snapped position
193
+ assert.equal(finalPosition.left, 140, 'Final left position should be snapped to 140px');
194
+ assert.equal(finalPosition.top, 120, 'Final top position should be snapped to 120px');
195
+ // Test Case 2: Test different snap scenarios
196
+ // Reset position
197
+ nodeStartPos = { left: 60, top: 80 };
198
+ testElement.style.left = '60px';
199
+ testElement.style.top = '80px';
200
+ const mouseDownEvent2 = new MouseEvent('mousedown', {
201
+ clientX: 100,
202
+ clientY: 100,
203
+ bubbles: true
204
+ });
205
+ testElement.dispatchEvent(mouseDownEvent2);
206
+ // Drag by (15, 25) -> should snap to nearest 20px grid
207
+ // (60 + 15, 80 + 25) = (75, 105) -> snapped to (80, 100)
208
+ const mouseMoveEvent2 = new MouseEvent('mousemove', {
209
+ clientX: 115, // 100 + 15
210
+ clientY: 125, // 100 + 25
211
+ bubbles: true
212
+ });
213
+ document.dispatchEvent(mouseMoveEvent2);
214
+ assert.equal(testElement.style.left, '80px');
215
+ assert.equal(testElement.style.top, '100px');
216
+ const mouseUpEvent2 = new MouseEvent('mouseup', {
217
+ clientX: 115,
218
+ clientY: 125,
219
+ bubbles: true
220
+ });
221
+ document.dispatchEvent(mouseUpEvent2);
222
+ assert.equal(finalPosition.left, 80, 'Final left position should be snapped to 80px');
223
+ assert.equal(finalPosition.top, 100, 'Final top position should be snapped to 100px');
224
+ // Test Case 3: Test exact grid positions remain unchanged
225
+ nodeStartPos = { left: 120, top: 160 }; // Already on grid
226
+ testElement.style.left = '120px';
227
+ testElement.style.top = '160px';
228
+ const mouseDownEvent3 = new MouseEvent('mousedown', {
229
+ clientX: 200,
230
+ clientY: 200,
231
+ bubbles: true
232
+ });
233
+ testElement.dispatchEvent(mouseDownEvent3);
234
+ // Small movement that should stay on same grid position
235
+ const mouseMoveEvent3 = new MouseEvent('mousemove', {
236
+ clientX: 202, // +2 pixels
237
+ clientY: 203, // +3 pixels
238
+ bubbles: true
239
+ });
240
+ document.dispatchEvent(mouseMoveEvent3);
241
+ // (120 + 2, 160 + 3) = (122, 163) -> snapped to (120, 160)
242
+ assert.equal(testElement.style.left, '120px');
243
+ assert.equal(testElement.style.top, '160px');
244
+ const mouseUpEvent3 = new MouseEvent('mouseup', {
245
+ clientX: 202,
246
+ clientY: 203,
247
+ bubbles: true
248
+ });
249
+ document.dispatchEvent(mouseUpEvent3);
250
+ assert.equal(finalPosition.left, 120, 'Position should remain on grid');
251
+ assert.equal(finalPosition.top, 160, 'Position should remain on grid');
252
+ // Clean up
253
+ document.removeEventListener('mousemove', handleMouseMove);
254
+ document.removeEventListener('mouseup', handleMouseUp);
255
+ });
256
+ });
257
+ //# sourceMappingURL=temba-flow-node-drag.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"temba-flow-node-drag.test.js","sourceRoot":"","sources":["../../test/temba-flow-node-drag.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { fixture, assert } from '@open-wc/testing';\nimport { html } from 'lit';\n\ndescribe('temba-flow-node drag and drop functionality', () => {\n it('should add drag styling and event listeners to elements', async () => {\n // Create a simple div to test our drag functionality\n const testElement = await fixture(html`\n <div\n class=\"node\"\n style=\"position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;\"\n >\n Test Node\n <div class=\"exit\" style=\"padding: 5px; background: red;\">Exit</div>\n </div>\n `);\n\n // Test that the node has the correct cursor style\n const computedStyle = window.getComputedStyle(testElement);\n assert.equal(computedStyle.cursor, 'move');\n\n // Test drag event simulation\n let dragStarted = false;\n let dragEnded = false;\n let newPosition = { left: 0, top: 0 };\n\n // Simulate our drag implementation\n let isDragging = false;\n let dragStartPos = { x: 0, y: 0 };\n let nodeStartPos = { left: 100, top: 100 };\n\n const handleMouseDown = (event: MouseEvent) => {\n const target = event.target as HTMLElement;\n if (target.classList.contains('exit') || target.closest('.exit')) {\n return;\n }\n\n isDragging = true;\n dragStarted = true;\n dragStartPos = { x: event.clientX, y: event.clientY };\n nodeStartPos = { left: 100, top: 100 };\n event.preventDefault();\n event.stopPropagation();\n };\n\n const handleMouseMove = (event: MouseEvent) => {\n if (!isDragging) return;\n\n const deltaX = event.clientX - dragStartPos.x;\n const deltaY = event.clientY - dragStartPos.y;\n\n const newLeft = nodeStartPos.left + deltaX;\n const newTop = nodeStartPos.top + deltaY;\n\n // Update position\n (testElement as HTMLElement).style.left = `${newLeft}px`;\n (testElement as HTMLElement).style.top = `${newTop}px`;\n };\n\n const handleMouseUp = (event: MouseEvent) => {\n if (!isDragging) return;\n\n isDragging = false;\n dragEnded = true;\n\n const deltaX = event.clientX - dragStartPos.x;\n const deltaY = event.clientY - dragStartPos.y;\n\n newPosition = {\n left: nodeStartPos.left + deltaX,\n top: nodeStartPos.top + deltaY\n };\n };\n\n // Add event listeners\n testElement.addEventListener('mousedown', handleMouseDown);\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n\n // Test drag from node (should work)\n const mouseDownEvent = new MouseEvent('mousedown', {\n clientX: 150,\n clientY: 150,\n bubbles: true\n });\n testElement.dispatchEvent(mouseDownEvent);\n\n assert.isTrue(dragStarted, 'Drag should start when clicking on node');\n\n const mouseMoveEvent = new MouseEvent('mousemove', {\n clientX: 200,\n clientY: 200,\n bubbles: true\n });\n document.dispatchEvent(mouseMoveEvent);\n\n const mouseUpEvent = new MouseEvent('mouseup', {\n clientX: 200,\n clientY: 200,\n bubbles: true\n });\n document.dispatchEvent(mouseUpEvent);\n\n assert.isTrue(dragEnded, 'Drag should end on mouse up');\n assert.equal(\n newPosition.left,\n 150,\n 'New left position should be calculated correctly'\n );\n assert.equal(\n newPosition.top,\n 150,\n 'New top position should be calculated correctly'\n );\n\n // Test that position was visually updated\n assert.equal((testElement as HTMLElement).style.left, '150px');\n assert.equal((testElement as HTMLElement).style.top, '150px');\n\n // Reset for next test\n dragStarted = false;\n dragEnded = false;\n\n // Test drag from exit element (should not work)\n const exitElement = testElement.querySelector('.exit') as HTMLElement;\n const exitMouseDownEvent = new MouseEvent('mousedown', {\n clientX: 150,\n clientY: 150,\n bubbles: true\n });\n exitElement.dispatchEvent(exitMouseDownEvent);\n\n assert.isFalse(\n dragStarted,\n 'Drag should not start when clicking on exit element'\n );\n\n // Clean up\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n });\n\n it('should snap node positions to 20px grid during drag and drop', async () => {\n // Create a test node element\n const testElement = await fixture(html`\n <div\n class=\"node\"\n style=\"position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;\"\n >\n Test Node for Grid Snapping\n </div>\n `);\n\n // Helper function to snap values to 20px grid (same as implementation)\n const snapToGrid = (value: number): number => {\n return Math.round(value / 20) * 20;\n };\n\n // Simulate drag implementation with grid snapping\n let isDragging = false;\n let dragStartPos = { x: 0, y: 0 };\n let nodeStartPos = { left: 100, top: 100 };\n let finalPosition = { left: 0, top: 0 };\n\n const handleMouseDown = (event: MouseEvent) => {\n isDragging = true;\n dragStartPos = { x: event.clientX, y: event.clientY };\n // Get current position from element style\n const currentLeft =\n parseInt((testElement as HTMLElement).style.left) || 0;\n const currentTop = parseInt((testElement as HTMLElement).style.top) || 0;\n nodeStartPos = { left: currentLeft, top: currentTop };\n event.preventDefault();\n event.stopPropagation();\n };\n\n const handleMouseMove = (event: MouseEvent) => {\n if (!isDragging) return;\n\n const deltaX = event.clientX - dragStartPos.x;\n const deltaY = event.clientY - dragStartPos.y;\n\n const newLeft = nodeStartPos.left + deltaX;\n const newTop = nodeStartPos.top + deltaY;\n\n // Snap to 20px grid\n const snappedLeft = snapToGrid(newLeft);\n const snappedTop = snapToGrid(newTop);\n\n // Update position with snapped values\n (testElement as HTMLElement).style.left = `${snappedLeft}px`;\n (testElement as HTMLElement).style.top = `${snappedTop}px`;\n };\n\n const handleMouseUp = (event: MouseEvent) => {\n if (!isDragging) return;\n isDragging = false;\n\n const deltaX = event.clientX - dragStartPos.x;\n const deltaY = event.clientY - dragStartPos.y;\n\n const newLeft = nodeStartPos.left + deltaX;\n const newTop = nodeStartPos.top + deltaY;\n\n // Snap final position to grid\n finalPosition = {\n left: snapToGrid(newLeft),\n top: snapToGrid(newTop)\n };\n };\n\n // Add event listeners\n testElement.addEventListener('mousedown', handleMouseDown);\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n\n // Test Case 1: Drag to position that should snap to grid\n // Starting at (100, 100), drag by (33, 27) pixels\n // Should result in (133, 127) -> snapped to (140, 120)\n const mouseDownEvent1 = new MouseEvent('mousedown', {\n clientX: 150,\n clientY: 150,\n bubbles: true\n });\n testElement.dispatchEvent(mouseDownEvent1);\n\n const mouseMoveEvent1 = new MouseEvent('mousemove', {\n clientX: 183, // 150 + 33\n clientY: 177, // 150 + 27\n bubbles: true\n });\n document.dispatchEvent(mouseMoveEvent1);\n\n // Check that position is snapped during drag\n assert.equal((testElement as HTMLElement).style.left, '140px');\n assert.equal((testElement as HTMLElement).style.top, '120px');\n\n const mouseUpEvent1 = new MouseEvent('mouseup', {\n clientX: 183,\n clientY: 177,\n bubbles: true\n });\n document.dispatchEvent(mouseUpEvent1);\n\n // Check final snapped position\n assert.equal(\n finalPosition.left,\n 140,\n 'Final left position should be snapped to 140px'\n );\n assert.equal(\n finalPosition.top,\n 120,\n 'Final top position should be snapped to 120px'\n );\n\n // Test Case 2: Test different snap scenarios\n // Reset position\n nodeStartPos = { left: 60, top: 80 };\n (testElement as HTMLElement).style.left = '60px';\n (testElement as HTMLElement).style.top = '80px';\n\n const mouseDownEvent2 = new MouseEvent('mousedown', {\n clientX: 100,\n clientY: 100,\n bubbles: true\n });\n testElement.dispatchEvent(mouseDownEvent2);\n\n // Drag by (15, 25) -> should snap to nearest 20px grid\n // (60 + 15, 80 + 25) = (75, 105) -> snapped to (80, 100)\n const mouseMoveEvent2 = new MouseEvent('mousemove', {\n clientX: 115, // 100 + 15\n clientY: 125, // 100 + 25\n bubbles: true\n });\n document.dispatchEvent(mouseMoveEvent2);\n\n assert.equal((testElement as HTMLElement).style.left, '80px');\n assert.equal((testElement as HTMLElement).style.top, '100px');\n\n const mouseUpEvent2 = new MouseEvent('mouseup', {\n clientX: 115,\n clientY: 125,\n bubbles: true\n });\n document.dispatchEvent(mouseUpEvent2);\n\n assert.equal(\n finalPosition.left,\n 80,\n 'Final left position should be snapped to 80px'\n );\n assert.equal(\n finalPosition.top,\n 100,\n 'Final top position should be snapped to 100px'\n );\n\n // Test Case 3: Test exact grid positions remain unchanged\n nodeStartPos = { left: 120, top: 160 }; // Already on grid\n (testElement as HTMLElement).style.left = '120px';\n (testElement as HTMLElement).style.top = '160px';\n\n const mouseDownEvent3 = new MouseEvent('mousedown', {\n clientX: 200,\n clientY: 200,\n bubbles: true\n });\n testElement.dispatchEvent(mouseDownEvent3);\n\n // Small movement that should stay on same grid position\n const mouseMoveEvent3 = new MouseEvent('mousemove', {\n clientX: 202, // +2 pixels\n clientY: 203, // +3 pixels\n bubbles: true\n });\n document.dispatchEvent(mouseMoveEvent3);\n\n // (120 + 2, 160 + 3) = (122, 163) -> snapped to (120, 160)\n assert.equal((testElement as HTMLElement).style.left, '120px');\n assert.equal((testElement as HTMLElement).style.top, '160px');\n\n const mouseUpEvent3 = new MouseEvent('mouseup', {\n clientX: 202,\n clientY: 203,\n bubbles: true\n });\n document.dispatchEvent(mouseUpEvent3);\n\n assert.equal(finalPosition.left, 120, 'Position should remain on grid');\n assert.equal(finalPosition.top, 160, 'Position should remain on grid');\n\n // Clean up\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n });\n});\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.125.0",
3
+ "version": "0.126.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -295,6 +295,9 @@ export class TembaChart extends RapidElement {
295
295
  @property({ type: String })
296
296
  yType: 'count' | 'duration' = 'count';
297
297
 
298
+ @property({ type: String })
299
+ xFormat: 'MMM dd' | 'DD' | 'EEE' = 'MMM dd';
300
+
298
301
  @property({ type: Boolean })
299
302
  hideOther: boolean = false;
300
303
 
@@ -674,11 +677,13 @@ export class TembaChart extends RapidElement {
674
677
  type: this.xType,
675
678
  grid: { display: false },
676
679
  stacked: true,
677
- time: {
678
- unit: 'day',
679
- tooltipFormat: 'DDD',
680
- displayFormats: { day: 'MMM dd' }
681
- }
680
+ ...(this.xType === 'time' && {
681
+ time: {
682
+ unit: 'day',
683
+ tooltipFormat: 'DDD',
684
+ displayFormats: { day: this.xFormat }
685
+ }
686
+ })
682
687
  }
683
688
  }
684
689
  }
@@ -9,15 +9,20 @@ import { RapidElement } from '../RapidElement';
9
9
  import { Plumber } from './Plumber';
10
10
  import { EditorNode } from './EditorNode';
11
11
 
12
+ const SAVE_QUIET_TIME = 500;
13
+
12
14
  export class Editor extends RapidElement {
13
- // Unfortunately, jsplumb requires that we be in light DOM
15
+ // unfortunately, jsplumb requires that we be in light DOM
14
16
  createRenderRoot() {
15
17
  return this;
16
18
  }
17
19
 
18
- // This is the master plumber
20
+ // this is the master plumber
19
21
  private plumber: Plumber;
20
22
 
23
+ // timer for debounced saving
24
+ private saveTimer: number | null = null;
25
+
21
26
  @property({ type: String })
22
27
  public flow: string;
23
28
 
@@ -30,6 +35,9 @@ export class Editor extends RapidElement {
30
35
  @fromStore(zustand, (state: AppState) => state.canvasSize)
31
36
  private canvasSize!: { width: number; height: number };
32
37
 
38
+ @fromStore(zustand, (state: AppState) => state.dirtyDate)
39
+ private dirtyDate!: Date;
40
+
33
41
  static get styles() {
34
42
  return css`
35
43
  #editor {
@@ -40,7 +48,7 @@ export class Editor extends RapidElement {
40
48
  #grid {
41
49
  position: relative;
42
50
  background-color: #f9f9f9;
43
- background-position: 13px 13px;
51
+ background-position: 10px 10px;
44
52
  background-image: linear-gradient(
45
53
  0deg,
46
54
  transparent 24%,
@@ -84,11 +92,11 @@ export class Editor extends RapidElement {
84
92
  }
85
93
 
86
94
  .jtk-endpoint {
87
- z-index: 1;
95
+ z-index: 600;
88
96
  }
89
97
 
90
98
  .plumb-source {
91
- z-index: 300;
99
+ z-index: 600;
92
100
  border: 0px solid var(--color-connectors);
93
101
  }
94
102
 
@@ -112,7 +120,7 @@ export class Editor extends RapidElement {
112
120
 
113
121
  .plumb-target {
114
122
  margin-top: -6px;
115
- z-index: 200;
123
+ z-index: 600;
116
124
  opacity: 0;
117
125
  cursor: pointer;
118
126
  }
@@ -120,6 +128,29 @@ export class Editor extends RapidElement {
120
128
  body .plumb-connector path {
121
129
  stroke: var(--color-connectors) !important;
122
130
  stroke-width: 3px;
131
+ z-index: 10;
132
+ }
133
+
134
+ body .plumb-connector {
135
+ z-index: 10;
136
+ }
137
+
138
+ body .plumb-connector.elevated {
139
+ z-index: 550;
140
+ }
141
+
142
+ body .plumb-connector.elevated path {
143
+ stroke: var(--color-connectors) !important;
144
+ stroke-width: 3px;
145
+ z-index: 550;
146
+ }
147
+
148
+ body .plumb-connector.elevated .plumb-arrow {
149
+ fill: var(--color-connectors);
150
+ stroke: var(--color-connectors);
151
+ stroke-width: 0px;
152
+ margin-top: 6px;
153
+ z-index: 550;
123
154
  }
124
155
 
125
156
  body .plumb-connector .plumb-arrow {
@@ -127,6 +158,7 @@ export class Editor extends RapidElement {
127
158
  stroke: var(--color-connectors);
128
159
  stroke-width: 0px;
129
160
  margin-top: 6px;
161
+ z-index: 10;
130
162
  }
131
163
 
132
164
  body svg.jtk-connector.jtk-hover path {
@@ -137,6 +169,7 @@ export class Editor extends RapidElement {
137
169
  body .plumb-connector.jtk-hover .plumb-arrow {
138
170
  fill: var(--color-success) !important;
139
171
  stroke-width: 0px;
172
+ z-index: 10;
140
173
  }
141
174
  `;
142
175
  }
@@ -162,6 +195,45 @@ export class Editor extends RapidElement {
162
195
  if (changes.has('canvasSize')) {
163
196
  // console.log('Setting canvas size', this.canvasSize);
164
197
  }
198
+
199
+ if (changes.has('dirtyDate')) {
200
+ if (this.dirtyDate) {
201
+ this.debouncedSave();
202
+ }
203
+ }
204
+ }
205
+
206
+ private debouncedSave(): void {
207
+ // Clear any existing timer
208
+ if (this.saveTimer !== null) {
209
+ clearTimeout(this.saveTimer);
210
+ }
211
+
212
+ this.saveTimer = window.setTimeout(() => {
213
+ const now = new Date();
214
+ const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
215
+
216
+ if (timeSinceLastChange >= SAVE_QUIET_TIME) {
217
+ this.saveChanges();
218
+ this.saveTimer = null;
219
+ } else {
220
+ this.debouncedSave();
221
+ }
222
+ }, SAVE_QUIET_TIME);
223
+ }
224
+
225
+ private saveChanges(): void {
226
+ // post the flow definition to the server
227
+ getStore().postJSON(`/flow/revisions/${this.flow}`, this.definition);
228
+ getStore().getState().setDirtyDate(null);
229
+ }
230
+
231
+ disconnectedCallback(): void {
232
+ super.disconnectedCallback();
233
+ if (this.saveTimer !== null) {
234
+ clearTimeout(this.saveTimer);
235
+ this.saveTimer = null;
236
+ }
165
237
  }
166
238
 
167
239
  public render(): TemplateResult {