@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.
@@ -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 { state } from 'lit/decorators.js';
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
- @state()
15
+ @property({ type: Object })
16
16
  private plumber: Plumber;
17
17
 
18
- @state()
18
+ @property({ type: Object })
19
19
  private node: Node;
20
20
 
21
- @state()
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}"
@@ -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
@@ -284,5 +284,6 @@ export enum CustomEventType {
284
284
  Error = 'temba-error',
285
285
  Interrupt = 'temba-interrupt',
286
286
  Opened = 'temba-opened',
287
- TicketUpdated = 'temba-ticket-updated'
287
+ TicketUpdated = 'temba-ticket-updated',
288
+ Moved = 'temba-moved'
288
289
  }
@@ -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
- setTestUpdate: () => void;
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) {
@@ -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', () => {