@adatechnology/conversations-ui 0.1.0-rc.39 → 0.1.0-rc.40
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 → chunk-DKPXKQGC.js} +1 -1
- package/dist/flows/index.d.ts +88 -5
- package/dist/flows/index.js +1104 -686
- package/dist/index.js +1 -1
- package/package.json +8 -7
- package/src/Tooltip.tsx +5 -2
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowNodeCard.tsx +188 -30
- package/src/flows/FlowNodePanel.tsx +5 -2
- package/src/flows/FlowPalette.tsx +105 -78
- package/src/flows/FlowsWorkspace.tsx +177 -7
- package/src/flows/flowCanvasModel.test.ts +145 -1
- package/src/flows/flowCanvasModel.ts +54 -44
- package/src/flows/flowEditorOps.test.ts +35 -0
- package/src/flows/flowEditorOps.ts +25 -0
- package/src/flows/flowGraph.ts +72 -47
- package/src/flows/index.ts +4 -1
- package/src/flows/labels.ts +39 -0
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.40",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^1.21.0",
|
|
33
33
|
"tailwind-merge": "^3.6.0",
|
|
34
|
-
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.
|
|
34
|
+
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.13"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
+
"@xyflow/react": "^12",
|
|
37
38
|
"react": "^18 || ^19",
|
|
38
|
-
"react-dom": "^18 || ^19"
|
|
39
|
-
"@xyflow/react": "^12"
|
|
39
|
+
"react-dom": "^18 || ^19"
|
|
40
40
|
},
|
|
41
41
|
"peerDependenciesMeta": {
|
|
42
42
|
"@xyflow/react": {
|
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/bun": "1.3.14",
|
|
48
|
-
"tsup": "^8.5.1",
|
|
49
|
-
"typescript": "^5.9.3",
|
|
50
48
|
"@types/react": "^18 || ^19",
|
|
51
|
-
"@types/react-dom": "^18 || ^19"
|
|
49
|
+
"@types/react-dom": "^18 || ^19",
|
|
50
|
+
"@xyflow/react": "^12",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
52
53
|
},
|
|
53
54
|
"scripts": {
|
|
54
55
|
"build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --clean --format esm --external react --external react-dom --external @xyflow/react",
|
package/src/Tooltip.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export function tooltipPositionOf({ targetRect, viewportWidth }: TooltipPosition
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function tooltipStateFor(target:
|
|
55
|
+
function tooltipStateFor(target: Element, text: string): TooltipState {
|
|
56
56
|
const position = tooltipPositionOf({
|
|
57
57
|
targetRect: target.getBoundingClientRect(),
|
|
58
58
|
viewportWidth: window.innerWidth,
|
|
@@ -102,8 +102,11 @@ export function TooltipLayer() {
|
|
|
102
102
|
function handleEnter(event: Event) {
|
|
103
103
|
const origin = event.target
|
|
104
104
|
if (!(origin instanceof Element)) return
|
|
105
|
+
// `Element`, e não `HTMLElement`: ícone é `<svg>`, que não é HTMLElement. Exigir HTMLElement
|
|
106
|
+
// fazia a dica de um ícone não só falhar — ela ENGOLIA a do card ao redor, porque o
|
|
107
|
+
// `closest` já tinha parado no svg e a busca não continuava para cima.
|
|
105
108
|
const target = origin.closest(`[${TOOLTIP_ATTRIBUTE}]`)
|
|
106
|
-
if (!
|
|
109
|
+
if (!target) {
|
|
107
110
|
hide()
|
|
108
111
|
return
|
|
109
112
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* A aresta do editor, com o botão de desligar em cima dela.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque desligar um fio não tinha caminho nenhum no canvas: as arestas são derivadas do
|
|
7
|
+
* grafo a cada render, então clicar e apertar Delete não tem onde guardar a seleção. O botão no
|
|
8
|
+
* hover resolve sem estado de seleção — e é o único lugar em que a pessoa já está olhando quando
|
|
9
|
+
* quer trocar o destino.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef, useState } from 'react'
|
|
13
|
+
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@xyflow/react'
|
|
14
|
+
import { X } from 'lucide-react'
|
|
15
|
+
|
|
16
|
+
/** Folga para o ponteiro sair do traço e alcançar o botão sem que ele desapareça no caminho. */
|
|
17
|
+
const HIDE_DELAY_MS = 320
|
|
18
|
+
|
|
19
|
+
export type FlowConnectionEdgeData = {
|
|
20
|
+
/** Ausente em aresta que não se desliga daqui (salto entre fluxos via portal). */
|
|
21
|
+
readonly onDisconnect?: (() => void) | undefined
|
|
22
|
+
readonly disconnectLabel: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function FlowConnectionEdge({
|
|
26
|
+
id,
|
|
27
|
+
sourceX,
|
|
28
|
+
sourceY,
|
|
29
|
+
targetX,
|
|
30
|
+
targetY,
|
|
31
|
+
sourcePosition,
|
|
32
|
+
targetPosition,
|
|
33
|
+
markerEnd,
|
|
34
|
+
style,
|
|
35
|
+
data,
|
|
36
|
+
interactionWidth,
|
|
37
|
+
}: EdgeProps) {
|
|
38
|
+
const [hovered, setHovered] = useState(false)
|
|
39
|
+
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
40
|
+
const { onDisconnect, disconnectLabel } = (data ?? {}) as FlowConnectionEdgeData
|
|
41
|
+
|
|
42
|
+
// Sair do traço não esconde o botão na hora: entre o fio e o botão há um vão de alguns pixels, e
|
|
43
|
+
// esconder no `mouseleave` seco fazia o alvo fugir do mouse a caminho dele.
|
|
44
|
+
function show() {
|
|
45
|
+
if (hideTimer.current) clearTimeout(hideTimer.current)
|
|
46
|
+
setHovered(true)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleHide() {
|
|
50
|
+
if (hideTimer.current) clearTimeout(hideTimer.current)
|
|
51
|
+
hideTimer.current = setTimeout(() => setHovered(false), HIDE_DELAY_MS)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
useEffect(() => () => clearTimeout(hideTimer.current), [])
|
|
55
|
+
|
|
56
|
+
const [path, labelX, labelY] = getBezierPath({
|
|
57
|
+
sourceX,
|
|
58
|
+
sourceY,
|
|
59
|
+
sourcePosition,
|
|
60
|
+
targetX,
|
|
61
|
+
targetY,
|
|
62
|
+
targetPosition,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} interactionWidth={interactionWidth ?? 20} />
|
|
68
|
+
|
|
69
|
+
{/* Faixa invisível larga por cima do traço: o traço tem ~1,5px e mirar nele com o mouse é
|
|
70
|
+
tarefa de precisão que ninguém deveria ter. */}
|
|
71
|
+
<path
|
|
72
|
+
d={path}
|
|
73
|
+
fill="none"
|
|
74
|
+
strokeWidth={22}
|
|
75
|
+
stroke="transparent"
|
|
76
|
+
className="react-flow__edge-interaction"
|
|
77
|
+
onMouseEnter={show}
|
|
78
|
+
onMouseLeave={scheduleHide}
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
{onDisconnect && hovered && (
|
|
82
|
+
<EdgeLabelRenderer>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
data-cv-tooltip={disconnectLabel}
|
|
86
|
+
aria-label={disconnectLabel}
|
|
87
|
+
onMouseEnter={show}
|
|
88
|
+
onMouseLeave={scheduleHide}
|
|
89
|
+
onClick={(event) => {
|
|
90
|
+
event.stopPropagation()
|
|
91
|
+
onDisconnect()
|
|
92
|
+
}}
|
|
93
|
+
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
|
|
94
|
+
className="nodrag nopan pointer-events-auto absolute flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 bg-white text-gray-500 shadow-sm hover:border-red-400 hover:text-red-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
95
|
+
>
|
|
96
|
+
<X size={12} strokeWidth={2.5} />
|
|
97
|
+
</button>
|
|
98
|
+
</EdgeLabelRenderer>
|
|
99
|
+
)}
|
|
100
|
+
</>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const flowEdgeTypes = { flowConnection: FlowConnectionEdge }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Legenda do canvas.
|
|
5
|
+
*
|
|
6
|
+
* O editor distingue os tipos de ligação só por cor e traço — opção escolhida, fallback, salto entre
|
|
7
|
+
* fluxos, conversa viva — e nada na tela dizia o que era o quê. Quem não desenhou o fluxo lia um
|
|
8
|
+
* emaranhado colorido; a cor sem chave não informa, decora.
|
|
9
|
+
*
|
|
10
|
+
* Os contornos e marcas do card entram na mesma chave: card solto, início do fluxo e o ícone de
|
|
11
|
+
* repetição são vocabulário do editor tanto quanto as cores dos fios.
|
|
12
|
+
*
|
|
13
|
+
* Recolhida por padrão: em canvas cheio, uma caixa fixa cobre card. Quem já conhece o vocabulário
|
|
14
|
+
* não paga o espaço, e quem não conhece está a um clique.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from 'react'
|
|
18
|
+
import { ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'
|
|
19
|
+
|
|
20
|
+
import type { FlowEditorLabels } from './labels'
|
|
21
|
+
import type { FlowNodeType } from './flowGraph'
|
|
22
|
+
|
|
23
|
+
export type FlowLegendEdgeSample = {
|
|
24
|
+
readonly color: string
|
|
25
|
+
readonly dash?: string | undefined
|
|
26
|
+
readonly label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FlowLegendProps = {
|
|
30
|
+
readonly labels: FlowEditorLabels
|
|
31
|
+
readonly edgeSamples: readonly FlowLegendEdgeSample[]
|
|
32
|
+
/** Classe de cor por tipo de card — a mesma que o `FlowNodeCard` usa, para a chave bater. */
|
|
33
|
+
readonly nodeSwatches: readonly { readonly type: FlowNodeType; readonly className: string }[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function EdgeSample({ sample }: { sample: FlowLegendEdgeSample }) {
|
|
37
|
+
return (
|
|
38
|
+
<li className="flex items-center gap-2">
|
|
39
|
+
<svg width="28" height="12" viewBox="0 0 28 12" aria-hidden="true" className="shrink-0">
|
|
40
|
+
<line
|
|
41
|
+
x1="2"
|
|
42
|
+
y1="6"
|
|
43
|
+
x2="26"
|
|
44
|
+
y2="6"
|
|
45
|
+
stroke={sample.color}
|
|
46
|
+
strokeWidth="1.75"
|
|
47
|
+
{...(sample.dash ? { strokeDasharray: sample.dash } : {})}
|
|
48
|
+
/>
|
|
49
|
+
</svg>
|
|
50
|
+
<span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">{sample.label}</span>
|
|
51
|
+
</li>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function FlowLegend({ labels, edgeSamples, nodeSwatches }: FlowLegendProps) {
|
|
56
|
+
const [open, setOpen] = useState(false)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="rounded-lg border border-gray-200 bg-white/95 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/95">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => setOpen((current) => !current)}
|
|
63
|
+
data-cv-tooltip={labels.legendPanel.title}
|
|
64
|
+
aria-label={labels.legendPanel.title}
|
|
65
|
+
aria-expanded={open}
|
|
66
|
+
className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
|
67
|
+
>
|
|
68
|
+
{labels.legendPanel.title}
|
|
69
|
+
{open ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
|
|
70
|
+
</button>
|
|
71
|
+
|
|
72
|
+
{open && (
|
|
73
|
+
<div className="max-h-72 w-64 overflow-y-auto border-t border-gray-100 px-2.5 py-2 dark:border-gray-700">
|
|
74
|
+
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
75
|
+
{labels.legendPanel.connections}
|
|
76
|
+
</p>
|
|
77
|
+
<ul className="space-y-1.5">
|
|
78
|
+
{edgeSamples.map((sample) => (
|
|
79
|
+
<EdgeSample key={sample.label} sample={sample} />
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
|
|
83
|
+
<p className="mb-1 mt-3 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
84
|
+
{labels.legendPanel.nodes}
|
|
85
|
+
</p>
|
|
86
|
+
<ul className="space-y-1.5">
|
|
87
|
+
{nodeSwatches.map((swatch) => (
|
|
88
|
+
<li key={swatch.type} className="flex items-center gap-2">
|
|
89
|
+
<span className={`h-3 w-5 shrink-0 rounded border-2 ${swatch.className}`} aria-hidden="true" />
|
|
90
|
+
<span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
|
|
91
|
+
{labels.legend[swatch.type]}
|
|
92
|
+
</span>
|
|
93
|
+
</li>
|
|
94
|
+
))}
|
|
95
|
+
<li className="flex items-center gap-2">
|
|
96
|
+
<span
|
|
97
|
+
className="h-3 w-5 shrink-0 rounded border-2 border-dashed border-amber-400"
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
/>
|
|
100
|
+
<span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
|
|
101
|
+
{labels.legendPanel.detached}
|
|
102
|
+
</span>
|
|
103
|
+
</li>
|
|
104
|
+
<li className="flex items-center gap-2">
|
|
105
|
+
<span className="flex h-3 w-5 shrink-0 items-center justify-center" aria-hidden="true">
|
|
106
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
|
107
|
+
</span>
|
|
108
|
+
<span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
|
|
109
|
+
{labels.legendPanel.startNode}
|
|
110
|
+
</span>
|
|
111
|
+
</li>
|
|
112
|
+
<li className="flex items-center gap-2">
|
|
113
|
+
<span className="flex h-3 w-5 shrink-0 items-center justify-center text-gray-400" aria-hidden="true">
|
|
114
|
+
<RotateCcw size={12} strokeWidth={2.5} />
|
|
115
|
+
</span>
|
|
116
|
+
<span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
|
|
117
|
+
{labels.legendPanel.selfLoop}
|
|
118
|
+
</span>
|
|
119
|
+
</li>
|
|
120
|
+
</ul>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
|
2
3
|
import {
|
|
3
4
|
MessageCircleQuestion,
|
|
5
|
+
Plus,
|
|
4
6
|
GitBranch,
|
|
5
7
|
Zap,
|
|
6
8
|
ListTree,
|
|
@@ -10,12 +12,21 @@ import {
|
|
|
10
12
|
Headset,
|
|
11
13
|
Clock3,
|
|
12
14
|
ShoppingBag,
|
|
15
|
+
RotateCcw,
|
|
13
16
|
type LucideIcon,
|
|
14
17
|
} from 'lucide-react'
|
|
15
18
|
import type { FlowEditorLabels } from './labels'
|
|
16
|
-
import type
|
|
19
|
+
import { PASS_THROUGH_ACTION_KINDS, type FlowNodeData, type GraphIssue } from './flowGraph'
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Diâmetro dos pontos de ligação.
|
|
23
|
+
*
|
|
24
|
+
* O padrão do react-flow tem ~6px, e mirar nele com o mouse é tarefa de precisão — some com a
|
|
25
|
+
* borda do card e não se lê como "puxe daqui". Vale para o alvo (topo) e para cada saída.
|
|
26
|
+
*/
|
|
27
|
+
const HANDLE_SIZE_PX = 14
|
|
28
|
+
|
|
29
|
+
export const NODE_TYPE_COLOR: Record<FlowNodeData['type'], string> = {
|
|
19
30
|
question: 'border-blue-300 bg-blue-50 dark:bg-blue-950/40 dark:border-blue-800',
|
|
20
31
|
entrada_choice: 'border-purple-300 bg-purple-50 dark:bg-purple-950/40 dark:border-purple-800',
|
|
21
32
|
action: 'border-orange-300 bg-orange-50 dark:bg-orange-950/40 dark:border-orange-800',
|
|
@@ -75,53 +86,161 @@ export type FlowNodeCardData = {
|
|
|
75
86
|
labels: FlowEditorLabels
|
|
76
87
|
actionKindIcons?: Record<string, LucideIcon>
|
|
77
88
|
onSelect: (id: string) => void
|
|
89
|
+
/**
|
|
90
|
+
* Criar o próximo nó já ligado nesta saída. Capacidade por ausência: sem o callback, o "+" não
|
|
91
|
+
* aparece — puxar o fio à mão continua funcionando igual.
|
|
92
|
+
*/
|
|
93
|
+
onQuickAdd?: ((params: { nodeId: string; handle: string; anchor: { x: number; y: number } }) => void) | undefined
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
// Uma linha por saída: nós de escolha (question+choice ou menu) ganham UMA linha por opção mais
|
|
81
97
|
// uma linha "caso contrário" — cada uma com seu próprio handle, arrastável pra uma conexão
|
|
82
98
|
// condicional distinta. Nós lineares (pergunta simples) têm uma única linha "Próximo". Ações são
|
|
83
99
|
// terminais (o motor do host nunca continua a partir de 'next' de uma ação) — sem linha.
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
type SourceRowSpec = { id: string; label: string; isDefault: boolean; isSelfLoop: boolean }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Quais saídas deste card voltam para ele mesmo.
|
|
104
|
+
*
|
|
105
|
+
* Sai do próprio nó porque é o único lugar que sabe: uma aresta de A para A não tem trajeto para
|
|
106
|
+
* desenhar, então quem mostra o comportamento é a linha de saída, com um ícone de repetição.
|
|
107
|
+
*/
|
|
108
|
+
function selfLoopHandles(node: FlowNodeData): Set<string> {
|
|
109
|
+
const loops = new Set<string>()
|
|
110
|
+
if (typeof node.next === 'string') {
|
|
111
|
+
if (node.next === node.id) loops.add('next')
|
|
112
|
+
return loops
|
|
113
|
+
}
|
|
114
|
+
if (!node.next) return loops
|
|
115
|
+
|
|
116
|
+
for (const [optionId, target] of Object.entries(node.next.byAnswer ?? {})) {
|
|
117
|
+
if (target === node.id) loops.add(optionId)
|
|
118
|
+
}
|
|
119
|
+
if (node.next.default === node.id) loops.add('__default')
|
|
120
|
+
|
|
121
|
+
return loops
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): SourceRowSpec[] {
|
|
125
|
+
// Ação de passagem tem saída; ação terminal, não — ver `PASS_THROUGH_ACTION_KINDS`.
|
|
126
|
+
if (node.type === 'action') {
|
|
127
|
+
return node.actionKind && PASS_THROUGH_ACTION_KINDS.includes(node.actionKind)
|
|
128
|
+
? [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false, isSelfLoop: false }]
|
|
129
|
+
: []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const loops = selfLoopHandles(node)
|
|
86
133
|
if (node.type === 'condition') {
|
|
87
134
|
return [
|
|
88
|
-
{ id: 'true', label: labels.nodePanel.conditionTrue, isDefault: false },
|
|
89
|
-
{ id: 'false', label: labels.nodePanel.conditionFalse, isDefault: false },
|
|
135
|
+
{ id: 'true', label: labels.nodePanel.conditionTrue, isDefault: false, isSelfLoop: loops.has('true') },
|
|
136
|
+
{ id: 'false', label: labels.nodePanel.conditionFalse, isDefault: false, isSelfLoop: loops.has('false') },
|
|
90
137
|
]
|
|
91
138
|
}
|
|
92
139
|
const isChoice = node.type === 'menu' || node.questionType === 'choice'
|
|
93
|
-
if (!isChoice)
|
|
140
|
+
if (!isChoice)
|
|
141
|
+
return [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false, isSelfLoop: loops.has('next') }]
|
|
94
142
|
const options = node.options ?? []
|
|
95
143
|
return [
|
|
96
|
-
...options.map(([id, label]) => ({ id, label, isDefault: false })),
|
|
97
|
-
{ id: '__default', label: labels.edgeFallbackLabel, isDefault: true },
|
|
144
|
+
...options.map(([id, label]) => ({ id, label, isDefault: false, isSelfLoop: loops.has(id) })),
|
|
145
|
+
{ id: '__default', label: labels.edgeFallbackLabel, isDefault: true, isSelfLoop: loops.has('__default') },
|
|
98
146
|
]
|
|
99
147
|
}
|
|
100
148
|
|
|
101
149
|
// Uma linha de saída, com seu próprio handle ancorado na borda direita da própria linha (não
|
|
102
150
|
// mais distribuído na borda inferior do card) — assim dá pra ler "opção → destino" sem seguir
|
|
103
151
|
// o fio até o label da ligação, que é justamente o que confundia num fluxo com muitos ramos.
|
|
104
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Uma saída do card: o rótulo, o ponto de onde se puxa o fio e o "+" que cria o próximo nó já
|
|
154
|
+
* ligado nele.
|
|
155
|
+
*
|
|
156
|
+
* Duas decisões que vieram de ver alguém usar:
|
|
157
|
+
*
|
|
158
|
+
* O "+" está **sempre visível**, esmaecido, e não aparece no hover. Aparecer no hover criava uma
|
|
159
|
+
* corrida contra o mouse: o botão fica à direita da linha, então o ponteiro atravessava o vão
|
|
160
|
+
* entre os dois, o hover caía e o botão sumia antes de ser alcançado.
|
|
161
|
+
*
|
|
162
|
+
* E a área que reage ao mouse **engloba o botão** (o `pr-9` do container), em vez de terminar na
|
|
163
|
+
* borda da linha — sem isso o vão continuaria existindo para o realce.
|
|
164
|
+
*/
|
|
165
|
+
function SourceRow({
|
|
166
|
+
label,
|
|
167
|
+
isDefault,
|
|
168
|
+
handleId,
|
|
169
|
+
addLabel,
|
|
170
|
+
isSelfLoop,
|
|
171
|
+
selfLoopLabel,
|
|
172
|
+
onQuickAdd,
|
|
173
|
+
}: {
|
|
174
|
+
label: string
|
|
175
|
+
isDefault: boolean
|
|
176
|
+
handleId: string
|
|
177
|
+
addLabel: string
|
|
178
|
+
isSelfLoop: boolean
|
|
179
|
+
selfLoopLabel: string
|
|
180
|
+
onQuickAdd?: ((params: { handle: string; anchor: { x: number; y: number } }) => void) | undefined
|
|
181
|
+
}) {
|
|
182
|
+
const [hovered, setHovered] = useState(false)
|
|
183
|
+
|
|
105
184
|
return (
|
|
106
|
-
<div
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
185
|
+
<div
|
|
186
|
+
className={`relative ${onQuickAdd ? 'pr-9' : ''}`}
|
|
187
|
+
onMouseEnter={() => setHovered(true)}
|
|
188
|
+
onMouseLeave={() => setHovered(false)}
|
|
189
|
+
>
|
|
190
|
+
<div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
|
|
191
|
+
<span
|
|
192
|
+
className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
|
|
193
|
+
>
|
|
194
|
+
{label}
|
|
195
|
+
</span>
|
|
196
|
+
{isSelfLoop && (
|
|
197
|
+
<RotateCcw
|
|
198
|
+
size={12}
|
|
199
|
+
strokeWidth={2.5}
|
|
200
|
+
data-cv-tooltip={selfLoopLabel}
|
|
201
|
+
aria-label={selfLoopLabel}
|
|
202
|
+
className="shrink-0 text-gray-400 dark:text-gray-500"
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
<Handle
|
|
206
|
+
type="source"
|
|
207
|
+
position={Position.Right}
|
|
208
|
+
id={handleId}
|
|
209
|
+
style={{
|
|
210
|
+
position: 'absolute',
|
|
211
|
+
right: -(HANDLE_SIZE_PX / 2 + 1),
|
|
212
|
+
top: '50%',
|
|
213
|
+
transform: 'translateY(-50%)',
|
|
214
|
+
width: HANDLE_SIZE_PX,
|
|
215
|
+
height: HANDLE_SIZE_PX,
|
|
216
|
+
}}
|
|
217
|
+
className={`!border-2 !border-white dark:!border-gray-800 ${isDefault ? '!bg-gray-400 dark:!bg-gray-500' : '!bg-purple-500'}`}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
{onQuickAdd && (
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
data-cv-tooltip={addLabel}
|
|
224
|
+
aria-label={addLabel}
|
|
225
|
+
onClick={(event) => {
|
|
226
|
+
event.stopPropagation()
|
|
227
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
228
|
+
onQuickAdd({ handle: handleId, anchor: { x: rect.right, y: rect.top } })
|
|
229
|
+
}}
|
|
230
|
+
// Opacidade inline, e não classe utilitária: o realce é estado de interação deste
|
|
231
|
+
// botão, e prende o valor ao componente em vez de depender do CSS gerado no build.
|
|
232
|
+
style={{ opacity: hovered ? 1 : 0.35 }}
|
|
233
|
+
className="nodrag absolute right-0 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border border-blue-300 bg-white text-blue-600 shadow-sm transition-opacity hover:bg-blue-50 dark:border-blue-700 dark:bg-gray-800 dark:text-blue-400 dark:hover:bg-gray-700"
|
|
234
|
+
>
|
|
235
|
+
<Plus size={12} strokeWidth={3} />
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
119
238
|
</div>
|
|
120
239
|
)
|
|
121
240
|
}
|
|
122
241
|
|
|
123
242
|
export function FlowNodeCard({ data }: NodeProps) {
|
|
124
|
-
const { node, liveCount, isStart, isSelected, isDetached, issues, labels, actionKindIcons, onSelect } =
|
|
243
|
+
const { node, liveCount, isStart, isSelected, isDetached, issues, labels, actionKindIcons, onSelect, onQuickAdd } =
|
|
125
244
|
data as unknown as FlowNodeCardData
|
|
126
245
|
const label = nodeLabel(node, labels)
|
|
127
246
|
const iconMap = { ...DEFAULT_ACTION_KIND_ICON, ...actionKindIcons }
|
|
@@ -130,8 +249,16 @@ export function FlowNodeCard({ data }: NodeProps) {
|
|
|
130
249
|
? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type])
|
|
131
250
|
: NODE_TYPE_ICON[node.type]
|
|
132
251
|
const rows = sourceRows(node, labels)
|
|
133
|
-
|
|
134
|
-
|
|
252
|
+
// Filtrado pelo nó, e não pelo fluxo: sem isto TODO card do fluxo acendia o alerta quando UM
|
|
253
|
+
// deles tinha problema, e o ícone deixava de apontar qualquer coisa. Aviso sem alvo é ruído.
|
|
254
|
+
const nodeIssues = issues.filter((issue) => issue.nodeId === node.id)
|
|
255
|
+
const errors = nodeIssues.filter((issue) => issue.severity === 'error')
|
|
256
|
+
const warnings = nodeIssues.filter((issue) => issue.severity === 'warning')
|
|
257
|
+
const hasError = errors.length > 0
|
|
258
|
+
const hasWarning = !hasError && warnings.length > 0
|
|
259
|
+
// Separador em vez de quebra de linha: o balão não preserva `\n`. O ícone dizia que havia algo
|
|
260
|
+
// errado sem dizer o quê, e corrigir passava por abrir o painel de cada card procurando.
|
|
261
|
+
const issueTooltip = (hasError ? errors : warnings).map((issue) => issue.message).join(' · ')
|
|
135
262
|
|
|
136
263
|
return (
|
|
137
264
|
<div
|
|
@@ -139,7 +266,13 @@ export function FlowNodeCard({ data }: NodeProps) {
|
|
|
139
266
|
className={`relative rounded-lg border-2 px-3 py-2 w-60 cursor-pointer shadow-sm hover:shadow-md transition-shadow ${NODE_TYPE_COLOR[node.type]} ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''} ${isDetached ? 'border-dashed !border-amber-400 animate-pulse' : ''}`}
|
|
140
267
|
onClick={() => onSelect(node.id)}
|
|
141
268
|
>
|
|
142
|
-
<Handle
|
|
269
|
+
<Handle
|
|
270
|
+
type="target"
|
|
271
|
+
position={Position.Top}
|
|
272
|
+
id="target"
|
|
273
|
+
style={{ width: HANDLE_SIZE_PX, height: HANDLE_SIZE_PX }}
|
|
274
|
+
className="!bg-gray-400 !border-2 !border-white dark:!bg-gray-500 dark:!border-gray-800"
|
|
275
|
+
/>
|
|
143
276
|
|
|
144
277
|
<div className="flex items-center justify-between gap-2">
|
|
145
278
|
<span className="flex items-center gap-1.5 uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400 text-xs">
|
|
@@ -148,8 +281,22 @@ export function FlowNodeCard({ data }: NodeProps) {
|
|
|
148
281
|
{labels.legend[node.type]}
|
|
149
282
|
</span>
|
|
150
283
|
<div className="flex items-center gap-1">
|
|
151
|
-
{hasError &&
|
|
152
|
-
|
|
284
|
+
{hasError && (
|
|
285
|
+
<AlertCircle
|
|
286
|
+
size={13}
|
|
287
|
+
data-cv-tooltip={issueTooltip}
|
|
288
|
+
aria-label={issueTooltip}
|
|
289
|
+
className="text-red-600 dark:text-red-400"
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
{hasWarning && (
|
|
293
|
+
<AlertTriangle
|
|
294
|
+
size={13}
|
|
295
|
+
data-cv-tooltip={issueTooltip}
|
|
296
|
+
aria-label={issueTooltip}
|
|
297
|
+
className="text-amber-500 dark:text-amber-400"
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
153
300
|
{liveCount > 0 && (
|
|
154
301
|
<span
|
|
155
302
|
data-cv-tooltip={labels.liveCountTooltip(liveCount)}
|
|
@@ -166,7 +313,18 @@ export function FlowNodeCard({ data }: NodeProps) {
|
|
|
166
313
|
{rows.length > 0 && (
|
|
167
314
|
<div className="mt-2 space-y-1">
|
|
168
315
|
{rows.map((row) => (
|
|
169
|
-
<SourceRow
|
|
316
|
+
<SourceRow
|
|
317
|
+
key={row.id}
|
|
318
|
+
label={row.label}
|
|
319
|
+
isDefault={row.isDefault}
|
|
320
|
+
handleId={row.id}
|
|
321
|
+
addLabel={labels.quickAdd.fromHandle}
|
|
322
|
+
isSelfLoop={row.isSelfLoop}
|
|
323
|
+
selfLoopLabel={labels.legendPanel.selfLoop}
|
|
324
|
+
{...(onQuickAdd
|
|
325
|
+
? { onQuickAdd: (params) => onQuickAdd({ nodeId: node.id, ...params }) }
|
|
326
|
+
: {})}
|
|
327
|
+
/>
|
|
170
328
|
))}
|
|
171
329
|
</div>
|
|
172
330
|
)}
|
|
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'
|
|
|
2
2
|
import { Plus, Trash2, Save, X, AlertTriangle, AlertCircle } from 'lucide-react'
|
|
3
3
|
import { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
|
|
4
4
|
import { nodeLabel } from './FlowNodeCard'
|
|
5
|
-
import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS, BUILT_IN_ACTION_KINDS } from './flowGraph'
|
|
5
|
+
import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS, BUILT_IN_ACTION_KINDS, PASS_THROUGH_ACTION_KINDS } from './flowGraph'
|
|
6
6
|
import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
|
|
7
7
|
import type { FlowGraphData, FlowNodeData, GraphIssue } from './flowGraph'
|
|
8
8
|
|
|
@@ -102,6 +102,7 @@ export function FlowNodePanel({
|
|
|
102
102
|
const isFixedLogic = draft.type === 'entrada_choice'
|
|
103
103
|
const isAction = draft.type === 'action'
|
|
104
104
|
const isSendMedia = isAction && draft.actionKind === BUILT_IN_ACTION_KINDS.SEND_MEDIA
|
|
105
|
+
const isPassThroughAction = isAction && Boolean(draft.actionKind && PASS_THROUGH_ACTION_KINDS.includes(draft.actionKind))
|
|
105
106
|
const isCondition = draft.type === 'condition'
|
|
106
107
|
const isStart = graph.startNodeId === node.id
|
|
107
108
|
const nodeIssues = issues.filter((i) => i.nodeId === node.id)
|
|
@@ -386,7 +387,9 @@ export function FlowNodePanel({
|
|
|
386
387
|
</div>
|
|
387
388
|
)}
|
|
388
389
|
|
|
389
|
-
{
|
|
390
|
+
{/* Ação de passagem também escolhe destino: sem isto, o material era enviado e a conversa
|
|
391
|
+
parava ali, sem caminho para o atendimento. Ação terminal continua sem o campo. */}
|
|
392
|
+
{(!isAction || isPassThroughAction) && (
|
|
390
393
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
391
394
|
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.next}</label>
|
|
392
395
|
<p className="text-[11px] text-gray-400 dark:text-gray-500 mb-1">{labels.nodePanel.nextHint}</p>
|