@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,337 @@
1
+ import { fixture, assert } from '@open-wc/testing';
2
+ import { html } from 'lit';
3
+
4
+ describe('temba-flow-node drag and drop functionality', () => {
5
+ it('should add drag styling and event listeners to elements', async () => {
6
+ // Create a simple div to test our drag functionality
7
+ const testElement = await fixture(html`
8
+ <div
9
+ class="node"
10
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
11
+ >
12
+ Test Node
13
+ <div class="exit" style="padding: 5px; background: red;">Exit</div>
14
+ </div>
15
+ `);
16
+
17
+ // Test that the node has the correct cursor style
18
+ const computedStyle = window.getComputedStyle(testElement);
19
+ assert.equal(computedStyle.cursor, 'move');
20
+
21
+ // Test drag event simulation
22
+ let dragStarted = false;
23
+ let dragEnded = false;
24
+ let newPosition = { left: 0, top: 0 };
25
+
26
+ // Simulate our drag implementation
27
+ let isDragging = false;
28
+ let dragStartPos = { x: 0, y: 0 };
29
+ let nodeStartPos = { left: 100, top: 100 };
30
+
31
+ const handleMouseDown = (event: MouseEvent) => {
32
+ const target = event.target as HTMLElement;
33
+ if (target.classList.contains('exit') || target.closest('.exit')) {
34
+ return;
35
+ }
36
+
37
+ isDragging = true;
38
+ dragStarted = true;
39
+ dragStartPos = { x: event.clientX, y: event.clientY };
40
+ nodeStartPos = { left: 100, top: 100 };
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+ };
44
+
45
+ const handleMouseMove = (event: MouseEvent) => {
46
+ if (!isDragging) return;
47
+
48
+ const deltaX = event.clientX - dragStartPos.x;
49
+ const deltaY = event.clientY - dragStartPos.y;
50
+
51
+ const newLeft = nodeStartPos.left + deltaX;
52
+ const newTop = nodeStartPos.top + deltaY;
53
+
54
+ // Update position
55
+ (testElement as HTMLElement).style.left = `${newLeft}px`;
56
+ (testElement as HTMLElement).style.top = `${newTop}px`;
57
+ };
58
+
59
+ const handleMouseUp = (event: MouseEvent) => {
60
+ if (!isDragging) return;
61
+
62
+ isDragging = false;
63
+ dragEnded = true;
64
+
65
+ const deltaX = event.clientX - dragStartPos.x;
66
+ const deltaY = event.clientY - dragStartPos.y;
67
+
68
+ newPosition = {
69
+ left: nodeStartPos.left + deltaX,
70
+ top: nodeStartPos.top + deltaY
71
+ };
72
+ };
73
+
74
+ // Add event listeners
75
+ testElement.addEventListener('mousedown', handleMouseDown);
76
+ document.addEventListener('mousemove', handleMouseMove);
77
+ document.addEventListener('mouseup', handleMouseUp);
78
+
79
+ // Test drag from node (should work)
80
+ const mouseDownEvent = new MouseEvent('mousedown', {
81
+ clientX: 150,
82
+ clientY: 150,
83
+ bubbles: true
84
+ });
85
+ testElement.dispatchEvent(mouseDownEvent);
86
+
87
+ assert.isTrue(dragStarted, 'Drag should start when clicking on node');
88
+
89
+ const mouseMoveEvent = new MouseEvent('mousemove', {
90
+ clientX: 200,
91
+ clientY: 200,
92
+ bubbles: true
93
+ });
94
+ document.dispatchEvent(mouseMoveEvent);
95
+
96
+ const mouseUpEvent = new MouseEvent('mouseup', {
97
+ clientX: 200,
98
+ clientY: 200,
99
+ bubbles: true
100
+ });
101
+ document.dispatchEvent(mouseUpEvent);
102
+
103
+ assert.isTrue(dragEnded, 'Drag should end on mouse up');
104
+ assert.equal(
105
+ newPosition.left,
106
+ 150,
107
+ 'New left position should be calculated correctly'
108
+ );
109
+ assert.equal(
110
+ newPosition.top,
111
+ 150,
112
+ 'New top position should be calculated correctly'
113
+ );
114
+
115
+ // Test that position was visually updated
116
+ assert.equal((testElement as HTMLElement).style.left, '150px');
117
+ assert.equal((testElement as HTMLElement).style.top, '150px');
118
+
119
+ // Reset for next test
120
+ dragStarted = false;
121
+ dragEnded = false;
122
+
123
+ // Test drag from exit element (should not work)
124
+ const exitElement = testElement.querySelector('.exit') as HTMLElement;
125
+ const exitMouseDownEvent = new MouseEvent('mousedown', {
126
+ clientX: 150,
127
+ clientY: 150,
128
+ bubbles: true
129
+ });
130
+ exitElement.dispatchEvent(exitMouseDownEvent);
131
+
132
+ assert.isFalse(
133
+ dragStarted,
134
+ 'Drag should not start when clicking on exit element'
135
+ );
136
+
137
+ // Clean up
138
+ document.removeEventListener('mousemove', handleMouseMove);
139
+ document.removeEventListener('mouseup', handleMouseUp);
140
+ });
141
+
142
+ it('should snap node positions to 20px grid during drag and drop', async () => {
143
+ // Create a test node element
144
+ const testElement = await fixture(html`
145
+ <div
146
+ class="node"
147
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
148
+ >
149
+ Test Node for Grid Snapping
150
+ </div>
151
+ `);
152
+
153
+ // Helper function to snap values to 20px grid (same as implementation)
154
+ const snapToGrid = (value: number): number => {
155
+ return Math.round(value / 20) * 20;
156
+ };
157
+
158
+ // Simulate drag implementation with grid snapping
159
+ let isDragging = false;
160
+ let dragStartPos = { x: 0, y: 0 };
161
+ let nodeStartPos = { left: 100, top: 100 };
162
+ let finalPosition = { left: 0, top: 0 };
163
+
164
+ const handleMouseDown = (event: MouseEvent) => {
165
+ isDragging = true;
166
+ dragStartPos = { x: event.clientX, y: event.clientY };
167
+ // Get current position from element style
168
+ const currentLeft =
169
+ parseInt((testElement as HTMLElement).style.left) || 0;
170
+ const currentTop = parseInt((testElement as HTMLElement).style.top) || 0;
171
+ nodeStartPos = { left: currentLeft, top: currentTop };
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ };
175
+
176
+ const handleMouseMove = (event: MouseEvent) => {
177
+ if (!isDragging) return;
178
+
179
+ const deltaX = event.clientX - dragStartPos.x;
180
+ const deltaY = event.clientY - dragStartPos.y;
181
+
182
+ const newLeft = nodeStartPos.left + deltaX;
183
+ const newTop = nodeStartPos.top + deltaY;
184
+
185
+ // Snap to 20px grid
186
+ const snappedLeft = snapToGrid(newLeft);
187
+ const snappedTop = snapToGrid(newTop);
188
+
189
+ // Update position with snapped values
190
+ (testElement as HTMLElement).style.left = `${snappedLeft}px`;
191
+ (testElement as HTMLElement).style.top = `${snappedTop}px`;
192
+ };
193
+
194
+ const handleMouseUp = (event: MouseEvent) => {
195
+ if (!isDragging) return;
196
+ isDragging = false;
197
+
198
+ const deltaX = event.clientX - dragStartPos.x;
199
+ const deltaY = event.clientY - dragStartPos.y;
200
+
201
+ const newLeft = nodeStartPos.left + deltaX;
202
+ const newTop = nodeStartPos.top + deltaY;
203
+
204
+ // Snap final position to grid
205
+ finalPosition = {
206
+ left: snapToGrid(newLeft),
207
+ top: snapToGrid(newTop)
208
+ };
209
+ };
210
+
211
+ // Add event listeners
212
+ testElement.addEventListener('mousedown', handleMouseDown);
213
+ document.addEventListener('mousemove', handleMouseMove);
214
+ document.addEventListener('mouseup', handleMouseUp);
215
+
216
+ // Test Case 1: Drag to position that should snap to grid
217
+ // Starting at (100, 100), drag by (33, 27) pixels
218
+ // Should result in (133, 127) -> snapped to (140, 120)
219
+ const mouseDownEvent1 = new MouseEvent('mousedown', {
220
+ clientX: 150,
221
+ clientY: 150,
222
+ bubbles: true
223
+ });
224
+ testElement.dispatchEvent(mouseDownEvent1);
225
+
226
+ const mouseMoveEvent1 = new MouseEvent('mousemove', {
227
+ clientX: 183, // 150 + 33
228
+ clientY: 177, // 150 + 27
229
+ bubbles: true
230
+ });
231
+ document.dispatchEvent(mouseMoveEvent1);
232
+
233
+ // Check that position is snapped during drag
234
+ assert.equal((testElement as HTMLElement).style.left, '140px');
235
+ assert.equal((testElement as HTMLElement).style.top, '120px');
236
+
237
+ const mouseUpEvent1 = new MouseEvent('mouseup', {
238
+ clientX: 183,
239
+ clientY: 177,
240
+ bubbles: true
241
+ });
242
+ document.dispatchEvent(mouseUpEvent1);
243
+
244
+ // Check final snapped position
245
+ assert.equal(
246
+ finalPosition.left,
247
+ 140,
248
+ 'Final left position should be snapped to 140px'
249
+ );
250
+ assert.equal(
251
+ finalPosition.top,
252
+ 120,
253
+ 'Final top position should be snapped to 120px'
254
+ );
255
+
256
+ // Test Case 2: Test different snap scenarios
257
+ // Reset position
258
+ nodeStartPos = { left: 60, top: 80 };
259
+ (testElement as HTMLElement).style.left = '60px';
260
+ (testElement as HTMLElement).style.top = '80px';
261
+
262
+ const mouseDownEvent2 = new MouseEvent('mousedown', {
263
+ clientX: 100,
264
+ clientY: 100,
265
+ bubbles: true
266
+ });
267
+ testElement.dispatchEvent(mouseDownEvent2);
268
+
269
+ // Drag by (15, 25) -> should snap to nearest 20px grid
270
+ // (60 + 15, 80 + 25) = (75, 105) -> snapped to (80, 100)
271
+ const mouseMoveEvent2 = new MouseEvent('mousemove', {
272
+ clientX: 115, // 100 + 15
273
+ clientY: 125, // 100 + 25
274
+ bubbles: true
275
+ });
276
+ document.dispatchEvent(mouseMoveEvent2);
277
+
278
+ assert.equal((testElement as HTMLElement).style.left, '80px');
279
+ assert.equal((testElement as HTMLElement).style.top, '100px');
280
+
281
+ const mouseUpEvent2 = new MouseEvent('mouseup', {
282
+ clientX: 115,
283
+ clientY: 125,
284
+ bubbles: true
285
+ });
286
+ document.dispatchEvent(mouseUpEvent2);
287
+
288
+ assert.equal(
289
+ finalPosition.left,
290
+ 80,
291
+ 'Final left position should be snapped to 80px'
292
+ );
293
+ assert.equal(
294
+ finalPosition.top,
295
+ 100,
296
+ 'Final top position should be snapped to 100px'
297
+ );
298
+
299
+ // Test Case 3: Test exact grid positions remain unchanged
300
+ nodeStartPos = { left: 120, top: 160 }; // Already on grid
301
+ (testElement as HTMLElement).style.left = '120px';
302
+ (testElement as HTMLElement).style.top = '160px';
303
+
304
+ const mouseDownEvent3 = new MouseEvent('mousedown', {
305
+ clientX: 200,
306
+ clientY: 200,
307
+ bubbles: true
308
+ });
309
+ testElement.dispatchEvent(mouseDownEvent3);
310
+
311
+ // Small movement that should stay on same grid position
312
+ const mouseMoveEvent3 = new MouseEvent('mousemove', {
313
+ clientX: 202, // +2 pixels
314
+ clientY: 203, // +3 pixels
315
+ bubbles: true
316
+ });
317
+ document.dispatchEvent(mouseMoveEvent3);
318
+
319
+ // (120 + 2, 160 + 3) = (122, 163) -> snapped to (120, 160)
320
+ assert.equal((testElement as HTMLElement).style.left, '120px');
321
+ assert.equal((testElement as HTMLElement).style.top, '160px');
322
+
323
+ const mouseUpEvent3 = new MouseEvent('mouseup', {
324
+ clientX: 202,
325
+ clientY: 203,
326
+ bubbles: true
327
+ });
328
+ document.dispatchEvent(mouseUpEvent3);
329
+
330
+ assert.equal(finalPosition.left, 120, 'Position should remain on grid');
331
+ assert.equal(finalPosition.top, 160, 'Position should remain on grid');
332
+
333
+ // Clean up
334
+ document.removeEventListener('mousemove', handleMouseMove);
335
+ document.removeEventListener('mouseup', handleMouseUp);
336
+ });
337
+ });
@@ -12,21 +12,46 @@ export default {
12
12
  preventAssignment: true,
13
13
  'process.env.NODE_ENV': JSON.stringify('development'),
14
14
  }),
