@nyaruka/temba-components 0.138.6 → 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 (196) hide show
  1. package/.github/workflows/cla.yml +1 -1
  2. package/.github/workflows/copilot-setup-steps.yml +6 -1
  3. package/CHANGELOG.md +26 -0
  4. package/demo/data/flows/sample-flow.json +24 -0
  5. package/dist/locales/es.js +5 -5
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +5 -5
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/locale-codes.js +2 -11
  10. package/dist/locales/locale-codes.js.map +1 -1
  11. package/dist/locales/pt.js +5 -5
  12. package/dist/locales/pt.js.map +1 -1
  13. package/dist/temba-components.js +1112 -882
  14. package/dist/temba-components.js.map +1 -1
  15. package/out-tsc/src/display/Chat.js +10 -7
  16. package/out-tsc/src/display/Chat.js.map +1 -1
  17. package/out-tsc/src/display/Dropdown.js +3 -1
  18. package/out-tsc/src/display/Dropdown.js.map +1 -1
  19. package/out-tsc/src/display/FloatingTab.js +25 -32
  20. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  21. package/out-tsc/src/display/Thumbnail.js +163 -5
  22. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  23. package/out-tsc/src/flow/CanvasMenu.js +5 -3
  24. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  25. package/out-tsc/src/flow/CanvasNode.js +70 -29
  26. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  27. package/out-tsc/src/flow/Editor.js +290 -239
  28. package/out-tsc/src/flow/Editor.js.map +1 -1
  29. package/out-tsc/src/flow/NodeEditor.js +118 -10
  30. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  31. package/out-tsc/src/flow/Plumber.js +757 -403
  32. package/out-tsc/src/flow/Plumber.js.map +1 -1
  33. package/out-tsc/src/flow/StickyNote.js +13 -4
  34. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  35. package/out-tsc/src/flow/actions/audio-player.js +112 -0
  36. package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
  37. package/out-tsc/src/flow/actions/enter_flow.js +43 -0
  38. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
  39. package/out-tsc/src/flow/actions/play_audio.js +57 -4
  40. package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
  41. package/out-tsc/src/flow/actions/say_msg.js +86 -3
  42. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  43. package/out-tsc/src/flow/config.js +11 -3
  44. package/out-tsc/src/flow/config.js.map +1 -1
  45. package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
  46. package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
  47. package/out-tsc/src/flow/nodes/terminal.js +7 -0
  48. package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
  49. package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
  50. package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
  51. package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
  52. package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
  53. package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
  54. package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
  55. package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
  56. package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
  57. package/out-tsc/src/flow/operators.js +21 -5
  58. package/out-tsc/src/flow/operators.js.map +1 -1
  59. package/out-tsc/src/flow/types.js.map +1 -1
  60. package/out-tsc/src/flow/utils.js +213 -65
  61. package/out-tsc/src/flow/utils.js.map +1 -1
  62. package/out-tsc/src/form/ArrayEditor.js +4 -2
  63. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  64. package/out-tsc/src/form/FieldRenderer.js +49 -0
  65. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  66. package/out-tsc/src/interfaces.js +2 -0
  67. package/out-tsc/src/interfaces.js.map +1 -1
  68. package/out-tsc/src/layout/Dialog.js +52 -7
  69. package/out-tsc/src/layout/Dialog.js.map +1 -1
  70. package/out-tsc/src/list/TicketList.js +4 -1
  71. package/out-tsc/src/list/TicketList.js.map +1 -1
  72. package/out-tsc/src/live/TembaChart.js.map +1 -1
  73. package/out-tsc/src/locales/es.js +5 -5
  74. package/out-tsc/src/locales/es.js.map +1 -1
  75. package/out-tsc/src/locales/fr.js +5 -5
  76. package/out-tsc/src/locales/fr.js.map +1 -1
  77. package/out-tsc/src/locales/locale-codes.js +2 -11
  78. package/out-tsc/src/locales/locale-codes.js.map +1 -1
  79. package/out-tsc/src/locales/pt.js +5 -5
  80. package/out-tsc/src/locales/pt.js.map +1 -1
  81. package/out-tsc/src/simulator/Simulator.js +10 -3
  82. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  83. package/out-tsc/src/store/AppState.js +89 -3
  84. package/out-tsc/src/store/AppState.js.map +1 -1
  85. package/out-tsc/test/actions/play_audio.test.js +118 -0
  86. package/out-tsc/test/actions/play_audio.test.js.map +1 -0
  87. package/out-tsc/test/actions/say_msg.test.js +158 -0
  88. package/out-tsc/test/actions/say_msg.test.js.map +1 -0
  89. package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
  90. package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
  91. package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
  92. package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
  93. package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
  94. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
  95. package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
  96. package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
  97. package/out-tsc/test/temba-floating-tab.test.js +4 -6
  98. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  99. package/out-tsc/test/temba-flow-collision.test.js +473 -220
  100. package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
  101. package/out-tsc/test/temba-flow-editor.test.js +0 -2
  102. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  103. package/out-tsc/test/temba-flow-plumber-connections.test.js +83 -84
  104. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  105. package/out-tsc/test/temba-flow-plumber.test.js +102 -93
  106. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  107. package/out-tsc/test/temba-node-type-selector.test.js +6 -6
  108. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  109. package/package.json +1 -1
  110. package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
  111. package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
  112. package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
  113. package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
  114. package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
  115. package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
  116. package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
  117. package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
  118. package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
  119. package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
  120. package/screenshots/truth/editor/router.png +0 -0
  121. package/screenshots/truth/editor/wait.png +0 -0
  122. package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
  123. package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
  124. package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
  125. package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
  126. package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
  127. package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
  128. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  129. package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
  130. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  131. package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
  132. package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
  133. package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
  134. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  135. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  136. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  137. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  138. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  139. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  140. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  141. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  142. package/src/display/Chat.ts +13 -7
  143. package/src/display/Dropdown.ts +3 -1
  144. package/src/display/FloatingTab.ts +24 -33
  145. package/src/display/Thumbnail.ts +162 -2
  146. package/src/flow/CanvasMenu.ts +8 -3
  147. package/src/flow/CanvasNode.ts +75 -30
  148. package/src/flow/Editor.ts +336 -288
  149. package/src/flow/NodeEditor.ts +137 -9
  150. package/src/flow/Plumber.ts +1011 -457
  151. package/src/flow/StickyNote.ts +14 -4
  152. package/src/flow/actions/audio-player.ts +127 -0
  153. package/src/flow/actions/enter_flow.ts +44 -0
  154. package/src/flow/actions/play_audio.ts +64 -5
  155. package/src/flow/actions/say_msg.ts +94 -4
  156. package/src/flow/config.ts +11 -3
  157. package/src/flow/nodes/shared-rules.ts +1 -1
  158. package/src/flow/nodes/terminal.ts +9 -0
  159. package/src/flow/nodes/wait_for_audio.ts +88 -0
  160. package/src/flow/nodes/wait_for_dial.ts +176 -0
  161. package/src/flow/nodes/wait_for_digits.ts +86 -2
  162. package/src/flow/nodes/wait_for_menu.ts +209 -3
  163. package/src/flow/operators.ts +23 -5
  164. package/src/flow/types.ts +23 -1
  165. package/src/flow/utils.ts +238 -81
  166. package/src/form/ArrayEditor.ts +4 -2
  167. package/src/form/FieldRenderer.ts +64 -1
  168. package/src/interfaces.ts +3 -1
  169. package/src/layout/Dialog.ts +53 -7
  170. package/src/list/TicketList.ts +4 -1
  171. package/src/live/TembaChart.ts +1 -1
  172. package/src/locales/es.ts +13 -18
  173. package/src/locales/fr.ts +13 -18
  174. package/src/locales/locale-codes.ts +2 -11
  175. package/src/locales/pt.ts +13 -18
  176. package/src/simulator/Simulator.ts +13 -3
  177. package/src/store/AppState.ts +105 -1
  178. package/src/store/flow-definition.d.ts +2 -0
  179. package/test/actions/play_audio.test.ts +155 -0
  180. package/test/actions/say_msg.test.ts +196 -0
  181. package/test/nodes/wait_for_audio.test.ts +182 -0
  182. package/test/nodes/wait_for_dial.test.ts +382 -0
  183. package/test/nodes/wait_for_digits.test.ts +233 -109
  184. package/test/nodes/wait_for_menu.test.ts +383 -0
  185. package/test/temba-floating-tab.test.ts +4 -6
  186. package/test/temba-flow-collision.test.ts +495 -293
  187. package/test/temba-flow-editor.test.ts +0 -2
  188. package/test/temba-flow-plumber-connections.test.ts +97 -97
  189. package/test/temba-flow-plumber.test.ts +116 -103
  190. package/test/temba-node-type-selector.test.ts +6 -6
  191. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
