@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
|
@@ -28,21 +28,117 @@ const QUESTION_TYPES: FlowQuestionType[] = ['text', 'money', 'date', 'int', 'cpf
|
|
|
28
28
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowPalette.tsx —
|
|
29
29
|
// botão "Adicionar" com submenus por categoria (Pergunta/Decisão/Condição/Ação). Sem
|
|
30
30
|
// dependência de dropdown externa — menu simples com fechamento por clique fora.
|
|
31
|
-
export
|
|
32
|
-
|
|
31
|
+
export interface FlowPaletteMenuProps {
|
|
32
|
+
onSelect: (spec: NewNodeSpec) => void
|
|
33
|
+
labels: FlowEditorLabels
|
|
34
|
+
actionOptions?: FlowPaletteActionOption[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A lista de tipos de nó, sem o botão que a abre.
|
|
39
|
+
*
|
|
40
|
+
* Separada porque o "+" do card abre exatamente esta lista: duplicar os itens era garantir que um
|
|
41
|
+
* tipo novo aparecesse num caminho e faltasse no outro.
|
|
42
|
+
*/
|
|
43
|
+
export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPaletteMenuProps) {
|
|
33
44
|
const resolvedActionOptions = actionOptions ?? [
|
|
34
45
|
{ actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
|
|
35
46
|
]
|
|
47
|
+
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
48
|
+
|
|
49
|
+
function select(spec: NewNodeSpec) {
|
|
50
|
+
onSelect(spec)
|
|
51
|
+
setSubmenu(null)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<div className="relative">
|
|
57
|
+
<button
|
|
58
|
+
data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
|
|
59
|
+
onMouseEnter={() => setSubmenu('question')}
|
|
60
|
+
onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
|
|
61
|
+
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
62
|
+
>
|
|
63
|
+
<span className="flex items-center gap-2">
|
|
64
|
+
<MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
|
|
65
|
+
</span>
|
|
66
|
+
<ChevronRight size={13} className="text-gray-400" />
|
|
67
|
+
</button>
|
|
68
|
+
{submenu === 'question' && (
|
|
69
|
+
<div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
|
|
70
|
+
{QUESTION_TYPES.map((qt) => (
|
|
71
|
+
<button
|
|
72
|
+
data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
|
|
73
|
+
key={qt}
|
|
74
|
+
onClick={() => select({ kind: 'question', questionType: qt })}
|
|
75
|
+
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
76
|
+
>
|
|
77
|
+
{labels.questionTypeLabels[qt]}
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<button
|
|
85
|
+
data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
|
|
86
|
+
onMouseEnter={() => setSubmenu(null)}
|
|
87
|
+
onClick={() => select({ kind: 'decision' })}
|
|
88
|
+
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
89
|
+
>
|
|
90
|
+
<GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
onMouseEnter={() => setSubmenu(null)}
|
|
95
|
+
onClick={() => select({ kind: 'condition' })}
|
|
96
|
+
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
97
|
+
data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
|
|
98
|
+
>
|
|
99
|
+
<Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
|
|
100
|
+
</button>
|
|
101
|
+
|
|
102
|
+
<div className="relative">
|
|
103
|
+
<button
|
|
104
|
+
data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
|
|
105
|
+
onMouseEnter={() => setSubmenu('action')}
|
|
106
|
+
onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
|
|
107
|
+
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
108
|
+
>
|
|
109
|
+
<span className="flex items-center gap-2">
|
|
110
|
+
<Zap size={15} className="text-orange-500" /> {labels.palette.action}
|
|
111
|
+
</span>
|
|
112
|
+
<ChevronRight size={13} className="text-gray-400" />
|
|
113
|
+
</button>
|
|
114
|
+
{submenu === 'action' && (
|
|
115
|
+
<div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
|
|
116
|
+
{resolvedActionOptions.map((option) => (
|
|
117
|
+
<button
|
|
118
|
+
data-cv-tooltip={option.label} aria-label={option.label}
|
|
119
|
+
key={option.actionKind}
|
|
120
|
+
onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
|
|
121
|
+
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
122
|
+
>
|
|
123
|
+
{option.label}
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
36
132
|
|
|
133
|
+
export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
|
|
134
|
+
const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
|
|
37
135
|
const [open, setOpen] = useState(false)
|
|
38
|
-
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
39
136
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
40
137
|
|
|
41
138
|
useEffect(() => {
|
|
42
139
|
function handleClickOutside(event: MouseEvent) {
|
|
43
140
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
44
141
|
setOpen(false)
|
|
45
|
-
setSubmenu(null)
|
|
46
142
|
}
|
|
47
143
|
}
|
|
48
144
|
document.addEventListener('mousedown', handleClickOutside)
|
|
@@ -52,7 +148,6 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
|
|
|
52
148
|
function select(spec: NewNodeSpec) {
|
|
53
149
|
onAdd(spec)
|
|
54
150
|
setOpen(false)
|
|
55
|
-
setSubmenu(null)
|
|
56
151
|
}
|
|
57
152
|
|
|
58
153
|
return (
|
|
@@ -67,79 +162,11 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
|
|
|
67
162
|
|
|
68
163
|
{open && (
|
|
69
164
|
<div className="absolute left-0 top-full mt-1.5 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 py-1">
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
76
|
-
>
|
|
77
|
-
<span className="flex items-center gap-2">
|
|
78
|
-
<MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
|
|
79
|
-
</span>
|
|
80
|
-
<ChevronRight size={13} className="text-gray-400" />
|
|
81
|
-
</button>
|
|
82
|
-
{submenu === 'question' && (
|
|
83
|
-
<div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
|
|
84
|
-
{QUESTION_TYPES.map((qt) => (
|
|
85
|
-
<button
|
|
86
|
-
data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
|
|
87
|
-
key={qt}
|
|
88
|
-
onClick={() => select({ kind: 'question', questionType: qt })}
|
|
89
|
-
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
90
|
-
>
|
|
91
|
-
{labels.questionTypeLabels[qt]}
|
|
92
|
-
</button>
|
|
93
|
-
))}
|
|
94
|
-
</div>
|
|
95
|
-
)}
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<button
|
|
99
|
-
data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
|
|
100
|
-
onMouseEnter={() => setSubmenu(null)}
|
|
101
|
-
onClick={() => select({ kind: 'decision' })}
|
|
102
|
-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
103
|
-
>
|
|
104
|
-
<GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
|
|
105
|
-
</button>
|
|
106
|
-
|
|
107
|
-
<button
|
|
108
|
-
onMouseEnter={() => setSubmenu(null)}
|
|
109
|
-
onClick={() => select({ kind: 'condition' })}
|
|
110
|
-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
111
|
-
data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
|
|
112
|
-
>
|
|
113
|
-
<Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
|
|
114
|
-
</button>
|
|
115
|
-
|
|
116
|
-
<div className="relative">
|
|
117
|
-
<button
|
|
118
|
-
data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
|
|
119
|
-
onMouseEnter={() => setSubmenu('action')}
|
|
120
|
-
onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
|
|
121
|
-
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
122
|
-
>
|
|
123
|
-
<span className="flex items-center gap-2">
|
|
124
|
-
<Zap size={15} className="text-orange-500" /> {labels.palette.action}
|
|
125
|
-
</span>
|
|
126
|
-
<ChevronRight size={13} className="text-gray-400" />
|
|
127
|
-
</button>
|
|
128
|
-
{submenu === 'action' && (
|
|
129
|
-
<div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
|
|
130
|
-
{resolvedActionOptions.map((option) => (
|
|
131
|
-
<button
|
|
132
|
-
data-cv-tooltip={option.label} aria-label={option.label}
|
|
133
|
-
key={option.actionKind}
|
|
134
|
-
onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
|
|
135
|
-
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
136
|
-
>
|
|
137
|
-
{option.label}
|
|
138
|
-
</button>
|
|
139
|
-
))}
|
|
140
|
-
</div>
|
|
141
|
-
)}
|
|
142
|
-
</div>
|
|
165
|
+
<FlowPaletteMenu
|
|
166
|
+
onSelect={select}
|
|
167
|
+
labels={labels}
|
|
168
|
+
{...(actionOptions ? { actionOptions } : {})}
|
|
169
|
+
/>
|
|
143
170
|
</div>
|
|
144
171
|
)}
|
|
145
172
|
</div>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Controls,
|
|
6
6
|
MiniMap,
|
|
7
7
|
MarkerType,
|
|
8
|
+
Panel,
|
|
8
9
|
applyNodeChanges,
|
|
9
10
|
type Node,
|
|
10
11
|
type NodeChange,
|
|
@@ -16,12 +17,14 @@ import '@xyflow/react/dist/style.css'
|
|
|
16
17
|
import { Plus, Trash2, LayoutGrid, AlertTriangle, AlertCircle, Save, Undo2, Map as MapIcon, Workflow } from 'lucide-react'
|
|
17
18
|
|
|
18
19
|
import { useIsDarkTheme } from '../useDarkMode'
|
|
19
|
-
import { flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
|
|
20
|
+
import { NODE_TYPE_COLOR, flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
|
|
21
|
+
import { FlowLegend, type FlowLegendEdgeSample } from './FlowLegend'
|
|
20
22
|
import { flowPortalNodeTypes, type FlowPortalNodeData } from './FlowPortalNode'
|
|
21
23
|
import { flowGroupHeaderNodeTypes, type FlowGroupHeaderData } from './FlowGroupHeader'
|
|
22
24
|
import { flowGroupFrameNodeTypes, type FlowGroupFrameData } from './FlowGroupFrame'
|
|
23
25
|
import { FlowNodePanel } from './FlowNodePanel'
|
|
24
|
-
import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
|
|
26
|
+
import { FlowPalette, FlowPaletteMenu, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
|
|
27
|
+
import { flowEdgeTypes, type FlowConnectionEdgeData } from './FlowConnectionEdge'
|
|
25
28
|
import { FlowMapCanvas } from './FlowMapCanvas'
|
|
26
29
|
import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
|
|
27
30
|
// Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
|
|
@@ -32,6 +35,7 @@ import {
|
|
|
32
35
|
chainFrameNodeId,
|
|
33
36
|
computeMergedLayout,
|
|
34
37
|
countLiveByNode,
|
|
38
|
+
findFreeSlot,
|
|
35
39
|
newNodeFromSpec,
|
|
36
40
|
portalNodeId,
|
|
37
41
|
GROUP_HEADER_NODE_ID,
|
|
@@ -40,6 +44,7 @@ import {
|
|
|
40
44
|
} from './flowCanvasModel'
|
|
41
45
|
import {
|
|
42
46
|
applyConnection,
|
|
47
|
+
clearConnection,
|
|
43
48
|
mergedFlowKeysFrom,
|
|
44
49
|
namespaceNodeId,
|
|
45
50
|
parseNamespacedId,
|
|
@@ -68,7 +73,28 @@ const RF_NODE_TYPES = {
|
|
|
68
73
|
...flowGroupFrameNodeTypes,
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A legenda lê as mesmas constantes que pintam as arestas — chave que diverge do desenho é pior que
|
|
78
|
+
* chave nenhuma, porque ensina errado com ar de autoridade.
|
|
79
|
+
*/
|
|
80
|
+
function legendEdgeSamples(labels: FlowEditorLabels): FlowLegendEdgeSample[] {
|
|
81
|
+
return [
|
|
82
|
+
{ color: EDGE_COLOR_LINEAR, label: labels.legendPanel.linear },
|
|
83
|
+
{ color: EDGE_COLOR_BRANCH, label: labels.legendPanel.branch },
|
|
84
|
+
{ color: EDGE_COLOR_FALLBACK, dash: '5 4', label: labels.legendPanel.fallback },
|
|
85
|
+
{ color: EDGE_COLOR_CROSS_FLOW, dash: '3 3', label: labels.legendPanel.crossFlow },
|
|
86
|
+
{ color: EDGE_COLOR_LIVE, label: labels.legendPanel.live },
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const LEGEND_NODE_SWATCHES = (Object.keys(NODE_TYPE_COLOR) as (keyof typeof NODE_TYPE_COLOR)[]).map((type) => ({
|
|
91
|
+
type,
|
|
92
|
+
className: NODE_TYPE_COLOR[type],
|
|
93
|
+
}))
|
|
94
|
+
|
|
71
95
|
const CHAIN_FRAME_PADDING = 36
|
|
96
|
+
/** Coluna à direita do card de origem em que o nó criado pelo "+" nasce. */
|
|
97
|
+
const QUICK_ADD_COLUMN_GAP = 320
|
|
72
98
|
const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
|
|
73
99
|
|
|
74
100
|
const EDGE_COLOR_LINEAR = '#94a3b8'
|
|
@@ -145,7 +171,7 @@ export interface FlowsWorkspaceProps {
|
|
|
145
171
|
* O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
|
|
146
172
|
* conversa do cliente parar; cor errada aparece na primeira olhada.
|
|
147
173
|
*/
|
|
148
|
-
function styleEdge(spec: FlowEdgeSpec): Edge {
|
|
174
|
+
function styleEdge(spec: FlowEdgeSpec, params: { disconnectLabel: string; onDisconnect: (spec: FlowEdgeSpec) => void }): Edge {
|
|
149
175
|
const color = spec.crossFlow
|
|
150
176
|
? EDGE_COLOR_CROSS_FLOW
|
|
151
177
|
: spec.kind === 'fallback'
|
|
@@ -164,7 +190,12 @@ function styleEdge(spec: FlowEdgeSpec): Edge {
|
|
|
164
190
|
source: spec.source,
|
|
165
191
|
target: spec.target,
|
|
166
192
|
...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
|
|
167
|
-
type: '
|
|
193
|
+
type: 'flowConnection',
|
|
194
|
+
reconnectable: 'target',
|
|
195
|
+
data: {
|
|
196
|
+
disconnectLabel: params.disconnectLabel,
|
|
197
|
+
onDisconnect: () => params.onDisconnect(spec),
|
|
198
|
+
} satisfies FlowConnectionEdgeData,
|
|
168
199
|
animated: spec.live,
|
|
169
200
|
style: {
|
|
170
201
|
stroke: color,
|
|
@@ -222,6 +253,10 @@ export function FlowsWorkspace({
|
|
|
222
253
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
|
|
223
254
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
|
224
255
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
256
|
+
/** Saída de onde o "+" foi clicado, e o ponto da tela onde ancorar o menu. */
|
|
257
|
+
const [quickAddFrom, setQuickAddFrom] = useState<
|
|
258
|
+
{ flowKey: string; nodeId: string; handle: string; anchor: { x: number; y: number } } | null
|
|
259
|
+
>(null)
|
|
225
260
|
const [newFlow, setNewFlow] = useState({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
|
|
226
261
|
const [flowMutationState, setFlowMutationState] = useState<{ pending: boolean; error?: string }>({ pending: false })
|
|
227
262
|
const [rfNodes, setRfNodes] = useState<Node[]>([])
|
|
@@ -417,7 +452,19 @@ export function FlowsWorkspace({
|
|
|
417
452
|
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
|
|
418
453
|
}
|
|
419
454
|
// Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
|
|
420
|
-
|
|
455
|
+
//
|
|
456
|
+
// O `renderedPositionsRef` no meio é o que impede o card de sumir ao desligar um fio. Nó
|
|
457
|
+
// vindo do seed não tem posição salva, então quem manda nele é o auto-layout — e o
|
|
458
|
+
// auto-layout joga todo nó sem ligação de entrada para uma faixa ABAIXO de tudo. Desligar
|
|
459
|
+
// a última ação a mandava para fora da área visível no mesmo instante, o que se lê como
|
|
460
|
+
// "o editor apagou meu card". Congelando o lugar em que ele já foi desenhado, desligar o
|
|
461
|
+
// fio passa a mudar só o fio.
|
|
462
|
+
const nsId = namespaceNodeId(flowKey, nodeId)
|
|
463
|
+
return (
|
|
464
|
+
graph!.nodes[nodeId]?.position ??
|
|
465
|
+
renderedPositionsRef.current.get(nsId) ??
|
|
466
|
+
fallbackPositions[nodeId] ?? { x: 0, y: 0 }
|
|
467
|
+
)
|
|
421
468
|
}
|
|
422
469
|
|
|
423
470
|
for (const node of Object.values(graph.nodes)) {
|
|
@@ -436,6 +483,7 @@ export function FlowsWorkspace({
|
|
|
436
483
|
issues: flowIssues,
|
|
437
484
|
labels,
|
|
438
485
|
onSelect: (nodeId: string) => setEditingRef({ flowKey, nodeId }),
|
|
486
|
+
onQuickAdd: ({ nodeId, handle, anchor }) => setQuickAddFrom({ flowKey, nodeId, handle, anchor }),
|
|
439
487
|
} satisfies FlowNodeCardData,
|
|
440
488
|
})
|
|
441
489
|
|
|
@@ -523,10 +571,52 @@ export function FlowsWorkspace({
|
|
|
523
571
|
labels,
|
|
524
572
|
])
|
|
525
573
|
|
|
574
|
+
// Desligar um fio é gravar destino vazio no nó de origem — o nó do outro lado NÃO se mexe. Era
|
|
575
|
+
// isso que faltava: sem caminho para desligar, trocar o destino da última ação passava por
|
|
576
|
+
// apagar o card e refazê-lo.
|
|
577
|
+
const disconnectEdge = useCallback(
|
|
578
|
+
(spec: FlowEdgeSpec) => {
|
|
579
|
+
const { flowKey, nodeId } = parseNamespacedId(spec.source)
|
|
580
|
+
updateFlow(flowKey, (graph) => {
|
|
581
|
+
const node = graph.nodes[nodeId]
|
|
582
|
+
if (!node) return graph
|
|
583
|
+
return { ...graph, nodes: { ...graph.nodes, [nodeId]: clearConnection(node, spec.sourceHandle ?? 'next') } }
|
|
584
|
+
})
|
|
585
|
+
},
|
|
586
|
+
[updateFlow],
|
|
587
|
+
)
|
|
588
|
+
|
|
526
589
|
const edges = useMemo(
|
|
527
590
|
() =>
|
|
528
|
-
buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map(
|
|
529
|
-
|
|
591
|
+
buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map((spec) =>
|
|
592
|
+
// Salto entre fluxos desenhado como portal não se desliga daqui: quem manda nele é o `next`
|
|
593
|
+
// do nó de origem, e o portal é só a caixa que representa o fluxo alvo ausente.
|
|
594
|
+
styleEdge(spec, { disconnectLabel: labels.quickAdd.disconnect, onDisconnect: disconnectEdge }),
|
|
595
|
+
),
|
|
596
|
+
[openFlowKeys, workingGraphs, rootFlowKey, livePositions, labels, disconnectEdge],
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
// Arrastar a ponta de um fio para outro card: religa em UMA edição, sem passar por um estado
|
|
600
|
+
// intermediário em que o fluxo está quebrado.
|
|
601
|
+
const onReconnect = useCallback(
|
|
602
|
+
(oldEdge: Edge, connection: Connection) => {
|
|
603
|
+
const resolved = resolveConnection({
|
|
604
|
+
connection: {
|
|
605
|
+
source: connection.source,
|
|
606
|
+
target: connection.target,
|
|
607
|
+
sourceHandle: connection.sourceHandle ?? oldEdge.sourceHandle,
|
|
608
|
+
},
|
|
609
|
+
graphs: workingGraphs,
|
|
610
|
+
})
|
|
611
|
+
if (!resolved) return
|
|
612
|
+
|
|
613
|
+
updateFlow(resolved.flowKey, (graph) => {
|
|
614
|
+
const node = graph.nodes[resolved.nodeId]
|
|
615
|
+
if (!node) return graph
|
|
616
|
+
return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
|
|
617
|
+
})
|
|
618
|
+
},
|
|
619
|
+
[workingGraphs, updateFlow],
|
|
530
620
|
)
|
|
531
621
|
|
|
532
622
|
useEffect(() => {
|
|
@@ -596,6 +686,56 @@ export function FlowsWorkspace({
|
|
|
596
686
|
setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
|
|
597
687
|
}
|
|
598
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Cria o próximo nó e liga o fio na MESMA edição.
|
|
691
|
+
*
|
|
692
|
+
* Uma edição só, e não duas, porque o desfazer é por passo: criar e ligar separados fariam um
|
|
693
|
+
* "desfazer" deixar o card novo solto no canvas, que é justamente o estado que ninguém quer.
|
|
694
|
+
* A posição sai do card de origem, à direita dele — o nó nasce onde a pessoa estava olhando,
|
|
695
|
+
* em vez de na faixa de órfãos abaixo de tudo.
|
|
696
|
+
*/
|
|
697
|
+
function handleQuickAdd(spec: NewNodeSpec) {
|
|
698
|
+
const origin = quickAddFrom
|
|
699
|
+
setQuickAddFrom(null)
|
|
700
|
+
if (!origin) return
|
|
701
|
+
|
|
702
|
+
const originGraph = workingGraphs[origin.flowKey]
|
|
703
|
+
if (!originGraph) return
|
|
704
|
+
|
|
705
|
+
const newNode = newNodeFromSpec(spec, new Set(Object.keys(originGraph.nodes)))
|
|
706
|
+
const originPosition =
|
|
707
|
+
renderedPositionsRef.current.get(namespaceNodeId(origin.flowKey, origin.nodeId)) ??
|
|
708
|
+
originGraph.nodes[origin.nodeId]?.position ?? { x: 0, y: 0 }
|
|
709
|
+
// À direita de quem criou, e descendo se aquele lugar já tiver dono — tipicamente o próprio nó
|
|
710
|
+
// que acabou de perder a ligação, que é exatamente quem está naquela coluna.
|
|
711
|
+
const taken = openFlowKeys.flatMap((key) =>
|
|
712
|
+
Object.entries(workingGraphs[key]?.nodes ?? {}).map(
|
|
713
|
+
([id, node]) => renderedPositionsRef.current.get(namespaceNodeId(key, id)) ?? node.position ?? { x: 0, y: 0 },
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
newNode.position = findFreeSlot({
|
|
717
|
+
desired: { x: originPosition.x + QUICK_ADD_COLUMN_GAP, y: originPosition.y },
|
|
718
|
+
taken,
|
|
719
|
+
})
|
|
720
|
+
// Com fluxos mesclados quem decide o lugar é o layout, não o `position` do nó — sem semear
|
|
721
|
+
// aqui, o card nascia na coluna calculada, em cima do nó que acabou de ficar solto.
|
|
722
|
+
renderedPositionsRef.current.set(namespaceNodeId(origin.flowKey, newNode.id), newNode.position)
|
|
723
|
+
|
|
724
|
+
updateFlow(origin.flowKey, (graph) => {
|
|
725
|
+
const sourceNode = graph.nodes[origin.nodeId]
|
|
726
|
+
if (!sourceNode) return graph
|
|
727
|
+
const connected = applyConnection(sourceNode, {
|
|
728
|
+
flowKey: origin.flowKey,
|
|
729
|
+
nodeId: origin.nodeId,
|
|
730
|
+
handle: origin.handle,
|
|
731
|
+
targetValue: newNode.id,
|
|
732
|
+
})
|
|
733
|
+
return { ...graph, nodes: { ...graph.nodes, [origin.nodeId]: connected, [newNode.id]: newNode } }
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
setEditingRef({ flowKey: origin.flowKey, nodeId: newNode.id })
|
|
737
|
+
}
|
|
738
|
+
|
|
599
739
|
function handleNodePanelChange(updated: FlowNodeData) {
|
|
600
740
|
if (!editingRef) return
|
|
601
741
|
updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [updated.id]: updated } }))
|
|
@@ -875,9 +1015,11 @@ export function FlowsWorkspace({
|
|
|
875
1015
|
nodes={rfNodes}
|
|
876
1016
|
edges={edges}
|
|
877
1017
|
nodeTypes={RF_NODE_TYPES}
|
|
1018
|
+
edgeTypes={flowEdgeTypes}
|
|
878
1019
|
onNodesChange={onNodesChange}
|
|
879
1020
|
onNodeDragStop={onNodeDragStop}
|
|
880
1021
|
onConnect={onConnect}
|
|
1022
|
+
onReconnect={onReconnect}
|
|
881
1023
|
onInit={setFlowInstance}
|
|
882
1024
|
fitView
|
|
883
1025
|
proOptions={{ hideAttribution: true }}
|
|
@@ -885,11 +1027,39 @@ export function FlowsWorkspace({
|
|
|
885
1027
|
>
|
|
886
1028
|
<Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
|
|
887
1029
|
<Controls />
|
|
1030
|
+
<Panel position="top-right">
|
|
1031
|
+
<FlowLegend
|
|
1032
|
+
labels={labels}
|
|
1033
|
+
edgeSamples={legendEdgeSamples(labels)}
|
|
1034
|
+
nodeSwatches={LEGEND_NODE_SWATCHES}
|
|
1035
|
+
/>
|
|
1036
|
+
</Panel>
|
|
888
1037
|
<MiniMap pannable zoomable className="!bg-white dark:!bg-gray-800" />
|
|
889
1038
|
</ReactFlow>
|
|
890
1039
|
)}
|
|
891
1040
|
</div>
|
|
892
1041
|
|
|
1042
|
+
{/* Menu do "+": ancorado no ponto clicado e em coordenadas de tela (`fixed`), porque o canvas
|
|
1043
|
+
tem pan e zoom próprios — posicionar dentro dele faria o menu escorregar junto. */}
|
|
1044
|
+
{quickAddFrom && (
|
|
1045
|
+
<>
|
|
1046
|
+
<div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
|
|
1047
|
+
<div
|
|
1048
|
+
className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
1049
|
+
style={{ left: quickAddFrom.anchor.x + 12, top: quickAddFrom.anchor.y }}
|
|
1050
|
+
>
|
|
1051
|
+
<p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
1052
|
+
{labels.quickAdd.title}
|
|
1053
|
+
</p>
|
|
1054
|
+
<FlowPaletteMenu
|
|
1055
|
+
onSelect={handleQuickAdd}
|
|
1056
|
+
labels={labels}
|
|
1057
|
+
{...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
|
|
1058
|
+
/>
|
|
1059
|
+
</div>
|
|
1060
|
+
</>
|
|
1061
|
+
)}
|
|
1062
|
+
|
|
893
1063
|
{/* `key` por nó: o painel guarda um rascunho local em estado, e sem remontar ao trocar de nó
|
|
894
1064
|
selecionado ele seguia mostrando (e salvando) os campos do nó anterior. */}
|
|
895
1065
|
{editingNode && editingGraph && (
|
|
@@ -17,8 +17,9 @@ import {
|
|
|
17
17
|
newNodeFromSpec,
|
|
18
18
|
portalNodeId,
|
|
19
19
|
type FlowLivePosition,
|
|
20
|
+
findFreeSlot,
|
|
20
21
|
} from './flowCanvasModel'
|
|
21
|
-
import type
|
|
22
|
+
import { estimateNodeHeight, type FlowGraphData, type FlowNodeData } from './flowGraph'
|
|
22
23
|
|
|
23
24
|
const ROOT = 'menu'
|
|
24
25
|
|
|
@@ -310,3 +311,146 @@ describe('nó novo da paleta', () => {
|
|
|
310
311
|
expect(newNodeFromSpec({ kind: 'action', actionKind: 'handoff' }, new Set()).actionKind).toBe('handoff')
|
|
311
312
|
})
|
|
312
313
|
})
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* O "+" cria o nó à direita de quem o criou — que é onde costuma estar o card que acabou de perder
|
|
317
|
+
* a ligação. Dois cards no mesmo pixel se leem como um card só, e "sumiu" de novo.
|
|
318
|
+
*/
|
|
319
|
+
describe('findFreeSlot', () => {
|
|
320
|
+
it('lugar vazio é usado como está', () => {
|
|
321
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [] })).toEqual({ x: 100, y: 0 })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('lugar ocupado desce até sobrar espaço, sem sair da coluna', () => {
|
|
325
|
+
const slot = findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 100, y: 0 }] })
|
|
326
|
+
|
|
327
|
+
expect(slot.x).toBe(100)
|
|
328
|
+
expect(slot.y).toBeGreaterThan(0)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('desce quantas vezes precisar quando a coluna está empilhada', () => {
|
|
332
|
+
const taken = [
|
|
333
|
+
{ x: 100, y: 0 },
|
|
334
|
+
{ x: 100, y: 120 },
|
|
335
|
+
{ x: 100, y: 240 },
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken }).y).toBe(360)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('card em outra coluna não empurra ninguém', () => {
|
|
342
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 900, y: 0 }] })).toEqual({ x: 100, y: 0 })
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A altura do card decide o espaçamento do layout. `send_media` ganhou uma linha de saída (o motor
|
|
348
|
+
* do bot anda para o `next` depois dela), e a altura tem de acompanhar — senão a camada de baixo
|
|
349
|
+
* encosta no card.
|
|
350
|
+
*/
|
|
351
|
+
describe('estimateNodeHeight em nó de ação', () => {
|
|
352
|
+
it('ação de passagem é mais alta que ação terminal, pela linha de saída', () => {
|
|
353
|
+
const passThrough = estimateNodeHeight({ id: 'a', type: 'action', actionKind: 'send_media' })
|
|
354
|
+
const terminal = estimateNodeHeight({ id: 'b', type: 'action', actionKind: 'handoff' })
|
|
355
|
+
|
|
356
|
+
expect(passThrough).toBeGreaterThan(terminal)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('ação sem kind conta como terminal, em vez de estourar', () => {
|
|
360
|
+
expect(estimateNodeHeight({ id: 'c', type: 'action' })).toBe(
|
|
361
|
+
estimateNodeHeight({ id: 'd', type: 'action', actionKind: 'handoff' }),
|
|
362
|
+
)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A regra que o empilhamento por camada quebrava: dois cards na mesma altura fazem o fio entre eles
|
|
368
|
+
* correr na horizontal, e um fio horizontal passa POR TRÁS de qualquer card que esteja no caminho.
|
|
369
|
+
* Um card por linha transforma toda ligação numa diagonal curta e visível.
|
|
370
|
+
*/
|
|
371
|
+
describe('cascata: um card por linha', () => {
|
|
372
|
+
it('cada card tem altura própria — ninguém divide linha com ninguém', () => {
|
|
373
|
+
const graphs = {
|
|
374
|
+
menu: graph({
|
|
375
|
+
key: 'menu',
|
|
376
|
+
start: 'a',
|
|
377
|
+
nodes: [node('a', { byAnswer: { sim: 'b', nao: 'c' }, default: 'd' }), node('b'), node('c'), node('d')],
|
|
378
|
+
}),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
382
|
+
const ys = [...positions.values()].map((each) => each.y)
|
|
383
|
+
|
|
384
|
+
expect(new Set(ys).size).toBe(ys.length)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('o card seguinte fica à direita E abaixo de quem o alimenta', () => {
|
|
388
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
|
|
389
|
+
|
|
390
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
391
|
+
const a = positions.get('menu::a')!
|
|
392
|
+
const b = positions.get('menu::b')!
|
|
393
|
+
const c = positions.get('menu::c')!
|
|
394
|
+
|
|
395
|
+
expect(b.x).toBeGreaterThan(a.x)
|
|
396
|
+
expect(b.y).toBeGreaterThan(a.y)
|
|
397
|
+
expect(c.x).toBeGreaterThan(b.x)
|
|
398
|
+
expect(c.y).toBeGreaterThan(b.y)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('um ramo inteiro sai antes do próximo começar — caminho de conversa não se intercala', () => {
|
|
402
|
+
const graphs = {
|
|
403
|
+
menu: graph({
|
|
404
|
+
key: 'menu',
|
|
405
|
+
start: 'a',
|
|
406
|
+
nodes: [
|
|
407
|
+
node('a', { byAnswer: { sim: 'b1', nao: 'c1' }, default: '' }),
|
|
408
|
+
node('b1', 'b2'),
|
|
409
|
+
node('b2'),
|
|
410
|
+
node('c1'),
|
|
411
|
+
],
|
|
412
|
+
}),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
416
|
+
|
|
417
|
+
// b1 e b2 são o mesmo ramo: c1 (o outro ramo) só aparece depois dos dois.
|
|
418
|
+
expect(positions.get('menu::b2')!.y).toBeLessThan(positions.get('menu::c1')!.y)
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Um `next` apontando para o próprio nó não vira aresta: de A para A não há trajeto que caiba —
|
|
424
|
+
* por baixo some atrás do card, por cima o cobre. Quem mostra é o ícone de repetição na linha de
|
|
425
|
+
* saída. O teste existe porque "não desenhar" é fácil de perder numa refatoração de arestas.
|
|
426
|
+
*/
|
|
427
|
+
describe('laço no próprio card', () => {
|
|
428
|
+
it('saída que volta ao mesmo nó não gera aresta', () => {
|
|
429
|
+
const graphs = {
|
|
430
|
+
menu: graph({
|
|
431
|
+
key: 'menu',
|
|
432
|
+
start: 'a',
|
|
433
|
+
nodes: [node('a', { byAnswer: { sim: 'b' }, default: 'a' }), node('b')],
|
|
434
|
+
}),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
|
|
438
|
+
|
|
439
|
+
expect(edges.map((each) => each.id)).toEqual(['menu::a->menu::b-sim'])
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('as demais saídas do mesmo card continuam desenhadas', () => {
|
|
443
|
+
const graphs = {
|
|
444
|
+
menu: graph({
|
|
445
|
+
key: 'menu',
|
|
446
|
+
start: 'a',
|
|
447
|
+
nodes: [node('a', { byAnswer: { sim: 'b', repete: 'a' }, default: 'b' }), node('b')],
|
|
448
|
+
}),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
|
|
452
|
+
|
|
453
|
+
expect(edges).toHaveLength(2)
|
|
454
|
+
expect(edges.every((each) => each.target === 'menu::b')).toBe(true)
|
|
455
|
+
})
|
|
456
|
+
})
|