@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.
@@ -13,6 +13,7 @@
13
13
  import { namespaceNodeId, parseNamespacedId } from './flowEditorOps'
14
14
  import {
15
15
  NODE_CARD_WIDTH,
16
+ cascadeOrder,
16
17
  crossFlowKey,
17
18
  estimateNodeHeight,
18
19
  isCrossFlowTarget,
@@ -107,11 +108,12 @@ export function countLiveByNode(params: {
107
108
  }
108
109
 
109
110
  /**
110
- * Um layout só para TODOS os nós de TODOS os fluxos abertos juntos.
111
+ * Um layout só para TODOS os nós de TODOS os fluxos abertos juntos, na mesma cascata do fluxo
112
+ * único: um passo para a direita a cada avanço da conversa, um degrau para baixo a cada card.
111
113
  *
112
114
  * Posicionar cada fluxo à parte e deslocar não resolve: nada impede dois fluxos de ocuparem o mesmo
113
- * espaço, e a altura real de cada card é ignorada. Aqui o ranqueamento por BFS roda sobre o grafo
114
- * mesclado inteiro, com os saltos `flow:<key>` já resolvidos para o nó inicial do alvo.
115
+ * espaço, e a altura real de cada card é ignorada. Aqui a travessia roda sobre o grafo mesclado
116
+ * inteiro, com os saltos `flow:<key>` já resolvidos para o nó inicial do alvo.
115
117
  */
116
118
  export function computeMergedLayout(params: {
117
119
  readonly openKeys: readonly string[]
@@ -128,7 +130,10 @@ export function computeMergedLayout(params: {
128
130
  }
129
131
  }
130
132
 
131
- function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
133
+ function forwardEdges(id: string): string[] {
134
+ const node = nodeById.get(id)
135
+ if (!node) return []
136
+ const flowKey = parseNamespacedId(id).flowKey
132
137
  const result: string[] = []
133
138
 
134
139
  for (const { target } of targetsOf(node)) {
@@ -144,49 +149,19 @@ export function computeMergedLayout(params: {
144
149
  return result
145
150
  }
146
151
 
147
- const rank = new Map<string, number>()
148
152
  const primaryGraph = graphs[primaryFlowKey]
149
- const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
150
-
151
- if (rootId && nodeById.has(rootId)) {
152
- rank.set(rootId, 0)
153
- const queue = [rootId]
154
- while (queue.length > 0) {
155
- const id = queue.shift()!
156
- const node = nodeById.get(id)
157
- if (!node) continue
158
- for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
159
- if (!rank.has(nextId)) {
160
- rank.set(nextId, rank.get(id)! + 1)
161
- queue.push(nextId)
162
- }
163
- }
164
- }
165
- }
166
-
167
- // Todo nó não alcançado vai para UMA coluna extra depois de tudo. Uma coluna por órfão empurrava
168
- // cada um mais para longe da área visível — e desligar um fio se lia como "o card sumiu".
169
- const strayRank = Math.max(0, ...rank.values()) + 1
170
- for (const id of nodeById.keys()) {
171
- if (!rank.has(id)) rank.set(id, strayRank)
172
- }
173
-
174
- const layers = new Map<number, string[]>()
175
- for (const [id, value] of rank) {
176
- const layer = layers.get(value)
177
- if (layer) layer.push(id)
178
- else layers.set(value, [id])
179
- }
153
+ const placed = cascadeOrder({
154
+ rootId: primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : '',
155
+ allIds: [...nodeById.keys()],
156
+ forwardEdges,
157
+ })
180
158
 
181
159
  const positions = new Map<string, FlowNodePosition>()
182
- for (const value of [...layers.keys()].sort((a, b) => a - b)) {
183
- // Alinhado pelo topo, e não centralizado: coluna centralizada saltava inteira a cada nó a mais
184
- // num ramo qualquer.
185
- let cursorY = 0
186
- for (const id of layers.get(value)!) {
187
- positions.set(id, { x: value * COLUMN_GAP, y: cursorY })
188
- cursorY += estimateNodeHeight(nodeById.get(id)!) + ROW_GAP
189
- }
160
+ let cursorY = 0
161
+
162
+ for (const [id, { depth }] of [...placed.entries()].sort((a, b) => a[1].order - b[1].order)) {
163
+ positions.set(id, { x: depth * COLUMN_GAP, y: cursorY })
164
+ cursorY += estimateNodeHeight(nodeById.get(id)!) + ROW_GAP
190
165
  }
191
166
 
192
167
  return positions
@@ -256,6 +231,11 @@ export function buildFlowEdges(params: {
256
231
  const source = namespaceNodeId(flowKey, nodeId)
257
232
  const edgeTarget = namespaceNodeId(targetFlowKey, targetNodeId)
258
233
 
234
+ // Voltar ao próprio card não é trajeto, é comportamento — e como aresta não tem onde
235
+ // caber: por baixo some atrás do card, por cima o cobre. Quem mostra isso é o ícone de
236
+ // repetição na linha de saída, dentro do card (ver `FlowNodeCard`).
237
+ if (source === edgeTarget) continue
238
+
259
239
  if (isDefault) {
260
240
  edges.push({
261
241
  id: `${source}->${edgeTarget}-default`,
@@ -366,3 +346,33 @@ export function newNodeFromSpec(spec: NewNodeSpec, existingIds: ReadonlySet<stri
366
346
  const id = slugifyNodeId('nova_acao', existingIds)
367
347
  return { id, type: 'action', actionKind: spec.actionKind }
368
348
  }
349
+
350
+ /** Distância vertical mínima entre dois cards para que não se leiam como um só. */
351
+ const FREE_SLOT_STEP = 120
352
+
353
+ /**
354
+ * Empurra a posição para baixo até achar espaço livre na mesma coluna.
355
+ *
356
+ * O "+" põe o nó novo à direita de quem o criou — e é justamente ali que costuma estar o nó que
357
+ * acabou de perder a ligação. Sem isto, os dois se sobrepõem e a tela mostra um card onde há dois,
358
+ * que é o mesmo susto de "sumiu" por outro caminho.
359
+ */
360
+ export function findFreeSlot(params: {
361
+ readonly desired: FlowNodePosition
362
+ readonly taken: readonly FlowNodePosition[]
363
+ readonly step?: number
364
+ }): FlowNodePosition {
365
+ const step = params.step ?? FREE_SLOT_STEP
366
+ const isOccupied = (candidate: FlowNodePosition) =>
367
+ params.taken.some(
368
+ (each) => Math.abs(each.x - candidate.x) < NODE_CARD_WIDTH && Math.abs(each.y - candidate.y) < step,
369
+ )
370
+
371
+ let slot = params.desired
372
+ // Teto igual ao número de cards: com N ocupados, N descidas bastam para sair de todos.
373
+ for (let attempt = 0; attempt <= params.taken.length && isOccupied(slot); attempt += 1) {
374
+ slot = { x: slot.x, y: slot.y + step }
375
+ }
376
+
377
+ return slot
378
+ }
@@ -15,6 +15,7 @@ import type { FlowGraphData, FlowNodeData } from '@adatechnology/meta-whatsapp-c
15
15
 
16
16
  import {
17
17
  applyConnection,
18
+ clearConnection,
18
19
  isGraphDirty,
19
20
  mergedFlowKeysFrom,
20
21
  namespaceNodeId,
@@ -239,3 +240,37 @@ describe('rascunho sujo', () => {
239
240
  expect(isGraphDirty(published, undefined)).toBe(false)
240
241
  })
241
242
  })
243
+
244
+ /**
245
+ * Desligar um fio é a operação que o relato de campo pegou errada: o card sumia. Some porque a
246
+ * opção deixa de existir no `byAnswer`, não porque alguém apagou o nó — e o teste fixa isso.
247
+ */
248
+ describe('clearConnection', () => {
249
+ it('saída única vira destino vazio, e o nó continua lá', () => {
250
+ expect(clearConnection(node('a', 'b'), 'next').next).toBe('')
251
+ })
252
+
253
+ it('desligar uma opção NÃO apaga a chave — a opção sumindo levaria junto o botão do WhatsApp', () => {
254
+ const branching = node('a', { byAnswer: { sim: 'b', nao: 'c' }, default: 'd' })
255
+
256
+ const result = clearConnection(branching, 'sim')
257
+
258
+ expect(result.next).toEqual({ byAnswer: { sim: '', nao: 'c' }, default: 'd' })
259
+ })
260
+
261
+ it('desligar o fallback preserva as opções', () => {
262
+ const branching = node('a', { byAnswer: { sim: 'b' }, default: 'd' })
263
+
264
+ expect(clearConnection(branching, '__default').next).toEqual({ byAnswer: { sim: 'b' }, default: '' })
265
+ })
266
+
267
+ it('handle inexistente não inventa ramificação nem derruba as existentes', () => {
268
+ const branching = node('a', { byAnswer: { sim: 'b' }, default: 'd' })
269
+
270
+ expect(clearConnection(branching, 'talvez').next).toEqual({ byAnswer: { sim: 'b', talvez: '' }, default: 'd' })
271
+ })
272
+
273
+ it('nó sem next algum não quebra', () => {
274
+ expect(clearConnection(node('a'), 'next').next).toBe('')
275
+ })
276
+ })
@@ -175,3 +175,28 @@ export function isGraphDirty(working: FlowGraphData | undefined, published: Flow
175
175
  if (!working || !published) return false
176
176
  return JSON.stringify(working) !== JSON.stringify(published)
177
177
  }
178
+
179
+ /**
180
+ * Desliga UMA saída do nó, preservando as outras.
181
+ *
182
+ * O que não é óbvio: desligar é gravar destino vazio, nunca apagar a chave. Numa ramificação, a
183
+ * opção sem `byAnswer` some do card — e some junto o botão que o cliente via no WhatsApp, o que se
184
+ * lê como "o editor apagou minha opção". Vazio mantém a linha visível, cobrando a religação.
185
+ */
186
+ export function clearConnection(node: FlowNodeData, handle: string): FlowNodeData {
187
+ if (handle === 'next' || typeof node.next === 'string' || node.next === undefined) {
188
+ return { ...node, next: '' }
189
+ }
190
+
191
+ if (handle === '__default') {
192
+ return { ...node, next: { byAnswer: node.next.byAnswer ?? {}, default: '' } }
193
+ }
194
+
195
+ return {
196
+ ...node,
197
+ next: {
198
+ byAnswer: { ...(node.next.byAnswer ?? {}), [handle]: '' },
199
+ default: node.next.default ?? '',
200
+ },
201
+ }
202
+ }
@@ -38,6 +38,16 @@ export const BUILT_IN_ACTION_KINDS = FLOW_ACTION_KIND
38
38
  // (em vez de importado do contracts) de propósito: são três linhas triviais e importá-las como
39
39
  // valor puxaria o runtime do contracts para o bundle do frontend só por causa disso. O contracts
40
40
  // exporta as mesmas funções para o backend; a convenção "flow:" é o contrato de fato.
41
+ /**
42
+ * Ações depois das quais a conversa CONTINUA no grafo, em vez de terminar.
43
+ *
44
+ * A distinção não é estética: o motor do bot só anda para o `next` depois de `send_media` — as
45
+ * demais ações (handoff, encerrar, simular, catálogo) encerram o passo ali. Dar saída a uma ação
46
+ * que o motor não atravessa desenharia um fio que o bot ignora em produção, deixando a conversa
47
+ * parada sem ninguém entender por quê — o mesmo erro que `resolveConnection` recusa cometer.
48
+ */
49
+ export const PASS_THROUGH_ACTION_KINDS: readonly string[] = ['send_media']
50
+
41
51
  export const CROSS_FLOW_PREFIX = 'flow:'
42
52
  export const isCrossFlowTarget = (target: string): boolean => target.startsWith(CROSS_FLOW_PREFIX)
43
53
  export const crossFlowKey = (target: string): string => target.slice(CROSS_FLOW_PREFIX.length)
@@ -54,7 +64,10 @@ export function estimateNodeHeight(node: FlowNodeData): number {
54
64
  const ROW_HEIGHT = 34
55
65
  const rowCount =
56
66
  node.type === 'action'
57
- ? 0
67
+ ? // Ação de passagem desenha uma linha de saída; terminal não desenha nenhuma.
68
+ node.actionKind && PASS_THROUGH_ACTION_KINDS.includes(node.actionKind)
69
+ ? 1
70
+ : 0
58
71
  : node.type === 'condition'
59
72
  ? 2
60
73
  : node.type === 'menu' || node.questionType === 'choice'
@@ -206,59 +219,71 @@ function findUnreachable(graph: FlowGraphData): string[] {
206
219
  return Object.keys(graph.nodes).filter((id) => !reachable.has(id))
207
220
  }
208
221
 
209
- // Auto-layout hierárquico (sem dependência externa): ranqueia por BFS a partir do início,
210
- // distribui cada camada horizontalmente e centraliza. Suficiente para fluxos de conversa
211
- // (grafos rasos, quase-árvores) sem puxar uma lib de layout inteira.
212
- export function computeAutoLayout(graph: FlowGraphData): Record<string, { x: number; y: number }> {
213
- const H_GAP = 300
214
- // Espaço extra entre a camada mais alta de uma linha e a próxima linha, além da altura real
215
- // do card mais alto dela sem isso, um menu com várias opções (card bem mais alto) encostaria
216
- // na camada de baixo mesmo com um gap fixo pensado pra cards curtos.
217
- const V_GAP = 90
218
- const rank: Record<string, number> = {}
219
- const queue: string[] = [graph.startNodeId]
220
- rank[graph.startNodeId] = 0
221
-
222
- while (queue.length > 0) {
223
- const id = queue.shift()!
224
- const node = graph.nodes[id]
225
- if (!node) continue
226
- for (const { target } of targetsOf(node)) {
227
- if (isCrossFlowTarget(target) || !graph.nodes[target]) continue
228
- if (rank[target] === undefined) {
229
- rank[target] = rank[id]! + 1
230
- queue.push(target)
231
- }
232
- }
222
+ /** Distância horizontal entre um card e o seguinte na cascata. */
223
+ export const LAYOUT_COLUMN_GAP = 300
224
+ /** Folga vertical entre um card e o de baixo, somada à altura real do de cima. */
225
+ export const LAYOUT_ROW_GAP = 40
226
+
227
+ /**
228
+ * Ordem de leitura do grafo: profundidade primeiro, seguindo as saídas na ordem em que o card as
229
+ * mostra.
230
+ *
231
+ * Profundidade, e não largura, porque é ela que mantém um caminho de conversa junto na tela — com
232
+ * BFS, os dois ramos de uma decisão se intercalam linha a linha e o olho perde qual leva a qual.
233
+ */
234
+ export function cascadeOrder(params: {
235
+ readonly rootId: string
236
+ readonly allIds: readonly string[]
237
+ readonly forwardEdges: (id: string) => readonly string[]
238
+ }): Map<string, { depth: number; order: number }> {
239
+ const placed = new Map<string, { depth: number; order: number }>()
240
+
241
+ function visit(id: string, depth: number): void {
242
+ if (placed.has(id)) return
243
+ placed.set(id, { depth, order: placed.size })
244
+ for (const next of params.forwardEdges(id)) visit(next, depth + 1)
233
245
  }
234
246
 
235
- // Nós não alcançáveis vão para uma camada abaixo da última, na ordem em que aparecem.
236
- const maxRank = Math.max(0, ...Object.values(rank))
237
- let strayRank = maxRank + 1
238
- for (const id of Object.keys(graph.nodes)) {
239
- if (rank[id] === undefined) rank[id] = strayRank++
240
- }
247
+ if (params.allIds.includes(params.rootId)) visit(params.rootId, 0)
241
248
 
242
- const layers: Record<number, string[]> = {}
243
- for (const [id, r] of Object.entries(rank)) {
244
- layers[r] = [...(layers[r] ?? []), id]
249
+ // Órfãos entram depois, todos na mesma coluna extra: uma coluna por órfão empurrava cada um para
250
+ // mais longe da área visível, e desligar um fio se lia como "o card sumiu".
251
+ const strayDepth = Math.max(0, ...[...placed.values()].map((each) => each.depth)) + 1
252
+ for (const id of params.allIds) {
253
+ if (!placed.has(id)) placed.set(id, { depth: strayDepth, order: placed.size })
245
254
  }
246
255
 
256
+ return placed
257
+ }
258
+
259
+ /**
260
+ * Auto-layout em cascata: avança para a direita a cada passo do fluxo e desce a cada card.
261
+ *
262
+ * **Um card por linha, sempre.** Empilhar por camada — todos do mesmo nível na mesma coluna, todas
263
+ * as colunas começando na mesma altura — deixava os fios correndo na horizontal, e um fio horizontal
264
+ * passa por trás de qualquer card que esteja entre a origem e o destino. Descendo um degrau por
265
+ * card, toda ligação vira uma diagonal curta e visível, e o caminho da conversa se lê de cima para
266
+ * baixo enquanto avança da esquerda para a direita.
267
+ */
268
+ export function computeAutoLayout(graph: FlowGraphData): Record<string, { x: number; y: number }> {
269
+ const placed = cascadeOrder({
270
+ rootId: graph.startNodeId,
271
+ allIds: Object.keys(graph.nodes),
272
+ forwardEdges: (id) =>
273
+ targetsOf(graph.nodes[id] ?? { id, type: 'action' })
274
+ .map((edge) => edge.target)
275
+ .filter((target) => !isCrossFlowTarget(target) && Boolean(graph.nodes[target])),
276
+ })
277
+
278
+ const byOrder = [...placed.entries()].sort((a, b) => a[1].order - b[1].order)
247
279
  const positions: Record<string, { x: number; y: number }> = {}
248
- const sortedRanks = Object.keys(layers)
249
- .map(Number)
250
- .sort((a, b) => a - b)
251
- let cumulativeY = 0
252
- for (const r of sortedRanks) {
253
- const ids = layers[r]!
254
- const width = (ids.length - 1) * H_GAP
255
- let maxHeight = 0
256
- ids.forEach((id, index) => {
257
- maxHeight = Math.max(maxHeight, estimateNodeHeight(graph.nodes[id]!))
258
- positions[id] = { x: index * H_GAP - width / 2, y: cumulativeY }
259
- })
260
- cumulativeY += maxHeight + V_GAP
280
+ let cursorY = 0
281
+
282
+ for (const [id, { depth }] of byOrder) {
283
+ positions[id] = { x: depth * LAYOUT_COLUMN_GAP, y: cursorY }
284
+ cursorY += estimateNodeHeight(graph.nodes[id]!) + LAYOUT_ROW_GAP
261
285
  }
286
+
262
287
  return positions
263
288
  }
264
289
 
@@ -6,7 +6,9 @@ export { FlowMapCanvas } from './FlowMapCanvas'
6
6
  export { FlowGroupFrame, flowGroupFrameNodeTypes } from './FlowGroupFrame'
7
7
  export { FlowGroupHeader, flowGroupHeaderNodeTypes } from './FlowGroupHeader'
8
8
  export { FlowPortalNode, flowPortalNodeTypes } from './FlowPortalNode'
9
- export { FlowPalette } from './FlowPalette'
9
+ export { FlowPalette, FlowPaletteMenu } from './FlowPalette'
10
+ export { FlowLegend } from './FlowLegend'
11
+ export { FlowConnectionEdge, flowEdgeTypes } from './FlowConnectionEdge'
10
12
  export { FlowNodePanel } from './FlowNodePanel'
11
13
  export { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
12
14
  export { FlowsWorkspace } from './FlowsWorkspace'
@@ -16,6 +18,7 @@ export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
16
18
  export {
17
19
  CONDITION_OPERATORS,
18
20
  BUILT_IN_ACTION_KINDS,
21
+ PASS_THROUGH_ACTION_KINDS,
19
22
  NODE_CARD_WIDTH,
20
23
  WHATSAPP_LIMITS,
21
24
  CROSS_FLOW_PREFIX,
@@ -56,6 +56,25 @@ export interface FlowEditorLabels {
56
56
  media: string
57
57
  mediaUnavailable: string
58
58
  }
59
+ quickAdd: {
60
+ fromHandle: string
61
+ title: string
62
+ disconnect: string
63
+ }
64
+ /** Legenda do canvas: o que cada traço e cada contorno querem dizer. */
65
+ legendPanel: {
66
+ title: string
67
+ nodes: string
68
+ connections: string
69
+ linear: string
70
+ branch: string
71
+ fallback: string
72
+ crossFlow: string
73
+ live: string
74
+ selfLoop: string
75
+ detached: string
76
+ startNode: string
77
+ }
59
78
  palette: {
60
79
  title: string
61
80
  question: string
@@ -224,6 +243,24 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
224
243
  media: 'Arquivos enviados neste ponto',
225
244
  mediaUnavailable: 'A biblioteca de arquivos não está disponível neste painel.',
226
245
  },
246
+ quickAdd: {
247
+ fromHandle: 'Criar o próximo nó já ligado aqui',
248
+ title: 'Ligar em um nó novo',
249
+ disconnect: 'Desligar este fio (o nó continua no fluxo)',
250
+ },
251
+ legendPanel: {
252
+ title: 'Legenda',
253
+ nodes: 'Cards',
254
+ connections: 'Ligações',
255
+ linear: 'Segue direto para o próximo',
256
+ branch: 'Caminho de uma opção escolhida',
257
+ fallback: 'Quando a resposta não casa com nenhuma opção',
258
+ crossFlow: 'Salta para outro fluxo',
259
+ live: 'Tem conversa passando por aqui agora',
260
+ selfLoop: 'Volta ao mesmo card — repete a pergunta',
261
+ detached: 'Ninguém aponta para este card: o bot não chega nele',
262
+ startNode: 'Onde o fluxo começa',
263
+ },
227
264
  palette: {
228
265
  title: 'Adicionar ao fluxo',
229
266
  question: 'Pergunta',
@@ -316,6 +353,8 @@ export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): Flo
316
353
  },
317
354
  questionTypeLabels: { ...DEFAULT_FLOW_EDITOR_LABELS.questionTypeLabels, ...override.questionTypeLabels },
318
355
  nodePanel: { ...DEFAULT_FLOW_EDITOR_LABELS.nodePanel, ...override.nodePanel },
356
+ quickAdd: { ...DEFAULT_FLOW_EDITOR_LABELS.quickAdd, ...override.quickAdd },
357
+ legendPanel: { ...DEFAULT_FLOW_EDITOR_LABELS.legendPanel, ...override.legendPanel },
319
358
  palette: { ...DEFAULT_FLOW_EDITOR_LABELS.palette, ...override.palette },
320
359
  flowMap: { ...DEFAULT_FLOW_EDITOR_LABELS.flowMap, ...override.flowMap },
321
360
  flowGroup: { ...DEFAULT_FLOW_EDITOR_LABELS.flowGroup, ...override.flowGroup },