@nyaruka/temba-components 0.135.9 → 0.136.1

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 (144) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/demo/components/webchat/example.html +4 -2
  3. package/dist/static/svg/index.svg +1 -1
  4. package/dist/temba-components.js +1351 -322
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/Icons.js +2 -1
  7. package/out-tsc/src/Icons.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +2 -6
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasNode.js +29 -1
  11. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  12. package/out-tsc/src/flow/Editor.js +229 -5
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/Plumber.js +320 -1
  15. package/out-tsc/src/flow/Plumber.js.map +1 -1
  16. package/out-tsc/src/interfaces.js +1 -0
  17. package/out-tsc/src/interfaces.js.map +1 -1
  18. package/out-tsc/src/layout/FloatingWindow.js +30 -8
  19. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  20. package/out-tsc/src/simulator/Simulator.js +1861 -0
  21. package/out-tsc/src/simulator/Simulator.js.map +1 -0
  22. package/out-tsc/src/store/AppState.js +66 -0
  23. package/out-tsc/src/store/AppState.js.map +1 -1
  24. package/out-tsc/src/utils.js +48 -0
  25. package/out-tsc/src/utils.js.map +1 -1
  26. package/out-tsc/temba-modules.js +2 -0
  27. package/out-tsc/temba-modules.js.map +1 -1
  28. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  29. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  30. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  31. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  32. package/out-tsc/test/temba-flow-editor.test.js +262 -1
  33. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  34. package/out-tsc/test/temba-flow-plumber-connections.test.js +3 -1
  35. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  36. package/out-tsc/test/temba-flow-plumber.test.js +3 -1
  37. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  38. package/out-tsc/test/temba-simulator.test.js +642 -0
  39. package/out-tsc/test/temba-simulator.test.js.map +1 -0
  40. package/out-tsc/test/utils.test.js +1 -1
  41. package/out-tsc/test/utils.test.js.map +1 -1
  42. package/package.json +1 -1
  43. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  47. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  57. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  63. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/floating-tab/gray.png +0 -0
  91. package/screenshots/truth/floating-tab/green.png +0 -0
  92. package/screenshots/truth/floating-tab/purple.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  106. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  114. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  115. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  116. package/screenshots/truth/simulator/after-reset.png +0 -0
  117. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  118. package/screenshots/truth/simulator/context-expanded.png +0 -0
  119. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  120. package/screenshots/truth/simulator/event-info.png +0 -0
  121. package/screenshots/truth/simulator/image-attachment.png +0 -0
  122. package/screenshots/truth/simulator/open-initial.png +0 -0
  123. package/screenshots/truth/simulator/quick-replies.png +0 -0
  124. package/src/Icons.ts +2 -1
  125. package/src/display/FloatingTab.ts +2 -7
  126. package/src/flow/CanvasNode.ts +30 -1
  127. package/src/flow/Editor.ts +246 -4
  128. package/src/flow/Plumber.ts +371 -2
  129. package/src/interfaces.ts +2 -1
  130. package/src/layout/FloatingWindow.ts +37 -12
  131. package/src/simulator/Simulator.ts +2061 -0
  132. package/src/store/AppState.ts +109 -0
  133. package/src/utils.ts +53 -0
  134. package/static/svg/index.svg +1 -1
  135. package/static/svg/work/traced/route.svg +1 -0
  136. package/static/svg/work/used/route.svg +3 -0
  137. package/temba-modules.ts +2 -0
  138. package/test/temba-appstate-node-sorting.test.ts +506 -0
  139. package/test/temba-floating-tab.test.ts +0 -11
  140. package/test/temba-flow-editor.test.ts +298 -1
  141. package/test/temba-flow-plumber-connections.test.ts +4 -1
  142. package/test/temba-flow-plumber.test.ts +4 -1
  143. package/test/temba-simulator.test.ts +866 -0
  144. package/test/utils.test.ts +1 -1
