@nyaruka/temba-components 0.137.0 → 0.138.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/temba-components.js +392 -258
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/display/FloatingTab.js +2 -2
  5. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasNode.js +45 -24
  7. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  8. package/out-tsc/src/flow/Editor.js +305 -16
  9. package/out-tsc/src/flow/Editor.js.map +1 -1
  10. package/out-tsc/src/flow/Plumber.js +110 -64
  11. package/out-tsc/src/flow/Plumber.js.map +1 -1
  12. package/out-tsc/src/simulator/Simulator.js +11 -4
  13. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  14. package/out-tsc/src/store/AppState.js +12 -2
  15. package/out-tsc/src/store/AppState.js.map +1 -1
  16. package/out-tsc/test/temba-flow-editor-node.test.js +2 -1
  17. package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
  18. package/out-tsc/test/temba-flow-editor-revisions.test.js +106 -0
  19. package/out-tsc/test/temba-flow-editor-revisions.test.js.map +1 -0
  20. package/out-tsc/test/temba-flow-editor.test.js +14 -10
  21. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  22. package/out-tsc/test/temba-flow-plumber-connections.test.js +7 -1
  23. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  24. package/out-tsc/test/temba-flow-plumber.test.js +6 -0
  25. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/display/FloatingTab.ts +2 -2
  28. package/src/flow/CanvasNode.ts +54 -29
  29. package/src/flow/Editor.ts +357 -17
  30. package/src/flow/Plumber.ts +123 -69
  31. package/src/simulator/Simulator.ts +11 -5
  32. package/src/store/AppState.ts +13 -2
  33. package/test/temba-flow-editor-node.test.ts +2 -1
  34. package/test/temba-flow-editor-revisions.test.ts +134 -0
  35. package/test/temba-flow-editor.test.ts +16 -10
  36. package/test/temba-flow-plumber-connections.test.ts +7 -1
  37. package/test/temba-flow-plumber.test.ts +6 -0
