@nyaruka/temba-components 0.137.0 → 0.138.4

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 (88) hide show
  1. package/.devcontainer/Dockerfile +0 -9
  2. package/.devcontainer/devcontainer.json +8 -3
  3. package/.github/workflows/build.yml +6 -1
  4. package/.github/workflows/cla.yml +1 -1
  5. package/.github/workflows/publish.yml +6 -1
  6. package/CHANGELOG.md +42 -0
  7. package/dist/locales/es.js +5 -5
  8. package/dist/locales/es.js.map +1 -1
  9. package/dist/locales/fr.js +5 -5
  10. package/dist/locales/fr.js.map +1 -1
  11. package/dist/locales/locale-codes.js +11 -2
  12. package/dist/locales/locale-codes.js.map +1 -1
  13. package/dist/locales/pt.js +5 -5
  14. package/dist/locales/pt.js.map +1 -1
  15. package/dist/temba-components.js +445 -278
  16. package/dist/temba-components.js.map +1 -1
  17. package/out-tsc/src/display/FloatingTab.js +16 -8
  18. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  19. package/out-tsc/src/flow/CanvasMenu.js +33 -15
  20. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  21. package/out-tsc/src/flow/CanvasNode.js +49 -24
  22. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  23. package/out-tsc/src/flow/Editor.js +583 -70
  24. package/out-tsc/src/flow/Editor.js.map +1 -1
  25. package/out-tsc/src/flow/NodeTypeSelector.js +13 -11
  26. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  27. package/out-tsc/src/flow/Plumber.js +110 -64
  28. package/out-tsc/src/flow/Plumber.js.map +1 -1
  29. package/out-tsc/src/flow/actions/set_contact_field.js +5 -1
  30. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  31. package/out-tsc/src/list/RunList.js +2 -1
  32. package/out-tsc/src/list/RunList.js.map +1 -1
  33. package/out-tsc/src/list/TicketList.js +2 -1
  34. package/out-tsc/src/list/TicketList.js.map +1 -1
  35. package/out-tsc/src/locales/es.js +5 -5
  36. package/out-tsc/src/locales/es.js.map +1 -1
  37. package/out-tsc/src/locales/fr.js +5 -5
  38. package/out-tsc/src/locales/fr.js.map +1 -1
  39. package/out-tsc/src/locales/locale-codes.js +11 -2
  40. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  41. package/out-tsc/src/locales/pt.js +5 -5
  42. package/out-tsc/src/locales/pt.js.map +1 -1
  43. package/out-tsc/src/simulator/Simulator.js +11 -4
  44. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  45. package/out-tsc/src/store/AppState.js +17 -2
  46. package/out-tsc/src/store/AppState.js.map +1 -1
  47. package/out-tsc/test/temba-contact-fields.test.js +3 -3
  48. package/out-tsc/test/temba-contact-fields.test.js.map +1 -1
  49. package/out-tsc/test/temba-flow-editor-node.test.js +3 -1
  50. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  51. package/out-tsc/test/temba-flow-editor-revisions.test.js +106 -0
  52. package/out-tsc/test/temba-flow-editor-revisions.test.js.map +1 -0
  53. package/out-tsc/test/temba-flow-editor.test.js +14 -10
  54. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  55. package/out-tsc/test/temba-flow-plumber-connections.test.js +7 -1
  56. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  57. package/out-tsc/test/temba-flow-plumber.test.js +6 -0
  58. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  59. package/out-tsc/test/temba-select.test.js +1 -0
  60. package/out-tsc/test/temba-select.test.js.map +1 -1
  61. package/package.json +1 -1
  62. package/screenshots/truth/floating-tab/gray.png +0 -0
  63. package/screenshots/truth/floating-tab/green.png +0 -0
  64. package/screenshots/truth/floating-tab/purple.png +0 -0
  65. package/screenshots/truth/node-type-selector/action-mode.png +0 -0
  66. package/screenshots/truth/node-type-selector/split-mode.png +0 -0
  67. package/src/display/FloatingTab.ts +18 -8
  68. package/src/flow/CanvasMenu.ts +38 -16
  69. package/src/flow/CanvasNode.ts +62 -29
  70. package/src/flow/Editor.ts +699 -74
  71. package/src/flow/NodeTypeSelector.ts +13 -11
  72. package/src/flow/Plumber.ts +123 -69
  73. package/src/flow/actions/set_contact_field.ts +5 -1
  74. package/src/list/RunList.ts +2 -1
  75. package/src/list/TicketList.ts +2 -1
  76. package/src/locales/es.ts +18 -13
  77. package/src/locales/fr.ts +18 -13
  78. package/src/locales/locale-codes.ts +11 -2
  79. package/src/locales/pt.ts +18 -13
  80. package/src/simulator/Simulator.ts +11 -5
  81. package/src/store/AppState.ts +18 -2
  82. package/test/temba-contact-fields.test.ts +8 -3
  83. package/test/temba-flow-editor-node.test.ts +3 -1
  84. package/test/temba-flow-editor-revisions.test.ts +134 -0
  85. package/test/temba-flow-editor.test.ts +16 -10
  86. package/test/temba-flow-plumber-connections.test.ts +7 -1
  87. package/test/temba-flow-plumber.test.ts +6 -0
  88. package/test/temba-select.test.ts +1 -0
@@ -9,12 +9,31 @@ import {
9
9
  NodeUI
10
10
  } from '../store/flow-definition';
11
11
  import { getStore } from '../store/Store';
12
- import { AppState, fromStore, zustand } from '../store/AppState';
12
+ import {
13
+ AppState,
14
+ fromStore,
15
+ zustand,
16
+ FLOW_SPEC_VERSION
17
+ } from '../store/AppState';
13
18
  import { RapidElement } from '../RapidElement';
14
19
  import { repeat } from 'lit-html/directives/repeat.js';
15
20
  import { CustomEventType, Workspace } from '../interfaces';