@@ -0,0 +1 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.115 1.133 C 2.696 1.471,1.633 2.478,1.181 3.914 C 0.936 4.690,0.923 4.940,1.088 5.726 C 1.460 7.499,2.897 8.803,4.614 8.927 C 6.021 9.029,6.796 8.745,7.768 7.773 C 8.690 6.851,8.958 6.199,8.959 4.871 C 8.961 3.418,7.848 1.809,6.480 1.286 C 5.895 1.062,4.728 0.987,4.115 1.133 M6.096 3.406 C 7.120 4.095,7.251 5.524,6.372 6.404 C 5.480 7.295,4.019 7.123,3.364 6.050 C 2.244 4.213,4.316 2.208,6.096 3.406 M10.920 4.236 C 10.375 4.633,10.456 5.444,11.091 5.942 C 11.107 5.955,12.293 6.009,13.726 6.062 C 16.491 6.166,16.632 6.211,16.638 6.988 C 16.641 7.448,15.690 8.236,11.840 10.960 C 5.191 15.664,4.940 15.895,4.836 17.416 C 4.781 18.216,4.831 18.405,5.272 19.065 C 6.043 20.217,6.559 20.369,9.970 20.447 C 12.518 20.505,12.853 20.482,13.130 20.231 C 13.504 19.892,13.534 19.157,13.189 18.811 C 12.991 18.614,12.553 18.558,11.149 18.552 C 8.766 18.541,7.214 18.359,7.031 18.070 C 6.729 17.594,6.883 17.143,7.537 16.582 C 7.898 16.273,10.113 14.651,12.459 12.978 C 14.805 11.304,17.032 9.672,17.408 9.350 C 18.968 8.015,19.151 6.319,17.877 4.998 C 17.180 4.276,16.607 4.144,13.760 4.053 C 11.631 3.985,11.229 4.011,10.920 4.236 M17.840 15.168 C 16.626 15.596,15.476 16.734,15.192 17.789 C 14.564 20.122,15.936 22.423,18.241 22.904 C 21.670 23.620,24.304 19.567,22.260 16.721 C 21.521 15.692,20.481 15.135,19.219 15.092 C 18.637 15.072,18.016 15.106,17.840 15.168 M19.762 17.190 C 20.837 17.640,21.287 19.101,20.649 20.069 C 19.758 21.421,17.803 21.227,17.175 19.725 C 16.644 18.454,17.522 17.139,19.005 16.982 C 19.118 16.970,19.458 17.064,19.762 17.190 " stroke="none" fill-rule="evenodd" fill="black"></path></svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M11.5 5H11.9344C14.9816 5 16.5053 5 17.0836 5.54729C17.5836 6.02037 17.8051 6.71728 17.6702 7.39221C17.514 8.17302 16.2701 9.05285 13.7823 10.8125L9.71772 13.6875C7.2299 15.4471 5.98599 16.327 5.82984 17.1078C5.69486 17.7827 5.91642 18.4796 6.41636 18.9527C6.99474 19.5 8.51836 19.5 11.5656 19.5H12.5M8 5C8 6.65685 6.65685 8 5 8C3.34315 8 2 6.65685 2 5C2 3.34315 3.34315 2 5 2C6.65685 2 8 3.34315 8 5ZM22 19C22 20.6569 20.6569 22 19 22C17.3431 22 16 20.6569 16 19C16 17.3431 17.3431 16 19 16C20.6569 16 22 17.3431 22 19Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3
+ </svg>
package/temba-modules.ts CHANGED
@@ -76,6 +76,7 @@ import { MessageEditor } from './src/form/MessageEditor';
76
76
  import './src/form/BaseListEditor'; // Import base class
77
77
  import { FloatingTab } from './src/display/FloatingTab';
78
78
  import { FloatingWindow } from './src/layout/FloatingWindow';
79
+ import { Simulator } from './src/simulator/Simulator';
79
80
 
