@nyaruka/temba-components 0.136.0 → 0.137.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 (137) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/demo/components/webchat/example.html +2 -2
  3. package/dist/temba-components.js +537 -578
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/display/Chat.js +123 -44
  6. package/out-tsc/src/display/Chat.js.map +1 -1
  7. package/out-tsc/src/display/FloatingTab.js +2 -6
  8. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  9. package/out-tsc/src/events/eventRenderers.js +442 -0
  10. package/out-tsc/src/events/eventRenderers.js.map +1 -0
  11. package/out-tsc/src/flow/CanvasNode.js +18 -1
  12. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  13. package/out-tsc/src/flow/Editor.js +10 -7
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/flow/NodeEditor.js +0 -1
  16. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  17. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  18. package/out-tsc/src/list/ShortcutList.js +1 -1
  19. package/out-tsc/src/list/ShortcutList.js.map +1 -1
  20. package/out-tsc/src/live/ContactChat.js +12 -321
  21. package/out-tsc/src/live/ContactChat.js.map +1 -1
  22. package/out-tsc/src/simulator/Simulator.js +432 -541
  23. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  24. package/out-tsc/src/store/AppState.js +33 -0
  25. package/out-tsc/src/store/AppState.js.map +1 -1
  26. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  27. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  28. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  29. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  30. package/out-tsc/test/temba-flow-editor.test.js +261 -0
  31. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  32. package/out-tsc/test/temba-simulator.test.js +51 -32
  33. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  34. package/package.json +1 -1
  35. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  36. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  37. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  38. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  39. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  40. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  41. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  42. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  43. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  44. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  45. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  46. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  47. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  50. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  51. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  52. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  53. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  54. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  55. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  56. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  57. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  58. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  59. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  60. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  61. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  62. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  63. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  64. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  65. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  66. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  67. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  68. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  69. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  70. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  71. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  72. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  73. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  74. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  75. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  76. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  77. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  78. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  79. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  80. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  81. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  82. package/screenshots/truth/contacts/chat-failure.png +0 -0
  83. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  84. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  85. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  86. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  87. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  88. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  89. package/screenshots/truth/floating-tab/gray.png +0 -0
  90. package/screenshots/truth/floating-tab/green.png +0 -0
  91. package/screenshots/truth/floating-tab/purple.png +0 -0
  92. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  114. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  115. package/screenshots/truth/simulator/after-reset.png +0 -0
  116. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  117. package/screenshots/truth/simulator/context-expanded.png +0 -0
  118. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  119. package/screenshots/truth/simulator/event-info.png +0 -0
  120. package/screenshots/truth/simulator/image-attachment.png +0 -0
  121. package/screenshots/truth/simulator/open-initial.png +0 -0
  122. package/screenshots/truth/simulator/quick-replies.png +0 -0
  123. package/src/display/Chat.ts +123 -44
  124. package/src/display/FloatingTab.ts +2 -7
  125. package/src/events/eventRenderers.ts +527 -0
  126. package/src/flow/CanvasNode.ts +18 -1
  127. package/src/flow/Editor.ts +11 -7
  128. package/src/flow/NodeEditor.ts +0 -1
  129. package/src/layout/FloatingWindow.ts +1 -1
  130. package/src/list/ShortcutList.ts +1 -1
  131. package/src/live/ContactChat.ts +17 -376
  132. package/src/simulator/Simulator.ts +492 -564
  133. package/src/store/AppState.ts +56 -0
  134. package/test/temba-appstate-node-sorting.test.ts +506 -0
  135. package/test/temba-floating-tab.test.ts +0 -11
  136. package/test/temba-flow-editor.test.ts +297 -0
  137. package/test/temba-simulator.test.ts +64 -34
@@ -18,6 +18,32 @@ import { produce } from 'immer';
18
18
  export const FLOW_SPEC_VERSION = '14.3';
19
19
  const CANVAS_PADDING = 800;
20
20
 
21
+ /**
22
+ * Sorts nodes by their position - first by y (top), then by x (left)
23
+ */
24
+ function sortNodesByPosition(
25
+ nodes: Node[],
26
+ nodePositions: Record<string, NodeUI>
27
+ ): void {
28
+ nodes.sort((a, b) => {
29
+ const posA = nodePositions[a.uuid]?.position;
30
+ const posB = nodePositions[b.uuid]?.position;
31
+
32
+ // if either position is missing, maintain current order
33
+ if (!posA || !posB) {
34
+ return 0;
35
+ }
36
+
37
+ // sort by y (top) first
38
+ if (posA.top !== posB.top) {
39
+ return posA.top - posB.top;
40
+ }
41
+
42
+ // if y is same, sort by x (left)
43
+ return posA.left - posB.left;
44
+ });
45
+ }
46
+
21
47
  export interface InfoResult {
22
48
  key: string;
23
49
  name: string;
@@ -261,6 +287,14 @@ export const zustand = createStore<AppState>()(
261
287
  // Reset to the flow's default language when loading a new flow
262
288
  state.languageCode = flowLang;
263
289
  state.isTranslating = false;
290
+
291
+ // Sort nodes by position when loading flow
292
+ if (state.flowDefinition?.nodes && state.flowDefinition?._ui?.nodes) {
293
+ sortNodesByPosition(
294
+ state.flowDefinition.nodes,
295
+ state.flowDefinition._ui.nodes
296
+ );
297
+ }
264
298
  });
265
299
  },
266
300
 
@@ -313,6 +347,13 @@ export const zustand = createStore<AppState>()(
313
347
  positions[uuid];
314
348
  }
315
349
  }
350
+
351
+ // Sort nodes by position since positions may have changed
352
+ sortNodesByPosition(
353
+ state.flowDefinition.nodes,
354
+ state.flowDefinition._ui.nodes
355
+ );
356
+
316
357
  state.dirtyDate = new Date();
317
358
  });
318
359
  },
@@ -371,6 +412,9 @@ export const zustand = createStore<AppState>()(
371
412
  }
372
413
  });
373
414
  });
415
+
416
+ // Sort nodes by position
417
+ sortNodesByPosition(draft.nodes, draft._ui.nodes);
374
418
  });
375
419
 
376
420
  state.dirtyDate = new Date();
@@ -505,6 +549,12 @@ export const zustand = createStore<AppState>()(
505
549
  config: {}
506
550
  };
507
551
 
552
+ // Sort nodes by position
553
+ sortNodesByPosition(
554
+ state.flowDefinition.nodes,
555
+ state.flowDefinition._ui.nodes
556
+ );
557
+
508
558
  state.dirtyDate = new Date();
509
559
  });
510
560
 
@@ -523,6 +573,12 @@ export const zustand = createStore<AppState>()(
523
573
 
524
574
  state.flowDefinition._ui.nodes[node.uuid] = nodeUI;
525
575
 
576
+ // Sort nodes by position
577
+ sortNodesByPosition(
578
+ state.flowDefinition.nodes,
579
+ state.flowDefinition._ui.nodes
580
+ );
581
+
526
582
  state.dirtyDate = new Date();
527
583
  });
528
584
  },
@@ -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
  });