@nyaruka/temba-components 0.139.0 → 0.140.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 (155) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +17 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/temba-components.js +562 -296
  6. package/dist/temba-components.js.map +1 -1
  7. package/out-tsc/src/display/Chat.js +10 -7
  8. package/out-tsc/src/display/Chat.js.map +1 -1
  9. package/out-tsc/src/display/Dropdown.js +3 -1
  10. package/out-tsc/src/display/Dropdown.js.map +1 -1
  11. package/out-tsc/src/display/FloatingTab.js +3 -3
  12. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  13. package/out-tsc/src/display/Thumbnail.js +163 -5
  14. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  15. package/out-tsc/src/flow/CanvasNode.js +64 -22
  16. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  17. package/out-tsc/src/flow/Editor.js +142 -8
  18. package/out-tsc/src/flow/Editor.js.map +1 -1
  19. package/out-tsc/src/flow/NodeEditor.js +118 -10
  20. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  21. package/out-tsc/src/flow/StickyNote.js +13 -4
  22. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  23. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  24. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  25. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  26. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  27. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  28. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  29. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  30. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  31. package/out-tsc/src/flow/config.js +11 -3
  32. package/out-tsc/src/flow/config.js.map +1 -1
  33. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  34. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  35. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  36. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  37. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  38. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  39. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  40. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  41. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  42. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  43. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  44. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  45. package/out-tsc/src/flow/operators.js +21 -5
  46. package/out-tsc/src/flow/operators.js.map +1 -1
  47. package/out-tsc/src/flow/types.js.map +1 -1
  48. package/out-tsc/src/flow/utils.js +79 -3
  49. package/out-tsc/src/flow/utils.js.map +1 -1
  50. package/out-tsc/src/form/ArrayEditor.js +4 -2
  51. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  52. package/out-tsc/src/form/FieldRenderer.js +49 -0
  53. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  54. package/out-tsc/src/interfaces.js +1 -0
  55. package/out-tsc/src/interfaces.js.map +1 -1
  56. package/out-tsc/src/layout/Dialog.js +52 -7
  57. package/out-tsc/src/layout/Dialog.js.map +1 -1
  58. package/out-tsc/src/live/TembaChart.js.map +1 -1
  59. package/out-tsc/src/simulator/Simulator.js +10 -4
  60. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  61. package/out-tsc/src/store/AppState.js +89 -3
  62. package/out-tsc/src/store/AppState.js.map +1 -1
  63. package/out-tsc/test/actions/play_audio.test.js +118 -0
  64. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  65. package/out-tsc/test/actions/say_msg.test.js +158 -0
  66. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  67. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  68. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  69. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  70. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  71. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  72. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  73. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  74. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  75. package/out-tsc/test/temba-flow-collision.test.js +261 -6
  76. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  77. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  78. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  79. package/package.json +1 -1
  80. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  81. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  82. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  83. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  84. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  85. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  86. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  87. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  88. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  89. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  90. package/screenshots/truth/editor/router.png +0 -0
  91. package/screenshots/truth/editor/wait.png +0 -0
  92. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  93. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  94. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  95. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  96. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  97. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  98. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  99. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  100. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  101. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  102. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  103. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  104. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  105. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  112. package/src/display/Chat.ts +13 -7
  113. package/src/display/Dropdown.ts +3 -1
  114. package/src/display/FloatingTab.ts +3 -3
  115. package/src/display/Thumbnail.ts +162 -2
  116. package/src/flow/CanvasNode.ts +69 -23
  117. package/src/flow/Editor.ts +156 -13
  118. package/src/flow/NodeEditor.ts +137 -9
  119. package/src/flow/StickyNote.ts +14 -4
  120. package/src/flow/actions/audio-player.ts +127 -0
  121. package/src/flow/actions/enter_flow.ts +44 -0
  122. package/src/flow/actions/play_audio.ts +64 -5
  123. package/src/flow/actions/say_msg.ts +94 -4
  124. package/src/flow/config.ts +11 -3
  125. package/src/flow/nodes/shared-rules.ts +1 -1
  126. package/src/flow/nodes/terminal.ts +9 -0
  127. package/src/flow/nodes/wait_for_audio.ts +88 -0
  128. package/src/flow/nodes/wait_for_dial.ts +176 -0
  129. package/src/flow/nodes/wait_for_digits.ts +86 -2
  130. package/src/flow/nodes/wait_for_menu.ts +209 -3
  131. package/src/flow/operators.ts +23 -5
  132. package/src/flow/types.ts +23 -1
  133. package/src/flow/utils.ts +82 -3
  134. package/src/form/ArrayEditor.ts +4 -2
  135. package/src/form/FieldRenderer.ts +64 -1
  136. package/src/interfaces.ts +2 -1
  137. package/src/layout/Dialog.ts +53 -7
  138. package/src/live/TembaChart.ts +1 -1
  139. package/src/simulator/Simulator.ts +13 -4
  140. package/src/store/AppState.ts +105 -1
  141. package/src/store/flow-definition.d.ts +2 -0
  142. package/test/actions/play_audio.test.ts +155 -0
  143. package/test/actions/say_msg.test.ts +196 -0
  144. package/test/nodes/wait_for_audio.test.ts +182 -0
  145. package/test/nodes/wait_for_dial.test.ts +382 -0
  146. package/test/nodes/wait_for_digits.test.ts +233 -109
  147. package/test/nodes/wait_for_menu.test.ts +383 -0
  148. package/test/temba-flow-collision.test.ts +286 -6
  149. package/test/temba-node-type-selector.test.ts +6 -6
  150. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  151. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  152. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  153. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  154. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  155. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -9,7 +9,7 @@ import { getClasses } from '../utils';
