@adatechnology/conversations-ui 0.1.0-rc.8 → 0.1.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 (132) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -678
  7. package/dist/index.d.ts +921 -17
  8. package/dist/index.js +3677 -678
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  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 +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.test.tsx +21 -0
  47. package/src/Wallpaper.tsx +67 -7
  48. package/src/WhatsAppMessageEditor.tsx +10 -7
  49. package/src/WindowExpiredNotice.tsx +12 -4
  50. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  51. package/src/buildOutput.test.ts +79 -0
  52. package/src/composer.constant.ts +33 -0
  53. package/src/conversationTranscript.test.ts +57 -0
  54. package/src/conversationTranscript.ts +29 -4
  55. package/src/conversationWindow.ts +7 -5
  56. package/src/documentTypeLabel.test.ts +57 -0
  57. package/src/documents/DocumentsWorkspace.tsx +550 -0
  58. package/src/documents/index.ts +8 -0
  59. package/src/documents/labels.ts +92 -0
  60. package/src/flows/FlowConnectionEdge.tsx +104 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowLegend.tsx +125 -0
  63. package/src/flows/FlowMapCanvas.tsx +15 -12
  64. package/src/flows/FlowMapNode.tsx +4 -1
  65. package/src/flows/FlowNodeCard.tsx +219 -34
  66. package/src/flows/FlowNodePanel.tsx +153 -39
  67. package/src/flows/FlowPalette.tsx +156 -70
  68. package/src/flows/FlowPortalNode.tsx +1 -1
  69. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  70. package/src/flows/FlowsWorkspace.tsx +1255 -0
  71. package/src/flows/flowCanvasModel.test.ts +456 -0
  72. package/src/flows/flowCanvasModel.ts +378 -0
  73. package/src/flows/flowEditorOps.test.ts +276 -0
  74. package/src/flows/flowEditorOps.ts +202 -0
  75. package/src/flows/flowGraph.ts +78 -53
  76. package/src/flows/flowMenuPlacement.test.ts +130 -0
  77. package/src/flows/flowMenuPlacement.ts +86 -0
  78. package/src/flows/index.ts +51 -2
  79. package/src/flows/labels.ts +180 -0
  80. package/src/flows/workspaceContract.test.ts +126 -0
  81. package/src/hooks/useContainerWidth.ts +35 -0
  82. package/src/hooks/useConversationRealtime.ts +10 -8
  83. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  84. package/src/hooks/useUrlFilterState.ts +107 -0
  85. package/src/icon.constant.ts +12 -0
  86. package/src/index.ts +100 -0
  87. package/src/lib/composer-formatting.test.ts +78 -0
  88. package/src/lib/composer-formatting.ts +145 -0
  89. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  90. package/src/lib/whatsapp-formatting.tsx +28 -3
  91. package/src/listing/index.tsx +202 -0
  92. package/src/pagination.constant.ts +10 -0
  93. package/src/preview/ConversationPreview.tsx +84 -45
  94. package/src/preview/ConversationSimulatorClient.ts +143 -0
  95. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  96. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  97. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  98. package/src/preview/createPreviewBridgeClient.ts +124 -0
  99. package/src/preview/createPreviewMediaUploader.ts +82 -0
  100. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  101. package/src/preview/createPreviewWebhookClient.ts +99 -3
  102. package/src/preview/index.ts +36 -2
  103. package/src/preview/previewMediaUploader.test.ts +61 -0
  104. package/src/providers/ConversationsProvider.tsx +8 -6
  105. package/src/providers/types.ts +59 -2
  106. package/src/quickReply.test.ts +58 -0
  107. package/src/replyLatency.test.ts +71 -0
  108. package/src/replyLatency.ts +57 -0
  109. package/src/settings/MessagesWorkspace.tsx +571 -0
  110. package/src/settings/TopicsForm.tsx +2 -0
  111. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  112. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  113. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  114. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  115. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  116. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  117. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  118. package/src/styles.css +858 -0
  119. package/src/theme.ts +13 -0
  120. package/src/types.ts +26 -0
  121. package/src/workspace/BulkTemplateModal.tsx +132 -0
  122. package/src/workspace/ConversationPane.tsx +432 -0
  123. package/src/workspace/ConversationsInboxList.tsx +194 -0
  124. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  125. package/src/workspace/index.ts +17 -0
  126. package/src/workspace/labels.test.ts +17 -0
  127. package/src/workspace/labels.ts +85 -0
  128. package/src/workspace/useConversationsInbox.ts +332 -0
  129. package/dist/chunk-73MW5HNT.js +0 -1717
  130. package/dist/chunk-NV2RZ5KT.js +0 -56
  131. package/dist/types-B5C1DLu1.d.ts +0 -365
  132. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,378 @@
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
+ cascadeOrder,
17
+ crossFlowKey,
18
+ estimateNodeHeight,
19
+ isCrossFlowTarget,
20
+ slugifyNodeId,
21
+ targetsOf,
22
+ type FlowGraphData,
23
+ type FlowNodeData,
24
+ } from './flowGraph'
25
+ import type { NewNodeSpec } from './FlowPalette'
26
+
27
+ const COLUMN_GAP = 360
28
+ const ROW_GAP = 40
29
+ /** O rótulo da moldura é desenhado acima da borda, e precisa de espaço próprio. */
30
+ const CHAIN_LABEL_ROOM = 24
31
+
32
+ export type FlowNodePosition = { readonly x: number; readonly y: number }
33
+
34
+ /** Onde cada conversa viva está parada agora — uma linha por sessão. */
35
+ export type FlowLivePosition = {
36
+ readonly currentState: string
37
+ readonly flow: string | null
38
+ readonly nodeId: string | null
39
+ readonly menuNodeId: string | null
40
+ }
41
+
42
+ /**
43
+ * Quantas conversas estão paradas num nó — o formato que o servidor já agrega.
44
+ *
45
+ * `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`, e trazer uma linha por
46
+ * sessão só para o editor recontar em memória seria pior em toda base grande. Os dois formatos
47
+ * valem porque produto que já implementou o de sessão não deve quebrar numa release de correção.
48
+ */
49
+ export type FlowLiveNodeCount = {
50
+ readonly flowKey: string
51
+ readonly nodeId: string
52
+ readonly count: number
53
+ }
54
+
55
+ export type FlowLivePositionInput = FlowLivePosition | FlowLiveNodeCount
56
+
57
+ function isNodeCount(position: FlowLivePositionInput): position is FlowLiveNodeCount {
58
+ return typeof (position as FlowLiveNodeCount).count === 'number'
59
+ }
60
+
61
+ /**
62
+ * Pseudo-nó que representa um fluxo alvo ainda não mesclado no canvas.
63
+ *
64
+ * Um portal por (nó de origem, fluxo alvo): duas opções do mesmo nó indo para o mesmo fluxo
65
+ * compartilham o portal, senão o card ficaria cercado de caixas idênticas.
66
+ */
67
+ export function portalNodeId(sourceNodeId: string, target: string): string {
68
+ return `__portal__${sourceNodeId}__${target}`
69
+ }
70
+
71
+ export function chainFrameNodeId(actionNodeId: string): string {
72
+ return `__chain__${actionNodeId}`
73
+ }
74
+
75
+ export const GROUP_HEADER_NODE_ID = '__group_header__'
76
+
77
+ /**
78
+ * Quantas conversas estão paradas em cada nó de um fluxo.
79
+ *
80
+ * A raiz é caso à parte no formato por sessão: o servidor guarda o passo do menu em `menuNodeId`,
81
+ * num campo próprio, e uma conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem
82
+ * zero na tela mais visitada do editor. No formato agregado a chave do fluxo já vem na linha,
83
+ * então a raiz não tem exceção.
84
+ */
85
+ export function countLiveByNode(params: {
86
+ readonly flowKey: string
87
+ readonly rootFlowKey: string
88
+ readonly positions: readonly FlowLivePositionInput[] | undefined
89
+ }): Record<string, number> {
90
+ const { flowKey, rootFlowKey, positions } = params
91
+ const isRoot = flowKey === rootFlowKey
92
+ const counts: Record<string, number> = {}
93
+
94
+ for (const position of positions ?? []) {
95
+ if (isNodeCount(position)) {
96
+ if (position.flowKey !== flowKey) continue
97
+ counts[position.nodeId] = (counts[position.nodeId] ?? 0) + position.count
98
+ continue
99
+ }
100
+
101
+ if (position.flow !== flowKey && !isRoot) continue
102
+ const nodeId = isRoot ? position.menuNodeId : position.nodeId
103
+ if (!nodeId) continue
104
+ counts[nodeId] = (counts[nodeId] ?? 0) + 1
105
+ }
106
+
107
+ return counts
108
+ }
109
+
110
+ /**
111
+ * Um layout só para TODOS os nós de TODOS os fluxos abertos juntos, na mesma cascata do fluxo
112
+ * único: um passo para a direita a cada avanço da conversa, um degrau para baixo a cada card.
113
+ *
114
+ * Posicionar cada fluxo à parte e deslocar não resolve: nada impede dois fluxos de ocuparem o mesmo
115
+ * espaço, e a altura real de cada card é ignorada. Aqui a travessia roda sobre o grafo mesclado
116
+ * inteiro, com os saltos `flow:<key>` já resolvidos para o nó inicial do alvo.
117
+ */
118
+ export function computeMergedLayout(params: {
119
+ readonly openKeys: readonly string[]
120
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
121
+ readonly primaryFlowKey: string
122
+ }): Map<string, FlowNodePosition> {
123
+ const { openKeys, graphs, primaryFlowKey } = params
124
+ const open = new Set(openKeys)
125
+
126
+ const nodeById = new Map<string, FlowNodeData>()
127
+ for (const key of openKeys) {
128
+ for (const node of Object.values(graphs[key]?.nodes ?? {})) {
129
+ nodeById.set(namespaceNodeId(key, node.id), node)
130
+ }
131
+ }
132
+
133
+ function forwardEdges(id: string): string[] {
134
+ const node = nodeById.get(id)
135
+ if (!node) return []
136
+ const flowKey = parseNamespacedId(id).flowKey
137
+ const result: string[] = []
138
+
139
+ for (const { target } of targetsOf(node)) {
140
+ if (isCrossFlowTarget(target)) {
141
+ const targetKey = crossFlowKey(target)
142
+ const targetGraph = open.has(targetKey) ? graphs[targetKey] : undefined
143
+ if (targetGraph) result.push(namespaceNodeId(targetKey, targetGraph.startNodeId))
144
+ } else if (graphs[flowKey]?.nodes[target]) {
145
+ result.push(namespaceNodeId(flowKey, target))
146
+ }
147
+ }
148
+
149
+ return result
150
+ }
151
+
152
+ const primaryGraph = graphs[primaryFlowKey]
153
+ const placed = cascadeOrder({
154
+ rootId: primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : '',
155
+ allIds: [...nodeById.keys()],
156
+ forwardEdges,
157
+ })
158
+
159
+ const positions = new Map<string, FlowNodePosition>()
160
+ let cursorY = 0
161
+
162
+ for (const [id, { depth }] of [...placed.entries()].sort((a, b) => a[1].order - b[1].order)) {
163
+ positions.set(id, { x: depth * COLUMN_GAP, y: cursorY })
164
+ cursorY += estimateNodeHeight(nodeById.get(id)!) + ROW_GAP
165
+ }
166
+
167
+ return positions
168
+ }
169
+
170
+ /** Papel visual da aresta. O componente traduz em traço, cor e seta; o modelo só decide qual é. */
171
+ export type FlowEdgeKind = 'linear' | 'branch' | 'fallback'
172
+
173
+ export type FlowEdgeSpec = {
174
+ readonly id: string
175
+ readonly source: string
176
+ readonly target: string
177
+ readonly sourceHandle?: string
178
+ readonly kind: FlowEdgeKind
179
+ /** Salto entre fluxos — tracejado, esteja o alvo mesclado ou num portal. */
180
+ readonly crossFlow: boolean
181
+ readonly live: boolean
182
+ }
183
+
184
+ /**
185
+ * Arestas de todos os fluxos abertos.
186
+ *
187
+ * A regra que não é óbvia: um salto `flow:<key>` vira ligação real até o nó inicial do alvo quando
188
+ * esse fluxo já está no canvas, e só cai no portal quando não está. Sem isso, abrir dois fluxos
189
+ * ligados mostrava uma caixa de portal entre dois cards que já estavam ali do lado.
190
+ */
191
+ export function buildFlowEdges(params: {
192
+ readonly openKeys: readonly string[]
193
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
194
+ readonly rootFlowKey: string
195
+ readonly livePositions?: readonly FlowLivePositionInput[] | undefined
196
+ }): FlowEdgeSpec[] {
197
+ const { openKeys, graphs, rootFlowKey, livePositions } = params
198
+ const open = new Set(openKeys)
199
+ const edges: FlowEdgeSpec[] = []
200
+
201
+ for (const flowKey of openKeys) {
202
+ const graph = graphs[flowKey]
203
+ if (!graph) continue
204
+ const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
205
+
206
+ for (const [nodeId, node] of Object.entries(graph.nodes)) {
207
+ const live = (liveCounts[nodeId] ?? 0) > 0
208
+
209
+ for (const { target, optionId, isDefault } of targetsOf(node)) {
210
+ // Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
211
+ // `targetsOf` devolve o `default` mesmo em branco. Emitir a aresta criaria ligação para um nó
212
+ // que não existe — o React Flow a descarta em silêncio, então o sintoma seria uma opção que
213
+ // parece ligada e não está, com a conversa do cliente parando ali.
214
+ if (!target) continue
215
+
216
+ const crossFlow = isCrossFlowTarget(target)
217
+ let targetFlowKey = flowKey
218
+ let targetNodeId = target
219
+
220
+ if (crossFlow) {
221
+ const wantedKey = crossFlowKey(target)
222
+ const targetGraph = open.has(wantedKey) ? graphs[wantedKey] : undefined
223
+ if (targetGraph) {
224
+ targetFlowKey = wantedKey
225
+ targetNodeId = targetGraph.startNodeId
226
+ } else {
227
+ targetNodeId = portalNodeId(nodeId, target)
228
+ }
229
+ }
230
+
231
+ const source = namespaceNodeId(flowKey, nodeId)
232
+ const edgeTarget = namespaceNodeId(targetFlowKey, targetNodeId)
233
+
234
+ // Voltar ao próprio card não é trajeto, é comportamento — e como aresta não tem onde
235
+ // caber: por baixo some atrás do card, por cima o cobre. Quem mostra isso é o ícone de
236
+ // repetição na linha de saída, dentro do card (ver `FlowNodeCard`).
237
+ if (source === edgeTarget) continue
238
+
239
+ if (isDefault) {
240
+ edges.push({
241
+ id: `${source}->${edgeTarget}-default`,
242
+ source,
243
+ target: edgeTarget,
244
+ sourceHandle: '__default',
245
+ kind: 'fallback',
246
+ crossFlow,
247
+ // Fallback não anima: é o caminho que ninguém escolheu, e piscar sugeria que a conversa
248
+ // estava passando por ali.
249
+ live: false,
250
+ })
251
+ continue
252
+ }
253
+
254
+ if (optionId === undefined) {
255
+ edges.push({ id: `${source}->${edgeTarget}`, source, target: edgeTarget, kind: 'linear', crossFlow, live })
256
+ continue
257
+ }
258
+
259
+ edges.push({
260
+ id: `${source}->${edgeTarget}-${optionId}`,
261
+ source,
262
+ target: edgeTarget,
263
+ sourceHandle: optionId,
264
+ kind: 'branch',
265
+ crossFlow,
266
+ live,
267
+ })
268
+ }
269
+ }
270
+ }
271
+
272
+ return edges
273
+ }
274
+
275
+ /** Nós de um fluxo que ninguém aponta — o card ganha contorno tracejado para cobrar a ligação. */
276
+ export function detachedNodeIds(graph: FlowGraphData): Set<string> {
277
+ const connected = new Set<string>()
278
+ for (const node of Object.values(graph.nodes)) {
279
+ for (const { target } of targetsOf(node)) {
280
+ if (!isCrossFlowTarget(target)) connected.add(target)
281
+ }
282
+ }
283
+
284
+ const detached = new Set<string>()
285
+ for (const nodeId of Object.keys(graph.nodes)) {
286
+ if (nodeId !== graph.startNodeId && !connected.has(nodeId)) detached.add(nodeId)
287
+ }
288
+
289
+ return detached
290
+ }
291
+
292
+ /** Retângulo que envolve uma cadeia de coleta, em coordenadas do canvas. */
293
+ export function chainFrameBounds(params: {
294
+ readonly nodeIds: readonly string[]
295
+ readonly graph: FlowGraphData
296
+ readonly positionOf: (nodeId: string) => FlowNodePosition
297
+ readonly padding: number
298
+ }): { x: number; y: number; width: number; height: number } {
299
+ const { nodeIds, graph, positionOf, padding } = params
300
+ const positions = nodeIds.map(positionOf)
301
+ const minX = Math.min(...positions.map((each) => each.x))
302
+ const maxX = Math.max(...positions.map((each) => each.x)) + NODE_CARD_WIDTH
303
+ const minY = Math.min(...positions.map((each) => each.y))
304
+ const maxY = Math.max(...nodeIds.map((id, index) => positions[index]!.y + estimateNodeHeight(graph.nodes[id]!)))
305
+
306
+ return {
307
+ x: minX - padding,
308
+ y: minY - padding - CHAIN_LABEL_ROOM,
309
+ width: maxX - minX + padding * 2,
310
+ height: maxY - minY + padding * 2 + CHAIN_LABEL_ROOM,
311
+ }
312
+ }
313
+
314
+ /**
315
+ * O nó que nasce de cada item da paleta.
316
+ *
317
+ * `contextKey` igual ao id porque é o que o motor usa para guardar a resposta: deixá-lo vazio faria a
318
+ * pergunta ser feita e a resposta descartada, sem erro em lugar nenhum.
319
+ */
320
+ export function newNodeFromSpec(spec: NewNodeSpec, existingIds: ReadonlySet<string>): FlowNodeData {
321
+ if (spec.kind === 'question') {
322
+ const id = slugifyNodeId('nova_pergunta', existingIds)
323
+ return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
324
+ }
325
+
326
+ if (spec.kind === 'decision') {
327
+ const id = slugifyNodeId('nova_decisao', existingIds)
328
+ return {
329
+ id,
330
+ type: 'question',
331
+ questionType: 'choice',
332
+ contextKey: id,
333
+ question: '',
334
+ options: [
335
+ ['1', 'Opção 1'],
336
+ ['2', 'Opção 2'],
337
+ ],
338
+ }
339
+ }
340
+
341
+ if (spec.kind === 'condition') {
342
+ const id = slugifyNodeId('nova_condicao', existingIds)
343
+ return { id, type: 'condition', conditionOperator: '>' }
344
+ }
345
+
346
+ const id = slugifyNodeId('nova_acao', existingIds)
347
+ return { id, type: 'action', actionKind: spec.actionKind }
348
+ }
349
+
350
+ /** Distância vertical mínima entre dois cards para que não se leiam como um só. */
351
+ const FREE_SLOT_STEP = 120
352
+
353
+ /**
354
+ * Empurra a posição para baixo até achar espaço livre na mesma coluna.
355
+ *
356
+ * O "+" põe o nó novo à direita de quem o criou — e é justamente ali que costuma estar o nó que
357
+ * acabou de perder a ligação. Sem isto, os dois se sobrepõem e a tela mostra um card onde há dois,
358
+ * que é o mesmo susto de "sumiu" por outro caminho.
359
+ */
360
+ export function findFreeSlot(params: {
361
+ readonly desired: FlowNodePosition
362
+ readonly taken: readonly FlowNodePosition[]
363
+ readonly step?: number
364
+ }): FlowNodePosition {
365
+ const step = params.step ?? FREE_SLOT_STEP
366
+ const isOccupied = (candidate: FlowNodePosition) =>
367
+ params.taken.some(
368
+ (each) => Math.abs(each.x - candidate.x) < NODE_CARD_WIDTH && Math.abs(each.y - candidate.y) < step,
369
+ )
370
+
371
+ let slot = params.desired
372
+ // Teto igual ao número de cards: com N ocupados, N descidas bastam para sair de todos.
373
+ for (let attempt = 0; attempt <= params.taken.length && isOccupied(slot); attempt += 1) {
374
+ slot = { x: slot.x, y: slot.y + step }
375
+ }
376
+
377
+ return slot
378
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Estas funções decidem o que acontece com o fluxo que alguém desenhou, e o modo de falhar delas é
5
+ * silencioso: aresta apontando para nó apagado não dá erro no editor — dá conversa travada no meio
6
+ * para o cliente. Salto para nó do meio de outro fluxo não dá erro — o motor simplesmente ignora, e
7
+ * o atendimento para sem ninguém entender.
8
+ *
9
+ * São puras de propósito: rodam sem navegador, sem React e sem estado, o que é exatamente o que
10
+ * faltava enquanto elas viviam dentro de uma página de 973 linhas.
11
+ */
12
+
13
+ import { describe, expect, it } from 'bun:test'
14
+ import type { FlowGraphData, FlowNodeData } from '@adatechnology/meta-whatsapp-contracts'
15
+
16
+ import {
17
+ applyConnection,
18
+ clearConnection,
19
+ isGraphDirty,
20
+ mergedFlowKeysFrom,
21
+ namespaceNodeId,
22
+ parseNamespacedId,
23
+ removeNodeAndCleanRefs,
24
+ resolveConnection,
25
+ } from './flowEditorOps'
26
+
27
+ /** Nó mínimo VÁLIDO pelo contrato — sem `as`, para o teste falhar se a forma do nó mudar. */
28
+ function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
29
+ return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
30
+ }
31
+
32
+ /**
33
+ * Grafo mínimo VÁLIDO. `key` e `version` são obrigatórios no contrato, e sem `as` o teste passa a
34
+ * quebrar quando o contrato ganhar campo novo — que é exatamente o drift que o comentário do
35
+ * `flowGraph.ts` registra ter acontecido antes com o `version`.
36
+ */
37
+ function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
38
+ return {
39
+ key: params.key,
40
+ label: params.key,
41
+ version: 1,
42
+ startNodeId: params.start,
43
+ nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
44
+ }
45
+ }
46
+
47
+ describe('id no canvas mesclado', () => {
48
+ it('vai e volta', () => {
49
+ expect(parseNamespacedId(namespaceNodeId('menu', 'inicio'))).toEqual({ flowKey: 'menu', nodeId: 'inicio' })
50
+ })
51
+
52
+ it('nó com separador no próprio id não parte errado', () => {
53
+ // O separador aparece no PRIMEIRO índice: um id que contenha `::` continua íntegro.
54
+ expect(parseNamespacedId('menu::passo::dois')).toEqual({ flowKey: 'menu', nodeId: 'passo::dois' })
55
+ })
56
+
57
+ it('id sem prefixo devolve fluxo vazio, em vez de adivinhar', () => {
58
+ expect(parseNamespacedId('inicio')).toEqual({ flowKey: '', nodeId: 'inicio' })
59
+ })
60
+ })
61
+
62
+ describe('apagar nó limpa quem apontava para ele', () => {
63
+ it('saída única que ia para o apagado fica vazia, não pendurada', () => {
64
+ const nodes = { a: node('a', 'b'), b: node('b') }
65
+
66
+ const result = removeNodeAndCleanRefs(nodes, 'b')
67
+
68
+ expect(Object.keys(result)).toEqual(['a'])
69
+ // Vazio e não `'b'`: aresta para nó inexistente trava a conversa no motor do bot.
70
+ expect(result.a?.next).toBe('')
71
+ })
72
+
73
+ it('ramificação perde o destino mas MANTÉM a opção', () => {
74
+ const nodes = {
75
+ pergunta: node('pergunta', { byAnswer: { sim: 'aprovado', nao: 'recusado' }, default: 'aprovado' }),
76
+ aprovado: node('aprovado'),
77
+ recusado: node('recusado'),
78
+ }
79
+
80
+ const result = removeNodeAndCleanRefs(nodes, 'aprovado')
81
+ const next = result.pergunta?.next as { byAnswer: Record<string, string>; default: string }
82
+
83
+ // A chave `sim` continua existindo com destino vazio: removê-la esconderia da tela que a opção
84
+ // existe e não leva a lugar nenhum, e alguém publicaria assim.
85
+ expect(next.byAnswer).toEqual({ sim: '', nao: 'recusado' })
86
+ expect(next.default).toBe('')
87
+ })
88
+
89
+ it('não mexe em quem não apontava para o apagado', () => {
90
+ const nodes = { a: node('a', 'c'), b: node('b'), c: node('c') }
91
+
92
+ expect(removeNodeAndCleanRefs(nodes, 'b').a?.next).toBe('c')
93
+ })
94
+
95
+ it('nó sem next segue sem next, e não ganha um vazio', () => {
96
+ expect(removeNodeAndCleanRefs({ a: node('a'), b: node('b') }, 'b').a?.next).toBeUndefined()
97
+ })
98
+ })
99
+
100
+ describe('fecho transitivo dos fluxos abertos juntos', () => {
101
+ const graphs = {
102
+ menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'flow:simulacao')] }),
103
+ simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1', 'flow:documentos')] }),
104
+ documentos: graph({ key: 'documentos', start: 'd1', nodes: [node('d1')] }),
105
+ orfao: graph({ key: 'orfao', start: 'o1', nodes: [node('o1')] }),
106
+ }
107
+
108
+ it('alcança em profundidade e ignora quem ninguém referencia', () => {
109
+ expect([...mergedFlowKeysFrom('menu', graphs)].sort()).toEqual(['documentos', 'menu', 'simulacao'])
110
+ })
111
+
112
+ it('ciclo não estoura — menu que volta ao menu é o caso mais comum que existe', () => {
113
+ const cyclic = {
114
+ menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:sub')] }),
115
+ sub: graph({ key: 'sub', start: 's', nodes: [node('s', 'flow:menu')] }),
116
+ }
117
+
118
+ expect([...mergedFlowKeysFrom('menu', cyclic)].sort()).toEqual(['menu', 'sub'])
119
+ })
120
+
121
+ it('referência para fluxo que não existe é ignorada em vez de abrir aba vazia', () => {
122
+ const dangling = { menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:apagado')] }) }
123
+
124
+ expect(mergedFlowKeysFrom('menu', dangling)).toEqual(['menu'])
125
+ })
126
+ })
127
+
128
+ describe('conectar aresta', () => {
129
+ const graphs = {
130
+ menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio'), node('meio')] }),
131
+ simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1'), node('s2')] }),
132
+ }
133
+
134
+ it('dentro do mesmo fluxo grava o id local', () => {
135
+ const resolved = resolveConnection({
136
+ connection: { source: 'menu::inicio', target: 'menu::meio' },
137
+ graphs,
138
+ })
139
+
140
+ expect(resolved).toMatchObject({ flowKey: 'menu', nodeId: 'inicio', targetValue: 'meio' })
141
+ })
142
+
143
+ it('para o nó INICIAL de outro fluxo grava o salto `flow:`', () => {
144
+ const resolved = resolveConnection({
145
+ connection: { source: 'menu::inicio', target: 'simulacao::s1' },
146
+ graphs,
147
+ })
148
+
149
+ expect(resolved?.targetValue).toBe('flow:simulacao')
150
+ })
151
+
152
+ it('para o MEIO de outro fluxo é RECUSADO — o motor não sabe pular para lá', () => {
153
+ // Recusa silenciosa é melhor que gravar um salto que o bot ignora em produção: o sintoma seria
154
+ // conversa parada, longe de quem editou.
155
+ const resolved = resolveConnection({
156
+ connection: { source: 'menu::inicio', target: 'simulacao::s2' },
157
+ graphs,
158
+ })
159
+
160
+ expect(resolved).toBeUndefined()
161
+ })
162
+
163
+ it('nó em si mesmo é recusado', () => {
164
+ expect(
165
+ resolveConnection({ connection: { source: 'menu::inicio', target: 'menu::inicio' }, graphs }),
166
+ ).toBeUndefined()
167
+ })
168
+
169
+ it('fluxo de destino inexistente é recusado', () => {
170
+ expect(resolveConnection({ connection: { source: 'menu::inicio', target: 'sumiu::x' }, graphs })).toBeUndefined()
171
+ })
172
+ })
173
+
174
+ describe('aplicar a conexão no nó', () => {
175
+ it('handle `next` vira saída única', () => {
176
+ const result = applyConnection(node('a'), { flowKey: 'menu', nodeId: 'a', handle: 'next', targetValue: 'b' })
177
+
178
+ expect(result.next).toBe('b')
179
+ })
180
+
181
+ it('handle de resposta acrescenta no byAnswer e PRESERVA o resto', () => {
182
+ const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
183
+
184
+ const result = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: 'nao', targetValue: 'z' })
185
+ const next = result.next as { byAnswer: Record<string, string>; default: string }
186
+
187
+ expect(next.byAnswer).toEqual({ sim: 'x', nao: 'z' })
188
+ // O default não pode ser perdido ao ligar um ramo: seria mudança silenciosa de comportamento.
189
+ expect(next.default).toBe('y')
190
+ })
191
+
192
+ it('handle `__default` troca só o default', () => {
193
+ const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
194
+
195
+ const next = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: '__default', targetValue: 'z' })
196
+ .next as { byAnswer: Record<string, string>; default: string }
197
+
198
+ expect(next).toEqual({ byAnswer: { sim: 'x' }, default: 'z' })
199
+ })
200
+
201
+ it('ramificar um nó que tinha saída única não carrega a antiga como default', () => {
202
+ // A saída única vira ramificação: o default nasce vazio, e a validação do grafo cobra. Herdar a
203
+ // antiga faria o ramo não configurado seguir para onde o nó ia antes, sem ninguém pedir.
204
+ const next = applyConnection(node('a', 'antigo'), {
205
+ flowKey: 'menu',
206
+ nodeId: 'a',
207
+ handle: 'sim',
208
+ targetValue: 'novo',
209
+ }).next as { byAnswer: Record<string, string>; default: string }
210
+
211
+ expect(next).toEqual({ byAnswer: { sim: 'novo' }, default: '' })
212
+ })
213
+ })
214
+
215
+ describe('rascunho sujo', () => {
216
+ const published = graph({ key: 'menu', start: 'i', nodes: [node('i', 'b'), node('b')] })
217
+
218
+ it('igual ao publicado não está sujo', () => {
219
+ expect(isGraphDirty(structuredClone(published), published)).toBe(false)
220
+ })
221
+
222
+ it('texto mudado está sujo', () => {
223
+ const working = structuredClone(published)
224
+ working.nodes.i!.question = 'outro'
225
+
226
+ expect(isGraphDirty(working, published)).toBe(true)
227
+ })
228
+
229
+ it('posição mudada TAMBÉM está sujo — arrastar card é edição que se publica', () => {
230
+ const working = structuredClone(published)
231
+ working.nodes.i!.position = { x: 10, y: 20 }
232
+
233
+ expect(isGraphDirty(working, published)).toBe(true)
234
+ })
235
+
236
+ it('sem rascunho ou sem publicado não está sujo, em vez de sujo por ausência', () => {
237
+ // Durante o carregamento os dois lados chegam separados; sujo aqui faria a tela pedir
238
+ // confirmação de descarte antes de o usuário ter tocado em nada.
239
+ expect(isGraphDirty(undefined, published)).toBe(false)
240
+ expect(isGraphDirty(published, undefined)).toBe(false)
241
+ })
242
+ })
243
+
244
+ /**
245
+ * Desligar um fio é a operação que o relato de campo pegou errada: o card sumia. Some porque a
246
+ * opção deixa de existir no `byAnswer`, não porque alguém apagou o nó — e o teste fixa isso.
247
+ */
248
+ describe('clearConnection', () => {
249
+ it('saída única vira destino vazio, e o nó continua lá', () => {
250
+ expect(clearConnection(node('a', 'b'), 'next').next).toBe('')
251
+ })
252
+
253
+ it('desligar uma opção NÃO apaga a chave — a opção sumindo levaria junto o botão do WhatsApp', () => {
254
+ const branching = node('a', { byAnswer: { sim: 'b', nao: 'c' }, default: 'd' })
255
+
256
+ const result = clearConnection(branching, 'sim')
257
+
258
+ expect(result.next).toEqual({ byAnswer: { sim: '', nao: 'c' }, default: 'd' })
259
+ })
260
+
261
+ it('desligar o fallback preserva as opções', () => {
262
+ const branching = node('a', { byAnswer: { sim: 'b' }, default: 'd' })
263
+
264
+ expect(clearConnection(branching, '__default').next).toEqual({ byAnswer: { sim: 'b' }, default: '' })
265
+ })
266
+
267
+ it('handle inexistente não inventa ramificação nem derruba as existentes', () => {
268
+ const branching = node('a', { byAnswer: { sim: 'b' }, default: 'd' })
269
+
270
+ expect(clearConnection(branching, 'talvez').next).toEqual({ byAnswer: { sim: 'b', talvez: '' }, default: 'd' })
271
+ })
272
+
273
+ it('nó sem next algum não quebra', () => {
274
+ expect(clearConnection(node('a'), 'next').next).toBe('')
275
+ })
276
+ })