@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- 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
|
+
})
|