@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.28
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/chunk-DXPSPUWF.js +110 -0
- package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +75 -2
- package/dist/flows/index.js +176 -95
- package/dist/index.d.ts +37 -10
- package/dist/index.js +694 -297
- package/dist/preview/index.js +3 -3
- package/dist/styles.css +119 -6
- package/package.json +2 -2
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +3 -3
- package/src/AudioRecorderButton.tsx +3 -3
- package/src/AudioTranscription.tsx +4 -1
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +2 -2
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +3 -2
- package/src/ConversationRow.tsx +31 -7
- package/src/DocumentsLibrary.tsx +9 -4
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +2 -0
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +27 -8
- package/src/MessageComposer.tsx +11 -8
- package/src/RichMessageComposer.test.tsx +42 -12
- package/src/RichMessageComposer.tsx +216 -45
- 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 +164 -0
- package/src/WhatsAppMessageEditor.tsx +4 -4
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationWindow.ts +7 -5
- package/src/documents/DocumentsWorkspace.tsx +10 -6
- package/src/flows/FlowGroupHeader.tsx +2 -2
- package/src/flows/FlowMapNode.tsx +2 -1
- package/src/flows/FlowNodeCard.tsx +3 -3
- package/src/flows/FlowNodePanel.tsx +6 -5
- package/src/flows/FlowPalette.tsx +7 -1
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowsWorkspace.tsx +69 -102
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +1 -1
- package/src/flows/index.ts +16 -0
- package/src/flows/labels.ts +4 -0
- package/src/flows/workspaceContract.test.ts +95 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +1 -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 +5 -1
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationSimulatorPanel.tsx +1 -1
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/settings/MessagesWorkspace.tsx +3 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
- package/src/settings/TranscriptionSettingsForm.tsx +1 -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.tsx +2 -0
- package/src/styles.css +100 -4
- package/src/workspace/BulkTemplateModal.tsx +4 -2
- package/src/workspace/ConversationPane.tsx +49 -19
- package/src/workspace/ConversationsInboxList.tsx +17 -8
- package/src/workspace/ConversationsWorkspace.tsx +24 -16
- package/src/workspace/useConversationsInbox.ts +5 -2
|
@@ -24,6 +24,16 @@ import { FlowNodePanel } from './FlowNodePanel'
|
|
|
24
24
|
import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
|
|
25
25
|
import { FlowMapCanvas } from './FlowMapCanvas'
|
|
26
26
|
import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
|
|
27
|
+
// Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
|
|
28
|
+
// erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
|
|
29
|
+
import {
|
|
30
|
+
applyConnection,
|
|
31
|
+
mergedFlowKeysFrom,
|
|
32
|
+
namespaceNodeId,
|
|
33
|
+
parseNamespacedId,
|
|
34
|
+
removeNodeAndCleanRefs,
|
|
35
|
+
resolveConnection,
|
|
36
|
+
} from './flowEditorOps'
|
|
27
37
|
import {
|
|
28
38
|
computeAutoLayout,
|
|
29
39
|
slugifyNodeId,
|
|
@@ -40,6 +50,7 @@ import {
|
|
|
40
50
|
type FlowNodeData,
|
|
41
51
|
type GraphIssue,
|
|
42
52
|
} from './flowGraph'
|
|
53
|
+
import { TooltipLayer } from '../Tooltip'
|
|
43
54
|
|
|
44
55
|
const RF_NODE_TYPES = {
|
|
45
56
|
...flowNodeTypes,
|
|
@@ -118,20 +129,6 @@ function portalNodeId(sourceId: string, target: string): string {
|
|
|
118
129
|
return `__portal__${sourceId}__${target}`
|
|
119
130
|
}
|
|
120
131
|
|
|
121
|
-
// Namespacing de ids: com fusão editável, nós de fluxos diferentes convivem no mesmo canvas
|
|
122
|
-
// React Flow, que exige ids únicos globalmente — "flowKey::nodeId" evita colisão entre fluxos
|
|
123
|
-
// que reutilizem o mesmo id de nó (ex.: vários fluxos com um nó "root").
|
|
124
|
-
function ns(flowKey: string, nodeId: string): string {
|
|
125
|
-
return `${flowKey}${NS_SEP}${nodeId}`
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function parseNs(id: string): { flowKey: string; nodeId: string } {
|
|
129
|
-
const index = id.indexOf(NS_SEP)
|
|
130
|
-
return index === -1
|
|
131
|
-
? { flowKey: '', nodeId: id }
|
|
132
|
-
: { flowKey: id.slice(0, index), nodeId: id.slice(index + NS_SEP.length) }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
132
|
// Um fluxo aberto no canvas de detalhe — o primeiro da lista é o "primário" (dono da paleta,
|
|
136
133
|
// organizar, publicar e excluir-fluxo); os demais chegaram por fusão editável (clique num portal)
|
|
137
134
|
// e ficam com um cabeçalho flutuante pra focar neles sozinhos ou fechar.
|
|
@@ -141,17 +138,9 @@ type OpenFlow = { key: string; offset: { x: number; y: number } }
|
|
|
141
138
|
// fluxo já traz junto tudo que ele referencia (e o que essas referências referenciam), sem
|
|
142
139
|
// precisar clicar em cada portal manualmente.
|
|
143
140
|
function autoMergeAll(rootKey: string, graphsSource: Record<string, FlowGraphData>): OpenFlow[] {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const key = queue.shift()!
|
|
148
|
-
if (visited.has(key) || !graphsSource[key]) continue
|
|
149
|
-
visited.add(key)
|
|
150
|
-
for (const target of crossFlowTargetsOf(graphsSource[key]!)) {
|
|
151
|
-
if (!visited.has(target) && graphsSource[target]) queue.push(target)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return [...visited].map((key) => ({ key, offset: { x: 0, y: 0 } }))
|
|
141
|
+
// O fecho em si vem de `flowEditorOps`, que é onde ele tem teste — inclusive para o caso mais banal
|
|
142
|
+
// que existe, o fluxo que volta a si mesmo.
|
|
143
|
+
return mergedFlowKeysFrom(rootKey, graphsSource).map((key) => ({ key, offset: { x: 0, y: 0 } }))
|
|
155
144
|
}
|
|
156
145
|
|
|
157
146
|
// Layout único pra TODOS os nós de TODOS os fluxos abertos juntos — ao contrário de posicionar
|
|
@@ -170,7 +159,7 @@ function computeMergedLayout(
|
|
|
170
159
|
for (const { key } of openFlows) {
|
|
171
160
|
const graph = workingGraphs[key]
|
|
172
161
|
if (!graph) continue
|
|
173
|
-
for (const node of Object.values(graph.nodes)) nodeByNsId.set(
|
|
162
|
+
for (const node of Object.values(graph.nodes)) nodeByNsId.set(namespaceNodeId(key, node.id), node)
|
|
174
163
|
}
|
|
175
164
|
|
|
176
165
|
function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
|
|
@@ -179,9 +168,9 @@ function computeMergedLayout(
|
|
|
179
168
|
if (isCrossFlowTarget(target)) {
|
|
180
169
|
const targetFlowKey = crossFlowKey(target)
|
|
181
170
|
const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : undefined
|
|
182
|
-
if (targetGraph) result.push(
|
|
171
|
+
if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
|
|
183
172
|
} else if (workingGraphs[flowKey]?.nodes[target]) {
|
|
184
|
-
result.push(
|
|
173
|
+
result.push(namespaceNodeId(flowKey, target))
|
|
185
174
|
}
|
|
186
175
|
}
|
|
187
176
|
return result
|
|
@@ -189,7 +178,7 @@ function computeMergedLayout(
|
|
|
189
178
|
|
|
190
179
|
const rank = new Map<string, number>()
|
|
191
180
|
const primaryGraph = workingGraphs[primaryFlowKey]
|
|
192
|
-
const rootId = primaryGraph ?
|
|
181
|
+
const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
|
|
193
182
|
if (rootId && nodeByNsId.has(rootId)) {
|
|
194
183
|
rank.set(rootId, 0)
|
|
195
184
|
const queue = [rootId]
|
|
@@ -197,7 +186,7 @@ function computeMergedLayout(
|
|
|
197
186
|
const id = queue.shift()!
|
|
198
187
|
const node = nodeByNsId.get(id)
|
|
199
188
|
if (!node) continue
|
|
200
|
-
for (const nextId of forwardEdges(
|
|
189
|
+
for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
|
|
201
190
|
if (!rank.has(nextId)) {
|
|
202
191
|
rank.set(nextId, rank.get(id)! + 1)
|
|
203
192
|
queue.push(nextId)
|
|
@@ -269,6 +258,12 @@ function buildAllEdges(
|
|
|
269
258
|
for (const [id, node] of Object.entries(graph.nodes)) {
|
|
270
259
|
const isLive = (liveCounts[id] ?? 0) > 0
|
|
271
260
|
for (const { target, optionId, isDefault } of targetsOf(node)) {
|
|
261
|
+
// Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
|
|
262
|
+
// `targetsOf` devolve o `default` mesmo em branco. Sem esta linha a aresta ia para um nó que
|
|
263
|
+
// não existe, o React Flow a descartava em silêncio, e a opção parecia ligada sem estar — a
|
|
264
|
+
// conversa do cliente para ali e quem editou não vê nada de errado.
|
|
265
|
+
if (!target) continue
|
|
266
|
+
|
|
272
267
|
const crossFlow = isCrossFlowTarget(target)
|
|
273
268
|
let targetFlowKey = flowKey
|
|
274
269
|
let rawTargetId = target
|
|
@@ -282,8 +277,8 @@ function buildAllEdges(
|
|
|
282
277
|
rawTargetId = portalNodeId(id, target)
|
|
283
278
|
}
|
|
284
279
|
}
|
|
285
|
-
const source =
|
|
286
|
-
const edgeTarget =
|
|
280
|
+
const source = namespaceNodeId(flowKey, id)
|
|
281
|
+
const edgeTarget = namespaceNodeId(targetFlowKey, rawTargetId)
|
|
287
282
|
|
|
288
283
|
if (optionId === undefined && !isDefault) {
|
|
289
284
|
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR
|
|
@@ -357,35 +352,6 @@ function newNodeFromSpec(spec: NewNodeSpec, existingIds: Set<string>): FlowNodeD
|
|
|
357
352
|
return { id, type: 'action', actionKind: spec.actionKind }
|
|
358
353
|
}
|
|
359
354
|
|
|
360
|
-
// Remove referências ao nó excluído: destinos apontando para ele viram '' (destino vazio),
|
|
361
|
-
// para a validação sinalizar claramente em vez de manter uma string órfã silenciosa.
|
|
362
|
-
function removeNodeAndCleanRefs(
|
|
363
|
-
nodes: Record<string, FlowNodeData>,
|
|
364
|
-
nodeId: string,
|
|
365
|
-
): Record<string, FlowNodeData> {
|
|
366
|
-
const { [nodeId]: _removed, ...rest } = nodes
|
|
367
|
-
return Object.fromEntries(
|
|
368
|
-
Object.entries(rest).map(([id, node]) => {
|
|
369
|
-
if (!node.next) return [id, node]
|
|
370
|
-
if (typeof node.next === 'string') {
|
|
371
|
-
return [id, node.next === nodeId ? { ...node, next: undefined } : node]
|
|
372
|
-
}
|
|
373
|
-
return [
|
|
374
|
-
id,
|
|
375
|
-
{
|
|
376
|
-
...node,
|
|
377
|
-
next: {
|
|
378
|
-
byAnswer: Object.fromEntries(
|
|
379
|
-
Object.entries(node.next.byAnswer).map(([key, value]) => [key, value === nodeId ? '' : value]),
|
|
380
|
-
),
|
|
381
|
-
default: node.next.default === nodeId ? '' : node.next.default,
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
]
|
|
385
|
-
}),
|
|
386
|
-
)
|
|
387
|
-
}
|
|
388
|
-
|
|
389
355
|
function extractErrorMessage(error: unknown): string | undefined {
|
|
390
356
|
if (error instanceof Error) return error.message
|
|
391
357
|
return undefined
|
|
@@ -637,7 +603,7 @@ export function FlowsWorkspace({
|
|
|
637
603
|
|
|
638
604
|
function resolvePosition(nodeId: string): { x: number; y: number } {
|
|
639
605
|
if (mergedPositions) {
|
|
640
|
-
const nsId =
|
|
606
|
+
const nsId = namespaceNodeId(flowKey, nodeId)
|
|
641
607
|
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
|
|
642
608
|
}
|
|
643
609
|
const local = graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
|
|
@@ -647,7 +613,7 @@ export function FlowsWorkspace({
|
|
|
647
613
|
for (const node of Object.values(graph.nodes)) {
|
|
648
614
|
const position = resolvePosition(node.id)
|
|
649
615
|
allNodes.push({
|
|
650
|
-
id:
|
|
616
|
+
id: namespaceNodeId(flowKey, node.id),
|
|
651
617
|
type: 'flowNode',
|
|
652
618
|
position,
|
|
653
619
|
draggable: true,
|
|
@@ -672,7 +638,7 @@ export function FlowsWorkspace({
|
|
|
672
638
|
const targetFlowKey = crossFlowKey(target)
|
|
673
639
|
if (openFlows.some((flow) => flow.key === targetFlowKey)) return
|
|
674
640
|
allNodes.push({
|
|
675
|
-
id:
|
|
641
|
+
id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
|
|
676
642
|
type: 'flowPortal',
|
|
677
643
|
draggable: false,
|
|
678
644
|
selectable: false,
|
|
@@ -696,7 +662,7 @@ export function FlowsWorkspace({
|
|
|
696
662
|
const minY = Math.min(...positions.map((point) => point.y))
|
|
697
663
|
const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]!)
|
|
698
664
|
allNodes.push({
|
|
699
|
-
id:
|
|
665
|
+
id: namespaceNodeId(flowKey, `__chain__${chain.actionNodeId}`),
|
|
700
666
|
type: 'flowGroupFrame',
|
|
701
667
|
draggable: false,
|
|
702
668
|
selectable: false,
|
|
@@ -717,7 +683,7 @@ export function FlowsWorkspace({
|
|
|
717
683
|
if (!isPrimary) {
|
|
718
684
|
const startPosition = resolvePosition(graph.startNodeId)
|
|
719
685
|
allNodes.push({
|
|
720
|
-
id:
|
|
686
|
+
id: namespaceNodeId(flowKey, '__group_header__'),
|
|
721
687
|
type: 'flowGroupHeader',
|
|
722
688
|
draggable: false,
|
|
723
689
|
selectable: false,
|
|
@@ -777,10 +743,21 @@ export function FlowsWorkspace({
|
|
|
777
743
|
const onNodeDragStop = useCallback(
|
|
778
744
|
(_event: unknown, node: Node) => {
|
|
779
745
|
renderedPositionsRef.current.set(node.id, node.position)
|
|
780
|
-
const { flowKey, nodeId } =
|
|
746
|
+
const { flowKey, nodeId } = parseNamespacedId(node.id)
|
|
781
747
|
const openFlow = openFlows.find((flow) => flow.key === flowKey)
|
|
782
748
|
if (!openFlow) return
|
|
783
|
-
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* O deslocamento só entra na conta quando ele foi usado para DESENHAR.
|
|
752
|
+
*
|
|
753
|
+
* Com mais de um fluxo aberto o layout mesclado posiciona tudo em coordenadas absolutas e
|
|
754
|
+
* ignora `offset` — subtrair aqui gravava no grafo uma posição que nunca foi a do card. O
|
|
755
|
+
* sintoma aparecia só depois: recarregar a tela e achar o nó deslocado sozinho.
|
|
756
|
+
*/
|
|
757
|
+
const drawnWithOffset = openFlows.length === 1
|
|
758
|
+
const localPosition = drawnWithOffset
|
|
759
|
+
? { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
|
|
760
|
+
: node.position
|
|
784
761
|
updateFlow(flowKey, (graph) =>
|
|
785
762
|
graph.nodes[nodeId]
|
|
786
763
|
? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: localPosition } } }
|
|
@@ -792,36 +769,18 @@ export function FlowsWorkspace({
|
|
|
792
769
|
|
|
793
770
|
const onConnect = useCallback(
|
|
794
771
|
(connection: Connection) => {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
// pro início). Conectar num nó do meio de outro fluxo é ignorado silenciosamente.
|
|
803
|
-
let targetValue: string
|
|
804
|
-
if (targetRef.flowKey === sourceRef.flowKey) {
|
|
805
|
-
targetValue = targetRef.nodeId
|
|
806
|
-
} else {
|
|
807
|
-
const targetGraph = workingGraphs[targetRef.flowKey]
|
|
808
|
-
if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return
|
|
809
|
-
targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
|
|
810
|
-
}
|
|
772
|
+
// Traduzir o arraste e aplicar no nó são as duas decisões que mandam a conversa do cliente
|
|
773
|
+
// para o lugar certo ou errado, sem erro no meio — por isso vivem em `flowEditorOps`, testadas.
|
|
774
|
+
const resolved = resolveConnection({
|
|
775
|
+
connection: { source: connection.source, target: connection.target, sourceHandle: connection.sourceHandle },
|
|
776
|
+
graphs: workingGraphs,
|
|
777
|
+
})
|
|
778
|
+
if (!resolved) return
|
|
811
779
|
|
|
812
|
-
updateFlow(
|
|
813
|
-
const node = graph.nodes[
|
|
780
|
+
updateFlow(resolved.flowKey, (graph) => {
|
|
781
|
+
const node = graph.nodes[resolved.nodeId]
|
|
814
782
|
if (!node) return graph
|
|
815
|
-
|
|
816
|
-
const currentByAnswer = typeof node.next === 'object' && node.next ? node.next.byAnswer : {}
|
|
817
|
-
const currentDefault = typeof node.next === 'object' && node.next ? node.next.default : ''
|
|
818
|
-
const updatedNode: FlowNodeData =
|
|
819
|
-
handle === 'next'
|
|
820
|
-
? { ...node, next: targetValue }
|
|
821
|
-
: handle === '__default'
|
|
822
|
-
? { ...node, next: { byAnswer: currentByAnswer, default: targetValue } }
|
|
823
|
-
: { ...node, next: { byAnswer: { ...currentByAnswer, [handle]: targetValue }, default: currentDefault } }
|
|
824
|
-
return { ...graph, nodes: { ...graph.nodes, [sourceRef.nodeId]: updatedNode } }
|
|
783
|
+
return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
|
|
825
784
|
})
|
|
826
785
|
},
|
|
827
786
|
[workingGraphs, updateFlow],
|
|
@@ -834,7 +793,7 @@ export function FlowsWorkspace({
|
|
|
834
793
|
newNode.position = { x: 0, y: maxY + 170 }
|
|
835
794
|
updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }))
|
|
836
795
|
setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id })
|
|
837
|
-
setPendingFocusNodeId(
|
|
796
|
+
setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
|
|
838
797
|
}
|
|
839
798
|
|
|
840
799
|
function handleNodePanelChange(updated: FlowNodeData) {
|
|
@@ -862,7 +821,7 @@ export function FlowsWorkspace({
|
|
|
862
821
|
...graph,
|
|
863
822
|
nodes: Object.fromEntries(
|
|
864
823
|
Object.entries(graph.nodes).map(([id, node]) => {
|
|
865
|
-
const position = positions.get(
|
|
824
|
+
const position = positions.get(namespaceNodeId(key, id))
|
|
866
825
|
return [
|
|
867
826
|
id,
|
|
868
827
|
position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node,
|
|
@@ -966,6 +925,7 @@ export function FlowsWorkspace({
|
|
|
966
925
|
|
|
967
926
|
return (
|
|
968
927
|
<div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
|
|
928
|
+
<TooltipLayer />
|
|
969
929
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
970
930
|
<div>
|
|
971
931
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
|
|
@@ -977,6 +937,7 @@ export function FlowsWorkspace({
|
|
|
977
937
|
<span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
|
|
978
938
|
)}
|
|
979
939
|
<button
|
|
940
|
+
data-cv-tooltip={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap} aria-label={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
|
|
980
941
|
type="button"
|
|
981
942
|
onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
|
|
982
943
|
className={OUTLINE_BUTTON}
|
|
@@ -990,7 +951,7 @@ export function FlowsWorkspace({
|
|
|
990
951
|
type="button"
|
|
991
952
|
onClick={handleOrganize}
|
|
992
953
|
className={OUTLINE_BUTTON}
|
|
993
|
-
|
|
954
|
+
data-cv-tooltip={labels.workspace.organizeTooltip} aria-label={labels.workspace.organizeTooltip}
|
|
994
955
|
disabled={!primaryGraph}
|
|
995
956
|
>
|
|
996
957
|
<LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
|
|
@@ -999,12 +960,13 @@ export function FlowsWorkspace({
|
|
|
999
960
|
type="button"
|
|
1000
961
|
onClick={handleDiscardChanges}
|
|
1001
962
|
className={OUTLINE_BUTTON}
|
|
1002
|
-
|
|
963
|
+
data-cv-tooltip={labels.workspace.discardTooltip} aria-label={labels.workspace.discardTooltip}
|
|
1003
964
|
disabled={!isDirty || saveState === 'saving'}
|
|
1004
965
|
>
|
|
1005
966
|
<Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
|
|
1006
967
|
</button>
|
|
1007
968
|
<button
|
|
969
|
+
data-cv-tooltip={labels.workspace.saveGraph} aria-label={labels.workspace.saveGraph}
|
|
1008
970
|
type="button"
|
|
1009
971
|
onClick={() => void handlePublish()}
|
|
1010
972
|
className={PRIMARY_BUTTON}
|
|
@@ -1024,6 +986,7 @@ export function FlowsWorkspace({
|
|
|
1024
986
|
{graphs &&
|
|
1025
987
|
Object.values(graphs).map((graph) => (
|
|
1026
988
|
<button
|
|
989
|
+
data-cv-tooltip={graph.label} aria-label={graph.label}
|
|
1027
990
|
key={graph.key}
|
|
1028
991
|
type="button"
|
|
1029
992
|
onClick={() => focusFlow(graph.key)}
|
|
@@ -1038,6 +1001,7 @@ export function FlowsWorkspace({
|
|
|
1038
1001
|
))}
|
|
1039
1002
|
{canCreateFlow && (
|
|
1040
1003
|
<button
|
|
1004
|
+
data-cv-tooltip={labels.flowManager.newFlow} aria-label={labels.flowManager.newFlow}
|
|
1041
1005
|
type="button"
|
|
1042
1006
|
onClick={() => {
|
|
1043
1007
|
setFlowMutationState({ pending: false })
|
|
@@ -1059,6 +1023,7 @@ export function FlowsWorkspace({
|
|
|
1059
1023
|
)}
|
|
1060
1024
|
{primaryGraph && canDeleteFlow && (
|
|
1061
1025
|
<button
|
|
1026
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
1062
1027
|
type="button"
|
|
1063
1028
|
onClick={() => {
|
|
1064
1029
|
setFlowMutationState({ pending: false })
|
|
@@ -1186,10 +1151,11 @@ export function FlowsWorkspace({
|
|
|
1186
1151
|
{flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
|
|
1187
1152
|
</div>
|
|
1188
1153
|
<div className="flex justify-end gap-2 mt-4">
|
|
1189
|
-
<button type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
|
|
1154
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
|
|
1190
1155
|
{labels.nodePanel.cancel}
|
|
1191
1156
|
</button>
|
|
1192
1157
|
<button
|
|
1158
|
+
data-cv-tooltip={labels.flowManager.create} aria-label={labels.flowManager.create}
|
|
1193
1159
|
type="button"
|
|
1194
1160
|
className={PRIMARY_BUTTON}
|
|
1195
1161
|
onClick={() => void handleCreateFlow()}
|
|
@@ -1208,10 +1174,11 @@ export function FlowsWorkspace({
|
|
|
1208
1174
|
</p>
|
|
1209
1175
|
{flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
|
|
1210
1176
|
<div className="flex justify-end gap-2 mt-4">
|
|
1211
|
-
<button type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
|
|
1177
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
|
|
1212
1178
|
{labels.nodePanel.cancel}
|
|
1213
1179
|
</button>
|
|
1214
1180
|
<button
|
|
1181
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
1215
1182
|
type="button"
|
|
1216
1183
|
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
|
|
1217
1184
|
onClick={() => void handleDeleteFlow()}
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
isGraphDirty,
|
|
19
|
+
mergedFlowKeysFrom,
|
|
20
|
+
namespaceNodeId,
|
|
21
|
+
parseNamespacedId,
|
|
22
|
+
removeNodeAndCleanRefs,
|
|
23
|
+
resolveConnection,
|
|
24
|
+
} from './flowEditorOps'
|
|
25
|
+
|
|
26
|
+
/** Nó mínimo VÁLIDO pelo contrato — sem `as`, para o teste falhar se a forma do nó mudar. */
|
|
27
|
+
function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
|
|
28
|
+
return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Grafo mínimo VÁLIDO. `key` e `version` são obrigatórios no contrato, e sem `as` o teste passa a
|
|
33
|
+
* quebrar quando o contrato ganhar campo novo — que é exatamente o drift que o comentário do
|
|
34
|
+
* `flowGraph.ts` registra ter acontecido antes com o `version`.
|
|
35
|
+
*/
|
|
36
|
+
function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
|
|
37
|
+
return {
|
|
38
|
+
key: params.key,
|
|
39
|
+
label: params.key,
|
|
40
|
+
version: 1,
|
|
41
|
+
startNodeId: params.start,
|
|
42
|
+
nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('id no canvas mesclado', () => {
|
|
47
|
+
it('vai e volta', () => {
|
|
48
|
+
expect(parseNamespacedId(namespaceNodeId('menu', 'inicio'))).toEqual({ flowKey: 'menu', nodeId: 'inicio' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('nó com separador no próprio id não parte errado', () => {
|
|
52
|
+
// O separador aparece no PRIMEIRO índice: um id que contenha `::` continua íntegro.
|
|
53
|
+
expect(parseNamespacedId('menu::passo::dois')).toEqual({ flowKey: 'menu', nodeId: 'passo::dois' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('id sem prefixo devolve fluxo vazio, em vez de adivinhar', () => {
|
|
57
|
+
expect(parseNamespacedId('inicio')).toEqual({ flowKey: '', nodeId: 'inicio' })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('apagar nó limpa quem apontava para ele', () => {
|
|
62
|
+
it('saída única que ia para o apagado fica vazia, não pendurada', () => {
|
|
63
|
+
const nodes = { a: node('a', 'b'), b: node('b') }
|
|
64
|
+
|
|
65
|
+
const result = removeNodeAndCleanRefs(nodes, 'b')
|
|
66
|
+
|
|
67
|
+
expect(Object.keys(result)).toEqual(['a'])
|
|
68
|
+
// Vazio e não `'b'`: aresta para nó inexistente trava a conversa no motor do bot.
|
|
69
|
+
expect(result.a?.next).toBe('')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ramificação perde o destino mas MANTÉM a opção', () => {
|
|
73
|
+
const nodes = {
|
|
74
|
+
pergunta: node('pergunta', { byAnswer: { sim: 'aprovado', nao: 'recusado' }, default: 'aprovado' }),
|
|
75
|
+
aprovado: node('aprovado'),
|
|
76
|
+
recusado: node('recusado'),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = removeNodeAndCleanRefs(nodes, 'aprovado')
|
|
80
|
+
const next = result.pergunta?.next as { byAnswer: Record<string, string>; default: string }
|
|
81
|
+
|
|
82
|
+
// A chave `sim` continua existindo com destino vazio: removê-la esconderia da tela que a opção
|
|
83
|
+
// existe e não leva a lugar nenhum, e alguém publicaria assim.
|
|
84
|
+
expect(next.byAnswer).toEqual({ sim: '', nao: 'recusado' })
|
|
85
|
+
expect(next.default).toBe('')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('não mexe em quem não apontava para o apagado', () => {
|
|
89
|
+
const nodes = { a: node('a', 'c'), b: node('b'), c: node('c') }
|
|
90
|
+
|
|
91
|
+
expect(removeNodeAndCleanRefs(nodes, 'b').a?.next).toBe('c')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('nó sem next segue sem next, e não ganha um vazio', () => {
|
|
95
|
+
expect(removeNodeAndCleanRefs({ a: node('a'), b: node('b') }, 'b').a?.next).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('fecho transitivo dos fluxos abertos juntos', () => {
|
|
100
|
+
const graphs = {
|
|
101
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'flow:simulacao')] }),
|
|
102
|
+
simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1', 'flow:documentos')] }),
|
|
103
|
+
documentos: graph({ key: 'documentos', start: 'd1', nodes: [node('d1')] }),
|
|
104
|
+
orfao: graph({ key: 'orfao', start: 'o1', nodes: [node('o1')] }),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('alcança em profundidade e ignora quem ninguém referencia', () => {
|
|
108
|
+
expect([...mergedFlowKeysFrom('menu', graphs)].sort()).toEqual(['documentos', 'menu', 'simulacao'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('ciclo não estoura — menu que volta ao menu é o caso mais comum que existe', () => {
|
|
112
|
+
const cyclic = {
|
|
113
|
+
menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:sub')] }),
|
|
114
|
+
sub: graph({ key: 'sub', start: 's', nodes: [node('s', 'flow:menu')] }),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expect([...mergedFlowKeysFrom('menu', cyclic)].sort()).toEqual(['menu', 'sub'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('referência para fluxo que não existe é ignorada em vez de abrir aba vazia', () => {
|
|
121
|
+
const dangling = { menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:apagado')] }) }
|
|
122
|
+
|
|
123
|
+
expect(mergedFlowKeysFrom('menu', dangling)).toEqual(['menu'])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('conectar aresta', () => {
|
|
128
|
+
const graphs = {
|
|
129
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio'), node('meio')] }),
|
|
130
|
+
simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1'), node('s2')] }),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('dentro do mesmo fluxo grava o id local', () => {
|
|
134
|
+
const resolved = resolveConnection({
|
|
135
|
+
connection: { source: 'menu::inicio', target: 'menu::meio' },
|
|
136
|
+
graphs,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(resolved).toMatchObject({ flowKey: 'menu', nodeId: 'inicio', targetValue: 'meio' })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('para o nó INICIAL de outro fluxo grava o salto `flow:`', () => {
|
|
143
|
+
const resolved = resolveConnection({
|
|
144
|
+
connection: { source: 'menu::inicio', target: 'simulacao::s1' },
|
|
145
|
+
graphs,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(resolved?.targetValue).toBe('flow:simulacao')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('para o MEIO de outro fluxo é RECUSADO — o motor não sabe pular para lá', () => {
|
|
152
|
+
// Recusa silenciosa é melhor que gravar um salto que o bot ignora em produção: o sintoma seria
|
|
153
|
+
// conversa parada, longe de quem editou.
|
|
154
|
+
const resolved = resolveConnection({
|
|
155
|
+
connection: { source: 'menu::inicio', target: 'simulacao::s2' },
|
|
156
|
+
graphs,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(resolved).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('nó em si mesmo é recusado', () => {
|
|
163
|
+
expect(
|
|
164
|
+
resolveConnection({ connection: { source: 'menu::inicio', target: 'menu::inicio' }, graphs }),
|
|
165
|
+
).toBeUndefined()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('fluxo de destino inexistente é recusado', () => {
|
|
169
|
+
expect(resolveConnection({ connection: { source: 'menu::inicio', target: 'sumiu::x' }, graphs })).toBeUndefined()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('aplicar a conexão no nó', () => {
|
|
174
|
+
it('handle `next` vira saída única', () => {
|
|
175
|
+
const result = applyConnection(node('a'), { flowKey: 'menu', nodeId: 'a', handle: 'next', targetValue: 'b' })
|
|
176
|
+
|
|
177
|
+
expect(result.next).toBe('b')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('handle de resposta acrescenta no byAnswer e PRESERVA o resto', () => {
|
|
181
|
+
const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
|
|
182
|
+
|
|
183
|
+
const result = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: 'nao', targetValue: 'z' })
|
|
184
|
+
const next = result.next as { byAnswer: Record<string, string>; default: string }
|
|
185
|
+
|
|
186
|
+
expect(next.byAnswer).toEqual({ sim: 'x', nao: 'z' })
|
|
187
|
+
// O default não pode ser perdido ao ligar um ramo: seria mudança silenciosa de comportamento.
|
|
188
|
+
expect(next.default).toBe('y')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('handle `__default` troca só o default', () => {
|
|
192
|
+
const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
|
|
193
|
+
|
|
194
|
+
const next = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: '__default', targetValue: 'z' })
|
|
195
|
+
.next as { byAnswer: Record<string, string>; default: string }
|
|
196
|
+
|
|
197
|
+
expect(next).toEqual({ byAnswer: { sim: 'x' }, default: 'z' })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('ramificar um nó que tinha saída única não carrega a antiga como default', () => {
|
|
201
|
+
// A saída única vira ramificação: o default nasce vazio, e a validação do grafo cobra. Herdar a
|
|
202
|
+
// antiga faria o ramo não configurado seguir para onde o nó ia antes, sem ninguém pedir.
|
|
203
|
+
const next = applyConnection(node('a', 'antigo'), {
|
|
204
|
+
flowKey: 'menu',
|
|
205
|
+
nodeId: 'a',
|
|
206
|
+
handle: 'sim',
|
|
207
|
+
targetValue: 'novo',
|
|
208
|
+
}).next as { byAnswer: Record<string, string>; default: string }
|
|
209
|
+
|
|
210
|
+
expect(next).toEqual({ byAnswer: { sim: 'novo' }, default: '' })
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('rascunho sujo', () => {
|
|
215
|
+
const published = graph({ key: 'menu', start: 'i', nodes: [node('i', 'b'), node('b')] })
|
|
216
|
+
|
|
217
|
+
it('igual ao publicado não está sujo', () => {
|
|
218
|
+
expect(isGraphDirty(structuredClone(published), published)).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('texto mudado está sujo', () => {
|
|
222
|
+
const working = structuredClone(published)
|
|
223
|
+
working.nodes.i!.question = 'outro'
|
|
224
|
+
|
|
225
|
+
expect(isGraphDirty(working, published)).toBe(true)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('posição mudada TAMBÉM está sujo — arrastar card é edição que se publica', () => {
|
|
229
|
+
const working = structuredClone(published)
|
|
230
|
+
working.nodes.i!.position = { x: 10, y: 20 }
|
|
231
|
+
|
|
232
|
+
expect(isGraphDirty(working, published)).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('sem rascunho ou sem publicado não está sujo, em vez de sujo por ausência', () => {
|
|
236
|
+
// Durante o carregamento os dois lados chegam separados; sujo aqui faria a tela pedir
|
|
237
|
+
// confirmação de descarte antes de o usuário ter tocado em nada.
|
|
238
|
+
expect(isGraphDirty(undefined, published)).toBe(false)
|
|
239
|
+
expect(isGraphDirty(published, undefined)).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|