@@ -30,8 +30,6 @@ describe('Editor', () => {
30
30
  expect(styles.cssText).to.contain('#editor');
31
31
  expect(styles.cssText).to.contain('#grid');
32
32
  expect(styles.cssText).to.contain('#canvas');
33
- expect(styles.cssText).to.contain('.plumb-source');
34
- expect(styles.cssText).to.contain('.plumb-target');
35
33
  expect(styles.cssText).to.contain('.plumb-connector');
36
34
  });
37
35
 
@@ -8,144 +8,144 @@ describe('Plumber - Connection Management', () => {
8
8
  let clock: SinonFakeTimers;
9
9
 
10
10
  beforeEach(() => {
11
- // Use fake timers to control setTimeout
12
11
  clock = useFakeTimers();
13
12
 
14
- // Create mock canvas and make getElementById return a mock element
15
13
  mockCanvas = document.createElement('div');
16
- const mockElement = document.createElement('div');
17
- stub(document, 'getElementById').returns(mockElement);
14
+ mockCanvas.id = 'canvas';
15
+ document.body.appendChild(mockCanvas);
18
16
 
19
- // Create a mock editor with fireCustomEvent
20
17
  const mockEditor = { fireCustomEvent: stub() };
21
-
22
- // Create a new plumber instance
23
18
  plumber = new Plumber(mockCanvas, mockEditor);
24
-
25
- // Replace the internal jsPlumb instance with mocks
26
- (plumber as any).jsPlumb = {
27
- getConnections: stub().returns([]),
28
- addClass: stub(),
29
- removeClass: stub(),
30
- batch: stub().callsFake((fn: any) => fn()),
31
- addEndpoint: stub().returns({}),
32
- revalidate: stub(),
33
- connect: stub(),
34
- getEndpoints: stub().returns([
35
- { elementId: 'test-from', addClass: stub() }
36
- ]),
37
- select: stub().returns({
38
- deleteAll: stub()
39
- }),
40
- selectEndpoints: stub().returns({
41
- deleteAll: stub()
42
- }),
43
- deleteConnection: stub(),
44
- removeAllEndpoints: stub(),
45
- repaintEverything: stub()
46
- };
47
19
  });
48
20
 
49
21
  afterEach(() => {
50
- // Restore the original document.getElementById
51
- (document.getElementById as any).restore?.();
22
+ mockCanvas.remove();
52
23
  clock.restore();
53
24
  });
54
25
 
55
26
  describe('setConnectionRemovingState', () => {
56
- it('returns false when no connections are found', () => {
57
- const result = plumber.setConnectionRemovingState('test-exit', true);
27
+ it('returns false when no connection exists for the exit', () => {
28
+ const result = plumber.setConnectionRemovingState('nonexistent', true);
58
29
  expect(result).to.be.false;
59
- expect((plumber as any).jsPlumb.getConnections).to.have.been.called;
60
30
  });
61
31
 
62
- it('sets removing class on connections when isRemoving is true', () => {
63
- const mockConnections = [
64
- { id: 'conn1', addClass: stub() },
65
- { id: 'conn2', addClass: stub() }
66
- ];
32
+ it('adds removing class when isRemoving is true', () => {
33
+ // Create mock elements for a connection
34
+ const exitEl = document.createElement('div');
35
+ exitEl.id = 'exit-1';
36
+ const targetEl = document.createElement('div');
37
+ targetEl.id = 'target-1';
38
+ mockCanvas.appendChild(exitEl);
39
+ mockCanvas.appendChild(targetEl);
67
40
 
68
- (plumber as any).jsPlumb.getConnections = stub().returns(mockConnections);
41
+ // Create a connection
42
+ plumber.connectIds('node-1', 'exit-1', 'target-1');
43
+ clock.tick(16);
69
44
 
70
- const result = plumber.setConnectionRemovingState('test-exit', true);
45
+ const result = plumber.setConnectionRemovingState('exit-1', true);
71
46
  expect(result).to.be.true;
72
- expect(mockConnections[0].addClass).to.have.been.calledWith('removing');
73
- expect(mockConnections[1].addClass).to.have.been.calledWith('removing');
47
+
48
+ const conn = (plumber as any).connections.get('exit-1');
49
+ expect(conn.svgEl.classList.contains('removing')).to.be.true;
50
+
51
+ exitEl.remove();
52
+ targetEl.remove();
74
53
  });
75
54
 
76
- it('removes removing class from connections when isRemoving is false', () => {
77
- const mockConnections = [
78
- { id: 'conn1', removeClass: stub() },
79
- { id: 'conn2', removeClass: stub() }
80
- ];
55
+ it('removes removing class when isRemoving is false', () => {
56
+ const exitEl = document.createElement('div');
57
+ exitEl.id = 'exit-2';
58
+ const targetEl = document.createElement('div');
59
+ targetEl.id = 'target-2';
60
+ mockCanvas.appendChild(exitEl);
61
+ mockCanvas.appendChild(targetEl);
81
62
 
82
- (plumber as any).jsPlumb.getConnections = stub().returns(mockConnections);
63
+ plumber.connectIds('node-1', 'exit-2', 'target-2');
64
+ clock.tick(16);
83
65
 
84
- const result = plumber.setConnectionRemovingState('test-exit', false);
66
+ plumber.setConnectionRemovingState('exit-2', true);
67
+ plumber.setConnectionRemovingState('exit-2', false);
68
+
69
+ const conn = (plumber as any).connections.get('exit-2');
70
+ expect(conn.svgEl.classList.contains('removing')).to.be.false;
71
+
72
+ exitEl.remove();
73
+ targetEl.remove();
74
+ });
75
+ });
76
+
77
+ describe('removeExitConnection', () => {
78
+ it('removes a connection for an exit', () => {
79
+ const exitEl = document.createElement('div');
80
+ exitEl.id = 'exit-3';
81
+ const targetEl = document.createElement('div');
82
+ targetEl.id = 'target-3';
83
+ mockCanvas.appendChild(exitEl);
84
+ mockCanvas.appendChild(targetEl);
85
+
86
+ plumber.connectIds('node-1', 'exit-3', 'target-3');
87
+ clock.tick(16);
88
+
89
+ expect((plumber as any).connections.has('exit-3')).to.be.true;
90
+
91
+ const result = plumber.removeExitConnection('exit-3');
85
92
  expect(result).to.be.true;
86
- expect(mockConnections[0].removeClass).to.have.been.calledWith(
87
- 'removing'
88
- );
89
- expect(mockConnections[1].removeClass).to.have.been.calledWith(
90
- 'removing'
91
- );
93
+ expect((plumber as any).connections.has('exit-3')).to.be.false;
94
+
95
+ exitEl.remove();
96
+ targetEl.remove();
97
+ });
98
+
99
+ it('returns false when no connection exists', () => {
100
+ const result = plumber.removeExitConnection('nonexistent');
101
+ expect(result).to.be.false;
92
102
  });
93
103
  });
94
104
 
95
105
  describe('connectIds and processPendingConnections', () => {
96
106
  it('adds connection to pending connections', () => {
97
- // Call connectIds which should add to pending connections
98
107
  plumber.connectIds('test-node', 'test-from', 'test-to');
99
-
100
- // Verify pendingConnections has the new connection
101
108
  expect((plumber as any).pendingConnections.length).to.equal(1);
102
-
103
- // Advance timer to trigger the timeout
104
- clock.tick(51); // Just past the 50ms timeout
105
-
106
- // Now the batch should have been called
107
- expect((plumber as any).jsPlumb.batch).to.have.been.called;
108
- expect((plumber as any).jsPlumb.addEndpoint).to.have.been.called;
109
- expect((plumber as any).jsPlumb.connect).to.have.been.called;
110
109
  });
111
110
 
112
- it('clears previous timeout when called multiple times', () => {
113
- // Set up spies for window.setTimeout and window.clearTimeout instead of global
114
- const clearTimeoutSpy = stub(window, 'clearTimeout');
115
- const setTimeoutSpy = stub(window, 'setTimeout').returns(123 as any);
111
+ it('clears previous rAF when called multiple times', () => {
112
+ const cancelSpy = stub(window, 'cancelAnimationFrame');
113
+ const rafSpy = stub(window, 'requestAnimationFrame').returns(123 as any);
116
114
 
117
- // Call twice
118
115
  plumber.processPendingConnections();
119
116
  plumber.processPendingConnections();
120
117
 
121
- // Should have called clearTimeout once and setTimeout twice
122
- expect(clearTimeoutSpy).to.have.been.calledOnce;
123
- expect(setTimeoutSpy).to.have.been.calledTwice;
118
+ expect(cancelSpy).to.have.been.calledOnce;
119
+ expect(rafSpy).to.have.been.calledTwice;
124
120
 
125
- // Clean up
126
- clearTimeoutSpy.restore();
127
- setTimeoutSpy.restore();
121
+ cancelSpy.restore();
122
+ rafSpy.restore();
128
123
  });
129
124
  });
130
125
 
131
- describe('removeExitConnection', () => {
132
- it('removes connections for an exit', () => {
133
- const mockConnections = [{ id: 'conn1' }, { id: 'conn2' }];
134
- (plumber as any).jsPlumb.getConnections = stub().returns(mockConnections);
135
-
136
- const result = plumber.removeExitConnection('test-exit');
137
-
138
- expect(result).to.be.true;
139
- expect((plumber as any).jsPlumb.deleteConnection).to.have.been
140
- .calledTwice;
141
- });
142
-
143
- it('returns false when no connections exist', () => {
144
- (plumber as any).jsPlumb.getConnections = stub().returns([]);
145
-
146
- const result = plumber.removeExitConnection('test-exit');
147
-
148
- expect(result).to.be.false;
126
+ describe('removeNodeConnections', () => {
127
+ it('removes inbound and outbound connections for a node', () => {
128
+ const exitEl = document.createElement('div');
129
+ exitEl.id = 'exit-4';
130
+ exitEl.classList.add('exit');
131
+ const nodeEl = document.createElement('div');
132
+ nodeEl.id = 'node-1';
133
+ nodeEl.appendChild(exitEl);
134
+ const targetEl = document.createElement('div');
135
+ targetEl.id = 'target-4';
136
+ mockCanvas.appendChild(nodeEl);
137
+ mockCanvas.appendChild(targetEl);
138
+
139
+ plumber.connectIds('node-1', 'exit-4', 'target-4');
140
+ clock.tick(16);
141
+
142
+ expect((plumber as any).connections.size).to.equal(1);
143
+
144
+ plumber.removeNodeConnections('node-1', ['exit-4']);
145
+ expect((plumber as any).connections.size).to.equal(0);
146
+
147
+ nodeEl.remove();
148
+ targetEl.remove();
149
149
  });
150
150
  });
151
151
  });
@@ -1,60 +1,30 @@
1
1
  import { expect } from '@open-wc/testing';
2
- import { Plumber, SOURCE_DEFAULTS, TARGET_DEFAULTS } from '../src/flow/Plumber';
2
+ import { Plumber, calculateFlowchartPath } from '../src/flow/Plumber';
3
3
  import { stub, useFakeTimers, SinonFakeTimers } from 'sinon';
4
4
 
5
5
  describe('Plumber', () => {
6
6
  let plumber: Plumber;
7
- let mockJsPlumb: any;
8
7
  let mockCanvas: HTMLElement;
9
8
  let clock: SinonFakeTimers;
9
+ let mockElement: HTMLElement;
10
10
 
11
11
  beforeEach(() => {
12
- // Use fake timers to control setTimeout
13
12
  clock = useFakeTimers();
14
13
 
15
- // Create mock canvas and make getElementById return a mock element
16
14
  mockCanvas = document.createElement('div');
17
- const mockElement = document.createElement('div');
18
- stub(document, 'getElementById').returns(mockElement);
15
+ mockCanvas.id = 'canvas';
16
+ document.body.appendChild(mockCanvas);
19
17
 
20
- // Create a mock editor with fireCustomEvent
21
- const mockEditor = { fireCustomEvent: stub() };
18
+ mockElement = document.createElement('div');
19
+ mockElement.id = 'test-exit';
20
+ mockCanvas.appendChild(mockElement);
22
21
 
23
- // Create a new plumber instance
22
+ const mockEditor = { fireCustomEvent: stub() };
24
23
  plumber = new Plumber(mockCanvas, mockEditor);
25
-
26
- // Replace the internal jsPlumb instance with mocks
27
- mockJsPlumb = {
28
- getConnections: stub().returns([]),
29
- addClass: stub(),
30
- removeClass: stub(),
31
- batch: stub().callsFake((fn) => fn()),
32
- addEndpoint: stub().returns({}),
33
- connect: stub(),
34
- getEndpoints: stub().returns([
35
- { elementId: 'test-from', addClass: stub() }
36
- ]),
37
- select: stub().returns({
38
- deleteAll: stub()
39
- }),
40
- selectEndpoints: stub().returns({
41
- deleteAll: stub()
42
- }),
43
- deleteConnection: stub(),
44
- removeAllEndpoints: stub(),
45
- repaintEverything: stub(),
46
- revalidate: stub(),
47
- bind: stub()
48
- };
49
-
50
- (plumber as any).jsPlumb = mockJsPlumb;
51
- // Reset the connectionWait to avoid timing issues
52
- (plumber as any).connectionWait = null;
53
24
  });
54
25
 
55
26
  afterEach(() => {
56
- // Restore the original document.getElementById
57
- (document.getElementById as any).restore?.();
27
+ mockCanvas.remove();
58
28
  clock.restore();
59
29
  });
60
30
 
@@ -64,98 +34,141 @@ describe('Plumber', () => {
64
34
  });
65
35
  });
66
36
 
67
- describe('makeTarget', () => {
68
- it('creates a target endpoint for the specified element', () => {
69
- plumber.makeTarget('test-target');
70
- expect(mockJsPlumb.addEndpoint).to.have.been.called;
37
+ describe('makeSource', () => {
38
+ it('registers a mousedown listener on the exit element', () => {
39
+ const exitEl = document.createElement('div');
40
+ exitEl.id = 'exit-1';
41
+ mockCanvas.appendChild(exitEl);
42
+
43
+ plumber.makeSource('exit-1');
44
+
45
+ // Source should be tracked
46
+ expect((plumber as any).sources.has('exit-1')).to.be.true;
47
+
48
+ exitEl.remove();
49
+ });
50
+
51
+ it('cleans up previous listener when called again', () => {
52
+ const exitEl = document.createElement('div');
53
+ exitEl.id = 'exit-2';
54
+ mockCanvas.appendChild(exitEl);
55
+
56
+ plumber.makeSource('exit-2');
57
+ plumber.makeSource('exit-2');
58
+
59
+ expect((plumber as any).sources.has('exit-2')).to.be.true;
60
+
61
+ exitEl.remove();
71
62
  });
72
63
  });
73
64
 
74
- describe('makeSource', () => {
75
- it('creates a source endpoint for the specified element', () => {
76
- plumber.makeSource('test-source');
77
- expect(mockJsPlumb.addEndpoint).to.have.been.called;
65
+ describe('makeTarget', () => {
66
+ it('is a no-op', () => {
67
+ // Should not throw
68
+ plumber.makeTarget('test-node');
78
69
  });
79
70
  });
80
71
 
81
72
  describe('connectIds', () => {
82
73
  it('adds connection to pending connections and processes them', () => {
83
74
  plumber.connectIds('test-node', 'test-from', 'test-to');
84
-
85
- // Verify pendingConnections has the new connection
86
75
  expect((plumber as any).pendingConnections.length).to.equal(1);
87
-
88
- // Advance timer to trigger the timeout
89
- clock.tick(51); // Just past the 50ms timeout
90
-
91
- // Now the batch should have been called
92
- expect(mockJsPlumb.batch).to.have.been.called;
93
76
  });
94
77
  });
95
78
 
96
79
  describe('processPendingConnections', () => {
97
- it('processes pending connections with timeout', () => {
98
- // Add a connection to pending connections
99
- plumber.connectIds('test-node', 'test-from', 'test-to');
100
-
101
- // Fast-forward clock past the timeout
102
- clock.tick(51); // Just past the 50ms timeout
80
+ it('clears existing rAF when called multiple times', () => {
81
+ const cancelSpy = stub(window, 'cancelAnimationFrame');
82
+ const rafSpy = stub(window, 'requestAnimationFrame').returns(123 as any);
103
83
 
104
- expect(mockJsPlumb.batch).to.have.been.called;
105
- });
84
+ plumber.processPendingConnections();
85
+ plumber.processPendingConnections();
106
86
 
107
- it('creates endpoints and connections for pending connections', () => {
108
- plumber.connectIds('test-node', 'test-from', 'test-to');
87
+ expect(cancelSpy).to.have.been.calledOnce;
88
+ expect(rafSpy).to.have.been.calledTwice;
109
89
 
110
- // Fast-forward clock past the timeout
111
- clock.tick(51); // Just past the 50ms timeout
90
+ cancelSpy.restore();
91
+ rafSpy.restore();
92
+ });
93
+ });
112
94
 
113
- expect(mockJsPlumb.addEndpoint).to.have.been.called;
114
- expect(mockJsPlumb.connect).to.have.been.called;
95
+ describe('event system', () => {
96
+ it('supports on/off/notify pattern', () => {
97
+ let received = null;
98
+ const handler = (info: any) => {
99
+ received = info;
100
+ };
101
+
102
+ plumber.on('test-event', handler);
103
+ (plumber as any).notifyListeners('test-event', { data: 'test' });
104
+ expect(received).to.deep.equal({ data: 'test' });
105
+
106
+ received = null;
107
+ plumber.off('test-event', handler);
108
+ (plumber as any).notifyListeners('test-event', { data: 'test2' });
109
+ expect(received).to.be.null;
115
110
  });
111
+ });
116
112
 
117
- it('clears existing timeout when called multiple times', () => {
118
- // Set up spies for window.setTimeout and window.clearTimeout
119
- const clearTimeoutSpy = stub(window, 'clearTimeout');
120
- const setTimeoutSpy = stub(window, 'setTimeout').returns(123 as any);
113
+ describe('reset', () => {
114
+ it('clears all state', () => {
115
+ plumber.connectIds('test-node', 'test-from', 'test-to');
116
+ plumber.reset();
121
117
 
122
- // Call twice
123
- plumber.processPendingConnections();
124
- plumber.processPendingConnections();
118
+ expect((plumber as any).pendingConnections.length).to.equal(0);
119
+ expect((plumber as any).connections.size).to.equal(0);
120
+ expect((plumber as any).sources.size).to.equal(0);
121
+ });
122
+ });
123
+ });
125
124
 
126
- // Should have called clearTimeout once and setTimeout twice
127
- expect(clearTimeoutSpy).to.have.been.calledOnce;
128
- expect(setTimeoutSpy).to.have.been.calledTwice;
125
+ describe('calculateFlowchartPath', () => {
126
+ it('generates a straight vertical path when source and target are aligned', () => {
127
+ const path = calculateFlowchartPath(100, 0, 100, 100);
128
+ expect(path).to.include('M 100 0');
129
+ expect(path).to.include('L 100 100');
130
+ // Should not contain Q (quadratic curve) for aligned points
131
+ expect(path).to.not.include('Q');
132
+ });
129
133
 
130
- // Clean up
131
- clearTimeoutSpy.restore();
132
- setTimeoutSpy.restore();
133
- });
134
+ it('generates a path with corners when source and target are offset', () => {
135
+ const path = calculateFlowchartPath(50, 0, 150, 200);
136
+ expect(path).to.include('M 50 0');
137
+ expect(path).to.include('Q'); // Should have rounded corners
138
+ expect(path).to.include('L 150 200');
139
+ });
134
140
 
135
- it('handles empty pending connections', () => {
136
- // Call without adding any connections
137
- plumber.processPendingConnections();
141
+ it('handles custom stub and corner radius', () => {
142
+ const path = calculateFlowchartPath(0, 0, 100, 100, 30, 15, 10);
143
+ expect(path).to.include('M 0 0');
144
+ expect(path).to.include('L 100 100');
145
+ });
138
146
 
139
- // Fast-forward clock past the timeout
140
- clock.tick(51); // Just past the 50ms timeout
147
+ it('handles cases where vertical space is tight by using reduced-radius corners', () => {
148
+ // With stubs of 20+10=30, and only 35 total vertical space, there's only 5px for corners
149
+ const path = calculateFlowchartPath(50, 0, 150, 35);
150
+ expect(path).to.include('M 50 0');
151
+ // Should still use rounded corners (L-shape with curves)
152
+ expect(path).to.include('Q');
153
+ });
141
154
 
142
- expect(mockJsPlumb.batch).to.have.been.called;
143
- });
155
+ it('enforces midY is always below source exit for top face', () => {
156
+ // Target above source — midY should not go above sourceY + stubStart
157
+ const path = calculateFlowchartPath(50, 100, 150, 50);
158
+ expect(path).to.include('M 50 100');
159
+ // Should still exit downward with a curve at exitY (120)
160
+ expect(path).to.include('Q');
144
161
  });
145
162
 
146
- describe('constants', () => {
147
- it('has correct properties in SOURCE_DEFAULTS', () => {
148
- expect(SOURCE_DEFAULTS).to.have.property('endpoint');
149
- expect(SOURCE_DEFAULTS).to.have.property('anchors');
150
- expect(SOURCE_DEFAULTS).to.have.property('maxConnections');
151
- expect(SOURCE_DEFAULTS).to.have.property('source');
152
- });
163
+ it('generates a path entering from the left face', () => {
164
+ const path = calculateFlowchartPath(50, 0, 150, 100, 20, 10, 5, 'left');
165
+ expect(path).to.include('M 50 0');
166
+ expect(path).to.include('L 150 100'); // ends at target
167
+ });
153
168
 
154
- it('has correct properties in TARGET_DEFAULTS', () => {
155
- expect(TARGET_DEFAULTS).to.have.property('endpoint');
156
- expect(TARGET_DEFAULTS).to.have.property('anchor');
157
- expect(TARGET_DEFAULTS).to.have.property('maxConnections');
158
- expect(TARGET_DEFAULTS).to.have.property('target');
159
- });
169
+ it('generates a path entering from the right face', () => {
170
+ const path = calculateFlowchartPath(150, 0, 50, 100, 20, 10, 5, 'right');
171
+ expect(path).to.include('M 150 0');
172
+ expect(path).to.include('L 50 100'); // ends at target
160
173
  });
161
174
  });
@@ -163,9 +163,9 @@ describe('temba-node-type-selector', () => {
163
163
  item.textContent?.trim()
164
164
  );
165
165
 
166
- // voice flow should have Say Message and Play Audio
166
+ // voice flow should have Say Message and Play Recording
167
167
  expect(titles).to.include('Say Message');
168
- expect(titles).to.include('Play Audio');
168
+ expect(titles).to.include('Play Recording');
169
169
  });
170
170
 
171
171
  it('filters actions by flow type - message flow should not show voice-only actions', async () => {
@@ -181,9 +181,9 @@ describe('temba-node-type-selector', () => {
181
181
  item.textContent?.trim()
182
182
  );
183
183
 
184
- // message flow should not have Say Message or Play Audio
184
+ // message flow should not have Say Message or Play Recording
185
185
  expect(titles).to.not.include('Say Message');
186
- expect(titles).to.not.include('Play Audio');
186
+ expect(titles).to.not.include('Play Recording');
187
187
  });
188
188
 
189
189
  it('filters splits by flow type - message flow should show wait for response', async () => {
@@ -219,9 +219,9 @@ describe('temba-node-type-selector', () => {
219
219
  // voice flow should not have Wait for Response
220
220
  expect(titles).to.not.include('Wait for Response');
221
221
 
222
- // but should have Wait for Digits and Wait for Menu Selection
222
+ // but should have Wait for Digits and Wait for Menu
223
223
  expect(titles).to.include('Wait for Digits');
224
- expect(titles).to.include('Wait for Menu Selection');
224
+ expect(titles).to.include('Wait for Menu');
225
225
  });
226
226
 
227
227
  it('filters by features - AI feature enables AI splits', async () => {