80
81
  export function addCustomElement(name: string, comp: any) {
81
82
  if (!window.customElements.get(name)) {
@@ -162,3 +163,4 @@ addCustomElement('temba-array-editor', TembaArrayEditor);
162
163
  addCustomElement('temba-message-editor', MessageEditor);
163
164
  addCustomElement('temba-floating-tab', FloatingTab);
164
165
  addCustomElement('temba-floating-window', FloatingWindow);
166
+ addCustomElement('temba-simulator', Simulator);
@@ -0,0 +1,506 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { zustand } from '../src/store/AppState';
3
+ import { Node, NodeUI } from '../src/store/flow-definition';
4
+
5
+ describe('AppState Node Sorting', () => {
6
+ beforeEach(() => {
7
+ // reset the store state before each test
8
+ const state = zustand.getState();
9
+ zustand.setState({
10
+ ...state,
11
+ flowDefinition: {
12
+ language: 'en',
13
+ localization: {},
14
+ name: 'Test Flow',
15
+ nodes: [],
16
+ uuid: 'test-uuid',
17
+ type: 'messaging' as const,
18
+ revision: 1,
19
+ spec_version: '14.3',
20
+ _ui: {
21
+ nodes: {},
22
+ languages: []
23
+ }
24
+ }
25
+ });
26
+ });
27
+
28
+ describe('addNode', () => {
29
+ it('should sort nodes by position when adding nodes', () => {
30
+ const state = zustand.getState();
31
+
32
+ // add nodes in non-sorted order
33
+ const node1: Node = {
34
+ uuid: 'node-1',
35
+ actions: [],
36
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
37
+ };
38
+ const nodeUI1: NodeUI = {
39
+ position: { left: 100, top: 300 }, // middle
40
+ type: 'send_msg'
41
+ };
42
+
43
+ const node2: Node = {
44
+ uuid: 'node-2',
45
+ actions: [],
46
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
47
+ };
48
+ const nodeUI2: NodeUI = {
49
+ position: { left: 100, top: 100 }, // top
50
+ type: 'send_msg'
51
+ };
52
+
53
+ const node3: Node = {
54
+ uuid: 'node-3',
55
+ actions: [],
56
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
57
+ };
58
+ const nodeUI3: NodeUI = {
59
+ position: { left: 100, top: 500 }, // bottom
60
+ type: 'send_msg'
61
+ };
62
+
63
+ // add in order: middle, top, bottom
64
+ state.addNode(node1, nodeUI1);
65
+ state.addNode(node2, nodeUI2);
66
+ state.addNode(node3, nodeUI3);
67
+
68
+ const nodes = zustand.getState().flowDefinition.nodes;
69
+
70
+ // nodes should be sorted by y position (top to bottom)
71
+ expect(nodes[0].uuid).to.equal('node-2'); // top: 100
72
+ expect(nodes[1].uuid).to.equal('node-1'); // top: 300
73
+ expect(nodes[2].uuid).to.equal('node-3'); // top: 500
74
+ });
75
+
76
+ it('should sort by x when y positions are the same', () => {
77
+ const state = zustand.getState();
78
+
79
+ // add nodes with same y but different x
80
+ const node1: Node = {
81
+ uuid: 'node-1',
82
+ actions: [],
83
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
84
+ };
85
+ const nodeUI1: NodeUI = {
86
+ position: { left: 300, top: 100 },
87
+ type: 'send_msg'
88
+ };
89
+
90
+ const node2: Node = {
91
+ uuid: 'node-2',
92
+ actions: [],
93
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
94
+ };
95
+ const nodeUI2: NodeUI = {
96
+ position: { left: 100, top: 100 },
97
+ type: 'send_msg'
98
+ };
99
+
100
+ const node3: Node = {
101
+ uuid: 'node-3',
102
+ actions: [],
103
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
104
+ };
105
+ const nodeUI3: NodeUI = {
106
+ position: { left: 500, top: 100 },
107
+ type: 'send_msg'
108
+ };
109
+
110
+ // add in order: middle, left, right
111
+ state.addNode(node1, nodeUI1);
112
+ state.addNode(node2, nodeUI2);
113
+ state.addNode(node3, nodeUI3);
114
+
115
+ const nodes = zustand.getState().flowDefinition.nodes;
116
+
117
+ // nodes should be sorted by x position (left to right) since y is same
118
+ expect(nodes[0].uuid).to.equal('node-2'); // left: 100
119
+ expect(nodes[1].uuid).to.equal('node-1'); // left: 300
120
+ expect(nodes[2].uuid).to.equal('node-3'); // left: 500
121
+ });
122
+
123
+ it('should handle complex sorting with mixed positions', () => {
124
+ const state = zustand.getState();
125
+
126
+ // create a grid of nodes
127
+ // row 1: (100, 100), (200, 100)
128
+ // row 2: (100, 200), (200, 200)
129
+
130
+ const nodes = [
131
+ {
132
+ node: {
133
+ uuid: 'node-1',
134
+ actions: [],
135
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
136
+ },
137
+ ui: { position: { left: 200, top: 200 }, type: 'send_msg' as const }
138
+ },
139
+ {
140
+ node: {
141
+ uuid: 'node-2',
142
+ actions: [],
143
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
144
+ },
145
+ ui: { position: { left: 100, top: 100 }, type: 'send_msg' as const }
146
+ },
147
+ {
148
+ node: {
149
+ uuid: 'node-3',
150
+ actions: [],
151
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
152
+ },
153
+ ui: { position: { left: 200, top: 100 }, type: 'send_msg' as const }
154
+ },
155
+ {
156
+ node: {
157
+ uuid: 'node-4',
158
+ actions: [],
159
+ exits: [{ uuid: 'exit-4', destination_uuid: null }]
160
+ },
161
+ ui: { position: { left: 100, top: 200 }, type: 'send_msg' as const }
162
+ }
163
+ ];
164
+
165
+ // add in random order
166
+ nodes.forEach((n) => state.addNode(n.node, n.ui));
167
+
168
+ const sortedNodes = zustand.getState().flowDefinition.nodes;
169
+
170
+ // expected order: (100,100), (200,100), (100,200), (200,200)
171
+ expect(sortedNodes[0].uuid).to.equal('node-2'); // (100, 100)
172
+ expect(sortedNodes[1].uuid).to.equal('node-3'); // (200, 100)
173
+ expect(sortedNodes[2].uuid).to.equal('node-4'); // (100, 200)
174
+ expect(sortedNodes[3].uuid).to.equal('node-1'); // (200, 200)
175
+ });
176
+ });
177
+
178
+ describe('createNode', () => {
179
+ it('should sort nodes after creating a new node', () => {
180
+ const state = zustand.getState();
181
+
182
+ // create nodes in non-sorted order
183
+ const uuid1 = state.createNode('send_msg', { left: 300, top: 100 });
184
+ const uuid2 = state.createNode('send_msg', { left: 100, top: 100 });
185
+ const uuid3 = state.createNode('send_msg', { left: 200, top: 100 });
186
+
187
+ const nodes = zustand.getState().flowDefinition.nodes;
188
+
189
+ // nodes should be sorted by x position
190
+ expect(nodes[0].uuid).to.equal(uuid2); // left: 100
191
+ expect(nodes[1].uuid).to.equal(uuid3); // left: 200
192
+ expect(nodes[2].uuid).to.equal(uuid1); // left: 300
193
+ });
194
+ });
195
+
196
+ describe('removeNodes', () => {
197
+ it('should maintain sorting after removing nodes', () => {
198
+ const state = zustand.getState();
199
+
200
+ // create nodes
201
+ const node1: Node = {
202
+ uuid: 'node-1',
203
+ actions: [],
204
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
205
+ };
206
+ const nodeUI1: NodeUI = {
207
+ position: { left: 100, top: 100 },
208
+ type: 'send_msg'
209
+ };
210
+
211
+ const node2: Node = {
212
+ uuid: 'node-2',
213
+ actions: [],
214
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
215
+ };
216
+ const nodeUI2: NodeUI = {
217
+ position: { left: 200, top: 100 },
218
+ type: 'send_msg'
219
+ };
220
+
221
+ const node3: Node = {
222
+ uuid: 'node-3',
223
+ actions: [],
224
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
225
+ };
226
+ const nodeUI3: NodeUI = {
227
+ position: { left: 300, top: 100 },
228
+ type: 'send_msg'
229
+ };
230
+
231
+ state.addNode(node1, nodeUI1);
232
+ state.addNode(node2, nodeUI2);
233
+ state.addNode(node3, nodeUI3);
234
+
235
+ // remove middle node
236
+ state.removeNodes(['node-2']);
237
+
238
+ const nodes = zustand.getState().flowDefinition.nodes;
239
+
240
+ // remaining nodes should still be sorted
241
+ expect(nodes.length).to.equal(2);
242
+ expect(nodes[0].uuid).to.equal('node-1'); // left: 100
243
+ expect(nodes[1].uuid).to.equal('node-3'); // left: 300
244
+ });
245
+
246
+ it('should sort nodes after connection rerouting during removal', () => {
247
+ const state = zustand.getState();
248
+
249
+ // create a chain of nodes
250
+ const node1: Node = {
251
+ uuid: 'node-1',
252
+ actions: [],
253
+ exits: [{ uuid: 'exit-1', destination_uuid: 'node-2' }]
254
+ };
255
+ const nodeUI1: NodeUI = {
256
+ position: { left: 100, top: 300 },
257
+ type: 'send_msg'
258
+ };
259
+
260
+ const node2: Node = {
261
+ uuid: 'node-2',
262
+ actions: [],
263
+ exits: [{ uuid: 'exit-2', destination_uuid: 'node-3' }]
264
+ };
265
+ const nodeUI2: NodeUI = {
266
+ position: { left: 200, top: 200 },
267
+ type: 'send_msg'
268
+ };
269
+
270
+ const node3: Node = {
271
+ uuid: 'node-3',
272
+ actions: [],
273
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
274
+ };
275
+ const nodeUI3: NodeUI = {
276
+ position: { left: 300, top: 100 },
277
+ type: 'send_msg'
278
+ };
279
+
280
+ state.addNode(node1, nodeUI1);
281
+ state.addNode(node2, nodeUI2);
282
+ state.addNode(node3, nodeUI3);
283
+
284
+ // verify initial sorting
285
+ let nodes = zustand.getState().flowDefinition.nodes;
286
+ expect(nodes[0].uuid).to.equal('node-3'); // top: 100
287
+ expect(nodes[1].uuid).to.equal('node-2'); // top: 200
288
+ expect(nodes[2].uuid).to.equal('node-1'); // top: 300
289
+
290
+ // remove middle node - should reroute connection
291
+ state.removeNodes(['node-2']);
292
+
293
+ nodes = zustand.getState().flowDefinition.nodes;
294
+
295
+ // nodes should still be sorted
296
+ expect(nodes.length).to.equal(2);
297
+ expect(nodes[0].uuid).to.equal('node-3'); // top: 100
298
+ expect(nodes[1].uuid).to.equal('node-1'); // top: 300
299
+
300
+ // verify rerouting happened
301
+ expect(nodes[1].exits[0].destination_uuid).to.equal('node-3');
302
+ });
303
+ });
304
+
305
+ describe('updateCanvasPositions', () => {
306
+ it('should re-sort nodes when positions change', () => {
307
+ const state = zustand.getState();
308
+
309
+ // create nodes in sorted order
310
+ const node1: Node = {
311
+ uuid: 'node-1',
312
+ actions: [],
313
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
314
+ };
315
+ const nodeUI1: NodeUI = {
316
+ position: { left: 100, top: 100 },
317
+ type: 'send_msg'
318
+ };
319
+
320
+ const node2: Node = {
321
+ uuid: 'node-2',
322
+ actions: [],
323
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
324
+ };
325
+ const nodeUI2: NodeUI = {
326
+ position: { left: 100, top: 200 },
327
+ type: 'send_msg'
328
+ };
329
+
330
+ const node3: Node = {
331
+ uuid: 'node-3',
332
+ actions: [],
333
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
334
+ };
335
+ const nodeUI3: NodeUI = {
336
+ position: { left: 100, top: 300 },
337
+ type: 'send_msg'
338
+ };
339
+
340
+ state.addNode(node1, nodeUI1);
341
+ state.addNode(node2, nodeUI2);
342
+ state.addNode(node3, nodeUI3);
343
+
344
+ let nodes = zustand.getState().flowDefinition.nodes;
345
+ expect(nodes[0].uuid).to.equal('node-1'); // top: 100
346
+ expect(nodes[1].uuid).to.equal('node-2'); // top: 200
347
+ expect(nodes[2].uuid).to.equal('node-3'); // top: 300
348
+
349
+ // move node-1 to the bottom
350
+ state.updateCanvasPositions({
351
+ 'node-1': { left: 100, top: 400 }
352
+ });
353
+
354
+ nodes = zustand.getState().flowDefinition.nodes;
355
+
356
+ // nodes should be re-sorted
357
+ expect(nodes[0].uuid).to.equal('node-2'); // top: 200
358
+ expect(nodes[1].uuid).to.equal('node-3'); // top: 300
359
+ expect(nodes[2].uuid).to.equal('node-1'); // top: 400
360
+ });
361
+
362
+ it('should handle multiple position updates at once', () => {
363
+ const state = zustand.getState();
364
+
365
+ // create nodes
366
+ const node1: Node = {
367
+ uuid: 'node-1',
368
+ actions: [],
369
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
370
+ };
371
+ const nodeUI1: NodeUI = {
372
+ position: { left: 100, top: 100 },
373
+ type: 'send_msg'
374
+ };
375
+
376
+ const node2: Node = {
377
+ uuid: 'node-2',
378
+ actions: [],
379
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
380
+ };
381
+ const nodeUI2: NodeUI = {
382
+ position: { left: 100, top: 200 },
383
+ type: 'send_msg'
384
+ };
385
+
386
+ const node3: Node = {
387
+ uuid: 'node-3',
388
+ actions: [],
389
+ exits: [{ uuid: 'exit-3', destination_uuid: null }]
390
+ };
391
+ const nodeUI3: NodeUI = {
392
+ position: { left: 100, top: 300 },
393
+ type: 'send_msg'
394
+ };
395
+
396
+ state.addNode(node1, nodeUI1);
397
+ state.addNode(node2, nodeUI2);
398
+ state.addNode(node3, nodeUI3);
399
+
400
+ // swap positions of node-1 and node-3
401
+ state.updateCanvasPositions({
402
+ 'node-1': { left: 100, top: 300 },
403
+ 'node-3': { left: 100, top: 100 }
404
+ });
405
+
406
+ const nodes = zustand.getState().flowDefinition.nodes;
407
+
408
+ // nodes should be re-sorted
409
+ expect(nodes[0].uuid).to.equal('node-3'); // top: 100
410
+ expect(nodes[1].uuid).to.equal('node-2'); // top: 200
411
+ expect(nodes[2].uuid).to.equal('node-1'); // top: 300
412
+ });
413
+
414
+ it('should not affect sticky notes when updating positions', () => {
415
+ const state = zustand.getState();
416
+
417
+ // create a node
418
+ const node: Node = {
419
+ uuid: 'node-1',
420
+ actions: [],
421
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
422
+ };
423
+ const nodeUI: NodeUI = {
424
+ position: { left: 100, top: 100 },
425
+ type: 'send_msg'
426
+ };
427
+
428
+ state.addNode(node, nodeUI);
429
+
430
+ // create a sticky note
431
+ const stickyUuid = state.createStickyNote({ left: 200, top: 200 });
432
+
433
+ // update positions for both
434
+ state.updateCanvasPositions({
435
+ 'node-1': { left: 100, top: 300 },
436
+ [stickyUuid]: { left: 200, top: 100 }
437
+ });
438
+
439
+ const flowDef = zustand.getState().flowDefinition;
440
+
441
+ // verify node position was updated
442
+ expect(flowDef._ui.nodes['node-1'].position.top).to.equal(300);
443
+
444
+ // verify sticky position was updated
445
+ expect(flowDef._ui.stickies[stickyUuid].position.top).to.equal(100);
446
+ });
447
+ });
448
+
449
+ describe('edge cases', () => {
450
+ it('should handle nodes with missing position data', () => {
451
+ const state = zustand.getState();
452
+
453
+ // manually create a flow definition with a node that has no UI data
454
+ zustand.setState({
455
+ ...zustand.getState(),
456
+ flowDefinition: {
457
+ language: 'en',
458
+ localization: {},
459
+ name: 'Test Flow',
460
+ nodes: [
461
+ {
462
+ uuid: 'node-1',
463
+ actions: [],
464
+ exits: [{ uuid: 'exit-1', destination_uuid: null }]
465
+ }
466
+ ],
467
+ uuid: 'test-uuid',
468
+ type: 'messaging' as const,
469
+ revision: 1,
470
+ spec_version: '14.3',
471
+ _ui: {
472
+ nodes: {}, // no UI data for node-1
473
+ languages: []
474
+ }
475
+ }
476
+ });
477
+
478
+ // add a node with position data
479
+ const node2: Node = {
480
+ uuid: 'node-2',
481
+ actions: [],
482
+ exits: [{ uuid: 'exit-2', destination_uuid: null }]
483
+ };
484
+ const nodeUI2: NodeUI = {
485
+ position: { left: 100, top: 100 },
486
+ type: 'send_msg'
487
+ };
488
+
489
+ // should not throw error
490
+ expect(() => state.addNode(node2, nodeUI2)).to.not.throw();
491
+
492
+ const nodes = zustand.getState().flowDefinition.nodes;
493
+ expect(nodes.length).to.equal(2);
494
+ });
495
+
496
+ it('should handle empty nodes array', () => {
497
+ const state = zustand.getState();
498
+
499
+ // verify initial state is empty
500
+ expect(zustand.getState().flowDefinition.nodes.length).to.equal(0);
501
+
502
+ // try to remove nodes from empty flow - should not throw
503
+ expect(() => state.removeNodes(['non-existent'])).to.not.throw();
504
+ });
505
+ });
506
+ });
@@ -96,15 +96,4 @@ describe('temba-floating-tab', () => {
96
96
  await assertScreenshot('floating-tab/gray', getClip(tab2));
97
97
  await assertScreenshot('floating-tab/purple', getClip(tab3));
98
98
  });
99
-
100
- it('supports custom positioning', async () => {
101
- const tab = (await getComponent('temba-floating-tab', {
102
- icon: 'phone',
103
- label: 'Phone Simulator',
104
- color: '#10b981',
105
- top: 250
106
- })) as FloatingTab;
107
-
108
- expect(tab.top).to.equal(250);
109
- });
110
99
  });