@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.31

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 (143) hide show
  1. package/dist/chunk-CUYYYZWD.js +2317 -0
  2. package/dist/chunk-DXPSPUWF.js +110 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +338 -4
  5. package/dist/flows/index.js +1331 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3572 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +899 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1023 -0
  70. package/src/flows/flowCanvasModel.test.ts +312 -0
  71. package/src/flows/flowCanvasModel.ts +368 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +47 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +862 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -0,0 +1,312 @@
1
+ /**
2
+ * O que se prova aqui é topologia e posição, não estilo.
3
+ *
4
+ * Aresta com destino errado não pinta errado — ela manda a conversa do cliente para outro lugar, e
5
+ * quem editou nunca vê. Card que o layout joga para fora da área visível se lê como "apaguei sem
6
+ * querer". São os dois modos de falhar que já aconteceram nesta tela, e os dois são silenciosos.
7
+ */
8
+
9
+ import { describe, expect, it } from 'bun:test'
10
+
11
+ import {
12
+ buildFlowEdges,
13
+ chainFrameBounds,
14
+ computeMergedLayout,
15
+ countLiveByNode,
16
+ detachedNodeIds,
17
+ newNodeFromSpec,
18
+ portalNodeId,
19
+ type FlowLivePosition,
20
+ } from './flowCanvasModel'
21
+ import type { FlowGraphData, FlowNodeData } from './flowGraph'
22
+
23
+ const ROOT = 'menu'
24
+
25
+ /** Nós mínimos VÁLIDOS pelo contrato — sem `as`, para quebrar quando a forma do nó mudar. */
26
+ function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
27
+ return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
28
+ }
29
+
30
+ function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
31
+ return {
32
+ key: params.key,
33
+ label: params.key,
34
+ version: 1,
35
+ startNodeId: params.start,
36
+ nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
37
+ }
38
+ }
39
+
40
+ function live(params: Partial<FlowLivePosition>): FlowLivePosition {
41
+ return { currentState: 'x', flow: null, nodeId: null, menuNodeId: null, ...params }
42
+ }
43
+
44
+ describe('arestas', () => {
45
+ it('saída única vira aresta linear sem handle', () => {
46
+ const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b')] }) }
47
+
48
+ expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([
49
+ { id: 'menu::a->menu::b', source: 'menu::a', target: 'menu::b', kind: 'linear', crossFlow: false, live: false },
50
+ ])
51
+ })
52
+
53
+ it('ramificação leva o id da opção no handle, e o default vira fallback', () => {
54
+ const graphs = {
55
+ menu: graph({
56
+ key: 'menu',
57
+ start: 'p',
58
+ nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
59
+ }),
60
+ }
61
+
62
+ const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
63
+
64
+ expect(edges.find((each) => each.kind === 'branch')).toMatchObject({ sourceHandle: 'sim', target: 'menu::x' })
65
+ // O handle do fallback é fixo `__default` — é o que `applyConnection` espera de volta quando
66
+ // alguém arrasta esse fio.
67
+ expect(edges.find((each) => each.kind === 'fallback')).toMatchObject({
68
+ sourceHandle: '__default',
69
+ target: 'menu::y',
70
+ })
71
+ })
72
+
73
+ it('destino vazio NÃO gera aresta', () => {
74
+ // Apagar um nó zera quem apontava para ele. A aresta iria para `menu::`, que não existe, e o
75
+ // React Flow descarta em silêncio — a opção pareceria ligada sem estar.
76
+ const graphs = {
77
+ menu: graph({ key: 'menu', start: 'p', nodes: [node('p', { byAnswer: { sim: '' }, default: '' })] }),
78
+ }
79
+
80
+ expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([])
81
+ })
82
+
83
+ it('salto para fluxo MESCLADO liga no nó inicial dele, não num portal', () => {
84
+ const graphs = {
85
+ menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
86
+ sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
87
+ }
88
+
89
+ expect(buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })[0]).toMatchObject({
90
+ target: 'sim::s1',
91
+ crossFlow: true,
92
+ })
93
+ })
94
+
95
+ it('salto para fluxo NÃO mesclado cai no portal', () => {
96
+ const graphs = {
97
+ menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
98
+ sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
99
+ }
100
+
101
+ expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })[0]!.target).toBe(
102
+ `menu::${portalNodeId('a', 'flow:sim')}`,
103
+ )
104
+ })
105
+
106
+ it('duas opções para o mesmo fluxo compartilham UM portal', () => {
107
+ const graphs = {
108
+ menu: graph({
109
+ key: 'menu',
110
+ start: 'p',
111
+ nodes: [node('p', { byAnswer: { um: 'flow:sim', dois: 'flow:sim' }, default: '' })],
112
+ }),
113
+ sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
114
+ }
115
+
116
+ const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
117
+
118
+ // Duas arestas (uma por opção), um só destino: senão o card fica cercado de caixas iguais.
119
+ expect(edges).toHaveLength(2)
120
+ expect(new Set(edges.map((each) => each.target)).size).toBe(1)
121
+ })
122
+
123
+ it('conversa parada no nó anima a aresta que sai dele, mas nunca o fallback', () => {
124
+ const graphs = {
125
+ menu: graph({
126
+ key: 'menu',
127
+ start: 'p',
128
+ nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
129
+ }),
130
+ }
131
+
132
+ const edges = buildFlowEdges({
133
+ openKeys: ['menu'],
134
+ graphs,
135
+ rootFlowKey: ROOT,
136
+ livePositions: [live({ menuNodeId: 'p' })],
137
+ })
138
+
139
+ expect(edges.find((each) => each.kind === 'branch')!.live).toBe(true)
140
+ // Fallback piscando sugeria que a conversa estava passando pelo caminho que ninguém escolheu.
141
+ expect(edges.find((each) => each.kind === 'fallback')!.live).toBe(false)
142
+ })
143
+
144
+ it('ids de aresta são únicos entre fluxos com nós homônimos', () => {
145
+ const graphs = {
146
+ menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
147
+ sim: graph({ key: 'sim', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
148
+ }
149
+
150
+ const edges = buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })
151
+
152
+ expect(new Set(edges.map((each) => each.id)).size).toBe(edges.length)
153
+ })
154
+ })
155
+
156
+ describe('contagem ao vivo', () => {
157
+ it('no fluxo RAIZ a posição vem de menuNodeId', () => {
158
+ // O servidor guarda o passo do menu num campo próprio, e conversa no menu não carrega `flow`.
159
+ // Ler `nodeId` aqui daria zero na tela mais visitada do editor.
160
+ const positions = [live({ menuNodeId: 'saudacao' }), live({ menuNodeId: 'saudacao' })]
161
+
162
+ expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 2 })
163
+ })
164
+
165
+ it('fora da raiz a posição vem de nodeId, e outro fluxo é ignorado', () => {
166
+ const positions = [live({ flow: 'sim', nodeId: 'renda' }), live({ flow: 'outro', nodeId: 'renda' })]
167
+
168
+ expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 1 })
169
+ })
170
+
171
+ it('sem posições devolve vazio, em vez de estourar', () => {
172
+ expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions: undefined })).toEqual({})
173
+ })
174
+
175
+ it('linha já agregada pelo servidor soma pelo count, inclusive na raiz', () => {
176
+ // `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`. Contando como uma sessão
177
+ // por linha, dez conversas paradas no mesmo nó apareciam como uma — ou como nenhuma, porque a
178
+ // linha agregada não tem `menuNodeId` para a raiz ler.
179
+ const positions = [
180
+ { flowKey: ROOT, nodeId: 'saudacao', count: 7 },
181
+ { flowKey: 'sim', nodeId: 'renda', count: 3 },
182
+ ]
183
+
184
+ expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 7 })
185
+ expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
186
+ })
187
+
188
+ it('os dois formatos convivem na mesma resposta', () => {
189
+ const positions = [live({ flow: 'sim', nodeId: 'renda' }), { flowKey: 'sim', nodeId: 'renda', count: 2 }]
190
+
191
+ expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
192
+ })
193
+ })
194
+
195
+ describe('layout mesclado', () => {
196
+ it('ranqueia da esquerda para a direita a partir do início do fluxo primário', () => {
197
+ const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
198
+
199
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
200
+
201
+ expect(positions.get('menu::a')!.x).toBe(0)
202
+ expect(positions.get('menu::b')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
203
+ expect(positions.get('menu::c')!.x).toBeGreaterThan(positions.get('menu::b')!.x)
204
+ })
205
+
206
+ it('atravessa o salto entre fluxos ao ranquear', () => {
207
+ const graphs = {
208
+ menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
209
+ sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
210
+ }
211
+
212
+ const positions = computeMergedLayout({ openKeys: ['menu', 'sim'], graphs, primaryFlowKey: 'menu' })
213
+
214
+ expect(positions.get('sim::s1')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
215
+ })
216
+
217
+ it('TODOS os órfãos ficam na MESMA coluna extra', () => {
218
+ // Uma coluna por órfão empurrava cada um mais para longe da área visível, e desligar um fio se
219
+ // lia como "o card sumiu". É o motivo de existir uma camada única.
220
+ const graphs = {
221
+ menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2'), node('o3')] }),
222
+ }
223
+
224
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
225
+ const strayX = ['o1', 'o2', 'o3'].map((id) => positions.get(`menu::${id}`)!.x)
226
+
227
+ expect(new Set(strayX).size).toBe(1)
228
+ expect(strayX[0]).toBeGreaterThan(positions.get('menu::a')!.x)
229
+ })
230
+
231
+ it('órfãos na mesma coluna não se sobrepõem', () => {
232
+ const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2')] }) }
233
+
234
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
235
+
236
+ expect(positions.get('menu::o1')!.y).not.toBe(positions.get('menu::o2')!.y)
237
+ })
238
+
239
+ it('ciclo não estoura a pilha', () => {
240
+ const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'a')] }) }
241
+
242
+ expect(computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' }).size).toBe(2)
243
+ })
244
+
245
+ it('fluxo primário ausente ainda posiciona todo mundo, em vez de devolver vazio', () => {
246
+ const graphs = { sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }) }
247
+
248
+ // Acontece entre o clique em "focar" e a chegada do grafo: sem isto o canvas fica em branco.
249
+ expect(computeMergedLayout({ openKeys: ['sim'], graphs, primaryFlowKey: 'menu' }).size).toBe(1)
250
+ })
251
+ })
252
+
253
+ describe('nós soltos', () => {
254
+ it('quem ninguém aponta está solto; o nó inicial nunca está', () => {
255
+ const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b'), node('solto')] })
256
+
257
+ expect(detachedNodeIds(target)).toEqual(new Set(['solto']))
258
+ })
259
+
260
+ it('salto para outro fluxo não conta como ligação local', () => {
261
+ const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim'), node('b')] })
262
+
263
+ expect(detachedNodeIds(target).has('b')).toBe(true)
264
+ })
265
+ })
266
+
267
+ describe('moldura da cadeia de coleta', () => {
268
+ it('envolve todos os nós e abre espaço para o rótulo acima', () => {
269
+ const target = graph({ key: 'menu', start: 'a', nodes: [node('a'), node('b')] })
270
+ const positions: Record<string, { x: number; y: number }> = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 } }
271
+
272
+ const bounds = chainFrameBounds({
273
+ nodeIds: ['a', 'b'],
274
+ graph: target,
275
+ positionOf: (id) => positions[id]!,
276
+ padding: 10,
277
+ })
278
+
279
+ expect(bounds.x).toBe(-10)
280
+ // O rótulo é desenhado acima da borda, então o topo sobe além do padding.
281
+ expect(bounds.y).toBeLessThan(-10)
282
+ expect(bounds.width).toBeGreaterThan(100)
283
+ expect(bounds.height).toBeGreaterThan(50)
284
+ })
285
+ })
286
+
287
+ describe('nó novo da paleta', () => {
288
+ it('pergunta nasce com contextKey igual ao id', () => {
289
+ // Sem contextKey o motor faz a pergunta e descarta a resposta, sem erro em lugar nenhum.
290
+ const created = newNodeFromSpec({ kind: 'question', questionType: 'text' }, new Set())
291
+
292
+ expect(created.contextKey).toBe(created.id)
293
+ })
294
+
295
+ it('decisão nasce com duas opções, para já dar o que ligar', () => {
296
+ const created = newNodeFromSpec({ kind: 'decision' }, new Set())
297
+
298
+ expect(created.options).toHaveLength(2)
299
+ expect(created.questionType).toBe('choice')
300
+ })
301
+
302
+ it('id não colide com o que já existe', () => {
303
+ const first = newNodeFromSpec({ kind: 'condition' }, new Set())
304
+ const second = newNodeFromSpec({ kind: 'condition' }, new Set([first.id]))
305
+
306
+ expect(second.id).not.toBe(first.id)
307
+ })
308
+
309
+ it('ação carrega o kind escolhido na paleta', () => {
310
+ expect(newNodeFromSpec({ kind: 'action', actionKind: 'handoff' }, new Set()).actionKind).toBe('handoff')
311
+ })
312
+ })
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Derivações puras do canvas do editor: posições, arestas, contagem de conversas vivas e o nó que
3
+ * nasce de cada item da paleta.
4
+ *
5
+ * Viviam dentro do componente, e é ali que estava o risco. Uma aresta com destino errado não pinta
6
+ * errado — ela leva a conversa do cliente para o lugar errado, e o sintoma aparece longe de quem
7
+ * editou. Um card que o layout joga para fora da área visível se lê como "apaguei sem querer".
8
+ *
9
+ * A topologia sai daqui em forma neutra (`FlowEdgeSpec`) e o estilo fica no componente: o teste cobra
10
+ * o que importa e roda sem navegador nem `@xyflow/react`. Cor de traço ninguém quebra sem ver.
11
+ */
12
+
13
+ import { namespaceNodeId, parseNamespacedId } from './flowEditorOps'
14
+ import {
15
+ NODE_CARD_WIDTH,
16
+ crossFlowKey,
17
+ estimateNodeHeight,
18
+ isCrossFlowTarget,
19
+ slugifyNodeId,
20
+ targetsOf,
21
+ type FlowGraphData,
22
+ type FlowNodeData,
23
+ } from './flowGraph'
24
+ import type { NewNodeSpec } from './FlowPalette'
25
+
26
+ const COLUMN_GAP = 360
27
+ const ROW_GAP = 40
28
+ /** O rótulo da moldura é desenhado acima da borda, e precisa de espaço próprio. */
29
+ const CHAIN_LABEL_ROOM = 24
30
+
31
+ export type FlowNodePosition = { readonly x: number; readonly y: number }
32
+
33
+ /** Onde cada conversa viva está parada agora — uma linha por sessão. */
34
+ export type FlowLivePosition = {
35
+ readonly currentState: string
36
+ readonly flow: string | null
37
+ readonly nodeId: string | null
38
+ readonly menuNodeId: string | null
39
+ }
40
+
41
+ /**
42
+ * Quantas conversas estão paradas num nó — o formato que o servidor já agrega.
43
+ *
44
+ * `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`, e trazer uma linha por
45
+ * sessão só para o editor recontar em memória seria pior em toda base grande. Os dois formatos
46
+ * valem porque produto que já implementou o de sessão não deve quebrar numa release de correção.
47
+ */
48
+ export type FlowLiveNodeCount = {
49
+ readonly flowKey: string
50
+ readonly nodeId: string
51
+ readonly count: number
52
+ }
53
+
54
+ export type FlowLivePositionInput = FlowLivePosition | FlowLiveNodeCount
55
+
56
+ function isNodeCount(position: FlowLivePositionInput): position is FlowLiveNodeCount {
57
+ return typeof (position as FlowLiveNodeCount).count === 'number'
58
+ }
59
+
60
+ /**
61
+ * Pseudo-nó que representa um fluxo alvo ainda não mesclado no canvas.
62
+ *
63
+ * Um portal por (nó de origem, fluxo alvo): duas opções do mesmo nó indo para o mesmo fluxo
64
+ * compartilham o portal, senão o card ficaria cercado de caixas idênticas.
65
+ */
66
+ export function portalNodeId(sourceNodeId: string, target: string): string {
67
+ return `__portal__${sourceNodeId}__${target}`
68
+ }
69
+
70
+ export function chainFrameNodeId(actionNodeId: string): string {
71
+ return `__chain__${actionNodeId}`
72
+ }
73
+
74
+ export const GROUP_HEADER_NODE_ID = '__group_header__'
75
+
76
+ /**
77
+ * Quantas conversas estão paradas em cada nó de um fluxo.
78
+ *
79
+ * A raiz é caso à parte no formato por sessão: o servidor guarda o passo do menu em `menuNodeId`,
80
+ * num campo próprio, e uma conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem
81
+ * zero na tela mais visitada do editor. No formato agregado a chave do fluxo já vem na linha,
82
+ * então a raiz não tem exceção.
83
+ */
84
+ export function countLiveByNode(params: {
85
+ readonly flowKey: string
86
+ readonly rootFlowKey: string
87
+ readonly positions: readonly FlowLivePositionInput[] | undefined
88
+ }): Record<string, number> {
89
+ const { flowKey, rootFlowKey, positions } = params
90
+ const isRoot = flowKey === rootFlowKey
91
+ const counts: Record<string, number> = {}
92
+
93
+ for (const position of positions ?? []) {
94
+ if (isNodeCount(position)) {
95
+ if (position.flowKey !== flowKey) continue
96
+ counts[position.nodeId] = (counts[position.nodeId] ?? 0) + position.count
97
+ continue
98
+ }
99
+
100
+ if (position.flow !== flowKey && !isRoot) continue
101
+ const nodeId = isRoot ? position.menuNodeId : position.nodeId
102
+ if (!nodeId) continue
103
+ counts[nodeId] = (counts[nodeId] ?? 0) + 1
104
+ }
105
+
106
+ return counts
107
+ }
108
+
109
+ /**
110
+ * Um layout só para TODOS os nós de TODOS os fluxos abertos juntos.
111
+ *
112
+ * Posicionar cada fluxo à parte e deslocar não resolve: nada impede dois fluxos de ocuparem o mesmo
113
+ * espaço, e a altura real de cada card é ignorada. Aqui o ranqueamento por BFS roda sobre o grafo
114
+ * mesclado inteiro, com os saltos `flow:<key>` já resolvidos para o nó inicial do alvo.
115
+ */
116
+ export function computeMergedLayout(params: {
117
+ readonly openKeys: readonly string[]
118
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
119
+ readonly primaryFlowKey: string
120
+ }): Map<string, FlowNodePosition> {
121
+ const { openKeys, graphs, primaryFlowKey } = params
122
+ const open = new Set(openKeys)
123
+
124
+ const nodeById = new Map<string, FlowNodeData>()
125
+ for (const key of openKeys) {
126
+ for (const node of Object.values(graphs[key]?.nodes ?? {})) {
127
+ nodeById.set(namespaceNodeId(key, node.id), node)
128
+ }
129
+ }
130
+
131
+ function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
132
+ const result: string[] = []
133
+
134
+ for (const { target } of targetsOf(node)) {
135
+ if (isCrossFlowTarget(target)) {
136
+ const targetKey = crossFlowKey(target)
137
+ const targetGraph = open.has(targetKey) ? graphs[targetKey] : undefined
138
+ if (targetGraph) result.push(namespaceNodeId(targetKey, targetGraph.startNodeId))
139
+ } else if (graphs[flowKey]?.nodes[target]) {
140
+ result.push(namespaceNodeId(flowKey, target))
141
+ }
142
+ }
143
+
144
+ return result
145
+ }
146
+
147
+ const rank = new Map<string, number>()
148
+ const primaryGraph = graphs[primaryFlowKey]
149
+ const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
150
+
151
+ if (rootId && nodeById.has(rootId)) {
152
+ rank.set(rootId, 0)
153
+ const queue = [rootId]
154
+ while (queue.length > 0) {
155
+ const id = queue.shift()!
156
+ const node = nodeById.get(id)
157
+ if (!node) continue
158
+ for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
159
+ if (!rank.has(nextId)) {
160
+ rank.set(nextId, rank.get(id)! + 1)
161
+ queue.push(nextId)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ // Todo nó não alcançado vai para UMA coluna extra depois de tudo. Uma coluna por órfão empurrava
168
+ // cada um mais para longe da área visível — e desligar um fio se lia como "o card sumiu".
169
+ const strayRank = Math.max(0, ...rank.values()) + 1
170
+ for (const id of nodeById.keys()) {
171
+ if (!rank.has(id)) rank.set(id, strayRank)
172
+ }
173
+
174
+ const layers = new Map<number, string[]>()
175
+ for (const [id, value] of rank) {
176
+ const layer = layers.get(value)
177
+ if (layer) layer.push(id)
178
+ else layers.set(value, [id])
179
+ }
180
+
181
+ const positions = new Map<string, FlowNodePosition>()
182
+ for (const value of [...layers.keys()].sort((a, b) => a - b)) {
183
+ // Alinhado pelo topo, e não centralizado: coluna centralizada saltava inteira a cada nó a mais
184
+ // num ramo qualquer.
185
+ let cursorY = 0
186
+ for (const id of layers.get(value)!) {
187
+ positions.set(id, { x: value * COLUMN_GAP, y: cursorY })
188
+ cursorY += estimateNodeHeight(nodeById.get(id)!) + ROW_GAP
189
+ }
190
+ }
191
+
192
+ return positions
193
+ }
194
+
195
+ /** Papel visual da aresta. O componente traduz em traço, cor e seta; o modelo só decide qual é. */
196
+ export type FlowEdgeKind = 'linear' | 'branch' | 'fallback'
197
+
198
+ export type FlowEdgeSpec = {
199
+ readonly id: string
200
+ readonly source: string
201
+ readonly target: string
202
+ readonly sourceHandle?: string
203
+ readonly kind: FlowEdgeKind
204
+ /** Salto entre fluxos — tracejado, esteja o alvo mesclado ou num portal. */
205
+ readonly crossFlow: boolean
206
+ readonly live: boolean
207
+ }
208
+
209
+ /**
210
+ * Arestas de todos os fluxos abertos.
211
+ *
212
+ * A regra que não é óbvia: um salto `flow:<key>` vira ligação real até o nó inicial do alvo quando
213
+ * esse fluxo já está no canvas, e só cai no portal quando não está. Sem isso, abrir dois fluxos
214
+ * ligados mostrava uma caixa de portal entre dois cards que já estavam ali do lado.
215
+ */
216
+ export function buildFlowEdges(params: {
217
+ readonly openKeys: readonly string[]
218
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
219
+ readonly rootFlowKey: string
220
+ readonly livePositions?: readonly FlowLivePositionInput[] | undefined
221
+ }): FlowEdgeSpec[] {
222
+ const { openKeys, graphs, rootFlowKey, livePositions } = params
223
+ const open = new Set(openKeys)
224
+ const edges: FlowEdgeSpec[] = []
225
+
226
+ for (const flowKey of openKeys) {
227
+ const graph = graphs[flowKey]
228
+ if (!graph) continue
229
+ const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
230
+
231
+ for (const [nodeId, node] of Object.entries(graph.nodes)) {
232
+ const live = (liveCounts[nodeId] ?? 0) > 0
233
+
234
+ for (const { target, optionId, isDefault } of targetsOf(node)) {
235
+ // Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
236
+ // `targetsOf` devolve o `default` mesmo em branco. Emitir a aresta criaria ligação para um nó
237
+ // que não existe — o React Flow a descarta em silêncio, então o sintoma seria uma opção que
238
+ // parece ligada e não está, com a conversa do cliente parando ali.
239
+ if (!target) continue
240
+
241
+ const crossFlow = isCrossFlowTarget(target)
242
+ let targetFlowKey = flowKey
243
+ let targetNodeId = target
244
+
245
+ if (crossFlow) {
246
+ const wantedKey = crossFlowKey(target)
247
+ const targetGraph = open.has(wantedKey) ? graphs[wantedKey] : undefined
248
+ if (targetGraph) {
249
+ targetFlowKey = wantedKey
250
+ targetNodeId = targetGraph.startNodeId
251
+ } else {
252
+ targetNodeId = portalNodeId(nodeId, target)
253
+ }
254
+ }
255
+
256
+ const source = namespaceNodeId(flowKey, nodeId)
257
+ const edgeTarget = namespaceNodeId(targetFlowKey, targetNodeId)
258
+
259
+ if (isDefault) {
260
+ edges.push({
261
+ id: `${source}->${edgeTarget}-default`,
262
+ source,
263
+ target: edgeTarget,
264
+ sourceHandle: '__default',
265
+ kind: 'fallback',
266
+ crossFlow,
267
+ // Fallback não anima: é o caminho que ninguém escolheu, e piscar sugeria que a conversa
268
+ // estava passando por ali.
269
+ live: false,
270
+ })
271
+ continue
272
+ }
273
+
274
+ if (optionId === undefined) {
275
+ edges.push({ id: `${source}->${edgeTarget}`, source, target: edgeTarget, kind: 'linear', crossFlow, live })
276
+ continue
277
+ }
278
+
279
+ edges.push({
280
+ id: `${source}->${edgeTarget}-${optionId}`,
281
+ source,
282
+ target: edgeTarget,
283
+ sourceHandle: optionId,
284
+ kind: 'branch',
285
+ crossFlow,
286
+ live,
287
+ })
288
+ }
289
+ }
290
+ }
291
+
292
+ return edges
293
+ }
294
+
295
+ /** Nós de um fluxo que ninguém aponta — o card ganha contorno tracejado para cobrar a ligação. */
296
+ export function detachedNodeIds(graph: FlowGraphData): Set<string> {
297
+ const connected = new Set<string>()
298
+ for (const node of Object.values(graph.nodes)) {
299
+ for (const { target } of targetsOf(node)) {
300
+ if (!isCrossFlowTarget(target)) connected.add(target)
301
+ }
302
+ }
303
+
304
+ const detached = new Set<string>()
305
+ for (const nodeId of Object.keys(graph.nodes)) {
306
+ if (nodeId !== graph.startNodeId && !connected.has(nodeId)) detached.add(nodeId)
307
+ }
308
+
309
+ return detached
310
+ }
311
+
312
+ /** Retângulo que envolve uma cadeia de coleta, em coordenadas do canvas. */
313
+ export function chainFrameBounds(params: {
314
+ readonly nodeIds: readonly string[]
315
+ readonly graph: FlowGraphData
316
+ readonly positionOf: (nodeId: string) => FlowNodePosition
317
+ readonly padding: number
318
+ }): { x: number; y: number; width: number; height: number } {
319
+ const { nodeIds, graph, positionOf, padding } = params
320
+ const positions = nodeIds.map(positionOf)
321
+ const minX = Math.min(...positions.map((each) => each.x))
322
+ const maxX = Math.max(...positions.map((each) => each.x)) + NODE_CARD_WIDTH
323
+ const minY = Math.min(...positions.map((each) => each.y))
324
+ const maxY = Math.max(...nodeIds.map((id, index) => positions[index]!.y + estimateNodeHeight(graph.nodes[id]!)))
325
+
326
+ return {
327
+ x: minX - padding,
328
+ y: minY - padding - CHAIN_LABEL_ROOM,
329
+ width: maxX - minX + padding * 2,
330
+ height: maxY - minY + padding * 2 + CHAIN_LABEL_ROOM,
331
+ }
332
+ }
333
+
334
+ /**
335
+ * O nó que nasce de cada item da paleta.
336
+ *
337
+ * `contextKey` igual ao id porque é o que o motor usa para guardar a resposta: deixá-lo vazio faria a
338
+ * pergunta ser feita e a resposta descartada, sem erro em lugar nenhum.
339
+ */
340
+ export function newNodeFromSpec(spec: NewNodeSpec, existingIds: ReadonlySet<string>): FlowNodeData {
341
+ if (spec.kind === 'question') {
342
+ const id = slugifyNodeId('nova_pergunta', existingIds)
343
+ return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
344
+ }
345
+
346
+ if (spec.kind === 'decision') {
347
+ const id = slugifyNodeId('nova_decisao', existingIds)
348
+ return {
349
+ id,
350
+ type: 'question',
351
+ questionType: 'choice',
352
+ contextKey: id,
353
+ question: '',
354
+ options: [
355
+ ['1', 'Opção 1'],
356
+ ['2', 'Opção 2'],
357
+ ],
358
+ }
359
+ }
360
+
361
+ if (spec.kind === 'condition') {
362
+ const id = slugifyNodeId('nova_condicao', existingIds)
363
+ return { id, type: 'condition', conditionOperator: '>' }
364
+ }
365
+
366
+ const id = slugifyNodeId('nova_acao', existingIds)
367
+ return { id, type: 'action', actionKind: spec.actionKind }
368
+ }