@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.
Files changed (81) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +75 -2
  5. package/dist/flows/index.js +176 -95
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +2 -2
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/buildOutput.test.ts +79 -0
  40. package/src/composer.constant.ts +33 -0
  41. package/src/conversationWindow.ts +7 -5
  42. package/src/documents/DocumentsWorkspace.tsx +10 -6
  43. package/src/flows/FlowGroupHeader.tsx +2 -2
  44. package/src/flows/FlowMapNode.tsx +2 -1
  45. package/src/flows/FlowNodeCard.tsx +3 -3
  46. package/src/flows/FlowNodePanel.tsx +6 -5
  47. package/src/flows/FlowPalette.tsx +7 -1
  48. package/src/flows/FlowPortalNode.tsx +1 -1
  49. package/src/flows/FlowsWorkspace.tsx +69 -102
  50. package/src/flows/flowEditorOps.test.ts +241 -0
  51. package/src/flows/flowEditorOps.ts +177 -0
  52. package/src/flows/flowGraph.ts +1 -1
  53. package/src/flows/index.ts +16 -0
  54. package/src/flows/labels.ts +4 -0
  55. package/src/flows/workspaceContract.test.ts +95 -0
  56. package/src/hooks/useContainerWidth.ts +35 -0
  57. package/src/hooks/useConversationRealtime.ts +10 -8
  58. package/src/icon.constant.ts +12 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/composer-formatting.test.ts +78 -0
  61. package/src/lib/composer-formatting.ts +145 -0
  62. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  63. package/src/lib/whatsapp-formatting.tsx +28 -3
  64. package/src/listing/index.tsx +5 -1
  65. package/src/pagination.constant.ts +10 -0
  66. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  67. package/src/providers/ConversationsProvider.tsx +8 -6
  68. package/src/settings/MessagesWorkspace.tsx +3 -0
  69. package/src/settings/TopicsForm.tsx +2 -0
  70. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  71. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  72. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  73. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  74. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  75. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  76. package/src/styles.css +100 -4
  77. package/src/workspace/BulkTemplateModal.tsx +4 -2
  78. package/src/workspace/ConversationPane.tsx +49 -19
  79. package/src/workspace/ConversationsInboxList.tsx +17 -8
  80. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  81. 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
- const visited = new Set<string>()
145
- const queue = [rootKey]
146
- while (queue.length > 0) {
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(ns(key, node.id), node)
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(ns(targetFlowKey, targetGraph.startNodeId))
171
+ if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
183
172
  } else if (workingGraphs[flowKey]?.nodes[target]) {
184
- result.push(ns(flowKey, target))
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 ? ns(primaryFlowKey, primaryGraph.startNodeId) : undefined
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(parseNs(id).flowKey, node)) {
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 = ns(flowKey, id)
286
- const edgeTarget = ns(targetFlowKey, rawTargetId)
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 = ns(flowKey, nodeId)
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: ns(flowKey, node.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: ns(flowKey, portalNodeId(node.id, target)),
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: ns(flowKey, `__chain__${chain.actionNodeId}`),
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: ns(flowKey, '__group_header__'),
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 } = parseNs(node.id)
746
+ const { flowKey, nodeId } = parseNamespacedId(node.id)
781
747
  const openFlow = openFlows.find((flow) => flow.key === flowKey)
782
748
  if (!openFlow) return
783
- const localPosition = { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
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
- const { source, sourceHandle, target } = connection
796
- if (!source || !target || source === target) return
797
- const sourceRef = parseNs(source)
798
- const targetRef = parseNs(target)
799
-
800
- // Conectar num nó de outro fluxo só faz sentido se for o nó inicial dele — vira um salto
801
- // "flow:<key>" (o motor do bot não sabe pular pra um nó específico de outro fluxo, só
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(sourceRef.flowKey, (graph) => {
813
- const node = graph.nodes[sourceRef.nodeId]
780
+ updateFlow(resolved.flowKey, (graph) => {
781
+ const node = graph.nodes[resolved.nodeId]
814
782
  if (!node) return graph
815
- const handle = sourceHandle ?? 'next'
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(ns(primaryFlowKey, newNode.id))
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(ns(key, id))
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
- title={labels.workspace.organizeTooltip}
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
- title={labels.workspace.discardTooltip}
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
+ })