9
9
  import { Plumber } from './Plumber';
10
10
  import { getStore } from '../store/Store';
11
11
  import { CustomEventType } from '../interfaces';
12
- import { AppState, fromStore, zustand } from '../store/AppState';
12
+ import { AppState, FlowIssue, fromStore, zustand } from '../store/AppState';
13
13
 
14
14
  const DRAG_THRESHOLD = 5;
15
15
 
@@ -49,6 +49,12 @@ export class CanvasNode extends RapidElement {
49
49
  @fromStore(zustand, (state: AppState) => state.getCurrentActivity())
50
50
  private activity!: any;
51
51
 
52
+ @fromStore(zustand, (state: AppState) => state.issuesByNode)
53
+ private issuesByNode!: Map<string, FlowIssue[]>;
54
+
55
+ @fromStore(zustand, (state: AppState) => state.issuesByAction)
56
+ private issuesByAction!: Map<string, FlowIssue[]>;
57
+
52
58
  // Track exits that are in "removing" state
53
59
  private exitRemovalTimeouts: Map<string, number> = new Map();
54
60
 
@@ -185,6 +191,12 @@ export class CanvasNode extends RapidElement {
185
191
  pointer-events: none !important;
186
192
  }
187
193
 
194
+ /* Issue indicators - hatched red title bar */
195
+ .action-content.has-issues .cn-title,
196
+ .node.has-issues > .router .cn-title {
197
+ background: repeating-linear-gradient(120deg, tomato, tomato 6px, #ff7056 0, #ff7056 18px) !important;
198
+ }
199
+
188
200
  .action.sortable {
189
201
  display: flex;
190
202
  align-items: stretch;
@@ -548,14 +560,17 @@ export class CanvasNode extends RapidElement {
548
560
  // make our initial connections
549
561
  // We use setTimeout to allow for DOM updates to complete before querying for exits
550
562
  this.connectionTimeout = window.setTimeout(() => {
551
- for (const exit of this.node.exits) {
552
- this.plumber.makeSource(exit.uuid);
553
- if (exit.destination_uuid) {
554
- this.plumber.connectIds(
555
- this.node.uuid,
556
- exit.uuid,
557
- exit.destination_uuid
558
- );
563
+ // Terminal nodes have no visible exits
564
+ if (this.ui?.type !== 'terminal') {
565
+ for (const exit of this.node.exits) {
566
+ this.plumber.makeSource(exit.uuid);
567
+ if (exit.destination_uuid) {
568
+ this.plumber.connectIds(
569
+ this.node.uuid,
570
+ exit.uuid,
571
+ exit.destination_uuid
572
+ );
573
+ }
559
574
  }
560
575
  }
561
576
  // Note: revalidation is handled by plumber's processPendingConnections which calls repaintEverything
@@ -949,6 +964,11 @@ export class CanvasNode extends RapidElement {
949
964
  this.requestUpdate();
950
965
  }
951
966
 
967
+ private getTopCenter(el: Element): { x: number; y: number } {
968
+ const rect = el.getBoundingClientRect();
969
+ return { x: rect.left + rect.width / 2, y: rect.top };
970
+ }
971
+
952
972
  private handleActionMouseDown(event: MouseEvent, action: Action): void {
953
973
  // Don't handle clicks on the remove button, drag handle, or when action is in removing state
954
974
  const target = event.target as HTMLElement;
@@ -1002,10 +1022,18 @@ export class CanvasNode extends RapidElement {
1002
1022
  // Only fire the action edit event if we haven't dragged beyond the threshold
1003
1023
  // AND either there's no Editor parent (test case) or the Editor didn't drag the node
1004
1024
  if (distance <= DRAG_THRESHOLD && (!editor || !editorWasDragging)) {
1025
+ // Use top-center of the action element as the dialog origin
1026
+ const actionEl = event.currentTarget as Element;
1027
+ const origin = actionEl
1028
+ ? this.getTopCenter(actionEl)
1029
+ : { x: event.clientX, y: event.clientY };
1030
+
1005
1031
  // Fire event to request action editing
1006
1032
  this.fireCustomEvent(CustomEventType.ActionEditRequested, {
1007
1033
  action,
1008
- nodeUuid: this.node.uuid
1034
+ nodeUuid: this.node.uuid,
1035
+ originX: origin.x,
1036
+ originY: origin.y
1009
1037
  });
1010
1038
  }
1011
1039
  }
@@ -1125,17 +1153,24 @@ export class CanvasNode extends RapidElement {
1125
1153
  // Using literal 5 instead of DRAG_THRESHOLD since it's not imported
1126
1154
  // Fire event to request node editing if the node has a router
1127
1155
  if (this.node.router) {
1156
+ // Use top-center of the node as the dialog origin
1157
+ const origin = this.getTopCenter(this);
1158
+
1128
1159
  // If router node has exactly one action, open the action editor directly
1129
1160
  if (this.node.actions && this.node.actions.length === 1) {
1130
1161
  this.fireCustomEvent(CustomEventType.ActionEditRequested, {
1131
1162
  action: this.node.actions[0],
1132
- nodeUuid: this.node.uuid
1163
+ nodeUuid: this.node.uuid,
1164
+ originX: origin.x,
1165
+ originY: origin.y
1133
1166
  });
1134
1167
  } else {
1135
1168
  // Otherwise open the node editor as before
1136
1169
  this.fireCustomEvent(CustomEventType.NodeEditRequested, {
1137
1170
  node: this.node,
1138
- nodeUI: this.ui
1171
+ nodeUI: this.ui,
1172
+ originX: origin.x,
1173
+ originY: origin.y
1139
1174
  });
1140
1175
  }
1141
1176
  }
@@ -1310,13 +1345,11 @@ export class CanvasNode extends RapidElement {
1310
1345
  const color = config.group
1311
1346
  ? ACTION_GROUP_METADATA[config.group]?.color
1312
1347
  : '#aaaaaa';
1348
+ const isTerminal = this.ui?.type === 'terminal';
1313
1349
  return html`<div class="cn-title" style="background:${color}">
1314
- ${this.ui?.type === 'execute_actions'
1315
- ? html`<temba-icon
1316
- class="drag-handle ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1317
- name="sort"
1318
- ></temba-icon>`
1319
- : this.node?.actions?.length > 1
1350
+ ${isTerminal
1351
+ ? html`<div class="title-spacer"></div>`
1352
+ : this.ui?.type === 'execute_actions' || this.node?.actions?.length > 1
1320
1353
  ? html`<temba-icon
1321
1354
  class="drag-handle ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1322
1355
  name="sort"
@@ -1325,7 +1358,9 @@ export class CanvasNode extends RapidElement {
1325
1358
 
1326
1359
  <div class="name">${isRemoving ? 'Remove?' : config.name}</div>
1327
1360
  <div
1328
- class="remove-button ${this.isReadOnly() ? 'read-only-hidden' : ''}"
1361
+ class="remove-button ${isTerminal || this.isReadOnly()
1362
+ ? 'read-only-hidden'
1363
+ : ''}"
1329
1364
  @click=${(e: MouseEvent) =>
1330
1365
  this.handleActionRemoveClick(e, action, index)}
1331
1366
  title="Remove action"
@@ -1438,6 +1473,7 @@ export class CanvasNode extends RapidElement {
1438
1473
  const displayAction = this.getLocalizedAction(action);
1439
1474
 
1440
1475
  if (config) {
1476
+ const hasIssues = this.issuesByAction?.has(action.uuid);
1441
1477
  const classes = [
1442
1478
  'action',
1443
1479
  'sortable',
@@ -1452,7 +1488,7 @@ export class CanvasNode extends RapidElement {
1452
1488
 
1453
1489
  return html`<div class="${classes}" id="action-${index}">
1454
1490
  <div
1455
- class="action-content"
1491
+ class="action-content ${hasIssues ? 'has-issues' : ''}"
1456
1492
  @mousedown=${(e: MouseEvent) =>
1457
1493
  !isDisabled && this.handleActionMouseDown(e, action)}
1458
1494
  @mouseup=${(e: MouseEvent) =>
@@ -1628,13 +1664,19 @@ export class CanvasNode extends RapidElement {
1628
1664
  const activeCount =
1629
1665
  (this.activity?.nodes && this.activity.nodes[this.node.uuid]) || 0;
1630
1666
 
1667
+ // Check for node-level issues or action-level issues on any action in this node
1668
+ const nodeHasIssues =
1669
+ this.issuesByNode?.has(this.node.uuid) ||
1670
+ this.node.actions?.some((a) => this.issuesByAction?.has(a.uuid));
1671
+
1631
1672
  return html`
1632
1673
  <div
1633
1674
  id="${this.node.uuid}"
1634
1675
  class=${getClasses({
1635
1676
  node: true,
1636
1677
  'execute-actions': this.ui.type === 'execute_actions',
1637
- 'non-localizable': isNodeDisabled
1678
+ 'non-localizable': isNodeDisabled,
1679
+ 'has-issues': nodeHasIssues
1638
1680
  })}
1639
1681
  style="left:${this.ui.position.left}px;top:${this.ui.position.top}px"
1640
1682
  >
@@ -1643,7 +1685,9 @@ export class CanvasNode extends RapidElement {
1643
1685
  ${activeCount.toLocaleString()}
1644
1686
  </div>`
1645
1687
  : ''}
1646
- ${nodeConfig && nodeConfig.type !== 'execute_actions'
1688
+ ${nodeConfig &&
1689
+ nodeConfig.type !== 'execute_actions' &&
1690
+ nodeConfig.type !== 'terminal'
1647
1691
  ? html`<div class="router" style="position: relative;">
1648
1692
  <div
1649
1693
  @mousedown=${(e: MouseEvent) => this.handleNodeMouseDown(e)}
@@ -1661,7 +1705,7 @@ export class CanvasNode extends RapidElement {
1661
1705
  : null}
1662
1706
  </div>
1663
1707
  </div>`
1664
- : this.node.actions.length > 0
1708
+ : this.node.actions?.length > 0
1665
1709
  ? this.ui.type === 'execute_actions'
1666
1710
  ? html`<temba-sortable-list
1667
1711
  dragHandle="drag-handle"
@@ -1691,6 +1735,8 @@ export class CanvasNode extends RapidElement {
1691
1735
  ${this.renderRouter(this.node.router, this.ui)}
1692
1736
  ${this.renderCategories(this.node)}
1693
1737
  </div>`
1738
+ : this.ui.type === 'terminal'
1739
+ ? ''
1694
1740
  : html`<div class="action-exits">
1695
1741
  ${repeat(
1696
1742
  this.node.exits,
@@ -11,6 +11,7 @@ import {
11
11
  import { getStore } from '../store/Store';
12
12
  import {
13
13
  AppState,
14
+ FlowIssue,
14
15
  fromStore,
15
16
  zustand,
16
17
  FLOW_SPEC_VERSION
@@ -19,6 +20,13 @@ import { RapidElement } from '../RapidElement';
19
20
  import { repeat } from 'lit-html/directives/repeat.js';
20
21
  import { CustomEventType, Workspace } from '../interfaces';
21
22
  import { generateUUID, postJSON, fetchResults, getClasses } from '../utils';
23
+ import {
24
+ formatIssueMessage,
25
+ getNodeBounds,
26
+ calculateReflowPositions,
27
+ NodeBounds,
28
+ snapToGrid
29
+ } from './utils';
22
30
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
23
31
 
24
32
  interface Revision {
@@ -49,12 +57,6 @@ import { Dialog } from '../layout/Dialog';
49
57
 
50
58
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
51
59
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
52
- import {
53
- getNodeBounds,
54
- calculateReflowPositions,
55
- NodeBounds,
56
- snapToGrid
57
- } from './utils';
58
60
  import { FloatingWindow } from '../layout/FloatingWindow';
59
61
 
60
62
  export function findNodeForExit(
@@ -173,6 +175,9 @@ export class Editor extends RapidElement {
173
175
  @fromStore(zustand, (state: AppState) => state.getCurrentActivity())
174
176
  private activityData!: any;
175
177
 
178
+ @fromStore(zustand, (state: AppState) => state.flowInfo?.issues || [])
179
+ private flowIssues!: FlowIssue[];
180
+
176
181
  // Drag state
177
182
  @state()
178
183
  private isDragging = false;
@@ -217,6 +222,9 @@ export class Editor extends RapidElement {
217
222
  private connectionSourceX: number | null = null;
218
223
  private connectionSourceY: number | null = null;
219
224
 
225
+ @state()
226
+ private issuesWindowHidden = true;
227
+
220
228
  @state()
221
229
  private localizationWindowHidden = true;
222
230
 
@@ -269,6 +277,8 @@ export class Editor extends RapidElement {
269
277
  @state()
270
278
  private editingAction: Action | null = null;
271
279
 
280
+ private dialogOrigin: { x: number; y: number } | null = null;
281
+
272
282
  @state()
273
283
  private isCreatingNewNode = false;
274
284
 
@@ -357,6 +367,10 @@ export class Editor extends RapidElement {
357
367
  -webkit-font-smoothing: antialiased;
358
368
  }
359
369
 
370
+ temba-floating-tab {
371
+ --floating-tab-right: 15px;
372
+ }
373
+
360
374
  #grid {
361
375
  position: relative;
362
376
  background-color: #f9f9f9;
@@ -490,7 +504,7 @@ export class Editor extends RapidElement {
490
504
  font-weight: 600;
491
505
  line-height: 0.9;
492
506
  cursor: pointer;
493
- z-index: 500;
507
+ z-index: 10;
494
508
  pointer-events: auto;
495
509
  white-space: nowrap;
496
510
  user-select: none;
@@ -805,6 +819,26 @@ export class Editor extends RapidElement {
805
819
  color: #9ca3af;
806
820
  white-space: nowrap;
807
821
  }
822
+
823
+ .issue-list-item {
824
+ display: flex;
825
+ align-items: center;
826
+ gap: 8px;
827
+ padding: 8px;
828
+ border-radius: 4px;
829
+ cursor: pointer;
830
+ font-size: 13px;
831
+ color: #333;
832
+ }
833
+
834
+ .issue-list-item:hover {
835
+ background: #fff5f5;
836
+ }
837
+
838
+ .issue-list-item temba-icon {
839
+ color: tomato;
840
+ flex-shrink: 0;
841
+ }
808
842
  `;
809
843
  }
810
844
 
@@ -1219,6 +1253,7 @@ export class Editor extends RapidElement {
1219
1253
  if (event.button !== 0) return;
1220
1254
 
1221
1255
  if (this.isReadOnly()) return;
1256
+ this.blurActiveContentEditable();
1222
1257
 
1223
1258
  const element = event.currentTarget as HTMLElement;
1224
1259
  // Only start dragging if clicking on the element itself, not on exits or other interactive elements
@@ -1288,8 +1323,22 @@ export class Editor extends RapidElement {
1288
1323
  this.handleCanvasMouseDown(event);
1289
1324
  }
1290
1325
 
1326
+ private blurActiveContentEditable(): void {
1327
+ let active: Element | null = document.activeElement;
1328
+ while (active?.shadowRoot?.activeElement) {
1329
+ active = active.shadowRoot.activeElement;
1330
+ }
1331
+ if (
1332
+ active instanceof HTMLElement &&
1333
+ active.getAttribute('contenteditable') === 'true'
1334
+ ) {
1335
+ active.blur();
1336
+ }
1337
+ }
1338
+
1291
1339
  private handleCanvasMouseDown(event: MouseEvent): void {
1292
1340
  if (this.isReadOnly()) return;
1341
+ this.blurActiveContentEditable();
1293
1342
 
1294
1343
  const target = event.target as HTMLElement;
1295
1344
  if (target.id === 'canvas' || target.id === 'grid') {
@@ -2160,6 +2209,10 @@ export class Editor extends RapidElement {
2160
2209
  private handleActionEditRequested(event: CustomEvent): void {
2161
2210
  // For action editing, we set the action and find the corresponding node
2162
2211
  this.editingAction = event.detail.action;
2212
+ this.dialogOrigin =
2213
+ event.detail.originX != null
2214
+ ? { x: event.detail.originX, y: event.detail.originY }
2215
+ : null;
2163
2216
 
2164
2217
  // Find the node that contains this action
2165
2218
  const nodeUuid = event.detail.nodeUuid;
@@ -2205,6 +2258,10 @@ export class Editor extends RapidElement {
2205
2258
  private handleNodeEditRequested(event: CustomEvent): void {
2206
2259
  this.editingNode = event.detail.node;
2207
2260
  this.editingNodeUI = event.detail.nodeUI;
2261
+ this.dialogOrigin =
2262
+ event.detail.originX != null
2263
+ ? { x: event.detail.originX, y: event.detail.originY }
2264
+ : null;
2208
2265
  }
2209
2266
 
2210
2267
  private handleNodeDeleted(event: CustomEvent): void {
@@ -2294,6 +2351,7 @@ export class Editor extends RapidElement {
2294
2351
  this.editingNode = null;
2295
2352
  this.editingNodeUI = null;
2296
2353
  this.editingAction = null;
2354
+ this.dialogOrigin = null;
2297
2355
  }
2298
2356
 
2299
2357
  private handleActionEditCanceled(): void {
@@ -2948,6 +3006,7 @@ export class Editor extends RapidElement {
2948
3006
 
2949
3007
  this.localizationWindowHidden = false;
2950
3008
  this.revisionsWindowHidden = true;
3009
+ this.issuesWindowHidden = true;
2951
3010
 
2952
3011
  const alreadySelected = languages.some(
2953
3012
  (lang) => lang.code === this.languageCode
@@ -3210,11 +3269,47 @@ export class Editor extends RapidElement {
3210
3269
  this.autoTranslating = false;
3211
3270
  }
3212
3271
 
3272
+ private handleIssuesTabClick(): void {
3273
+ this.issuesWindowHidden = false;
3274
+ this.revisionsWindowHidden = true;
3275
+ this.localizationWindowHidden = true;
3276
+ }
3277
+
3278
+ private handleIssuesWindowClosed(): void {
3279
+ this.issuesWindowHidden = true;
3280
+ }
3281
+
3282
+ private handleIssueItemClick(issue: FlowIssue): void {
3283
+ const issuesWindow = document.getElementById(
3284
+ 'issues-window'
3285
+ ) as FloatingWindow;
3286
+ issuesWindow?.handleClose();
3287
+ this.issuesWindowHidden = true;
3288
+
3289
+ this.focusNode(issue.node_uuid);
3290
+
3291
+ const node = this.definition.nodes.find((n) => n.uuid === issue.node_uuid);
3292
+ if (!node) return;
3293
+
3294
+ if (issue.action_uuid) {
3295
+ const action = node.actions?.find((a) => a.uuid === issue.action_uuid);
3296
+ if (action) {
3297
+ this.editingAction = action;
3298
+ this.editingNode = node;
3299
+ this.editingNodeUI = this.definition._ui.nodes[issue.node_uuid];
3300
+ }
3301
+ } else {
3302
+ this.editingNode = node;
3303
+ this.editingNodeUI = this.definition._ui.nodes[issue.node_uuid];
3304
+ }
3305
+ }
3306
+
3213
3307
  private handleRevisionsTabClick(): void {
3214
3308
  if (this.revisionsWindowHidden) {
3215
3309
  this.fetchRevisions();
3216
3310
  this.revisionsWindowHidden = false;
3217
- this.localizationWindowHidden = true; // Close other window
3311
+ this.issuesWindowHidden = true;
3312
+ this.localizationWindowHidden = true;
3218
3313
  }
3219
3314
  }
3220
3315
 
@@ -3328,6 +3423,51 @@ export class Editor extends RapidElement {
3328
3423
  getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
3329
3424
  }
3330
3425
 
3426
+ private renderIssuesTab(): TemplateResult | string {
3427
+ if (!this.flowIssues?.length) return '';
3428
+ return html`
3429
+ <temba-floating-tab
3430
+ id="issues-tab"
3431
+ icon="alert_warning"
3432
+ label="Flow Issues"
3433
+ color="tomato"
3434
+ order="1"
3435
+ .hidden=${!this.issuesWindowHidden}
3436
+ @temba-button-clicked=${this.handleIssuesTabClick}
3437
+ ></temba-floating-tab>
3438
+ `;
3439
+ }
3440
+
3441
+ private renderIssuesWindow(): TemplateResult | string {
3442
+ if (!this.flowIssues?.length) return '';
3443
+ return html`
3444
+ <temba-floating-window
3445
+ id="issues-window"
3446
+ header="Flow Issues"
3447
+ .width=${360}
3448
+ .maxHeight=${600}
3449
+ .top=${75}
3450
+ color="tomato"
3451
+ .hidden=${this.issuesWindowHidden}
3452
+ @temba-dialog-hidden=${this.handleIssuesWindowClosed}
3453
+ >
3454
+ <div style="display:flex; flex-direction:column; gap:2px;">
3455
+ ${this.flowIssues.map(
3456
+ (issue) => html`
3457
+ <div
3458
+ class="issue-list-item"
3459
+ @click=${() => this.handleIssueItemClick(issue)}
3460
+ >
3461
+ <temba-icon name="alert_warning" size="1.2"></temba-icon>
3462
+ <span>${formatIssueMessage(issue)}</span>
3463
+ </div>
3464
+ `
3465
+ )}
3466
+ </div>
3467
+ </temba-floating-window>
3468
+ `;
3469
+ }
3470
+
3331
3471
  private renderRevisionsTab(): TemplateResult | string {
3332
3472
  return html`
3333
3473
  <temba-floating-tab
@@ -3335,7 +3475,7 @@ export class Editor extends RapidElement {
3335
3475
  icon="revisions"
3336
3476
  label="Revisions"
3337
3477
  color="rgb(142, 94, 167)"
3338
- order="1"
3478
+ order="2"
3339
3479
  .hidden=${!this.revisionsWindowHidden && this.localizationWindowHidden}
3340
3480
  @temba-button-clicked=${this.handleRevisionsTabClick}
3341
3481
  ></temba-floating-tab>
@@ -3633,7 +3773,7 @@ export class Editor extends RapidElement {
3633
3773
  icon="language"
3634
3774
  label="Translate Flow"
3635
3775
  color="#6b7280"
3636
- order="2"
3776
+ order="3"
3637
3777
  .hidden=${!this.localizationWindowHidden}
3638
3778
  @temba-button-clicked=${this.handleLocalizationTabClick}
3639
3779
  ></temba-floating-tab>
@@ -3691,8 +3831,9 @@ export class Editor extends RapidElement {
3691
3831
 
3692
3832
  const stickies = this.definition?._ui?.stickies || {};
3693
3833
 
3694
- return html`${style} ${this.renderRevisionsWindow()}
3695
- ${this.renderLocalizationWindow()} ${this.renderAutoTranslateDialog()}
3834
+ return html`${style} ${this.renderIssuesWindow()}
3835
+ ${this.renderRevisionsWindow()} ${this.renderLocalizationWindow()}
3836
+ ${this.renderAutoTranslateDialog()}
3696
3837
  <div id="editor">
3697
3838
  <div
3698
3839
  id="grid"
@@ -3782,6 +3923,7 @@ export class Editor extends RapidElement {
3782
3923
  .node=${this.editingNode}
3783
3924
  .nodeUI=${this.editingNodeUI}
3784
3925
  .action=${this.editingAction}
3926
+ .dialogOrigin=${this.dialogOrigin}
3785
3927
  @temba-node-saved=${(e: CustomEvent) =>
3786
3928
  this.handleNodeSaved(e.detail.node, e.detail.uiConfig)}
3787
3929
  @temba-action-saved=${(e: CustomEvent) =>
@@ -3797,6 +3939,7 @@ export class Editor extends RapidElement {
3797
3939
  .features=${this.features}
3798
3940
  ></temba-node-type-selector>`
3799
3941
  : ''}
3800
- ${this.renderRevisionsTab()} ${this.renderLocalizationTab()} `;
3942
+ ${this.renderIssuesTab()} ${this.renderRevisionsTab()}
3943
+ ${this.renderLocalizationTab()} `;
3801
3944
  }
3802
3945
  }