15
- ],
15
+ {
16
+ name: 'flow-files',
17
+ serve(context) {
18
+ if (context.request.method === 'POST') {
19
+ let body = '';
20
+ context.req.on('data', chunk => {
21
+ body += chunk.toString();
22
+ });
23
+
24
+ context.req.on('end', () => {
25
+ const parts = context.path.split('/');
26
+ const uuid = parts[3];
27
+
28
+ // read in the body
29
+ context.contentType = 'application/json';
30
+ if (body) {
31
+ fs.writeFileSync(
32
+ path.resolve(`./demo/data/flows/${uuid}.json`), JSON.stringify({ definition: JSON.parse(body) }, null, 2)
33
+ );
34
+
35
+ context.body = {
36
+ status: 'success',
37
+ message: `Flow ${uuid} saved successfully.`,
38
+ };
39
+ } else {
40
+ console.log(`No body received for flow ${uuid}.`);
41
+ }
42
+ });
43
+ }
16
44
 
17
- middlewares: [
18
- async (ctx, next) => {
19
- if (ctx.path.startsWith('/flow/revisions/')) {
20
- const parts = ctx.path.split('/');
21
- const uuid = parts[3];
22
- ctx.set('Content-Type', 'application/json');
23
- ctx.body = fs.readFileSync(
24
- path.resolve(`./demo/data/flows/${uuid}.json`),
25
- 'utf-8',
26
- );
27
- } else {
28
- await next();
45
+ if (context.request.method === 'GET' && context.path.startsWith('/flow/revisions/')) {
46
+ const parts = context.path.split('/');
47
+ const uuid = parts[3];
48
+ context.contentType = 'application/json';
49
+ context.body = fs.readFileSync(
50
+ path.resolve(`./demo/data/flows/${uuid}.json`),
51
+ 'utf-8',
52
+ );
53
+ }
29
54
  }
30
- },
55
+ }
31
56
  ],
32
57
  };