@adatechnology/conversations-ui 0.1.0-rc.29 → 0.1.0-rc.30
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/flows/index.d.ts +104 -8
- package/dist/flows/index.js +188 -157
- package/package.json +1 -1
- package/src/flows/FlowsWorkspace.tsx +94 -312
- package/src/flows/flowCanvasModel.test.ts +293 -0
- package/src/flows/flowCanvasModel.ts +342 -0
- package/src/flows/index.ts +21 -0
|
@@ -26,6 +26,18 @@ import { FlowMapCanvas } from './FlowMapCanvas'
|
|
|
26
26
|
import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
|
|
27
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
28
|
// erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
|
|
29
|
+
import {
|
|
30
|
+
buildFlowEdges,
|
|
31
|
+
chainFrameBounds,
|
|
32
|
+
chainFrameNodeId,
|
|
33
|
+
computeMergedLayout,
|
|
34
|
+
countLiveByNode,
|
|
35
|
+
newNodeFromSpec,
|
|
36
|
+
portalNodeId,
|
|
37
|
+
GROUP_HEADER_NODE_ID,
|
|
38
|
+
type FlowEdgeSpec,
|
|
39
|
+
type FlowLivePosition,
|
|
40
|
+
} from './flowCanvasModel'
|
|
29
41
|
import {
|
|
30
42
|
applyConnection,
|
|
31
43
|
mergedFlowKeysFrom,
|
|
@@ -36,16 +48,13 @@ import {
|
|
|
36
48
|
} from './flowEditorOps'
|
|
37
49
|
import {
|
|
38
50
|
computeAutoLayout,
|
|
39
|
-
slugifyNodeId,
|
|
40
51
|
targetsOf,
|
|
41
52
|
validateGraph,
|
|
42
53
|
isCrossFlowTarget,
|
|
43
54
|
crossFlowKey,
|
|
44
|
-
crossFlowTargetsOf,
|
|
45
55
|
findCollectionChains,
|
|
46
56
|
estimateNodeHeight,
|
|
47
57
|
NODE_CARD_WIDTH,
|
|
48
|
-
CROSS_FLOW_PREFIX,
|
|
49
58
|
type FlowGraphData,
|
|
50
59
|
type FlowNodeData,
|
|
51
60
|
type GraphIssue,
|
|
@@ -60,9 +69,6 @@ const RF_NODE_TYPES = {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
const CHAIN_FRAME_PADDING = 36
|
|
63
|
-
const COLUMN_GAP = 360
|
|
64
|
-
const ROW_GAP = 40
|
|
65
|
-
const NS_SEP = '::'
|
|
66
72
|
const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
|
|
67
73
|
|
|
68
74
|
const EDGE_COLOR_LINEAR = '#94a3b8'
|
|
@@ -73,13 +79,9 @@ const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
|
|
|
73
79
|
const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
|
|
74
80
|
const BACKGROUND_COLOR_DARK = '#334155'
|
|
75
81
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
flow: string | null
|
|
80
|
-
nodeId: string | null
|
|
81
|
-
menuNodeId: string | null
|
|
82
|
-
}
|
|
82
|
+
// `FlowLivePosition` mora no modelo, que é quem conta — e é reexportado aqui porque faz parte da api
|
|
83
|
+
// que o produto implementa. Declarar nos dois lugares faria as duas formas divergirem em silêncio.
|
|
84
|
+
export type { FlowLivePosition } from './flowCanvasModel'
|
|
83
85
|
|
|
84
86
|
export interface CreateFlowInput {
|
|
85
87
|
key: string
|
|
@@ -123,234 +125,43 @@ export interface FlowsWorkspaceProps {
|
|
|
123
125
|
readonly className?: string
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
for (const { key } of openFlows) {
|
|
160
|
-
const graph = workingGraphs[key]
|
|
161
|
-
if (!graph) continue
|
|
162
|
-
for (const node of Object.values(graph.nodes)) nodeByNsId.set(namespaceNodeId(key, node.id), node)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
|
|
166
|
-
const result: string[] = []
|
|
167
|
-
for (const { target } of targetsOf(node)) {
|
|
168
|
-
if (isCrossFlowTarget(target)) {
|
|
169
|
-
const targetFlowKey = crossFlowKey(target)
|
|
170
|
-
const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : undefined
|
|
171
|
-
if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
|
|
172
|
-
} else if (workingGraphs[flowKey]?.nodes[target]) {
|
|
173
|
-
result.push(namespaceNodeId(flowKey, target))
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return result
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const rank = new Map<string, number>()
|
|
180
|
-
const primaryGraph = workingGraphs[primaryFlowKey]
|
|
181
|
-
const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
|
|
182
|
-
if (rootId && nodeByNsId.has(rootId)) {
|
|
183
|
-
rank.set(rootId, 0)
|
|
184
|
-
const queue = [rootId]
|
|
185
|
-
while (queue.length > 0) {
|
|
186
|
-
const id = queue.shift()!
|
|
187
|
-
const node = nodeByNsId.get(id)
|
|
188
|
-
if (!node) continue
|
|
189
|
-
for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
|
|
190
|
-
if (!rank.has(nextId)) {
|
|
191
|
-
rank.set(nextId, rank.get(id)! + 1)
|
|
192
|
-
queue.push(nextId)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Nós não alcançados a partir do início do fluxo primário (outro fluxo mesclado sem ligação
|
|
199
|
-
// de volta pro primário, ou nó órfão) vão TODOS numa única camada extra abaixo de tudo — uma
|
|
200
|
-
// camada por órfão empurrava cada um para uma linha própria, cada vez mais longe da área
|
|
201
|
-
// visível, e desligar um fio dava a impressão de ter apagado o card.
|
|
202
|
-
const strayRank = Math.max(0, ...rank.values()) + 1
|
|
203
|
-
for (const nsId of nodeByNsId.keys()) {
|
|
204
|
-
if (!rank.has(nsId)) rank.set(nsId, strayRank)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const layers = new Map<number, string[]>()
|
|
208
|
-
for (const [nsId, nodeRank] of rank) {
|
|
209
|
-
if (!layers.has(nodeRank)) layers.set(nodeRank, [])
|
|
210
|
-
layers.get(nodeRank)!.push(nsId)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const positions = new Map<string, { x: number; y: number }>()
|
|
214
|
-
for (const nodeRank of [...layers.keys()].sort((a, b) => a - b)) {
|
|
215
|
-
// Alinhado pelo topo pelo mesmo motivo de `computeAutoLayout`: coluna centralizada saltava
|
|
216
|
-
// inteira a cada nó a mais num ramo.
|
|
217
|
-
let cursorY = 0
|
|
218
|
-
for (const nsId of layers.get(nodeRank)!) {
|
|
219
|
-
positions.set(nsId, { x: nodeRank * COLUMN_GAP, y: cursorY })
|
|
220
|
-
cursorY += estimateNodeHeight(nodeByNsId.get(nsId)!) + ROW_GAP
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return positions
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function computeLiveCounts(
|
|
227
|
-
flowKey: string,
|
|
228
|
-
rootFlowKey: string,
|
|
229
|
-
livePositions: readonly FlowLivePosition[] | undefined,
|
|
230
|
-
): Record<string, number> {
|
|
231
|
-
const counts: Record<string, number> = {}
|
|
232
|
-
for (const position of livePositions ?? []) {
|
|
233
|
-
if (position.flow !== flowKey && flowKey !== rootFlowKey) continue
|
|
234
|
-
const nodeId = flowKey === rootFlowKey ? position.menuNodeId : position.nodeId
|
|
235
|
-
if (!nodeId) continue
|
|
236
|
-
counts[nodeId] = (counts[nodeId] ?? 0) + 1
|
|
128
|
+
/**
|
|
129
|
+
* Traduz o papel da aresta em traço, cor e seta.
|
|
130
|
+
*
|
|
131
|
+
* O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
|
|
132
|
+
* conversa do cliente parar; cor errada aparece na primeira olhada.
|
|
133
|
+
*/
|
|
134
|
+
function styleEdge(spec: FlowEdgeSpec): Edge {
|
|
135
|
+
const color = spec.crossFlow
|
|
136
|
+
? EDGE_COLOR_CROSS_FLOW
|
|
137
|
+
: spec.kind === 'fallback'
|
|
138
|
+
? EDGE_COLOR_FALLBACK
|
|
139
|
+
: spec.live
|
|
140
|
+
? EDGE_COLOR_LIVE
|
|
141
|
+
: spec.kind === 'branch'
|
|
142
|
+
? EDGE_COLOR_BRANCH
|
|
143
|
+
: EDGE_COLOR_LINEAR
|
|
144
|
+
const baseWidth = spec.kind === 'branch' ? 1.75 : 1.5
|
|
145
|
+
const dash = spec.crossFlow ? '3 3' : spec.kind === 'fallback' ? '5 4' : undefined
|
|
146
|
+
const markerSize = spec.kind === 'fallback' ? 16 : 18
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: spec.id,
|
|
150
|
+
source: spec.source,
|
|
151
|
+
target: spec.target,
|
|
152
|
+
...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
|
|
153
|
+
type: 'bezier',
|
|
154
|
+
animated: spec.live,
|
|
155
|
+
style: {
|
|
156
|
+
stroke: color,
|
|
157
|
+
strokeWidth: spec.live && !spec.crossFlow ? 2.5 : baseWidth,
|
|
158
|
+
...(dash === undefined ? {} : { strokeDasharray: dash }),
|
|
159
|
+
},
|
|
160
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: markerSize, height: markerSize },
|
|
237
161
|
}
|
|
238
|
-
return counts
|
|
239
162
|
}
|
|
240
163
|
|
|
241
|
-
// Constrói as ligações de TODOS os fluxos abertos juntos. Um salto "flow:<key>" vira ligação
|
|
242
|
-
// real até o nó inicial do fluxo alvo quando esse fluxo já está mesclado no canvas; caso
|
|
243
|
-
// contrário, continua indo até o portal (pseudo-nó) daquele fluxo, como antes da fusão.
|
|
244
|
-
function buildAllEdges(
|
|
245
|
-
openFlows: readonly OpenFlow[],
|
|
246
|
-
workingGraphs: Record<string, FlowGraphData>,
|
|
247
|
-
rootFlowKey: string,
|
|
248
|
-
livePositions: readonly FlowLivePosition[] | undefined,
|
|
249
|
-
): Edge[] {
|
|
250
|
-
const openKeys = new Set(openFlows.map((flow) => flow.key))
|
|
251
|
-
const edges: Edge[] = []
|
|
252
|
-
|
|
253
|
-
for (const { key: flowKey } of openFlows) {
|
|
254
|
-
const graph = workingGraphs[flowKey]
|
|
255
|
-
if (!graph) continue
|
|
256
|
-
const liveCounts = computeLiveCounts(flowKey, rootFlowKey, livePositions)
|
|
257
|
-
|
|
258
|
-
for (const [id, node] of Object.entries(graph.nodes)) {
|
|
259
|
-
const isLive = (liveCounts[id] ?? 0) > 0
|
|
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
|
-
|
|
267
|
-
const crossFlow = isCrossFlowTarget(target)
|
|
268
|
-
let targetFlowKey = flowKey
|
|
269
|
-
let rawTargetId = target
|
|
270
|
-
if (crossFlow) {
|
|
271
|
-
const wantedKey = crossFlowKey(target)
|
|
272
|
-
const targetGraph = openKeys.has(wantedKey) ? workingGraphs[wantedKey] : undefined
|
|
273
|
-
if (targetGraph) {
|
|
274
|
-
targetFlowKey = wantedKey
|
|
275
|
-
rawTargetId = targetGraph.startNodeId
|
|
276
|
-
} else {
|
|
277
|
-
rawTargetId = portalNodeId(id, target)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
const source = namespaceNodeId(flowKey, id)
|
|
281
|
-
const edgeTarget = namespaceNodeId(targetFlowKey, rawTargetId)
|
|
282
|
-
|
|
283
|
-
if (optionId === undefined && !isDefault) {
|
|
284
|
-
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR
|
|
285
|
-
edges.push({
|
|
286
|
-
id: `${source}->${edgeTarget}`,
|
|
287
|
-
source,
|
|
288
|
-
target: edgeTarget,
|
|
289
|
-
type: 'bezier',
|
|
290
|
-
animated: isLive,
|
|
291
|
-
style: crossFlow
|
|
292
|
-
? { stroke: color, strokeWidth: 1.5, strokeDasharray: '3 3' }
|
|
293
|
-
: { stroke: color, strokeWidth: isLive ? 2.5 : 1.5 },
|
|
294
|
-
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
295
|
-
})
|
|
296
|
-
} else if (isDefault) {
|
|
297
|
-
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : EDGE_COLOR_FALLBACK
|
|
298
|
-
edges.push({
|
|
299
|
-
id: `${source}->${edgeTarget}-default`,
|
|
300
|
-
source,
|
|
301
|
-
sourceHandle: '__default',
|
|
302
|
-
target: edgeTarget,
|
|
303
|
-
type: 'bezier',
|
|
304
|
-
style: { stroke: color, strokeWidth: 1.5, strokeDasharray: '5 4' },
|
|
305
|
-
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
|
|
306
|
-
})
|
|
307
|
-
} else {
|
|
308
|
-
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_BRANCH
|
|
309
|
-
edges.push({
|
|
310
|
-
id: `${source}->${edgeTarget}-${optionId}`,
|
|
311
|
-
source,
|
|
312
|
-
sourceHandle: optionId,
|
|
313
|
-
target: edgeTarget,
|
|
314
|
-
type: 'bezier',
|
|
315
|
-
animated: isLive,
|
|
316
|
-
style: crossFlow
|
|
317
|
-
? { stroke: color, strokeWidth: 1.75, strokeDasharray: '3 3' }
|
|
318
|
-
: { stroke: color, strokeWidth: isLive ? 2.5 : 1.75 },
|
|
319
|
-
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
320
|
-
})
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return edges
|
|
326
|
-
}
|
|
327
164
|
|
|
328
|
-
function newNodeFromSpec(spec: NewNodeSpec, existingIds: Set<string>): FlowNodeData {
|
|
329
|
-
if (spec.kind === 'question') {
|
|
330
|
-
const id = slugifyNodeId('nova_pergunta', existingIds)
|
|
331
|
-
return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
|
|
332
|
-
}
|
|
333
|
-
if (spec.kind === 'decision') {
|
|
334
|
-
const id = slugifyNodeId('nova_decisao', existingIds)
|
|
335
|
-
return {
|
|
336
|
-
id,
|
|
337
|
-
type: 'question',
|
|
338
|
-
questionType: 'choice',
|
|
339
|
-
contextKey: id,
|
|
340
|
-
question: '',
|
|
341
|
-
options: [
|
|
342
|
-
['1', 'Opção 1'],
|
|
343
|
-
['2', 'Opção 2'],
|
|
344
|
-
],
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (spec.kind === 'condition') {
|
|
348
|
-
const id = slugifyNodeId('nova_condicao', existingIds)
|
|
349
|
-
return { id, type: 'condition', conditionOperator: '>' }
|
|
350
|
-
}
|
|
351
|
-
const id = slugifyNodeId('nova_acao', existingIds)
|
|
352
|
-
return { id, type: 'action', actionKind: spec.actionKind }
|
|
353
|
-
}
|
|
354
165
|
|
|
355
166
|
function extractErrorMessage(error: unknown): string | undefined {
|
|
356
167
|
if (error instanceof Error) return error.message
|
|
@@ -388,7 +199,7 @@ export function FlowsWorkspace({
|
|
|
388
199
|
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
389
200
|
const [livePositions, setLivePositions] = useState<FlowLivePosition[] | undefined>(undefined)
|
|
390
201
|
const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
|
|
391
|
-
const [
|
|
202
|
+
const [openFlowKeys, setOpenFlowKeys] = useState<readonly string[]>([rootFlowKey])
|
|
392
203
|
const [hasAutoMerged, setHasAutoMerged] = useState(false)
|
|
393
204
|
const [workingGraphs, setWorkingGraphs] = useState<Record<string, FlowGraphData>>({})
|
|
394
205
|
const [editingRef, setEditingRef] = useState<{ flowKey: string; nodeId: string } | null>(null)
|
|
@@ -440,7 +251,7 @@ export function FlowsWorkspace({
|
|
|
440
251
|
}
|
|
441
252
|
}, [api, livePollIntervalMs])
|
|
442
253
|
|
|
443
|
-
const primaryFlowKey =
|
|
254
|
+
const primaryFlowKey = openFlowKeys[0] ?? rootFlowKey
|
|
444
255
|
const primaryGraph = workingGraphs[primaryFlowKey]
|
|
445
256
|
|
|
446
257
|
// Assim que os fluxos carregam pela primeira vez, mescla automaticamente todo o fecho
|
|
@@ -449,7 +260,7 @@ export function FlowsWorkspace({
|
|
|
449
260
|
// fica inteiramente sob controle do usuário.
|
|
450
261
|
useEffect(() => {
|
|
451
262
|
if (!graphs || hasAutoMerged) return
|
|
452
|
-
|
|
263
|
+
setOpenFlowKeys(mergedFlowKeysFrom(rootFlowKey, graphs))
|
|
453
264
|
setHasAutoMerged(true)
|
|
454
265
|
}, [graphs, hasAutoMerged, rootFlowKey])
|
|
455
266
|
|
|
@@ -461,7 +272,7 @@ export function FlowsWorkspace({
|
|
|
461
272
|
setWorkingGraphs((prev) => {
|
|
462
273
|
let changed = false
|
|
463
274
|
const next = { ...prev }
|
|
464
|
-
for (const
|
|
275
|
+
for (const key of openFlowKeys) {
|
|
465
276
|
if (!next[key] && graphs[key]) {
|
|
466
277
|
next[key] = graphs[key]
|
|
467
278
|
changed = true
|
|
@@ -469,7 +280,7 @@ export function FlowsWorkspace({
|
|
|
469
280
|
}
|
|
470
281
|
return changed ? next : prev
|
|
471
282
|
})
|
|
472
|
-
}, [graphs,
|
|
283
|
+
}, [graphs, openFlowKeys])
|
|
473
284
|
|
|
474
285
|
const isFlowDirty = useCallback(
|
|
475
286
|
(key: string): boolean => {
|
|
@@ -483,12 +294,12 @@ export function FlowsWorkspace({
|
|
|
483
294
|
|
|
484
295
|
const issuesByFlow = useMemo<Record<string, GraphIssue[]>>(() => {
|
|
485
296
|
const map: Record<string, GraphIssue[]> = {}
|
|
486
|
-
for (const
|
|
297
|
+
for (const key of openFlowKeys) {
|
|
487
298
|
const graph = workingGraphs[key]
|
|
488
299
|
if (graph) map[key] = validateGraph(graph, labels.validation)
|
|
489
300
|
}
|
|
490
301
|
return map
|
|
491
|
-
}, [
|
|
302
|
+
}, [openFlowKeys, workingGraphs, labels])
|
|
492
303
|
|
|
493
304
|
const errorCount = Object.values(issuesByFlow).reduce(
|
|
494
305
|
(sum, list) => sum + list.filter((issue) => issue.severity === 'error').length,
|
|
@@ -498,7 +309,7 @@ export function FlowsWorkspace({
|
|
|
498
309
|
(sum, list) => sum + list.filter((issue) => issue.severity === 'warning').length,
|
|
499
310
|
0,
|
|
500
311
|
)
|
|
501
|
-
const dirtyKeys =
|
|
312
|
+
const dirtyKeys = openFlowKeys.filter(isFlowDirty)
|
|
502
313
|
const isDirty = dirtyKeys.length > 0
|
|
503
314
|
|
|
504
315
|
const updateFlow = useCallback((flowKey: string, updater: (graph: FlowGraphData) => FlowGraphData) => {
|
|
@@ -514,19 +325,19 @@ export function FlowsWorkspace({
|
|
|
514
325
|
// agora é sempre mostrar tudo que está conectado.
|
|
515
326
|
const focusFlow = useCallback(
|
|
516
327
|
(key: string) => {
|
|
517
|
-
const others =
|
|
518
|
-
if (others.some(
|
|
519
|
-
|
|
328
|
+
const others = openFlowKeys.filter((each) => each !== key)
|
|
329
|
+
if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
|
|
330
|
+
setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key])
|
|
520
331
|
if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
|
|
521
332
|
},
|
|
522
|
-
[
|
|
333
|
+
[openFlowKeys, isFlowDirty, editingRef, graphs, labels],
|
|
523
334
|
)
|
|
524
335
|
|
|
525
336
|
// "Fechar": remove um fluxo mesclado sem trocar o foco do primário.
|
|
526
337
|
const closeFlow = useCallback(
|
|
527
338
|
(key: string) => {
|
|
528
339
|
if (isFlowDirty(key) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
|
|
529
|
-
|
|
340
|
+
setOpenFlowKeys((prev) => prev.filter((each) => each !== key))
|
|
530
341
|
setWorkingGraphs((prev) => {
|
|
531
342
|
const { [key]: _removed, ...rest } = prev
|
|
532
343
|
return rest
|
|
@@ -536,40 +347,24 @@ export function FlowsWorkspace({
|
|
|
536
347
|
[isFlowDirty, editingRef, labels],
|
|
537
348
|
)
|
|
538
349
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
(sourceGraph ? computeAutoLayout(sourceGraph)[source.nodeId] : undefined) ?? { x: 0, y: 0 }
|
|
550
|
-
const baseOffset = sourceOpen?.offset ?? { x: 0, y: 0 }
|
|
551
|
-
return [
|
|
552
|
-
...prev,
|
|
553
|
-
{
|
|
554
|
-
key: targetFlowKey,
|
|
555
|
-
offset: {
|
|
556
|
-
x: baseOffset.x + sourceLocalPosition.x + 400,
|
|
557
|
-
y: baseOffset.y + sourceLocalPosition.y,
|
|
558
|
-
},
|
|
559
|
-
},
|
|
560
|
-
]
|
|
561
|
-
})
|
|
562
|
-
},
|
|
563
|
-
[workingGraphs],
|
|
564
|
-
)
|
|
350
|
+
/**
|
|
351
|
+
* Fusão editável: traz os nós de verdade do fluxo alvo para o mesmo canvas, no lugar do portal.
|
|
352
|
+
*
|
|
353
|
+
* Não calcula posição. Abrir um segundo fluxo liga o layout mesclado, que posiciona TODOS os nós
|
|
354
|
+
* junto e em coordenadas absolutas — um deslocamento calculado aqui seria descartado no desenho e
|
|
355
|
+
* ainda assim subtraído ao gravar, que era como a posição do card ia parar errada no grafo.
|
|
356
|
+
*/
|
|
357
|
+
const mergeFlow = useCallback((targetFlowKey: string) => {
|
|
358
|
+
setOpenFlowKeys((prev) => (prev.includes(targetFlowKey) ? prev : [...prev, targetFlowKey]))
|
|
359
|
+
}, [])
|
|
565
360
|
|
|
566
361
|
// Mais de um fluxo aberto = layout global (ignora node.position individual, recalcula tudo
|
|
567
362
|
// junto pra nunca sobrepor); um só fluxo aberto = comportamento de sempre (respeita posição
|
|
568
363
|
// salva/arrastada, com fallback pro auto-layout daquele fluxo isolado).
|
|
569
|
-
const isMerged =
|
|
364
|
+
const isMerged = openFlowKeys.length > 1
|
|
570
365
|
const mergedPositions = useMemo(
|
|
571
|
-
() => (isMerged ? computeMergedLayout(
|
|
572
|
-
[isMerged,
|
|
366
|
+
() => (isMerged ? computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey }) : null),
|
|
367
|
+
[isMerged, openFlowKeys, workingGraphs, primaryFlowKey],
|
|
573
368
|
)
|
|
574
369
|
|
|
575
370
|
// Última posição em que cada nó foi desenhado, e ela manda sobre o layout calculado.
|
|
@@ -583,11 +378,11 @@ export function FlowsWorkspace({
|
|
|
583
378
|
|
|
584
379
|
const derivedNodes = useMemo<Node[]>(() => {
|
|
585
380
|
const allNodes: Node[] = []
|
|
586
|
-
for (const
|
|
381
|
+
for (const flowKey of openFlowKeys) {
|
|
587
382
|
const graph = workingGraphs[flowKey]
|
|
588
383
|
if (!graph) continue
|
|
589
384
|
const fallbackPositions = mergedPositions ? {} : computeAutoLayout(graph)
|
|
590
|
-
const liveCounts =
|
|
385
|
+
const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
|
|
591
386
|
const flowIssues = issuesByFlow[flowKey] ?? []
|
|
592
387
|
const isPrimary = flowKey === primaryFlowKey
|
|
593
388
|
|
|
@@ -606,8 +401,8 @@ export function FlowsWorkspace({
|
|
|
606
401
|
const nsId = namespaceNodeId(flowKey, nodeId)
|
|
607
402
|
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
|
|
608
403
|
}
|
|
609
|
-
|
|
610
|
-
return { x:
|
|
404
|
+
// Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
|
|
405
|
+
return graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
|
|
611
406
|
}
|
|
612
407
|
|
|
613
408
|
for (const node of Object.values(graph.nodes)) {
|
|
@@ -636,7 +431,7 @@ export function FlowsWorkspace({
|
|
|
636
431
|
const crossFlowTargets = [...new Set(targetsOf(node).map((edge) => edge.target).filter(isCrossFlowTarget))]
|
|
637
432
|
crossFlowTargets.forEach((target, index) => {
|
|
638
433
|
const targetFlowKey = crossFlowKey(target)
|
|
639
|
-
if (
|
|
434
|
+
if (openFlowKeys.includes(targetFlowKey)) return
|
|
640
435
|
allNodes.push({
|
|
641
436
|
id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
|
|
642
437
|
type: 'flowPortal',
|
|
@@ -645,7 +440,7 @@ export function FlowsWorkspace({
|
|
|
645
440
|
position: { x: position.x + 320, y: position.y + index * 70 },
|
|
646
441
|
data: {
|
|
647
442
|
label: graphs?.[targetFlowKey]?.label ?? targetFlowKey,
|
|
648
|
-
onNavigate: () => mergeFlow(targetFlowKey
|
|
443
|
+
onNavigate: () => mergeFlow(targetFlowKey),
|
|
649
444
|
} satisfies FlowPortalNodeData,
|
|
650
445
|
})
|
|
651
446
|
})
|
|
@@ -698,7 +493,7 @@ export function FlowsWorkspace({
|
|
|
698
493
|
}
|
|
699
494
|
return allNodes
|
|
700
495
|
}, [
|
|
701
|
-
|
|
496
|
+
openFlowKeys,
|
|
702
497
|
workingGraphs,
|
|
703
498
|
mergedPositions,
|
|
704
499
|
livePositions,
|
|
@@ -714,8 +509,9 @@ export function FlowsWorkspace({
|
|
|
714
509
|
])
|
|
715
510
|
|
|
716
511
|
const edges = useMemo(
|
|
717
|
-
() =>
|
|
718
|
-
|
|
512
|
+
() =>
|
|
513
|
+
buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map(styleEdge),
|
|
514
|
+
[openFlowKeys, workingGraphs, rootFlowKey, livePositions],
|
|
719
515
|
)
|
|
720
516
|
|
|
721
517
|
useEffect(() => {
|
|
@@ -744,27 +540,16 @@ export function FlowsWorkspace({
|
|
|
744
540
|
(_event: unknown, node: Node) => {
|
|
745
541
|
renderedPositionsRef.current.set(node.id, node.position)
|
|
746
542
|
const { flowKey, nodeId } = parseNamespacedId(node.id)
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
543
|
+
if (!openFlowKeys.includes(flowKey)) return
|
|
544
|
+
|
|
545
|
+
// A posição do card É a posição do grafo: não há mais deslocamento por fluxo a desfazer aqui.
|
|
761
546
|
updateFlow(flowKey, (graph) =>
|
|
762
547
|
graph.nodes[nodeId]
|
|
763
|
-
? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position:
|
|
548
|
+
? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: node.position } } }
|
|
764
549
|
: graph,
|
|
765
550
|
)
|
|
766
551
|
},
|
|
767
|
-
[
|
|
552
|
+
[openFlowKeys, updateFlow],
|
|
768
553
|
)
|
|
769
554
|
|
|
770
555
|
const onConnect = useCallback(
|
|
@@ -815,17 +600,14 @@ export function FlowsWorkspace({
|
|
|
815
600
|
renderedPositionsRef.current.clear()
|
|
816
601
|
|
|
817
602
|
if (isMerged) {
|
|
818
|
-
const positions = computeMergedLayout(
|
|
819
|
-
for (const
|
|
603
|
+
const positions = computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey })
|
|
604
|
+
for (const key of openFlowKeys) {
|
|
820
605
|
updateFlow(key, (graph) => ({
|
|
821
606
|
...graph,
|
|
822
607
|
nodes: Object.fromEntries(
|
|
823
608
|
Object.entries(graph.nodes).map(([id, node]) => {
|
|
824
609
|
const position = positions.get(namespaceNodeId(key, id))
|
|
825
|
-
return [
|
|
826
|
-
id,
|
|
827
|
-
position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node,
|
|
828
|
-
]
|
|
610
|
+
return [id, position ? { ...node, position } : node]
|
|
829
611
|
}),
|
|
830
612
|
),
|
|
831
613
|
}))
|
|
@@ -886,7 +668,7 @@ export function FlowsWorkspace({
|
|
|
886
668
|
...(newFlow.showInMenu ? { menuOptionLabel: newFlow.menuOptionLabel || newFlow.label } : {}),
|
|
887
669
|
})
|
|
888
670
|
await reloadGraphs()
|
|
889
|
-
|
|
671
|
+
setOpenFlowKeys([newFlow.key])
|
|
890
672
|
setShowCreateDialog(false)
|
|
891
673
|
setNewFlow({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
|
|
892
674
|
setFlowMutationState({ pending: false })
|
|
@@ -902,7 +684,7 @@ export function FlowsWorkspace({
|
|
|
902
684
|
try {
|
|
903
685
|
await deleteFlow(primaryGraph.key)
|
|
904
686
|
const reloaded = await reloadGraphs()
|
|
905
|
-
|
|
687
|
+
setOpenFlowKeys(reloaded ? mergedFlowKeysFrom(rootFlowKey, reloaded) : [rootFlowKey])
|
|
906
688
|
setShowDeleteDialog(false)
|
|
907
689
|
setFlowMutationState({ pending: false })
|
|
908
690
|
} catch (error) {
|