@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.
- package/CHANGELOG.md +10 -0
- package/demo/data/flows/sample-flow.json +82 -228
- package/dist/temba-components.js +53 -15
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/chart/TembaChart.js +11 -5
- package/out-tsc/src/chart/TembaChart.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +70 -5
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/EditorNode.js +139 -4
- package/out-tsc/src/flow/EditorNode.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +57 -0
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/interfaces.js +1 -0
- package/out-tsc/src/interfaces.js.map +1 -1
- package/out-tsc/src/store/AppState.js +22 -5
- package/out-tsc/src/store/AppState.js.map +1 -1
- package/out-tsc/test/temba-chart.test.js +26 -0
- package/out-tsc/test/temba-chart.test.js.map +1 -1
- package/out-tsc/test/temba-flow-node-drag.test.js +257 -0
- package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -0
- package/package.json +1 -1
- package/src/chart/TembaChart.ts +10 -5
- package/src/flow/Editor.ts +78 -6
- package/src/flow/EditorNode.ts +163 -4
- package/src/flow/Plumber.ts +65 -0
- package/src/interfaces.ts +2 -1
- package/src/store/AppState.ts +28 -7
- package/test/temba-chart.test.ts +36 -0
- package/test/temba-flow-node-drag.test.ts +337 -0
- package/web-dev-server.config.mjs +39 -14
package/src/flow/EditorNode.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { css, html, PropertyValueMap, TemplateResult } from 'lit';
|
|
2
2
|
import { EDITOR_CONFIG, UIConfig } from './config';
|
|
3
3
|
import { Action, Exit, Node, NodeUI, Router } from '../store/flow-definition';
|
|
4
|
-
import {
|
|
4
|
+
import { property } from 'lit/decorators.js';
|
|
5
5
|
import { RapidElement } from '../RapidElement';
|
|
6
6
|
import { getClasses } from '../utils';
|
|
7
7
|
import { Plumber } from './Plumber';
|
|
@@ -12,15 +12,31 @@ export class EditorNode extends RapidElement {
|
|
|
12
12
|
return this;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
@
|
|
15
|
+
@property({ type: Object })
|
|
16
16
|
private plumber: Plumber;
|
|
17
17
|
|
|
18
|
-
@
|
|
18
|
+
@property({ type: Object })
|
|
19
19
|
private node: Node;
|
|
20
20
|
|
|
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
|
+
|
|
24
40
|
static get styles() {
|
|
25
41
|
return css`
|
|
26
42
|
.node {
|
|
@@ -31,6 +47,19 @@ export class EditorNode extends RapidElement {
|
|
|
31
47
|
border-radius: calc(var(--curvature) * 1.5);
|
|
32
48
|
overflow: hidden;
|
|
33
49
|
color: #333;
|
|
50
|
+
cursor: move;
|
|
51
|
+
user-select: none;
|
|
52
|
+
z-index: 500;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.node:hover {
|
|
56
|
+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.node.dragging {
|
|
60
|
+
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4);
|
|
61
|
+
transform: scale(1.02);
|
|
62
|
+
z-index: 1000;
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
.action {
|
|
@@ -129,9 +158,135 @@ export class EditorNode extends RapidElement {
|
|
|
129
158
|
this.ui.position.left + rect.width,
|
|
130
159
|
this.ui.position.top + rect.height
|
|
131
160
|
);
|
|
161
|
+
|
|
162
|
+
// Add drag event listeners to the node
|
|
163
|
+
this.addDragEventListeners();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
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
|
+
}
|
|
175
|
+
|
|
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
|
+
}
|
|
182
|
+
|
|
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
|
+
}
|
|
195
|
+
|
|
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
|
+
// Snap to 20px grid
|
|
215
|
+
const snappedLeft = this.snapToGrid(newLeft);
|
|
216
|
+
const snappedTop = this.snapToGrid(newTop);
|
|
217
|
+
|
|
218
|
+
// Update the UI position temporarily (for visual feedback)
|
|
219
|
+
const nodeElement = this.querySelector('.node') as HTMLElement;
|
|
220
|
+
if (nodeElement) {
|
|
221
|
+
nodeElement.style.left = `${snappedLeft}px`;
|
|
222
|
+
nodeElement.style.top = `${snappedTop}px`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Repaint connections during dragging for smooth updates
|
|
226
|
+
if (this.plumber) {
|
|
227
|
+
this.plumber.repaintEverything();
|
|
132
228
|
}
|
|
133
229
|
}
|
|
134
230
|
|
|
231
|
+
private handleMouseUp(event: MouseEvent): void {
|
|
232
|
+
if (!this.isDragging) return;
|
|
233
|
+
|
|
234
|
+
this.isDragging = false;
|
|
235
|
+
|
|
236
|
+
// Remove dragging class
|
|
237
|
+
const nodeElement = this.querySelector('.node') as HTMLElement;
|
|
238
|
+
if (nodeElement) {
|
|
239
|
+
nodeElement.classList.remove('dragging');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Restore normal z-index for connections
|
|
243
|
+
if (this.plumber) {
|
|
244
|
+
this.plumber.restoreNodeConnections(this.node.uuid);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const deltaX = event.clientX - this.dragStartPos.x;
|
|
248
|
+
const deltaY = event.clientY - this.dragStartPos.y;
|
|
249
|
+
|
|
250
|
+
const newLeft = this.nodeStartPos.left + deltaX;
|
|
251
|
+
const newTop = this.nodeStartPos.top + deltaY;
|
|
252
|
+
|
|
253
|
+
// Snap to 20px grid for final position
|
|
254
|
+
const snappedLeft = this.snapToGrid(newLeft);
|
|
255
|
+
const snappedTop = this.snapToGrid(newTop);
|
|
256
|
+
|
|
257
|
+
// Update the store with the new snapped position
|
|
258
|
+
const newPosition = { left: snappedLeft, top: snappedTop };
|
|
259
|
+
getStore()
|
|
260
|
+
.getState()
|
|
261
|
+
.updateCanvasPositions({
|
|
262
|
+
[this.node.uuid]: newPosition
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Repaint connections if plumber is available
|
|
266
|
+
if (this.plumber) {
|
|
267
|
+
this.plumber.repaintEverything();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
getStore().getState().updateNodePosition(this.node.uuid, newPosition);
|
|
271
|
+
|
|
272
|
+
// Fire a custom event with the new coordinates
|
|
273
|
+
/*this.fireCustomEvent(CustomEventType.Moved, {
|
|
274
|
+
nodeId: this.node.uuid,
|
|
275
|
+
position: newPosition,
|
|
276
|
+
oldPosition: {
|
|
277
|
+
left: this.nodeStartPos.left,
|
|
278
|
+
top: this.nodeStartPos.top
|
|
279
|
+
}
|
|
280
|
+
});*/
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
disconnectedCallback(): void {
|
|
284
|
+
super.disconnectedCallback();
|
|
285
|
+
// Clean up event listeners
|
|
286
|
+
document.removeEventListener('mousemove', this.boundMouseMove);
|
|
287
|
+
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
288
|
+
}
|
|
289
|
+
|
|
135
290
|
private renderTitle(config: UIConfig) {
|
|
136
291
|
return html`<div class="title" style="background:${config.color}">
|
|
137
292
|
${config.name}
|
|
@@ -198,6 +353,10 @@ export class EditorNode extends RapidElement {
|
|
|
198
353
|
}
|
|
199
354
|
|
|
200
355
|
public render() {
|
|
356
|
+
if (!this.node || !this.ui) {
|
|
357
|
+
return html`<div class="node">Loading...</div>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
201
360
|
return html`
|
|
202
361
|
<div
|
|
203
362
|
id="${this.node.uuid}"
|
package/src/flow/Plumber.ts
CHANGED
|
@@ -132,4 +132,69 @@ export class Plumber {
|
|
|
132
132
|
this.pendingConnections.push({ fromId, toId });
|
|
133
133
|
this.processPendingConnections();
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
public repaintEverything() {
|
|
137
|
+
if (this.jsPlumb) {
|
|
138
|
+
this.jsPlumb.repaintEverything();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
public elevateNodeConnections(nodeId: string) {
|
|
142
|
+
if (!this.jsPlumb) return;
|
|
143
|
+
|
|
144
|
+
// Get all connections
|
|
145
|
+
const connections = this.jsPlumb.getConnections();
|
|
146
|
+
|
|
147
|
+
// Get the node element to find its exit elements
|
|
148
|
+
const nodeElement = document.getElementById(nodeId);
|
|
149
|
+
const exitElements = nodeElement
|
|
150
|
+
? nodeElement.querySelectorAll('.exit')
|
|
151
|
+
: [];
|
|
152
|
+
const exitIds = Array.from(exitElements).map((exit) => exit.id);
|
|
153
|
+
|
|
154
|
+
connections.forEach((connection) => {
|
|
155
|
+
const sourceId = connection.source.id;
|
|
156
|
+
const targetId = connection.target.id;
|
|
157
|
+
|
|
158
|
+
// Check if this connection involves the dragged node:
|
|
159
|
+
// - Incoming: target is the node itself
|
|
160
|
+
// - Outgoing: source is one of the node's exits
|
|
161
|
+
if (targetId === nodeId || exitIds.includes(sourceId)) {
|
|
162
|
+
// Add elevated class to the connector element
|
|
163
|
+
const connectorElement = connection.connector.canvas;
|
|
164
|
+
if (connectorElement) {
|
|
165
|
+
connectorElement.classList.add('elevated');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public restoreNodeConnections(nodeId: string) {
|
|
172
|
+
if (!this.jsPlumb) return;
|
|
173
|
+
|
|
174
|
+
// Get all connections
|
|
175
|
+
const connections = this.jsPlumb.getConnections();
|
|
176
|
+
|
|
177
|
+
// Get the node element to find its exit elements
|
|
178
|
+
const nodeElement = document.getElementById(nodeId);
|
|
179
|
+
const exitElements = nodeElement
|
|
180
|
+
? nodeElement.querySelectorAll('.exit')
|
|
181
|
+
: [];
|
|
182
|
+
const exitIds = Array.from(exitElements).map((exit) => exit.id);
|
|
183
|
+
|
|
184
|
+
connections.forEach((connection) => {
|
|
185
|
+
const sourceId = connection.source.id;
|
|
186
|
+
const targetId = connection.target.id;
|
|
187
|
+
|
|
188
|
+
// Check if this connection involves the node:
|
|
189
|
+
// - Incoming: target is the node itself
|
|
190
|
+
// - Outgoing: source is one of the node's exits
|
|
191
|
+
if (targetId === nodeId || exitIds.includes(sourceId)) {
|
|
192
|
+
// Remove elevated class from the connector element
|
|
193
|
+
const connectorElement = connection.connector.canvas;
|
|
194
|
+
if (connectorElement) {
|
|
195
|
+
connectorElement.classList.remove('elevated');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
135
200
|
}
|
package/src/interfaces.ts
CHANGED
package/src/store/AppState.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface AppState {
|
|
|
65
65
|
workspace: Workspace;
|
|
66
66
|
isTranslating: boolean;
|
|
67
67
|
|
|
68
|
+
dirtyDate: Date | null;
|
|
69
|
+
|
|
68
70
|
canvasSize: { width: number; height: number };
|
|
69
71
|
|
|
70
72
|
fetchRevision: (endpoint: string, id?: string) => void;
|
|
@@ -77,10 +79,11 @@ export interface AppState {
|
|
|
77
79
|
setFlowContents: (flow: FlowContents) => void;
|
|
78
80
|
setFlowInfo: (info: FlowInfo) => void;
|
|
79
81
|
setLanguageCode: (languageCode: string) => void;
|
|
80
|
-
|
|
82
|
+
setDirtyDate: (date: Date) => void;
|
|
81
83
|
expandCanvas: (width: number, height: number) => void;
|
|
82
84
|
|
|
83
85
|
updateCanvasPositions: (positions: CanvasPositions) => void;
|
|
86
|
+
updateNodePosition(uuid: string, newPosition: FlowPosition): void;
|
|
84
87
|
removeNodes: (uuids: string[]) => void;
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -94,6 +97,13 @@ export const zustand = createStore<AppState>()(
|
|
|
94
97
|
flowDefinition: null,
|
|
95
98
|
flowInfo: null,
|
|
96
99
|
isTranslating: false,
|
|
100
|
+
dirtyDate: null,
|
|
101
|
+
|
|
102
|
+
setDirtyDate: (date: Date) => {
|
|
103
|
+
set((state: AppState) => {
|
|
104
|
+
state.dirtyDate = date;
|
|
105
|
+
});
|
|
106
|
+
},
|
|
97
107
|
|
|
98
108
|
fetchRevision: async (endpoint: string, id: string = null) => {
|
|
99
109
|
if (!id) {
|
|
@@ -178,12 +188,6 @@ export const zustand = createStore<AppState>()(
|
|
|
178
188
|
});
|
|
179
189
|
},
|
|
180
190
|
|
|
181
|
-
setTestUpdate: () => {
|
|
182
|
-
set((state: AppState) => {
|
|
183
|
-
state.flowDefinition.name = 'Bloop!';
|
|
184
|
-
});
|
|
185
|
-
},
|
|
186
|
-
|
|
187
191
|
expandCanvas: (width: number, height: number) => {
|
|
188
192
|
set((state: AppState) => {
|
|
189
193
|
const minWidth = Math.max(
|
|
@@ -211,6 +215,23 @@ export const zustand = createStore<AppState>()(
|
|
|
211
215
|
});
|
|
212
216
|
},
|
|
213
217
|
|
|
218
|
+
updateNodePosition: (uuid: string, newPosition: FlowPosition) => {
|
|
219
|
+
set((state: AppState) => {
|
|
220
|
+
if (state.flowDefinition._ui.nodes[uuid]) {
|
|
221
|
+
state.flowDefinition._ui.nodes[uuid].position = newPosition;
|
|
222
|
+
} else {
|
|
223
|
+
// If the node doesn't exist in _ui, we can add it
|
|
224
|
+
state.flowDefinition._ui.nodes[uuid] = {
|
|
225
|
+
position: newPosition,
|
|
226
|
+
type: null,
|
|
227
|
+
config: {}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
state.dirtyDate = new Date();
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
|
|
214
235
|
removeNodes: (uuids: string[]) => {
|
|
215
236
|
set((state: AppState) => {
|
|
216
237
|
for (const uuid of uuids) {
|
package/test/temba-chart.test.ts
CHANGED
|
@@ -146,6 +146,42 @@ describe('temba-chart', () => {
|
|
|
146
146
|
expect(tickCallback.call({}, 604800, 9, [])).to.equal('7d'); // 1 week in seconds
|
|
147
147
|
expect(tickCallback.call({}, 1209600, 10, [])).to.equal('14d'); // 2 weeks in seconds
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
it('applies xFormat when xType is time', async () => {
|
|
151
|
+
const chart: TembaChart = await getChart({
|
|
152
|
+
xType: 'time',
|
|
153
|
+
xFormat: 'DD'
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
chart.data = sampleData;
|
|
157
|
+
await chart.updateComplete;
|
|
158
|
+
|
|
159
|
+
// Wait for the chart to be created after data is set
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
161
|
+
|
|
162
|
+
expect(chart.chart).to.exist;
|
|
163
|
+
expect(chart.chart.options.scales.x.type).to.equal('time');
|
|
164
|
+
expect(
|
|
165
|
+
(chart.chart.options.scales.x as any).time.displayFormats.day
|
|
166
|
+
).to.equal('DD');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not include time config when xType is category', async () => {
|
|
170
|
+
const chart: TembaChart = await getChart({
|
|
171
|
+
xType: 'category',
|
|
172
|
+
xFormat: 'DD'
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
chart.data = sampleData;
|
|
176
|
+
await chart.updateComplete;
|
|
177
|
+
|
|
178
|
+
// Wait for the chart to be created after data is set
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
180
|
+
|
|
181
|
+
expect(chart.chart).to.exist;
|
|
182
|
+
expect(chart.chart.options.scales.x.type).to.equal('category');
|
|
183
|
+
expect((chart.chart.options.scales.x as any).time).to.be.undefined;
|
|
184
|
+
});
|
|
149
185
|
});
|
|
150
186
|
|
|
151
187
|
describe('formatDurationFromSeconds', () => {
|