16
- import { generateUUID, postJSON } from '../utils';
21
+ import { generateUUID, postJSON, fetchResults, getClasses } from '../utils';
17
22
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
23
+
24
+ interface Revision {
25
+ id: number;
26
+ user: {
27
+ id: number;
28
+ username: string;
29
+ first_name: string;
30
+ last_name: string;
31
+ name?: string;
32
+ };
33
+ created_on: string;
34
+ comment?: string;
35
+ }
36
+
18
37
  import { ACTION_GROUP_METADATA } from './types';
19
38
  import { Checkbox } from '../form/Checkbox';
20
39
 
@@ -30,6 +49,7 @@ import {
30
49
  NodeBounds,
31
50
  nodesOverlap
32
51
  } from './utils';
52
+ import { FloatingWindow } from '../layout/FloatingWindow';
33
53
 
34
54
  export function snapToGrid(value: number): number {
35
55
  const snapped = Math.round(value / 20) * 20;
@@ -186,6 +206,9 @@ export class Editor extends RapidElement {
186
206
  @state()
187
207
  private dragFromNodeId: string | null = null;
188
208
 
209
+ @state()
210
+ private originalConnectionTargetId: string | null = null;
211
+
189
212
  @state()
190
213
  private isValidTarget = true;
191
214
 
@@ -212,6 +235,23 @@ export class Editor extends RapidElement {
212
235
  @state()
213
236
  private autoTranslateError: string | null = null;
214
237
 
238
+ @state()
239
+ private revisionsWindowHidden = true;
240
+
241
+ @state()
242
+ private revisions: Revision[] = [];
243
+
244
+ @state()
245
+ private viewingRevision: Revision | null = null;
246
+
247
+ @state()
248
+ private isLoadingRevisions = false;
249
+
250
+ private preRevertState: {
251
+ definition: FlowDefinition;
252
+ dirtyDate: Date | null;
253
+ } | null = null;
254
+
215
255
  private translationCache = new Map<string, string>();
216
256
 
217
257
  // NodeEditor state - handles both node and action editing
@@ -249,6 +289,20 @@ export class Editor extends RapidElement {
249
289
  // Track previous target node to clear placeholder when moving between nodes
250
290
  private previousActionDragTargetNodeUuid: string | null = null;
251
291
 
292
+ // Connection placeholder state for dropping connections on empty canvas
293
+ @state()
294
+ private connectionPlaceholder: {
295
+ position: FlowPosition;
296
+ visible: boolean;
297
+ } | null = null;
298
+
299
+ // Track pending connection when dropping on canvas
300
+ private pendingCanvasConnection: {
301
+ fromNodeId: string;
302
+ exitId: string;
303
+ position: FlowPosition;
304
+ } | null = null;
305
+
252
306
  private canvasMouseDown = false;
253
307
 
254
308
  private getAvailableLanguages(): Array<{ code: string; name: string }> {
@@ -329,6 +383,29 @@ export class Editor extends RapidElement {
329
383
  transition: none !important;
330
384
  }
331
385
 
386
+ #canvas.viewing-revision {
387
+ pointer-events: none;
388
+ }
389
+
390
+ #canvas.read-only svg {
391
+ pointer-events: none;
392
+ }
393
+
394
+ #grid.viewing-revision {
395
+ background-color: #fff9fc;
396
+ background-image: radial-gradient(
397
+ circle,
398
+ rgba(166, 38, 164, 0.2) 1px,
399
+ transparent 1px
400
+ );
401
+ }
402
+
403
+ #grid.viewing-revision temba-flow-node,
404
+ #grid.viewing-revision svg.jtk-connector,
405
+ #grid.viewing-revision .activity-overlay {
406
+ opacity: 0.5;
407
+ }
408
+
332
409
  body .jtk-endpoint {
333
410
  width: initial;
334
411
  height: initial;
@@ -383,12 +460,28 @@ export class Editor extends RapidElement {
383
460
  stroke-width: 3px;
384
461
  }
385
462
 
463
+ body #canvas.read-only-connections svg.jtk-connector.jtk-hover path {
464
+ stroke: var(--color-connectors) !important;
465
+ }
466
+
386
467
  body .plumb-connector.jtk-hover .plumb-arrow {
387
468
  fill: var(--color-success) !important;
388
469
  stroke-width: 0px;
389
470
  z-index: 10;
390
471
  }
391
472
 
473
+ body
474
+ #canvas.read-only-connections
475
+ .plumb-connector.jtk-hover
476
+ .plumb-arrow {
477
+ fill: var(--color-connectors) !important;
478
+ ponter-events: none;
479
+ }
480
+
481
+ body #canvas.read-only-connections svg {
482
+ pointer-events: none;
483
+ }
484
+
392
485
  /* Activity overlays on connections */
