@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.
@@ -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
+ }
@@ -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'