@@ -33,6 +33,9 @@ export class CanvasNode extends RapidElement {
33
33
  @fromStore(zustand, (state: AppState) => state.languageCode)
34
34
  private languageCode!: string;
35
35
 
36
+ @fromStore(zustand, (state: AppState) => state.viewingRevision)
37
+ private viewingRevision!: boolean;
38
+
36
39
  @fromStore(zustand, (state: AppState) => state.flowDefinition)
37
40
  private flowDefinition!: any;
38
41
 
@@ -49,6 +52,8 @@ export class CanvasNode extends RapidElement {
49
52
  // Track exits that are in "removing" state
50
53
  private exitRemovalTimeouts: Map<string, number> = new Map();
51
54
 
55
+ private connectionTimeout: number | null = null;
56
+
52
57
  // Set of exit UUIDs that are in the removing state
53
58
  private exitRemovingState: Set<string> = new Set();
54
59
 
@@ -91,11 +96,8 @@ export class CanvasNode extends RapidElement {
91
96
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
92
97
  min-width: 200px;
93
98
  border-radius: var(--curvature);
94
-
95
99
  color: #333;
96
- cursor: move;
97
100
  user-select: none;
98
-
99
101
  }
100
102
 
101
103
  /* Flow start indicator */
@@ -178,7 +180,7 @@ export class CanvasNode extends RapidElement {
178
180
  opacity: 1;
179
181
  }
180
182
 
181
- .translating-hidden {
183
+ .read-only-hidden {
182
184
  visibility: hidden !important;
183
185
  pointer-events: none !important;
184
186
  }
@@ -373,6 +375,12 @@ export class CanvasNode extends RapidElement {
373
375
  .exit.connected:hover {
374
376
  background-color: var(--color-connectors, #e6e6e6);
375
377
  }
378
+
379
+ .exit.connected.read-only, .exit.connected.read-only:hover {
380
+ background-color: #fff;
381
+ pointer-events: none !important;
382
+ cursor: default;
383
+ }
376
384
 
377
385
  .exit.removing, .exit.removing:hover {
378
386
  background-color: var(--color-error);
@@ -530,23 +538,31 @@ export class CanvasNode extends RapidElement {
530
538
  if (changes.has('node')) {
531
539
  // Only proceed if plumber is available (for tests that don't set it up)
532
540
  if (this.plumber) {
533
- this.plumber.removeNodeConnections(this.node.uuid);
541
+ if (this.connectionTimeout) {
542
+ clearTimeout(this.connectionTimeout);
543
+ }
544
+
545
+ // Pass exit IDs explicitly to avoid DOM querying dependency
546
+ const exitIds = this.node.exits.map((e) => e.uuid);
547
+ this.plumber.removeNodeConnections(this.node.uuid, exitIds);
548
+
534
549
  // make our initial connections
535
- for (const exit of this.node.exits) {
536
- if (!exit.destination_uuid) {
537
- // if we have no destination, then we are a source
538
- // so make our source endpoint
550
+ // We use setTimeout to allow for DOM updates to complete before querying for exits
551
+ this.connectionTimeout = window.setTimeout(() => {
552
+ for (const exit of this.node.exits) {
539
553
  this.plumber.makeSource(exit.uuid);
540
- } else {
541
- this.plumber.connectIds(
542
- this.node.uuid,
543
- exit.uuid,
544
- exit.destination_uuid
545
- );
554
+ if (exit.destination_uuid) {
555
+ this.plumber.connectIds(
556
+ this.node.uuid,
557
+ exit.uuid,
558
+ exit.destination_uuid
559
+ );
560
+ }
546
561
  }
547
- }
548
-
549
- this.plumber.revalidate([this.node.uuid]);
562
+ // Note: revalidation is handled by plumber's processPendingConnections which calls repaintEverything
563
+ this.connectionTimeout = null;
564
+ this.plumber.revalidate([this.node.uuid]);
565
+ }, 0);
550
566
  }
551
567
 
552
568
  const ele = this.parentElement;
@@ -564,6 +580,15 @@ export class CanvasNode extends RapidElement {
564
580
  }
565
581
 
566
582
  disconnectedCallback() {
583
+ // Force cleanup of connections for this node
584
+ if (this.plumber && this.node) {
585
+ if (this.connectionTimeout) {
586
+ clearTimeout(this.connectionTimeout);
587
+ this.connectionTimeout = null;
588
+ }
589
+ this.plumber.forgetNode(this.node.uuid);
590
+ }
591
+
567
592
  // Remove the event listener when the component is removed
568
593
  super.disconnectedCallback();
569
594
 
@@ -609,7 +634,6 @@ export class CanvasNode extends RapidElement {
609
634
  private handleExitClick(event: MouseEvent, exit: Exit) {
610
635
  event.preventDefault();
611
636
  event.stopPropagation();
612
-
613
637
  const exitId = exit.uuid;
614
638
 
615
639
  // If exit is not connected, do nothing
@@ -1282,23 +1306,19 @@ export class CanvasNode extends RapidElement {
1282
1306
  return html`<div class="cn-title" style="background:${color}">
1283
1307
  ${this.ui?.type === 'execute_actions'
1284
1308
  ? html`<temba-icon
1285
- class="drag-handle ${this.isTranslating
1286
- ? 'translating-hidden'
1287
- : ''}"
1309
+ class="drag-handle ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1288
1310
  name="sort"
1289
1311
  ></temba-icon>`
1290
1312
  : this.node?.actions?.length > 1
1291
1313
  ? html`<temba-icon
1292
- class="drag-handle ${this.isTranslating
1293
- ? 'translating-hidden'
1294
- : ''}"
1314
+ class="drag-handle ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1295
1315
  name="sort"
1296
1316
  ></temba-icon>`
1297
1317
  : html`<div class="title-spacer"></div>`}
1298
1318
 
1299
1319
  <div class="name">${isRemoving ? 'Remove?' : config.name}</div>
1300
1320
  <div
1301
- class="remove-button ${this.isTranslating ? 'translating-hidden' : ''}"
1321
+ class="remove-button ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1302
1322
  @click=${(e: MouseEvent) =>
1303
1323
  this.handleActionRemoveClick(e, action, index)}
1304
1324
  title="Remove action"
@@ -1332,7 +1352,7 @@ export class CanvasNode extends RapidElement {
1332
1352
  : html`${config.name}`}
1333
1353
  </div>
1334
1354
  <div
1335
- class="remove-button ${this.isTranslating ? 'translating-hidden' : ''}"
1355
+ class="remove-button ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1336
1356
  @click=${(e: MouseEvent) => this.handleNodeRemoveClick(e)}
1337
1357
  title="Remove node"
1338
1358
  >
@@ -1571,13 +1591,18 @@ export class CanvasNode extends RapidElement {
1571
1591
  class=${getClasses({
1572
1592
  exit: true,
1573
1593
  connected: !!exit.destination_uuid,
1574
- removing: this.exitRemovingState.has(exit.uuid)
1594
+ removing: this.exitRemovingState.has(exit.uuid),
1595
+ 'read-only': this.isReadOnly()
1575
1596
  })}
1576
1597
  @click=${(e: MouseEvent) => this.handleExitClick(e, exit)}
1577
1598
  ></div>
1578
1599
  </div>`;
1579
1600
  }
1580
1601
 
1602
+ private isReadOnly(): boolean {
1603
+ return this.viewingRevision || this.isTranslating;
1604
+ }
1605
+
1581
1606
  public render() {
1582
1607
  if (!this.node || !this.ui) {
1583
1608
  return html`<div class="node">Loading...</div>`;
@@ -1666,7 +1691,7 @@ export class CanvasNode extends RapidElement {
1666
1691
  (exit) => this.renderExit(exit)
1667
1692
  )}
1668
1693
  </div>`}
1669
- ${this.ui.type === 'execute_actions' && !this.isTranslating
1694
+ ${this.ui.type === 'execute_actions' && !this.isReadOnly()
1670
1695
  ? html`<div
1671
1696
  class="add-action-button"
1672
1697
  @click=${(e: MouseEvent) => this.handleAddActionClick(e)}
@@ -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;
@@ -212,6 +232,23 @@ export class Editor extends RapidElement {
212
232
  @state()
213
233
  private autoTranslateError: string | null = null;
214
234
 
235
+ @state()
236
+ private revisionsWindowHidden = true;
237
+
238
+ @state()
239
+ private revisions: Revision[] = [];
240
+
241
+ @state()
242
+ private viewingRevision: Revision | null = null;
243
+
244
+ @state()
245
+ private isLoadingRevisions = false;
246
+
247
+ private preRevertState: {
248
+ definition: FlowDefinition;
249
+ dirtyDate: Date | null;
250
+ } | null = null;
251
+
215
252
  private translationCache = new Map<string, string>();
216
253
 
217
254
  // NodeEditor state - handles both node and action editing
@@ -329,6 +366,29 @@ export class Editor extends RapidElement {
329
366
  transition: none !important;
330
367
  }
331
368
 
369
+ #canvas.viewing-revision {
370
+ pointer-events: none;
371
+ }
372
+
373
+ #canvas.read-only svg {
374
+ pointer-events: none;
375
+ }
376
+
377
+ #grid.viewing-revision {
378
+ background-color: #fff9fc;
379
+ background-image: radial-gradient(
380
+ circle,
381
+ rgba(166, 38, 164, 0.2) 1px,
382
+ transparent 1px
383
+ );
384
+ }
385
+
386
+ #grid.viewing-revision temba-flow-node,
387
+ #grid.viewing-revision svg.jtk-connector,
388
+ #grid.viewing-revision .activity-overlay {
389
+ opacity: 0.5;
390
+ }
391
+
332
392
  body .jtk-endpoint {
333
393
  width: initial;
334
394
  height: initial;
@@ -383,12 +443,28 @@ export class Editor extends RapidElement {
383
443
  stroke-width: 3px;
384
444
  }
385
445
 
446
+ body #canvas.read-only-connections svg.jtk-connector.jtk-hover path {
447
+ stroke: var(--color-connectors) !important;
448
+ }
449
+
386
450
  body .plumb-connector.jtk-hover .plumb-arrow {
387
451
  fill: var(--color-success) !important;
388
452
  stroke-width: 0px;
389
453
  z-index: 10;
390
454
  }
391
455
 
456
+ body
457
+ #canvas.read-only-connections
458
+ .plumb-connector.jtk-hover
459
+ .plumb-arrow {
460
+ fill: var(--color-connectors) !important;
461
+ ponter-events: none;
462
+ }
463
+
464
+ body #canvas.read-only-connections svg {
465
+ pointer-events: none;
466
+ }
467
+
392
468
  /* Activity overlays on connections */
393
469
  .jtk-overlay.activity-overlay {
394
470
  background: #f3f3f3;
@@ -677,6 +753,18 @@ export class Editor extends RapidElement {
677
753
  width: 100%;
678
754
  }
679
755
 
756
+ .revert-button {
757
+ background: var(--color-primary-dark);
758
+ border: none;
759
+ color: #fff;
760
+ padding: 6px 10px;
761
+ border-radius: var(--curvature);
762
+ font-size: 11px;
763
+ font-weight: 600;
764
+ cursor: pointer;
765
+ transition: opacity 0.2s ease;
766
+ }
767
+
680
768
  .auto-translate-button {
681
769
  background: var(--color-primary-dark);
682
770
  border: none;
@@ -895,10 +983,11 @@ export class Editor extends RapidElement {
895
983
  }, SAVE_QUIET_TIME);
896
984
  }
897
985
 
898
- private saveChanges(): void {
986
+ private saveChanges(definitionOverride?: FlowDefinition): Promise<void> {
987
+ const definition = definitionOverride || this.definition;
899
988
  // post the flow definition to the server
900
- getStore()
901
- .postJSON(`/flow/revisions/${this.flow}/`, this.definition)
989
+ return getStore()
990
+ .postJSON(`/flow/revisions/${this.flow}/`, definition)
902
991
  .then((response) => {
903
992
  // Update flow info and revision with the response data
904
993
  if (response.json) {
@@ -911,6 +1000,11 @@ export class Editor extends RapidElement {
911
1000
  if (response.json.revision?.revision !== undefined) {
912
1001
  state.setRevision(response.json.revision.revision);
913
1002
  }
1003
+
1004
+ // if the revisions window is open, refresh the list
1005
+ if (!this.revisionsWindowHidden) {
1006
+ this.fetchRevisions();
1007
+ }
914
1008
  }
915
1009
  })
916
1010
  .catch((error) => {
@@ -961,7 +1055,7 @@ export class Editor extends RapidElement {
961
1055
  }
962
1056
 
963
1057
  this.activityTimer = window.setTimeout(() => {
964
- this.fetchActivityData();
1058
+ // this.fetchActivityData();
965
1059
  }, this.activityInterval);
966
1060
  });
967
1061
  }
@@ -1073,6 +1167,8 @@ export class Editor extends RapidElement {
1073
1167
  // ignore right clicks
1074
1168
  if (event.button !== 0) return;
1075
1169
 
1170
+ if (this.isReadOnly()) return;
1171
+
1076
1172
  const element = event.currentTarget as HTMLElement;
1077
1173
  // Only start dragging if clicking on the element itself, not on exits or other interactive elements
1078
1174
  const target = event.target as HTMLElement;
@@ -1142,6 +1238,8 @@ export class Editor extends RapidElement {
1142
1238
  }
1143
1239
 
1144
1240
  private handleCanvasMouseDown(event: MouseEvent): void {
1241
+ if (this.isReadOnly()) return;
1242
+
1145
1243
  const target = event.target as HTMLElement;
1146
1244
  if (target.id === 'canvas' || target.id === 'grid') {
1147
1245
  // Ignore clicks on exits
@@ -1215,6 +1313,7 @@ export class Editor extends RapidElement {
1215
1313
  // Clean up jsPlumb connections for nodes before removing them
1216
1314
  uuids.forEach((uuid) => {
1217
1315
  this.plumber.removeNodeConnections(uuid);
1316
+ this.plumber.removeAllEndpoints(uuid);
1218
1317
  });
1219
1318
 
1220
1319
  // Now remove them from the definition
@@ -1735,6 +1834,11 @@ export class Editor extends RapidElement {
1735
1834
  }
1736
1835
 
1737
1836
  private handleCanvasContextMenu(event: MouseEvent): void {
1837
+ if (this.isReadOnly()) {
1838
+ event.preventDefault();
1839
+ return;
1840
+ }
1841
+
1738
1842
  // Check if we right-clicked on empty canvas space
1739
1843
  const target = event.target as HTMLElement;
1740
1844
  if (target.id !== 'canvas') {
@@ -2654,6 +2758,7 @@ export class Editor extends RapidElement {
2654
2758
  }
2655
2759
 
2656
2760
  this.localizationWindowHidden = false;
2761
+ this.revisionsWindowHidden = true;
2657
2762
 
2658
2763
  const alreadySelected = languages.some(
2659
2764
  (lang) => lang.code === this.languageCode
@@ -2916,6 +3021,223 @@ export class Editor extends RapidElement {
2916
3021
  this.autoTranslating = false;
2917
3022
  }
2918
3023
 
3024
+ private handleRevisionsTabClick(): void {
3025
+ if (this.revisionsWindowHidden) {
3026
+ this.fetchRevisions();
3027
+ this.revisionsWindowHidden = false;
3028
+ this.localizationWindowHidden = true; // Close other window
3029
+ }
3030
+ }
3031
+
3032
+ private handleRevisionsWindowClosed(): void {
3033
+ this.resetRevisionsScroll();
3034
+ this.revisionsWindowHidden = true;
3035
+ if (this.viewingRevision) {
3036
+ this.handleCancelRevisionView();
3037
+ }
3038
+ }
3039
+
3040
+ private resetRevisionsScroll() {
3041
+ const list =
3042
+ this.querySelector('#revisions-window').shadowRoot?.querySelector(
3043
+ '.body'
3044
+ );
3045
+ if (list) {
3046
+ list.scrollTop = 0;
3047
+ }
3048
+ }
3049
+
3050
+ private async fetchRevisions() {
3051
+ this.isLoadingRevisions = true;
3052
+ try {
3053
+ const results = await fetchResults(
3054
+ `/flow/revisions/${this.flow}/?version=${FLOW_SPEC_VERSION}`
3055
+ );
3056
+ this.revisions = results.slice(1);
3057
+ } catch (e) {
3058
+ console.error('Error fetching revisions', e);
3059
+ } finally {
3060
+ this.isLoadingRevisions = false;
3061
+ }
3062
+ }
3063
+
3064
+ private async handleRevisionClick(revision: Revision) {
3065
+ if (this.viewingRevision?.id === revision.id) {
3066
+ return;
3067
+ }
3068
+
3069
+ if (!this.viewingRevision) {
3070
+ // Save current state first
3071
+ this.preRevertState = {
3072
+ definition: this.definition,
3073
+ dirtyDate: this.dirtyDate
3074
+ };
3075
+ }
3076
+
3077
+ this.viewingRevision = revision;
3078
+ this.isLoadingRevisions = true;
3079
+ this.plumber?.reset();
3080
+
3081
+ try {
3082
+ await getStore()
3083
+ .getState()
3084
+ .fetchRevision(`/flow/revisions/${this.flow}`, revision.id.toString());
3085
+ } catch (e) {
3086
+ console.error('Error fetching revision details', e);
3087
+ this.handleCancelRevisionView();
3088
+ } finally {
3089
+ this.isLoadingRevisions = false;
3090
+ }
3091
+ }
3092
+
3093
+ private handleCancelRevisionView() {
3094
+ this.plumber?.reset();
3095
+ if (this.preRevertState) {
3096
+ const currentInfo = getStore().getState().flowInfo;
3097
+ getStore().getState().setFlowContents({
3098
+ definition: this.preRevertState.definition,
3099
+ info: currentInfo
3100
+ });
3101
+ if (this.preRevertState.dirtyDate) {
3102
+ getStore().getState().setDirtyDate(this.preRevertState.dirtyDate);
3103
+ }
3104
+ } else {
3105
+ // Fallback if no pre-revert definition
3106
+ getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
3107
+ }
3108
+
3109
+ this.viewingRevision = null;
3110
+ this.preRevertState = null;
3111
+ }
3112
+
3113
+ private async handleRevertClick() {
3114
+ if (!this.viewingRevision || !this.preRevertState) return;
3115
+ this.plumber?.reset();
3116
+
3117
+ // Use the content of the viewing revision (this.definition)
3118
+ // but the revision number of the current head (preRevertState)
3119
+ // so the server accepts it as a valid update
3120
+ const definitionToSave = {
3121
+ ...this.definition,
3122
+ revision: this.preRevertState.definition.revision
3123
+ };
3124
+
3125
+ await this.saveChanges(definitionToSave);
3126
+ this.viewingRevision = null;
3127
+ this.preRevertState = null;
3128
+ this.revisionsWindowHidden = true;
3129
+
3130
+ const revisionsWindow = document.getElementById(
3131
+ 'revisions-window'
3132
+ ) as FloatingWindow;
3133
+ revisionsWindow.handleClose();
3134
+
3135
+ // Refresh revisions list to show the new one
3136
+ this.fetchRevisions();
3137
+
3138
+ // Fetch the latest version of the flow to ensure the store is up to date
3139
+ getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
3140
+ }
3141
+
3142
+ private renderRevisionsTab(): TemplateResult | string {
3143
+ return html`
3144
+ <temba-floating-tab
3145
+ id="revisions-tab"
3146
+ icon="revisions"
3147
+ label="Revisions"
3148
+ color="rgb(142, 94, 167)"
3149
+ top="105"
3150
+ .hidden=${!this.revisionsWindowHidden && this.localizationWindowHidden}
3151
+ @temba-button-clicked=${this.handleRevisionsTabClick}
3152
+ ></temba-floating-tab>
3153
+ `;
3154
+ }
3155
+
3156
+ private renderRevisionsWindow(): TemplateResult | string {
3157
+ return html`
3158
+ <temba-floating-window
3159
+ id="revisions-window"
3160
+ header="Revisions"
3161
+ .width=${360}
3162
+ .maxHeight=${600}
3163
+ .top=${75}
3164
+ color="rgb(142, 94, 167)"
3165
+ .hidden=${this.revisionsWindowHidden}
3166
+ @temba-dialog-hidden=${this.handleRevisionsWindowClosed}
3167
+ >
3168
+ <div class="localization-window-content">
3169
+ <div
3170
+ class="revisions-list"
3171
+ style="display:flex; flex-direction:column; gap:8px; overflow-y:auto; padding-bottom:10px;"
3172
+ >
3173
+ ${this.isLoadingRevisions && !this.revisions.length
3174
+ ? html`<temba-loading></temba-loading>`
3175
+ : this.revisions.map((rev) => {
3176
+ const isSelected = this.viewingRevision?.id === rev.id;
3177
+ return html`
3178
+ <div
3179
+ class="revision-item ${isSelected ? 'selected' : ''}"
3180
+ style="padding:8px; border-radius:4px; cursor:pointer; background:${
3181
+ isSelected
3182
+ ? '#f0f6ff' // Light blue bg for selected
3183
+ : '#f9fafb'
3184
+ }; border:1px solid ${
3185
+ isSelected ? '#a4cafe' : '#e5e7eb'
3186
+ }; transition: all 0.2s ease;"
3187
+ @click=${() => this.handleRevisionClick(rev)}
3188
+ >
3189
+ <div
3190
+ style="display:flex; justify-content:space-between; align-items:center;"
3191
+ >
3192
+ <div
3193
+ class="revision-header"
3194
+ style="margin-bottom: 2px;"
3195
+ >
3196
+ <div
3197
+ style="font-weight:600; font-size:13px; color:#111827;"
3198
+ >
3199
+ <temba-date value=${
3200
+ rev.created_on
3201
+ } display="duration"></temba-date>
3202
+
3203
+ </div>
3204
+ <div style="font-size:11px; color:#6b7280;">
3205
+ ${rev.user.name || rev.user.username}
3206
+ </div>
3207
+ </div>
3208
+ ${
3209
+ isSelected
3210
+ ? html`<button
3211
+ class="revert-button"
3212
+ @click=${this.handleRevertClick}
3213
+ >
3214
+ Revert
3215
+ </button>`
3216
+ : html``
3217
+ }
3218
+
3219
+ </button>
3220
+ </div>
3221
+
3222
+ ${
3223
+ rev.comment
3224
+ ? html`<div
3225
+ style="font-size:12px; color:#4b5563; margin-top:4px;"
3226
+ >
3227
+ ${rev.comment}
3228
+ </div>`
3229
+ : ''
3230
+ }
3231
+
3232
+ </div>
3233
+ `;
3234
+ })}
3235
+ </div>
3236
+ </div>
3237
+ </temba-floating-window>
3238
+ `;
3239
+ }
3240
+
2919
3241
  private renderLocalizationWindow(): TemplateResult | string {
2920
3242
  const languages = this.getLocalizationLanguages();
2921
3243
  if (!languages.length) {
@@ -3167,6 +3489,10 @@ export class Editor extends RapidElement {
3167
3489
  });
3168
3490
  }
3169
3491
 
3492
+ private isReadOnly(): boolean {
3493
+ return this.viewingRevision !== null || this.isTranslating;
3494
+ }
3495
+
3170
3496
  public render(): TemplateResult {
3171
3497
  // we have to embed our own style since we are in light DOM
3172
3498
  const style = html`<style>
@@ -3176,20 +3502,30 @@ export class Editor extends RapidElement {
3176
3502
 
3177
3503
  const stickies = this.definition?._ui?.stickies || {};
3178
3504
 
3179
- return html`${style} ${this.renderLocalizationWindow()}
3180
- ${this.renderAutoTranslateDialog()}
3505
+ return html`${style} ${this.renderRevisionsWindow()}
3506
+ ${this.renderLocalizationWindow()} ${this.renderAutoTranslateDialog()}
3181
3507
  <div id="editor">
3182
3508
  <div
3183
3509
  id="grid"
3510
+ class="${this.viewingRevision ? 'viewing-revision' : ''}"
3184
3511
  style="min-width:100%;width:${this.canvasSize.width}px; height:${this
3185
3512
  .canvasSize.height}px"
3186
3513
  >
3187
- <div id="canvas">
3514
+ <div
3515
+ id="canvas"
3516
+ class="${getClasses({
3517
+ 'viewing-revision': !!this.viewingRevision,
3518
+ 'read-only-connections':
3519
+ !!this.viewingRevision || this.isTranslating
3520
+ })}"
3521
+ >
3188
3522
  ${this.definition
3189
3523
  ? repeat(
3190
- this.definition.nodes,
3524
+ [...this.definition.nodes].sort((a, b) =>
3525
+ a.uuid.localeCompare(b.uuid)
3526
+ ),
3191
3527
  (node) => node.uuid,
3192
- (node, index) => {
3528
+ (node) => {
3193
3529
  const position = this.definition._ui?.nodes[node.uuid]
3194
3530
  ?.position || {
3195
3531
  left: 0,
@@ -3203,7 +3539,9 @@ export class Editor extends RapidElement {
3203
3539
  const selected = this.selectedItems.has(node.uuid);
3204
3540
 
3205
3541
  // first node is the flow start (nodes are sorted by position)
3206
- const isFlowStart = index === 0;
3542
+ const isFlowStart =
3543
+ this.definition.nodes.length > 0 &&
3544
+ this.definition.nodes[0].uuid === node.uuid;
3207
3545
 
3208
3546
  return html`<temba-flow-node
3209
3547
  class="draggable ${dragging ? 'dragging' : ''} ${selected
@@ -3263,10 +3601,12 @@ export class Editor extends RapidElement {
3263
3601
  : ''}
3264
3602
 
3265
3603
  <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()} `;
3604
+ ${!this.viewingRevision
3605
+ ? html`<temba-node-type-selector
3606
+ .flowType=${this.flowType}
3607
+ .features=${this.features}
3608
+ ></temba-node-type-selector>`
3609
+ : ''}
3610
+ ${this.renderRevisionsTab()} ${this.renderLocalizationTab()} `;
3271
3611
  }
3272
3612
  }