@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
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O que se prova aqui é topologia e posição, não estilo.
|
|
3
|
+
*
|
|
4
|
+
* Aresta com destino errado não pinta errado — ela manda a conversa do cliente para outro lugar, e
|
|
5
|
+
* quem editou nunca vê. Card que o layout joga para fora da área visível se lê como "apaguei sem
|
|
6
|
+
* querer". São os dois modos de falhar que já aconteceram nesta tela, e os dois são silenciosos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildFlowEdges,
|
|
13
|
+
chainFrameBounds,
|
|
14
|
+
computeMergedLayout,
|
|
15
|
+
countLiveByNode,
|
|
16
|
+
detachedNodeIds,
|
|
17
|
+
newNodeFromSpec,
|
|
18
|
+
portalNodeId,
|
|
19
|
+
type FlowLivePosition,
|
|
20
|
+
} from './flowCanvasModel'
|
|
21
|
+
import type { FlowGraphData, FlowNodeData } from './flowGraph'
|
|
22
|
+
|
|
23
|
+
const ROOT = 'menu'
|
|
24
|
+
|
|
25
|
+
/** Nós mínimos VÁLIDOS pelo contrato — sem `as`, para quebrar quando a forma do nó mudar. */
|
|
26
|
+
function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
|
|
27
|
+
return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
|
|
31
|
+
return {
|
|
32
|
+
key: params.key,
|
|
33
|
+
label: params.key,
|
|
34
|
+
version: 1,
|
|
35
|
+
startNodeId: params.start,
|
|
36
|
+
nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function live(params: Partial<FlowLivePosition>): FlowLivePosition {
|
|
41
|
+
return { currentState: 'x', flow: null, nodeId: null, menuNodeId: null, ...params }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('arestas', () => {
|
|
45
|
+
it('saída única vira aresta linear sem handle', () => {
|
|
46
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b')] }) }
|
|
47
|
+
|
|
48
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([
|
|
49
|
+
{ id: 'menu::a->menu::b', source: 'menu::a', target: 'menu::b', kind: 'linear', crossFlow: false, live: false },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ramificação leva o id da opção no handle, e o default vira fallback', () => {
|
|
54
|
+
const graphs = {
|
|
55
|
+
menu: graph({
|
|
56
|
+
key: 'menu',
|
|
57
|
+
start: 'p',
|
|
58
|
+
nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
|
|
63
|
+
|
|
64
|
+
expect(edges.find((each) => each.kind === 'branch')).toMatchObject({ sourceHandle: 'sim', target: 'menu::x' })
|
|
65
|
+
// O handle do fallback é fixo `__default` — é o que `applyConnection` espera de volta quando
|
|
66
|
+
// alguém arrasta esse fio.
|
|
67
|
+
expect(edges.find((each) => each.kind === 'fallback')).toMatchObject({
|
|
68
|
+
sourceHandle: '__default',
|
|
69
|
+
target: 'menu::y',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('destino vazio NÃO gera aresta', () => {
|
|
74
|
+
// Apagar um nó zera quem apontava para ele. A aresta iria para `menu::`, que não existe, e o
|
|
75
|
+
// React Flow descarta em silêncio — a opção pareceria ligada sem estar.
|
|
76
|
+
const graphs = {
|
|
77
|
+
menu: graph({ key: 'menu', start: 'p', nodes: [node('p', { byAnswer: { sim: '' }, default: '' })] }),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('salto para fluxo MESCLADO liga no nó inicial dele, não num portal', () => {
|
|
84
|
+
const graphs = {
|
|
85
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
86
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
expect(buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })[0]).toMatchObject({
|
|
90
|
+
target: 'sim::s1',
|
|
91
|
+
crossFlow: true,
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('salto para fluxo NÃO mesclado cai no portal', () => {
|
|
96
|
+
const graphs = {
|
|
97
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
98
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })[0]!.target).toBe(
|
|
102
|
+
`menu::${portalNodeId('a', 'flow:sim')}`,
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('duas opções para o mesmo fluxo compartilham UM portal', () => {
|
|
107
|
+
const graphs = {
|
|
108
|
+
menu: graph({
|
|
109
|
+
key: 'menu',
|
|
110
|
+
start: 'p',
|
|
111
|
+
nodes: [node('p', { byAnswer: { um: 'flow:sim', dois: 'flow:sim' }, default: '' })],
|
|
112
|
+
}),
|
|
113
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
|
|
117
|
+
|
|
118
|
+
// Duas arestas (uma por opção), um só destino: senão o card fica cercado de caixas iguais.
|
|
119
|
+
expect(edges).toHaveLength(2)
|
|
120
|
+
expect(new Set(edges.map((each) => each.target)).size).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('conversa parada no nó anima a aresta que sai dele, mas nunca o fallback', () => {
|
|
124
|
+
const graphs = {
|
|
125
|
+
menu: graph({
|
|
126
|
+
key: 'menu',
|
|
127
|
+
start: 'p',
|
|
128
|
+
nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
|
|
129
|
+
}),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const edges = buildFlowEdges({
|
|
133
|
+
openKeys: ['menu'],
|
|
134
|
+
graphs,
|
|
135
|
+
rootFlowKey: ROOT,
|
|
136
|
+
livePositions: [live({ menuNodeId: 'p' })],
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(edges.find((each) => each.kind === 'branch')!.live).toBe(true)
|
|
140
|
+
// Fallback piscando sugeria que a conversa estava passando pelo caminho que ninguém escolheu.
|
|
141
|
+
expect(edges.find((each) => each.kind === 'fallback')!.live).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('ids de aresta são únicos entre fluxos com nós homônimos', () => {
|
|
145
|
+
const graphs = {
|
|
146
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
|
|
147
|
+
sim: graph({ key: 'sim', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const edges = buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })
|
|
151
|
+
|
|
152
|
+
expect(new Set(edges.map((each) => each.id)).size).toBe(edges.length)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('contagem ao vivo', () => {
|
|
157
|
+
it('no fluxo RAIZ a posição vem de menuNodeId', () => {
|
|
158
|
+
// O servidor guarda o passo do menu num campo próprio, e conversa no menu não carrega `flow`.
|
|
159
|
+
// Ler `nodeId` aqui daria zero na tela mais visitada do editor.
|
|
160
|
+
const positions = [live({ menuNodeId: 'saudacao' }), live({ menuNodeId: 'saudacao' })]
|
|
161
|
+
|
|
162
|
+
expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 2 })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('fora da raiz a posição vem de nodeId, e outro fluxo é ignorado', () => {
|
|
166
|
+
const positions = [live({ flow: 'sim', nodeId: 'renda' }), live({ flow: 'outro', nodeId: 'renda' })]
|
|
167
|
+
|
|
168
|
+
expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 1 })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('sem posições devolve vazio, em vez de estourar', () => {
|
|
172
|
+
expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions: undefined })).toEqual({})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('layout mesclado', () => {
|
|
177
|
+
it('ranqueia da esquerda para a direita a partir do início do fluxo primário', () => {
|
|
178
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
|
|
179
|
+
|
|
180
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
181
|
+
|
|
182
|
+
expect(positions.get('menu::a')!.x).toBe(0)
|
|
183
|
+
expect(positions.get('menu::b')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
184
|
+
expect(positions.get('menu::c')!.x).toBeGreaterThan(positions.get('menu::b')!.x)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('atravessa o salto entre fluxos ao ranquear', () => {
|
|
188
|
+
const graphs = {
|
|
189
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
190
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const positions = computeMergedLayout({ openKeys: ['menu', 'sim'], graphs, primaryFlowKey: 'menu' })
|
|
194
|
+
|
|
195
|
+
expect(positions.get('sim::s1')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('TODOS os órfãos ficam na MESMA coluna extra', () => {
|
|
199
|
+
// Uma coluna por órfão empurrava cada um mais para longe da área visível, e desligar um fio se
|
|
200
|
+
// lia como "o card sumiu". É o motivo de existir uma camada única.
|
|
201
|
+
const graphs = {
|
|
202
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2'), node('o3')] }),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
206
|
+
const strayX = ['o1', 'o2', 'o3'].map((id) => positions.get(`menu::${id}`)!.x)
|
|
207
|
+
|
|
208
|
+
expect(new Set(strayX).size).toBe(1)
|
|
209
|
+
expect(strayX[0]).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('órfãos na mesma coluna não se sobrepõem', () => {
|
|
213
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2')] }) }
|
|
214
|
+
|
|
215
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
216
|
+
|
|
217
|
+
expect(positions.get('menu::o1')!.y).not.toBe(positions.get('menu::o2')!.y)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('ciclo não estoura a pilha', () => {
|
|
221
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'a')] }) }
|
|
222
|
+
|
|
223
|
+
expect(computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' }).size).toBe(2)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('fluxo primário ausente ainda posiciona todo mundo, em vez de devolver vazio', () => {
|
|
227
|
+
const graphs = { sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }) }
|
|
228
|
+
|
|
229
|
+
// Acontece entre o clique em "focar" e a chegada do grafo: sem isto o canvas fica em branco.
|
|
230
|
+
expect(computeMergedLayout({ openKeys: ['sim'], graphs, primaryFlowKey: 'menu' }).size).toBe(1)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('nós soltos', () => {
|
|
235
|
+
it('quem ninguém aponta está solto; o nó inicial nunca está', () => {
|
|
236
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b'), node('solto')] })
|
|
237
|
+
|
|
238
|
+
expect(detachedNodeIds(target)).toEqual(new Set(['solto']))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('salto para outro fluxo não conta como ligação local', () => {
|
|
242
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim'), node('b')] })
|
|
243
|
+
|
|
244
|
+
expect(detachedNodeIds(target).has('b')).toBe(true)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('moldura da cadeia de coleta', () => {
|
|
249
|
+
it('envolve todos os nós e abre espaço para o rótulo acima', () => {
|
|
250
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a'), node('b')] })
|
|
251
|
+
const positions: Record<string, { x: number; y: number }> = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 } }
|
|
252
|
+
|
|
253
|
+
const bounds = chainFrameBounds({
|
|
254
|
+
nodeIds: ['a', 'b'],
|
|
255
|
+
graph: target,
|
|
256
|
+
positionOf: (id) => positions[id]!,
|
|
257
|
+
padding: 10,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
expect(bounds.x).toBe(-10)
|
|
261
|
+
// O rótulo é desenhado acima da borda, então o topo sobe além do padding.
|
|
262
|
+
expect(bounds.y).toBeLessThan(-10)
|
|
263
|
+
expect(bounds.width).toBeGreaterThan(100)
|
|
264
|
+
expect(bounds.height).toBeGreaterThan(50)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('nó novo da paleta', () => {
|
|
269
|
+
it('pergunta nasce com contextKey igual ao id', () => {
|
|
270
|
+
// Sem contextKey o motor faz a pergunta e descarta a resposta, sem erro em lugar nenhum.
|
|
271
|
+
const created = newNodeFromSpec({ kind: 'question', questionType: 'text' }, new Set())
|
|
272
|
+
|
|
273
|
+
expect(created.contextKey).toBe(created.id)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('decisão nasce com duas opções, para já dar o que ligar', () => {
|
|
277
|
+
const created = newNodeFromSpec({ kind: 'decision' }, new Set())
|
|
278
|
+
|
|
279
|
+
expect(created.options).toHaveLength(2)
|
|
280
|
+
expect(created.questionType).toBe('choice')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('id não colide com o que já existe', () => {
|
|
284
|
+
const first = newNodeFromSpec({ kind: 'condition' }, new Set())
|
|
285
|
+
const second = newNodeFromSpec({ kind: 'condition' }, new Set([first.id]))
|
|
286
|
+
|
|
287
|
+
expect(second.id).not.toBe(first.id)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('ação carrega o kind escolhido na paleta', () => {
|
|
291
|
+
expect(newNodeFromSpec({ kind: 'action', actionKind: 'handoff' }, new Set()).actionKind).toBe('handoff')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derivações puras do canvas do editor: posições, arestas, contagem de conversas vivas e o nó que
|
|
3
|
+
* nasce de cada item da paleta.
|
|
4
|
+
*
|
|
5
|
+
* Viviam dentro do componente, e é ali que estava o risco. Uma aresta com destino errado não pinta
|
|
6
|
+
* errado — ela leva a conversa do cliente para o lugar errado, e o sintoma aparece longe de quem
|
|
7
|
+
* editou. Um card que o layout joga para fora da área visível se lê como "apaguei sem querer".
|
|
8
|
+
*
|
|
9
|
+
* A topologia sai daqui em forma neutra (`FlowEdgeSpec`) e o estilo fica no componente: o teste cobra
|
|
10
|
+
* o que importa e roda sem navegador nem `@xyflow/react`. Cor de traço ninguém quebra sem ver.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { namespaceNodeId, parseNamespacedId } from './flowEditorOps'
|
|
14
|
+
import {
|
|
15
|
+
NODE_CARD_WIDTH,
|
|
16
|
+
crossFlowKey,
|
|
17
|
+
estimateNodeHeight,
|
|
18
|
+
isCrossFlowTarget,
|
|
19
|
+
slugifyNodeId,
|
|
20
|
+
targetsOf,
|
|
21
|
+
type FlowGraphData,
|
|
22
|
+
type FlowNodeData,
|
|
23
|
+
} from './flowGraph'
|
|
24
|
+
import type { NewNodeSpec } from './FlowPalette'
|
|
25
|
+
|
|
26
|
+
const COLUMN_GAP = 360
|
|
27
|
+
const ROW_GAP = 40
|
|
28
|
+
/** O rótulo da moldura é desenhado acima da borda, e precisa de espaço próprio. */
|
|
29
|
+
const CHAIN_LABEL_ROOM = 24
|
|
30
|
+
|
|
31
|
+
export type FlowNodePosition = { readonly x: number; readonly y: number }
|
|
32
|
+
|
|
33
|
+
/** Onde cada conversa viva está parada agora. */
|
|
34
|
+
export type FlowLivePosition = {
|
|
35
|
+
readonly currentState: string
|
|
36
|
+
readonly flow: string | null
|
|
37
|
+
readonly nodeId: string | null
|
|
38
|
+
readonly menuNodeId: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pseudo-nó que representa um fluxo alvo ainda não mesclado no canvas.
|
|
43
|
+
*
|
|
44
|
+
* Um portal por (nó de origem, fluxo alvo): duas opções do mesmo nó indo para o mesmo fluxo
|
|
45
|
+
* compartilham o portal, senão o card ficaria cercado de caixas idênticas.
|
|
46
|
+
*/
|
|
47
|
+
export function portalNodeId(sourceNodeId: string, target: string): string {
|
|
48
|
+
return `__portal__${sourceNodeId}__${target}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function chainFrameNodeId(actionNodeId: string): string {
|
|
52
|
+
return `__chain__${actionNodeId}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const GROUP_HEADER_NODE_ID = '__group_header__'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Quantas conversas estão paradas em cada nó de um fluxo.
|
|
59
|
+
*
|
|
60
|
+
* A raiz é caso à parte: o servidor guarda o passo do menu em `menuNodeId`, num campo próprio, e uma
|
|
61
|
+
* conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem zero na tela mais visitada do
|
|
62
|
+
* editor.
|
|
63
|
+
*/
|
|
64
|
+
export function countLiveByNode(params: {
|
|
65
|
+
readonly flowKey: string
|
|
66
|
+
readonly rootFlowKey: string
|
|
67
|
+
readonly positions: readonly FlowLivePosition[] | undefined
|
|
68
|
+
}): Record<string, number> {
|
|
69
|
+
const { flowKey, rootFlowKey, positions } = params
|
|
70
|
+
const isRoot = flowKey === rootFlowKey
|
|
71
|
+
const counts: Record<string, number> = {}
|
|
72
|
+
|
|
73
|
+
for (const position of positions ?? []) {
|
|
74
|
+
if (position.flow !== flowKey && !isRoot) continue
|
|
75
|
+
const nodeId = isRoot ? position.menuNodeId : position.nodeId
|
|
76
|
+
if (!nodeId) continue
|
|
77
|
+
counts[nodeId] = (counts[nodeId] ?? 0) + 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return counts
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Um layout só para TODOS os nós de TODOS os fluxos abertos juntos.
|
|
85
|
+
*
|
|
86
|
+
* Posicionar cada fluxo à parte e deslocar não resolve: nada impede dois fluxos de ocuparem o mesmo
|
|
87
|
+
* espaço, e a altura real de cada card é ignorada. Aqui o ranqueamento por BFS roda sobre o grafo
|
|
88
|
+
* mesclado inteiro, com os saltos `flow:<key>` já resolvidos para o nó inicial do alvo.
|
|
89
|
+
*/
|
|
90
|
+
export function computeMergedLayout(params: {
|
|
91
|
+
readonly openKeys: readonly string[]
|
|
92
|
+
readonly graphs: Readonly<Record<string, FlowGraphData>>
|
|
93
|
+
readonly primaryFlowKey: string
|
|
94
|
+
}): Map<string, FlowNodePosition> {
|
|
95
|
+
const { openKeys, graphs, primaryFlowKey } = params
|
|
96
|
+
const open = new Set(openKeys)
|
|
97
|
+
|
|
98
|
+
const nodeById = new Map<string, FlowNodeData>()
|
|
99
|
+
for (const key of openKeys) {
|
|
100
|
+
for (const node of Object.values(graphs[key]?.nodes ?? {})) {
|
|
101
|
+
nodeById.set(namespaceNodeId(key, node.id), node)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
|
|
106
|
+
const result: string[] = []
|
|
107
|
+
|
|
108
|
+
for (const { target } of targetsOf(node)) {
|
|
109
|
+
if (isCrossFlowTarget(target)) {
|
|
110
|
+
const targetKey = crossFlowKey(target)
|
|
111
|
+
const targetGraph = open.has(targetKey) ? graphs[targetKey] : undefined
|
|
112
|
+
if (targetGraph) result.push(namespaceNodeId(targetKey, targetGraph.startNodeId))
|
|
113
|
+
} else if (graphs[flowKey]?.nodes[target]) {
|
|
114
|
+
result.push(namespaceNodeId(flowKey, target))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rank = new Map<string, number>()
|
|
122
|
+
const primaryGraph = graphs[primaryFlowKey]
|
|
123
|
+
const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
|
|
124
|
+
|
|
125
|
+
if (rootId && nodeById.has(rootId)) {
|
|
126
|
+
rank.set(rootId, 0)
|
|
127
|
+
const queue = [rootId]
|
|
128
|
+
while (queue.length > 0) {
|
|
129
|
+
const id = queue.shift()!
|
|
130
|
+
const node = nodeById.get(id)
|
|
131
|
+
if (!node) continue
|
|
132
|
+
for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
|
|
133
|
+
if (!rank.has(nextId)) {
|
|
134
|
+
rank.set(nextId, rank.get(id)! + 1)
|
|
135
|
+
queue.push(nextId)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Todo nó não alcançado vai para UMA coluna extra depois de tudo. Uma coluna por órfão empurrava
|
|
142
|
+
// cada um mais para longe da área visível — e desligar um fio se lia como "o card sumiu".
|
|
143
|
+
const strayRank = Math.max(0, ...rank.values()) + 1
|
|
144
|
+
for (const id of nodeById.keys()) {
|
|
145
|
+
if (!rank.has(id)) rank.set(id, strayRank)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const layers = new Map<number, string[]>()
|
|
149
|
+
for (const [id, value] of rank) {
|
|
150
|
+
const layer = layers.get(value)
|
|
151
|
+
if (layer) layer.push(id)
|
|
152
|
+
else layers.set(value, [id])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const positions = new Map<string, FlowNodePosition>()
|
|
156
|
+
for (const value of [...layers.keys()].sort((a, b) => a - b)) {
|
|
157
|
+
// Alinhado pelo topo, e não centralizado: coluna centralizada saltava inteira a cada nó a mais
|
|
158
|
+
// num ramo qualquer.
|
|
159
|
+
let cursorY = 0
|
|
160
|
+
for (const id of layers.get(value)!) {
|
|
161
|
+
positions.set(id, { x: value * COLUMN_GAP, y: cursorY })
|
|
162
|
+
cursorY += estimateNodeHeight(nodeById.get(id)!) + ROW_GAP
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return positions
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Papel visual da aresta. O componente traduz em traço, cor e seta; o modelo só decide qual é. */
|
|
170
|
+
export type FlowEdgeKind = 'linear' | 'branch' | 'fallback'
|
|
171
|
+
|
|
172
|
+
export type FlowEdgeSpec = {
|
|
173
|
+
readonly id: string
|
|
174
|
+
readonly source: string
|
|
175
|
+
readonly target: string
|
|
176
|
+
readonly sourceHandle?: string
|
|
177
|
+
readonly kind: FlowEdgeKind
|
|
178
|
+
/** Salto entre fluxos — tracejado, esteja o alvo mesclado ou num portal. */
|
|
179
|
+
readonly crossFlow: boolean
|
|
180
|
+
readonly live: boolean
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Arestas de todos os fluxos abertos.
|
|
185
|
+
*
|
|
186
|
+
* A regra que não é óbvia: um salto `flow:<key>` vira ligação real até o nó inicial do alvo quando
|
|
187
|
+
* esse fluxo já está no canvas, e só cai no portal quando não está. Sem isso, abrir dois fluxos
|
|
188
|
+
* ligados mostrava uma caixa de portal entre dois cards que já estavam ali do lado.
|
|
189
|
+
*/
|
|
190
|
+
export function buildFlowEdges(params: {
|
|
191
|
+
readonly openKeys: readonly string[]
|
|
192
|
+
readonly graphs: Readonly<Record<string, FlowGraphData>>
|
|
193
|
+
readonly rootFlowKey: string
|
|
194
|
+
readonly livePositions?: readonly FlowLivePosition[] | undefined
|
|
195
|
+
}): FlowEdgeSpec[] {
|
|
196
|
+
const { openKeys, graphs, rootFlowKey, livePositions } = params
|
|
197
|
+
const open = new Set(openKeys)
|
|
198
|
+
const edges: FlowEdgeSpec[] = []
|
|
199
|
+
|
|
200
|
+
for (const flowKey of openKeys) {
|
|
201
|
+
const graph = graphs[flowKey]
|
|
202
|
+
if (!graph) continue
|
|
203
|
+
const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
|
|
204
|
+
|
|
205
|
+
for (const [nodeId, node] of Object.entries(graph.nodes)) {
|
|
206
|
+
const live = (liveCounts[nodeId] ?? 0) > 0
|
|
207
|
+
|
|
208
|
+
for (const { target, optionId, isDefault } of targetsOf(node)) {
|
|
209
|
+
// Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
|
|
210
|
+
// `targetsOf` devolve o `default` mesmo em branco. Emitir a aresta criaria ligação para um nó
|
|
211
|
+
// que não existe — o React Flow a descarta em silêncio, então o sintoma seria uma opção que
|
|
212
|
+
// parece ligada e não está, com a conversa do cliente parando ali.
|
|
213
|
+
if (!target) continue
|
|
214
|
+
|
|
215
|
+
const crossFlow = isCrossFlowTarget(target)
|
|
216
|
+
let targetFlowKey = flowKey
|
|
217
|
+
let targetNodeId = target
|
|
218
|
+
|
|
219
|
+
if (crossFlow) {
|
|
220
|
+
const wantedKey = crossFlowKey(target)
|
|
221
|
+
const targetGraph = open.has(wantedKey) ? graphs[wantedKey] : undefined
|
|
222
|
+
if (targetGraph) {
|
|
223
|
+
targetFlowKey = wantedKey
|
|
224
|
+
targetNodeId = targetGraph.startNodeId
|
|
225
|
+
} else {
|
|
226
|
+
targetNodeId = portalNodeId(nodeId, target)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const source = namespaceNodeId(flowKey, nodeId)
|
|
231
|
+
const edgeTarget = namespaceNodeId(targetFlowKey, targetNodeId)
|
|
232
|
+
|
|
233
|
+
if (isDefault) {
|
|
234
|
+
edges.push({
|
|
235
|
+
id: `${source}->${edgeTarget}-default`,
|
|
236
|
+
source,
|
|
237
|
+
target: edgeTarget,
|
|
238
|
+
sourceHandle: '__default',
|
|
239
|
+
kind: 'fallback',
|
|
240
|
+
crossFlow,
|
|
241
|
+
// Fallback não anima: é o caminho que ninguém escolheu, e piscar sugeria que a conversa
|
|
242
|
+
// estava passando por ali.
|
|
243
|
+
live: false,
|
|
244
|
+
})
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (optionId === undefined) {
|
|
249
|
+
edges.push({ id: `${source}->${edgeTarget}`, source, target: edgeTarget, kind: 'linear', crossFlow, live })
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
edges.push({
|
|
254
|
+
id: `${source}->${edgeTarget}-${optionId}`,
|
|
255
|
+
source,
|
|
256
|
+
target: edgeTarget,
|
|
257
|
+
sourceHandle: optionId,
|
|
258
|
+
kind: 'branch',
|
|
259
|
+
crossFlow,
|
|
260
|
+
live,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return edges
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Nós de um fluxo que ninguém aponta — o card ganha contorno tracejado para cobrar a ligação. */
|
|
270
|
+
export function detachedNodeIds(graph: FlowGraphData): Set<string> {
|
|
271
|
+
const connected = new Set<string>()
|
|
272
|
+
for (const node of Object.values(graph.nodes)) {
|
|
273
|
+
for (const { target } of targetsOf(node)) {
|
|
274
|
+
if (!isCrossFlowTarget(target)) connected.add(target)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const detached = new Set<string>()
|
|
279
|
+
for (const nodeId of Object.keys(graph.nodes)) {
|
|
280
|
+
if (nodeId !== graph.startNodeId && !connected.has(nodeId)) detached.add(nodeId)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return detached
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Retângulo que envolve uma cadeia de coleta, em coordenadas do canvas. */
|
|
287
|
+
export function chainFrameBounds(params: {
|
|
288
|
+
readonly nodeIds: readonly string[]
|
|
289
|
+
readonly graph: FlowGraphData
|
|
290
|
+
readonly positionOf: (nodeId: string) => FlowNodePosition
|
|
291
|
+
readonly padding: number
|
|
292
|
+
}): { x: number; y: number; width: number; height: number } {
|
|
293
|
+
const { nodeIds, graph, positionOf, padding } = params
|
|
294
|
+
const positions = nodeIds.map(positionOf)
|
|
295
|
+
const minX = Math.min(...positions.map((each) => each.x))
|
|
296
|
+
const maxX = Math.max(...positions.map((each) => each.x)) + NODE_CARD_WIDTH
|
|
297
|
+
const minY = Math.min(...positions.map((each) => each.y))
|
|
298
|
+
const maxY = Math.max(...nodeIds.map((id, index) => positions[index]!.y + estimateNodeHeight(graph.nodes[id]!)))
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
x: minX - padding,
|
|
302
|
+
y: minY - padding - CHAIN_LABEL_ROOM,
|
|
303
|
+
width: maxX - minX + padding * 2,
|
|
304
|
+
height: maxY - minY + padding * 2 + CHAIN_LABEL_ROOM,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* O nó que nasce de cada item da paleta.
|
|
310
|
+
*
|
|
311
|
+
* `contextKey` igual ao id porque é o que o motor usa para guardar a resposta: deixá-lo vazio faria a
|
|
312
|
+
* pergunta ser feita e a resposta descartada, sem erro em lugar nenhum.
|
|
313
|
+
*/
|
|
314
|
+
export function newNodeFromSpec(spec: NewNodeSpec, existingIds: ReadonlySet<string>): FlowNodeData {
|
|
315
|
+
if (spec.kind === 'question') {
|
|
316
|
+
const id = slugifyNodeId('nova_pergunta', existingIds)
|
|
317
|
+
return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (spec.kind === 'decision') {
|
|
321
|
+
const id = slugifyNodeId('nova_decisao', existingIds)
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
type: 'question',
|
|
325
|
+
questionType: 'choice',
|
|
326
|
+
contextKey: id,
|
|
327
|
+
question: '',
|
|
328
|
+
options: [
|
|
329
|
+
['1', 'Opção 1'],
|
|
330
|
+
['2', 'Opção 2'],
|
|
331
|
+
],
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (spec.kind === 'condition') {
|
|
336
|
+
const id = slugifyNodeId('nova_condicao', existingIds)
|
|
337
|
+
return { id, type: 'condition', conditionOperator: '>' }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const id = slugifyNodeId('nova_acao', existingIds)
|
|
341
|
+
return { id, type: 'action', actionKind: spec.actionKind }
|
|
342
|
+
}
|
package/src/flows/index.ts
CHANGED
|
@@ -71,3 +71,24 @@ export {
|
|
|
71
71
|
resolveConnection,
|
|
72
72
|
} from './flowEditorOps'
|
|
73
73
|
export type { ConnectionRequest, ResolvedConnection } from './flowEditorOps'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Modelo do canvas: posições, arestas, contagem ao vivo e o nó novo da paleta.
|
|
77
|
+
*
|
|
78
|
+
* Exportado porque é consumido pelo `FlowsWorkspace` e tem teste próprio — as decisões daqui não dão
|
|
79
|
+
* erro quando erram, dão conversa parada e card fora da tela.
|
|
80
|
+
*/
|
|
81
|
+
export {
|
|
82
|
+
buildFlowEdges,
|
|
83
|
+
chainFrameBounds,
|
|
84
|
+
chainFrameNodeId,
|
|
85
|
+
computeMergedLayout,
|
|
86
|
+
countLiveByNode,
|
|
87
|
+
detachedNodeIds,
|
|
88
|
+
newNodeFromSpec,
|
|
89
|
+
portalNodeId,
|
|
90
|
+
GROUP_HEADER_NODE_ID,
|
|
91
|
+
} from './flowCanvasModel'
|
|
92
|
+
// `FlowLivePosition` já sai pelo `FlowsWorkspace`, que é onde o produto a encontra ao implementar a
|
|
93
|
+
// api — exportar de novo aqui daria dois caminhos para o mesmo tipo.
|
|
94
|
+
export type { FlowEdgeKind, FlowEdgeSpec, FlowNodePosition } from './flowCanvasModel'
|