393
486
  .jtk-overlay.activity-overlay {
394
487
  background: #f3f3f3;
@@ -677,6 +770,18 @@ export class Editor extends RapidElement {
677
770
  width: 100%;
678
771
  }
679
772
 
773
+ .revert-button {
774
+ background: var(--color-primary-dark);
775
+ border: none;
776
+ color: #fff;
777
+ padding: 6px 10px;
778
+ border-radius: var(--curvature);
779
+ font-size: 11px;
780
+ font-weight: 600;
781
+ cursor: pointer;
782
+ transition: opacity 0.2s ease;
783
+ }
784
+
680
785
  .auto-translate-button {
681
786
  background: var(--color-primary-dark);
682
787
  border: none;
@@ -747,36 +852,91 @@ export class Editor extends RapidElement {
747
852
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
748
853
  }
749
854
 
750
- this.plumber.on('connection:drag', (info: Connection) => {
751
- this.dragFromNodeId = document
752
- .getElementById(info.sourceId)
753
- .closest('.node').id;
754
- this.sourceId = info.sourceId;
855
+ this.plumber.on('connection:drag', (connection: Connection) => {
856
+ // console.log('connection:drag', connection);
857
+ this.dragFromNodeId =
858
+ connection.data.nodeId ||
859
+ document.getElementById(connection.sourceId).closest('.node').id;
860
+ this.sourceId = connection.sourceId;
861
+ this.originalConnectionTargetId = connection.target.id;
755
862
  });
756
863
 
757
- this.plumber.on('connection:abort', () => {
758
- this.makeConnection();
864
+ this.plumber.on('connection:abort', (info) => {
865
+ // console.log('Connection aborted', info);
866
+ this.makeConnection(info);
759
867
  });
760
868
 
761
- this.plumber.on('connection:detach', () => {
762
- this.makeConnection();
869
+ this.plumber.on('connection:detach', (info) => {
870
+ // console.log('Connection detached', info);
871
+ this.makeConnection(info);
763
872
  });
764
873
  }
765
874
 
766
- private makeConnection() {
875
+ private makeConnection(info) {
767
876
  if (this.sourceId && this.targetId && this.isValidTarget) {
768
- this.plumber.connectIds(
769
- this.dragFromNodeId,
770
- this.sourceId,
771
- this.targetId
772
- );
773
- getStore()
774
- .getState()
775
- .updateConnection(this.dragFromNodeId, this.sourceId, this.targetId);
877
+ // going to the same target, just put it back
878
+ if (info.target.id === this.targetId) {
879
+ this.plumber.connectIds(
880
+ this.dragFromNodeId,
881
+ this.sourceId,
882
+ this.targetId
883
+ );
884
+ }
885
+ // otherwise update the connection
886
+ else {
887
+ getStore()
888
+ .getState()
889
+ .updateConnection(this.dragFromNodeId, this.sourceId, this.targetId);
890
+ }
891
+ } else if (
892
+ this.connectionPlaceholder &&
893
+ this.connectionPlaceholder.visible &&
894
+ this.sourceId
895
+ ) {
896
+ // Snap the placeholder position to grid
897
+ const snappedPosition = {
898
+ left: snapToGrid(this.connectionPlaceholder.position.left),
899
+ top: snapToGrid(this.connectionPlaceholder.position.top)
900
+ };
901
+
902
+ // Update the placeholder to the snapped position
903
+ this.connectionPlaceholder.position = snappedPosition;
904
+
905
+ // Store the pending connection info
906
+ this.pendingCanvasConnection = {
907
+ fromNodeId: this.dragFromNodeId,
908
+ exitId: this.sourceId,
909
+ position: snappedPosition
910
+ };
911
+
912
+ // Show the context menu just below the placeholder
913
+ const canvas = this.querySelector('#canvas');
914
+ if (canvas) {
915
+ const canvasRect = canvas.getBoundingClientRect();
916
+ const menuX = canvasRect.left + snappedPosition.left - 40; // center horizontally
917
+ const menuY = canvasRect.top + snappedPosition.top + 80; // just below placeholder
918
+
919
+ const canvasMenu = this.querySelector(
920
+ 'temba-canvas-menu'
921
+ ) as CanvasMenu;
922
+ if (canvasMenu) {
923
+ canvasMenu.show(
924
+ menuX,
925
+ menuY,
926
+ {
927
+ x: snappedPosition.left,
928
+ y: snappedPosition.top
929
+ },
930
+ false
931
+ ); // Don't show sticky note option for connection drops
932
+ }
933
+ }
934
+
935
+ // Request update to render the connection line
936
+ this.requestUpdate();
776
937
 
777
- setTimeout(() => {
778
- this.plumber.repaintEverything();
779
- }, 100);
938
+ // Don't clear placeholder or connection info yet - keep them for menu interaction
939
+ return;
780
940
  }
781
941
 
782
942
  // Clean up visual feedback
@@ -787,9 +947,12 @@ export class Editor extends RapidElement {
787
947
  );
788
948
  });
789
949
 
790
- this.sourceId = null;
950
+ // Clear connection state (but keep sourceId/dragFromNodeId if we have a pending connection)
951
+ if (!this.pendingCanvasConnection) {
952
+ this.sourceId = null;
953
+ this.dragFromNodeId = null;
954
+ }
791
955
  this.targetId = null;
792
- this.dragFromNodeId = null;
793
956
  this.isValidTarget = true;
794
957
  }
795
958
 
@@ -895,10 +1058,11 @@ export class Editor extends RapidElement {
895
1058
  }, SAVE_QUIET_TIME);
896
1059
  }
897
1060
 
898
- private saveChanges(): void {
1061
+ private saveChanges(definitionOverride?: FlowDefinition): Promise<void> {
1062
+ const definition = definitionOverride || this.definition;
899
1063
  // post the flow definition to the server
900
- getStore()
901
- .postJSON(`/flow/revisions/${this.flow}/`, this.definition)
1064
+ return getStore()
1065
+ .postJSON(`/flow/revisions/${this.flow}/`, definition)
902
1066
  .then((response) => {
903
1067
  // Update flow info and revision with the response data
904
1068
  if (response.json) {
@@ -911,6 +1075,11 @@ export class Editor extends RapidElement {
911
1075
  if (response.json.revision?.revision !== undefined) {
912
1076
  state.setRevision(response.json.revision.revision);
913
1077
  }
1078
+
1079
+ // if the revisions window is open, refresh the list
1080
+ if (!this.revisionsWindowHidden) {
1081
+ this.fetchRevisions();
1082
+ }
914
1083
  }
915
1084
  })
916
1085
  .catch((error) => {
@@ -961,20 +1130,13 @@ export class Editor extends RapidElement {
961
1130
  }
962
1131
 
963
1132
  this.activityTimer = window.setTimeout(() => {
964
- this.fetchActivityData();
1133
+ // this.fetchActivityData();
965
1134
  }, this.activityInterval);
966
1135
  });
967
1136
  }
968
1137
 
969
1138
  private handleLanguageChange(languageCode: string): void {
970
1139
  zustand.getState().setLanguageCode(languageCode);
971
-
972
- // Repaint connections after language change since node sizes can change
973
- if (this.plumber) {
974
- requestAnimationFrame(() => {
975
- this.plumber.repaintEverything();
976
- });
977
- }
978
1140
  }
979
1141
 
980
1142
  disconnectedCallback(): void {
@@ -1043,6 +1205,16 @@ export class Editor extends RapidElement {
1043
1205
  }
1044
1206
  });
1045
1207
 
1208
+ // Listen for canvas menu cancel (close without selection)
1209
+ this.addEventListener(CustomEventType.Canceled, (event: CustomEvent) => {
1210
+ const target = event.target as HTMLElement;
1211
+ if (target.tagName === 'TEMBA-CANVAS-MENU') {
1212
+ this.handleCanvasMenuClosed();
1213
+ } else if (target.tagName === 'TEMBA-NODE-TYPE-SELECTOR') {
1214
+ this.handleNodeTypeSelectorClosed();
1215
+ }
1216
+ });
1217
+
1046
1218
  // Listen for action drag events from nodes
1047
1219
  this.addEventListener(
1048
1220
  CustomEventType.DragExternal,
@@ -1073,6 +1245,8 @@ export class Editor extends RapidElement {
1073
1245
  // ignore right clicks
1074
1246
  if (event.button !== 0) return;
1075
1247
 
1248
+ if (this.isReadOnly()) return;
1249
+
1076
1250
  const element = event.currentTarget as HTMLElement;
1077
1251
  // Only start dragging if clicking on the element itself, not on exits or other interactive elements
1078
1252
  const target = event.target as HTMLElement;
@@ -1142,6 +1316,8 @@ export class Editor extends RapidElement {
1142
1316
  }
1143
1317
 
1144
1318
  private handleCanvasMouseDown(event: MouseEvent): void {
1319
+ if (this.isReadOnly()) return;
1320
+
1145
1321
  const target = event.target as HTMLElement;
1146
1322
  if (target.id === 'canvas' || target.id === 'grid') {
1147
1323
  // Ignore clicks on exits
@@ -1212,13 +1388,8 @@ export class Editor extends RapidElement {
1212
1388
  }
1213
1389
 
1214
1390
  private deleteNodes(uuids: string[]): void {
1215
- // Clean up jsPlumb connections for nodes before removing them
1216
- uuids.forEach((uuid) => {
1217
- this.plumber.removeNodeConnections(uuid);
1218
- });
1219
-
1220
- // Now remove them from the definition
1221
- if (uuids.length > 0 && this.plumber) {
1391
+ // Remove nodes from the definition - CanvasNode will handle plumber cleanup
1392
+ if (uuids.length > 0) {
1222
1393
  getStore().getState().removeNodes(uuids);
1223
1394
  }
1224
1395
  }
@@ -1395,6 +1566,111 @@ export class Editor extends RapidElement {
1395
1566
  </div>`;
1396
1567
  }
1397
1568
 
1569
+ private renderConnectionPlaceholder(): TemplateResult | string {
1570
+ if (!this.connectionPlaceholder || !this.connectionPlaceholder.visible)
1571
+ return '';
1572
+
1573
+ const { position } = this.connectionPlaceholder;
1574
+
1575
+ // Render connection line when we have a pending connection (after drop)
1576
+ let svgPath = null;
1577
+ if (this.sourceId && this.dragFromNodeId && this.pendingCanvasConnection) {
1578
+ const sourceElement = document.getElementById(this.sourceId);
1579
+ if (sourceElement) {
1580
+ const sourceRect = sourceElement.getBoundingClientRect();
1581
+ const canvas = this.querySelector('#canvas');
1582
+ const canvasRect = canvas.getBoundingClientRect();
1583
+
1584
+ // Source point (bottom center of exit)
1585
+ const sourceX =
1586
+ sourceRect.left + sourceRect.width / 2 - canvasRect.left;
1587
+ const sourceY = sourceRect.bottom - canvasRect.top;
1588
+
1589
+ // Target point (top center of placeholder)
1590
+ const targetX = position.left + 100; // 100 is half the placeholder width (200px)
1591
+ const targetY = position.top;
1592
+
1593
+ // Use jsPlumb FlowchartConnector parameters: stub [20, 10], cornerRadius 5
1594
+ const stubStart = 20;
1595
+ const stubEnd = 10;
1596
+ const cornerRadius = 5;
1597
+
1598
+ // Calculate flowchart path with corners
1599
+ const verticalStart = sourceY + stubStart;
1600
+ const verticalEnd = targetY - stubEnd;
1601
+ const midY = (verticalStart + verticalEnd) / 2;
1602
+
1603
+ // Build path with rounded corners (flowchart style)
1604
+ let pathData = `M ${sourceX} ${sourceY} L ${sourceX} ${verticalStart}`;
1605
+
1606
+ if (sourceX !== targetX) {
1607
+ // Horizontal segment needed
1608
+ if (Math.abs(verticalEnd - verticalStart) > cornerRadius * 2) {
1609
+ // Enough space for corners
1610
+ pathData += ` L ${sourceX} ${midY - cornerRadius}`;
1611
+ pathData += ` Q ${sourceX} ${midY}, ${
1612
+ sourceX + (targetX > sourceX ? cornerRadius : -cornerRadius)
1613
+ } ${midY}`;
1614
+ pathData += ` L ${
1615
+ targetX - (targetX > sourceX ? cornerRadius : -cornerRadius)
1616
+ } ${midY}`;
1617
+ pathData += ` Q ${targetX} ${midY}, ${targetX} ${
1618
+ midY + cornerRadius
1619
+ }`;
1620
+ pathData += ` L ${targetX} ${verticalEnd}`;
1621
+ } else {
1622
+ // Direct horizontal transition
1623
+ pathData += ` L ${targetX} ${verticalStart}`;
1624
+ pathData += ` L ${targetX} ${verticalEnd}`;
1625
+ }
1626
+ } else {
1627
+ // Straight vertical line
1628
+ pathData += ` L ${targetX} ${verticalEnd}`;
1629
+ }
1630
+
1631
+ pathData += ` L ${targetX} ${targetY}`;
1632
+
1633
+ svgPath = html`
1634
+ <svg
1635
+ style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9999;"
1636
+ >
1637
+ <path
1638
+ d="${pathData}"
1639
+ fill="none"
1640
+ stroke="var(--color-connectors, #ccc)"
1641
+ stroke-width="3"
1642
+ class="plumb-connector"
1643
+ />
1644
+ <polygon
1645
+ points="${targetX},${targetY} ${targetX - 6.5},${targetY -
1646
+ 13} ${targetX + 6.5},${targetY - 13}"
1647
+ fill="var(--color-connectors, #ccc)"
1648
+ class="plumb-arrow"
1649
+ />
1650
+ </svg>
1651
+ `;
1652
+ }
1653
+ }
1654
+
1655
+ return html`${svgPath}
1656
+ <div
1657
+ class="connection-placeholder"
1658
+ style="position: absolute; left: ${position.left}px; top: ${position.top}px; opacity: 0.6; pointer-events: none; z-index: 10000;"
1659
+ >
1660
+ <div
1661
+ class="node execute-actions"
1662
+ style="outline: 3px dashed var(--color-primary, #3b82f6); outline-offset: 2px; border-radius: var(--curvature); min-width: 200px;"
1663
+ >
1664
+ <div class="empty-node-placeholder" style="height: 60px;"></div>
1665
+ <div class="action-exits">
1666
+ <div class="exit-wrapper">
1667
+ <div class="exit"></div>
1668
+ </div>
1669
+ </div>
1670
+ </div>
1671
+ </div>`;
1672
+ }
1673
+
1398
1674
  /**
1399
1675
  * Checks for node collisions and reflows nodes as needed.
1400
1676
  * Nodes are only moved downward to resolve collisions.
@@ -1496,10 +1772,37 @@ export class Editor extends RapidElement {
1496
1772
  } else {
1497
1773
  targetNode.classList.add('connection-target-invalid');
1498
1774
  }
1775
+
1776
+ // Hide connection placeholder when over a node
1777
+ this.connectionPlaceholder = null;
1499
1778
  } else {
1500
1779
  this.targetId = null;
1501
1780
  this.isValidTarget = true;
1781
+
1782
+ // Show connection placeholder when over empty canvas
1783
+ // Calculate position: horizontally centered at mouse, vertically just below mouse
1784
+ const canvas = this.querySelector('#canvas');
1785
+ if (canvas) {
1786
+ const canvasRect = canvas.getBoundingClientRect();
1787
+ const relativeX = event.clientX - canvasRect.left;
1788
+ const relativeY = event.clientY - canvasRect.top;
1789
+
1790
+ // offset the placeholder so it's centered horizontally and just below the mouse
1791
+ const placeholderWidth = 200; // approximate node width
1792
+ const placeholderOffset = 20; // distance below mouse cursor
1793
+
1794
+ this.connectionPlaceholder = {
1795
+ position: {
1796
+ left: relativeX - placeholderWidth / 2,
1797
+ top: relativeY + placeholderOffset
1798
+ },
1799
+ visible: true
1800
+ };
1801
+ }
1502
1802
  }
1803
+
1804
+ // Force update to show/hide placeholder
1805
+ this.requestUpdate();
1503
1806
  }
1504
1807
 
1505
1808
  // Handle item dragging
@@ -1735,6 +2038,11 @@ export class Editor extends RapidElement {
1735
2038
  }
1736
2039
 
1737
2040
  private handleCanvasContextMenu(event: MouseEvent): void {
2041
+ if (this.isReadOnly()) {
2042
+ event.preventDefault();
2043
+ return;
2044
+ }
2045
+
1738
2046
  // Check if we right-clicked on empty canvas space
1739
2047
  const target = event.target as HTMLElement;
1740
2048
  if (target.id !== 'canvas') {
@@ -1779,6 +2087,11 @@ export class Editor extends RapidElement {
1779
2087
  left: selection.position.x,
1780
2088
  top: selection.position.y
1781
2089
  });
2090
+ // Clear all pending connection state and placeholder
2091
+ this.pendingCanvasConnection = null;
2092
+ this.connectionPlaceholder = null;
2093
+ this.sourceId = null;
2094
+ this.dragFromNodeId = null;
1782
2095
  } else {
1783
2096
  // Show node type selector
1784
2097
  const selector = this.querySelector(
@@ -1787,12 +2100,55 @@ export class Editor extends RapidElement {
1787
2100
  if (selector) {
1788
2101
  selector.show(selection.action, selection.position);
1789
2102
  }
2103
+ // Note: we don't clear pendingCanvasConnection or placeholder here,
2104
+ // they will be used in handleNodeTypeSelection
1790
2105
  }
1791
2106
  }
1792
2107
 
2108
+ private cleanUpConnection(): void {
2109
+ if (this.isCreatingNewNode) {
2110
+ this.isCreatingNewNode = false;
2111
+ this.pendingNodePosition = null;
2112
+ }
2113
+
2114
+ // see if we need to put our connection back
2115
+ if (this.originalConnectionTargetId) {
2116
+ this.plumber.connectIds(
2117
+ this.dragFromNodeId,
2118
+ this.sourceId,
2119
+ this.originalConnectionTargetId
2120
+ );
2121
+ this.originalConnectionTargetId = null;
2122
+ }
2123
+
2124
+ // Menu closed without selection - clear placeholder and pending connection
2125
+ if (this.pendingCanvasConnection) {
2126
+ this.pendingCanvasConnection = null;
2127
+ this.connectionPlaceholder = null;
2128
+ this.sourceId = null;
2129
+ this.dragFromNodeId = null;
2130
+ this.originalConnectionTargetId = null;
2131
+ }
2132
+ }
2133
+
2134
+ private handleCanvasMenuClosed(): void {
2135
+ this.cleanUpConnection();
2136
+ }
2137
+
2138
+ private handleNodeTypeSelectorClosed(): void {
2139
+ this.cleanUpConnection();
2140
+ }
2141
+
1793
2142
  private handleNodeTypeSelection(event: CustomEvent): void {
1794
2143
  const selection = event.detail as NodeTypeSelection;
1795
2144
 
2145
+ // Check if we have a pending canvas connection (from dropping on empty canvas)
2146
+ if (this.pendingCanvasConnection) {
2147
+ // Don't clear the placeholder yet - keep it visible while editing
2148
+ // The position is already stored in pendingCanvasConnection
2149
+ // Fall through to normal node creation flow below
2150
+ }
2151
+
1796
2152
  // Check if we're adding an action to an existing node
1797
2153
  if (this.addActionToNodeUuid) {
1798
2154
  // Find the existing node
@@ -1867,20 +2223,24 @@ export class Editor extends RapidElement {
1867
2223
  }
1868
2224
 
1869
2225
  const tempNodeUI: NodeUI = {
1870
- position: {
1871
- left: selection.position.x,
1872
- top: selection.position.y
1873
- },
2226
+ position: this.pendingCanvasConnection
2227
+ ? this.pendingCanvasConnection.position
2228
+ : {
2229
+ left: selection.position.x,
2230
+ top: selection.position.y
2231
+ },
1874
2232
  type: nodeType as any,
1875
2233
  config: {}
1876
2234
  };
1877
2235
 
1878
2236
  // Mark that we're creating a new node and store the position
1879
2237
  this.isCreatingNewNode = true;
1880
- this.pendingNodePosition = {
1881
- left: selection.position.x,
1882
- top: selection.position.y
1883
- };
2238
+ this.pendingNodePosition = this.pendingCanvasConnection
2239
+ ? this.pendingCanvasConnection.position
2240
+ : {
2241
+ left: selection.position.x,
2242
+ top: selection.position.y
2243
+ };
1884
2244
 
1885
2245
  // Open the node editor with the temporary node
1886
2246
  this.editingNode = tempNode;
@@ -1988,6 +2348,23 @@ export class Editor extends RapidElement {
1988
2348
  // Add the node to the store
1989
2349
  store.getState().addNode(updatedNode, nodeUI);
1990
2350
 
2351
+ // If we have a pending canvas connection, connect it to this new node
2352
+ if (this.pendingCanvasConnection) {
2353
+ store
2354
+ .getState()
2355
+ .updateConnection(
2356
+ this.pendingCanvasConnection.fromNodeId,
2357
+ this.pendingCanvasConnection.exitId,
2358
+ updatedNode.uuid
2359
+ );
2360
+
2361
+ // Clear the pending connection and placeholder
2362
+ this.pendingCanvasConnection = null;
2363
+ this.connectionPlaceholder = null;
2364
+ this.sourceId = null;
2365
+ this.dragFromNodeId = null;
2366
+ }
2367
+
1991
2368
  // Reset the creation flags
1992
2369
  this.isCreatingNewNode = false;
1993
2370
  this.pendingNodePosition = null;
@@ -1997,12 +2374,13 @@ export class Editor extends RapidElement {
1997
2374
  this.checkCollisionsAndReflow([updatedNode.uuid]);
1998
2375
  });
1999
2376
  } else {
2377
+ const uuid = this.editingNode.uuid;
2000
2378
  // Update existing node in the store
2001
- getStore()?.getState().updateNode(this.editingNode.uuid, updatedNode);
2379
+ getStore()?.getState().updateNode(uuid, updatedNode);
2002
2380
 
2003
2381
  // Check for collisions and reflow in case node size changed
2004
2382
  requestAnimationFrame(() => {
2005
- this.checkCollisionsAndReflow([this.editingNode.uuid]);
2383
+ this.checkCollisionsAndReflow([uuid]);
2006
2384
  });
2007
2385
  }
2008
2386
  }
@@ -2017,10 +2395,7 @@ export class Editor extends RapidElement {
2017
2395
 
2018
2396
  private handleActionEditCanceled(): void {
2019
2397
  // If we were creating a new node, just discard it
2020
- if (this.isCreatingNewNode) {
2021
- this.isCreatingNewNode = false;
2022
- this.pendingNodePosition = null;
2023
- }
2398
+ this.cleanUpConnection();
2024
2399
  this.closeNodeEditor();
2025
2400
  }
2026
2401
 
@@ -2042,6 +2417,23 @@ export class Editor extends RapidElement {
2042
2417
  // Add the node to the store
2043
2418
  store.getState().addNode(updatedNode, nodeUI);
2044
2419
 
2420
+ // If we have a pending canvas connection, connect it to this new node
2421
+ if (this.pendingCanvasConnection) {
2422
+ store
2423
+ .getState()
2424
+ .updateConnection(
2425
+ this.pendingCanvasConnection.fromNodeId,
2426
+ this.pendingCanvasConnection.exitId,
2427
+ updatedNode.uuid
2428
+ );
2429
+
2430
+ // Clear the pending connection and placeholder
2431
+ this.pendingCanvasConnection = null;
2432
+ this.connectionPlaceholder = null;
2433
+ this.sourceId = null;
2434
+ this.dragFromNodeId = null;
2435
+ }
2436
+
2045
2437
  // Reset the creation flags
2046
2438
  this.isCreatingNewNode = false;
2047
2439
  this.pendingNodePosition = null;
@@ -2089,11 +2481,7 @@ export class Editor extends RapidElement {
2089
2481
  }
2090
2482
 
2091
2483
  private handleNodeEditCanceled(): void {
2092
- // If we were creating a new node, just discard it
2093
- if (this.isCreatingNewNode) {
2094
- this.isCreatingNewNode = false;
2095
- this.pendingNodePosition = null;
2096
- }
2484
+ this.cleanUpConnection();
2097
2485
  this.closeNodeEditor();
2098
2486
  }
2099
2487
 
@@ -2468,7 +2856,7 @@ export class Editor extends RapidElement {
2468
2856
  const bundles: TranslationBundle[] = [];
2469
2857
 
2470
2858
  this.definition.nodes.forEach((node) => {
2471
- node.actions.forEach((action) => {
2859
+ node.actions?.forEach((action) => {
2472
2860
  const config = ACTION_CONFIG[action.type];
2473
2861
  if (!config?.localizable || config.localizable.length === 0) {
2474
2862
  return;
@@ -2654,6 +3042,7 @@ export class Editor extends RapidElement {
2654
3042
  }
2655
3043
 
2656
3044
  this.localizationWindowHidden = false;
3045
+ this.revisionsWindowHidden = true;
2657
3046
 
2658
3047
  const alreadySelected = languages.some(
2659
3048
  (lang) => lang.code === this.languageCode
@@ -2916,6 +3305,223 @@ export class Editor extends RapidElement {
2916
3305
  this.autoTranslating = false;
2917
3306
  }
2918
3307
 
3308
+ private handleRevisionsTabClick(): void {
3309
+ if (this.revisionsWindowHidden) {
3310
+ this.fetchRevisions();
3311
+ this.revisionsWindowHidden = false;
3312
+ this.localizationWindowHidden = true; // Close other window
3313
+ }
3314
+ }
3315
+
3316
+ private handleRevisionsWindowClosed(): void {
3317
+ this.resetRevisionsScroll();
3318
+ this.revisionsWindowHidden = true;
3319
+ if (this.viewingRevision) {
3320
+ this.handleCancelRevisionView();
3321
+ }
3322
+ }
3323
+
3324
+ private resetRevisionsScroll() {
3325
+ const list =
3326
+ this.querySelector('#revisions-window').shadowRoot?.querySelector(
3327
+ '.body'
3328
+ );
3329
+ if (list) {
3330
+ list.scrollTop = 0;
3331
+ }
3332
+ }
3333
+
3334
+ private async fetchRevisions() {
3335
+ this.isLoadingRevisions = true;
3336
+ try {
3337
+ const results = await fetchResults(
3338
+ `/flow/revisions/${this.flow}/?version=${FLOW_SPEC_VERSION}`
3339
+ );
3340
+ this.revisions = results.slice(1);
3341
+ } catch (e) {
3342
+ console.error('Error fetching revisions', e);
3343
+ } finally {
3344
+ this.isLoadingRevisions = false;
3345
+ }
3346
+ }
3347
+
3348
+ private async handleRevisionClick(revision: Revision) {
3349
+ if (this.viewingRevision?.id === revision.id) {
3350
+ return;
3351
+ }
3352
+
3353
+ if (!this.viewingRevision) {
3354
+ // Save current state first
3355
+ this.preRevertState = {
3356
+ definition: this.definition,
3357
+ dirtyDate: this.dirtyDate
3358
+ };
3359
+ }
3360
+
3361
+ this.viewingRevision = revision;
3362
+ this.isLoadingRevisions = true;
3363
+ this.plumber?.reset();
3364
+
3365
+ try {
3366
+ await getStore()
3367
+ .getState()
3368
+ .fetchRevision(`/flow/revisions/${this.flow}`, revision.id.toString());
3369
+ } catch (e) {
3370
+ console.error('Error fetching revision details', e);
3371
+ this.handleCancelRevisionView();
3372
+ } finally {
3373
+ this.isLoadingRevisions = false;
3374
+ }
3375
+ }
3376
+
3377
+ private handleCancelRevisionView() {
3378
+ this.plumber?.reset();
3379
+ if (this.preRevertState) {
3380
+ const currentInfo = getStore().getState().flowInfo;
3381
+ getStore().getState().setFlowContents({
3382
+ definition: this.preRevertState.definition,
3383
+ info: currentInfo
3384
+ });
3385
+ if (this.preRevertState.dirtyDate) {
3386
+ getStore().getState().setDirtyDate(this.preRevertState.dirtyDate);
3387
+ }
3388
+ } else {
3389
+ // Fallback if no pre-revert definition
3390
+ getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
3391
+ }
3392
+
3393
+ this.viewingRevision = null;
3394
+ this.preRevertState = null;
3395
+ }
3396
+
3397
+ private async handleRevertClick() {
3398
+ if (!this.viewingRevision || !this.preRevertState) return;
3399
+ this.plumber?.reset();
3400
+
3401
+ // Use the content of the viewing revision (this.definition)
3402
+ // but the revision number of the current head (preRevertState)
3403
+ // so the server accepts it as a valid update
3404
+ const definitionToSave = {
3405
+ ...this.definition,
3406
+ revision: this.preRevertState.definition.revision
3407
+ };
3408
+
3409
+ await this.saveChanges(definitionToSave);
3410
+ this.viewingRevision = null;
3411
+ this.preRevertState = null;
3412
+ this.revisionsWindowHidden = true;
3413
+
3414
+ const revisionsWindow = document.getElementById(
3415
+ 'revisions-window'
3416
+ ) as FloatingWindow;
3417
+ revisionsWindow.handleClose();
3418
+
3419
+ // Refresh revisions list to show the new one
3420
+ this.fetchRevisions();
3421
+
3422
+ // Fetch the latest version of the flow to ensure the store is up to date
3423
+ getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
3424
+ }
3425
+
3426
+ private renderRevisionsTab(): TemplateResult | string {
3427
+ return html`
3428
+ <temba-floating-tab
3429
+ id="revisions-tab"
3430
+ icon="revisions"
3431
+ label="Revisions"
3432
+ color="rgb(142, 94, 167)"
3433
+ top="105"
3434
+ .hidden=${!this.revisionsWindowHidden && this.localizationWindowHidden}
3435
+ @temba-button-clicked=${this.handleRevisionsTabClick}
3436
+ ></temba-floating-tab>
3437
+ `;
3438
+ }
3439
+
3440
+ private renderRevisionsWindow(): TemplateResult | string {
3441
+ return html`
3442
+ <temba-floating-window
3443
+ id="revisions-window"
3444
+ header="Revisions"
3445
+ .width=${360}
3446
+ .maxHeight=${600}
3447
+ .top=${75}
3448
+ color="rgb(142, 94, 167)"
3449
+ .hidden=${this.revisionsWindowHidden}
3450
+ @temba-dialog-hidden=${this.handleRevisionsWindowClosed}
3451
+ >
3452
+ <div class="localization-window-content">
3453
+ <div
3454
+ class="revisions-list"
3455
+ style="display:flex; flex-direction:column; gap:8px; overflow-y:auto; padding-bottom:10px;"
3456
+ >
3457
+ ${this.isLoadingRevisions && !this.revisions.length
3458
+ ? html`<temba-loading></temba-loading>`
3459
+ : this.revisions.map((rev) => {
3460
+ const isSelected = this.viewingRevision?.id === rev.id;
3461
+ return html`
3462
+ <div
3463
+ class="revision-item ${isSelected ? 'selected' : ''}"
3464
+ style="padding:8px; border-radius:4px; cursor:pointer; background:${
3465
+ isSelected
3466
+ ? '#f0f6ff' // Light blue bg for selected
3467
+ : '#f9fafb'
3468
+ }; border:1px solid ${
3469
+ isSelected ? '#a4cafe' : '#e5e7eb'
3470
+ }; transition: all 0.2s ease;"
3471
+ @click=${() => this.handleRevisionClick(rev)}
3472
+ >
3473
+ <div
3474
+ style="display:flex; justify-content:space-between; align-items:center;"
3475
+ >
3476
+ <div
3477
+ class="revision-header"
3478
+ style="margin-bottom: 2px;"
3479
+ >
3480
+ <div
3481
+ style="font-weight:600; font-size:13px; color:#111827;"
3482
+ >
3483
+ <temba-date value=${
3484
+ rev.created_on
3485
+ } display="duration"></temba-date>
3486
+
3487
+ </div>
3488
+ <div style="font-size:11px; color:#6b7280;">
3489
+ ${rev.user.name || rev.user.username}
3490
+ </div>
3491
+ </div>
3492
+ ${
3493
+ isSelected
3494
+ ? html`<button
3495
+ class="revert-button"
3496
+ @click=${this.handleRevertClick}
3497
+ >
3498
+ Revert
3499
+ </button>`
3500
+ : html``
3501
+ }
3502
+
3503
+ </button>
3504
+ </div>
3505
+
3506
+ ${
3507
+ rev.comment
3508
+ ? html`<div
3509
+ style="font-size:12px; color:#4b5563; margin-top:4px;"
3510
+ >
3511
+ ${rev.comment}
3512
+ </div>`
3513
+ : ''
3514
+ }
3515
+
3516
+ </div>
3517
+ `;
3518
+ })}
3519
+ </div>
3520
+ </div>
3521
+ </temba-floating-window>
3522
+ `;
3523
+ }
3524
+
2919
3525
  private renderLocalizationWindow(): TemplateResult | string {
2920
3526
  const languages = this.getLocalizationLanguages();
2921
3527
  if (!languages.length) {
@@ -3167,6 +3773,10 @@ export class Editor extends RapidElement {
3167
3773
  });
3168
3774
  }
3169
3775
 
3776
+ private isReadOnly(): boolean {
3777
+ return this.viewingRevision !== null || this.isTranslating;
3778
+ }
3779
+
3170
3780
  public render(): TemplateResult {
3171
3781
  // we have to embed our own style since we are in light DOM
3172
3782
  const style = html`<style>
@@ -3176,20 +3786,30 @@ export class Editor extends RapidElement {
3176
3786
 
3177
3787
  const stickies = this.definition?._ui?.stickies || {};
3178
3788
 
3179
- return html`${style} ${this.renderLocalizationWindow()}
3180
- ${this.renderAutoTranslateDialog()}
3789
+ return html`${style} ${this.renderRevisionsWindow()}
3790
+ ${this.renderLocalizationWindow()} ${this.renderAutoTranslateDialog()}
3181
3791
  <div id="editor">
3182
3792
  <div
3183
3793
  id="grid"
3794
+ class="${this.viewingRevision ? 'viewing-revision' : ''}"
3184
3795
  style="min-width:100%;width:${this.canvasSize.width}px; height:${this
3185
3796
  .canvasSize.height}px"
3186
3797
  >
3187
- <div id="canvas">
3798
+ <div
3799
+ id="canvas"
3800
+ class="${getClasses({
3801
+ 'viewing-revision': !!this.viewingRevision,
3802
+ 'read-only-connections':
3803
+ !!this.viewingRevision || this.isTranslating
3804
+ })}"
3805
+ >
3188
3806
  ${this.definition
3189
3807
  ? repeat(
3190
- this.definition.nodes,
3808
+ [...this.definition.nodes].sort((a, b) =>
3809
+ a.uuid.localeCompare(b.uuid)
3810
+ ),
3191
3811
  (node) => node.uuid,
3192
- (node, index) => {
3812
+ (node) => {
3193
3813
  const position = this.definition._ui?.nodes[node.uuid]
3194
3814
  ?.position || {
3195
3815
  left: 0,
@@ -3203,7 +3823,9 @@ export class Editor extends RapidElement {
3203
3823
  const selected = this.selectedItems.has(node.uuid);
3204
3824
 
3205
3825
  // first node is the flow start (nodes are sorted by position)
3206
- const isFlowStart = index === 0;
3826
+ const isFlowStart =
3827
+ this.definition.nodes.length > 0 &&
3828
+ this.definition.nodes[0].uuid === node.uuid;
3207
3829
 
3208
3830
  return html`<temba-flow-node
3209
3831
  class="draggable ${dragging ? 'dragging' : ''} ${selected
@@ -3245,6 +3867,7 @@ export class Editor extends RapidElement {
3245
3867
  }
3246
3868
  )}
3247
3869
  ${this.renderSelectionBox()} ${this.renderCanvasDropPreview()}
3870
+ ${this.renderConnectionPlaceholder()}
3248
3871
  </div>
3249
3872
  </div>
3250
3873
  </div>
@@ -3263,10 +3886,12 @@ export class Editor extends RapidElement {
3263
3886
  : ''}
3264
3887
 
3265
3888
  <temba-canvas-menu></temba-canvas-menu>
3266
- <temba-node-type-selector
3267
- .flowType=${this.flowType}
3268
- .features=${this.features}
3269
- ></temba-node-type-selector>
3270
- ${this.renderLocalizationTab()} `;
3889
+ ${!this.viewingRevision
3890
+ ? html`<temba-node-type-selector
3891
+ .flowType=${this.flowType}
3892
+ .features=${this.features}
3893
+ ></temba-node-type-selector>`
3894
+ : ''}
3895
+ ${this.renderRevisionsTab()} ${this.renderLocalizationTab()} `;
3271
3896
  }
3272
3897
  }