@adatechnology/conversations-ui 0.1.0-rc.27 → 0.1.0-rc.28

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,177 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Operações puras de grafo que o editor de fluxo precisa, e que viviam soltas dentro da página de
5
+ * 973 linhas do financiamento.
6
+ *
7
+ * Puras e separadas do hook de propósito: são a parte que dá para testar sem navegador, sem React e
8
+ * sem estado — e três delas (`removeNodeAndCleanRefs`, `resolveConnection`, `mergedFlowKeysFrom`)
9
+ * decidem o que acontece com o fluxo que alguém desenhou. Errar ali não dá erro; dá aresta apontando
10
+ * para nó que não existe mais, ou fluxo que some do canvas.
11
+ */
12
+
13
+ // Os TIPOS do grafo vêm do meta-whatsapp-contracts, fonte única da verdade do trio
14
+ // (`pluggable-module.md` §2) — mesmo motivo documentado em `flowGraph.ts`. `import type` puro: nada
15
+ // do contracts entra por aqui em tempo de execução.
16
+ import type { FlowGraphData, FlowNodeData, FlowNodeNext } from '@adatechnology/meta-whatsapp-contracts'
17
+
18
+ // A constante vem do `flowGraph`, que já a define, e não do contracts: duas fontes do mesmo prefixo
19
+ // divergem em silêncio, e o sintoma seria salto entre fluxos que o motor do bot não reconhece.
20
+ import { CROSS_FLOW_PREFIX, crossFlowTargetsOf } from './flowGraph'
21
+
22
+ const NAMESPACE_SEPARATOR = '::'
23
+
24
+ /**
25
+ * Id de nó no canvas mesclado: vários fluxos dividem o mesmo espaço, e `boas-vindas` pode existir em
26
+ * dois deles. Sem o prefixo, arrastar um card moveria o homônimo do outro fluxo.
27
+ */
28
+ export function namespaceNodeId(flowKey: string, nodeId: string): string {
29
+ return `${flowKey}${NAMESPACE_SEPARATOR}${nodeId}`
30
+ }
31
+
32
+ export function parseNamespacedId(value: string): { flowKey: string; nodeId: string } {
33
+ const index = value.indexOf(NAMESPACE_SEPARATOR)
34
+ // Sem separador é id de fluxo único — trata como do fluxo vazio para o chamador decidir.
35
+ if (index === -1) return { flowKey: '', nodeId: value }
36
+ return { flowKey: value.slice(0, index), nodeId: value.slice(index + NAMESPACE_SEPARATOR.length) }
37
+ }
38
+
39
+ /**
40
+ * Apaga o nó E as referências a ele.
41
+ *
42
+ * A limpeza não é cortesia: uma aresta apontando para nó inexistente faz o motor do bot parar a
43
+ * conversa no meio, e o sintoma aparece para o cliente, não para quem editou.
44
+ */
45
+ export function removeNodeAndCleanRefs(
46
+ nodes: Readonly<Record<string, FlowNodeData>>,
47
+ removedId: string,
48
+ ): Record<string, FlowNodeData> {
49
+ const remaining: Record<string, FlowNodeData> = {}
50
+
51
+ for (const [id, node] of Object.entries(nodes)) {
52
+ if (id === removedId) continue
53
+ remaining[id] = { ...node, next: cleanNext(node.next, removedId) }
54
+ }
55
+
56
+ return remaining
57
+ }
58
+
59
+ function cleanNext(next: FlowNodeNext | undefined, removedId: string): FlowNodeNext | undefined {
60
+ if (next === undefined) return undefined
61
+ if (typeof next === 'string') return next === removedId ? '' : next
62
+
63
+ const byAnswer: Record<string, string> = {}
64
+ for (const [answer, target] of Object.entries(next.byAnswer ?? {})) {
65
+ // Resposta que levava ao nó apagado fica com destino vazio, e não é removida: apagar a chave
66
+ // esconderia da tela que aquela opção existe e não vai a lugar nenhum.
67
+ byAnswer[answer] = target === removedId ? '' : target
68
+ }
69
+
70
+ return { byAnswer, default: next.default === removedId ? '' : (next.default ?? '') }
71
+ }
72
+
73
+ /**
74
+ * O fecho transitivo dos fluxos alcançáveis a partir de um — é o conjunto que o canvas abre junto.
75
+ *
76
+ * BFS e não recursão: fluxo que referencia a si mesmo (menu que volta ao menu) é comum, e recursão
77
+ * ingênua estouraria a pilha no caso mais banal que existe.
78
+ */
79
+ export function mergedFlowKeysFrom(
80
+ rootKey: string,
81
+ graphs: Readonly<Record<string, FlowGraphData>>,
82
+ ): readonly string[] {
83
+ const visited = new Set<string>()
84
+ const queue = [rootKey]
85
+
86
+ while (queue.length > 0) {
87
+ const key = queue.shift()!
88
+ if (visited.has(key) || !graphs[key]) continue
89
+ visited.add(key)
90
+ for (const target of crossFlowTargetsOf(graphs[key]!)) {
91
+ if (!visited.has(target) && graphs[target]) queue.push(target)
92
+ }
93
+ }
94
+
95
+ return [...visited]
96
+ }
97
+
98
+ export type ConnectionRequest = {
99
+ readonly source: string
100
+ readonly target: string
101
+ readonly sourceHandle?: string | null | undefined
102
+ }
103
+
104
+ export type ResolvedConnection = {
105
+ readonly flowKey: string
106
+ readonly nodeId: string
107
+ readonly handle: string
108
+ /** O que gravar no `next`: id de nó local, ou `flow:<key>` quando atravessa fluxo. */
109
+ readonly targetValue: string
110
+ }
111
+
112
+ /**
113
+ * Traduz um arraste de aresta no valor que vai para o `next` — e recusa o que o motor do bot não
114
+ * sabe executar.
115
+ *
116
+ * A regra que não é óbvia: conectar num nó de OUTRO fluxo só funciona se for o nó inicial dele,
117
+ * porque o motor só sabe pular para o começo de um fluxo, não para um nó do meio. Conectar no meio
118
+ * devolve `undefined` — recusa silenciosa é melhor que gravar um salto que o bot vai ignorar em
119
+ * produção, deixando a conversa parada sem ninguém entender por quê.
120
+ */
121
+ export function resolveConnection(params: {
122
+ readonly connection: ConnectionRequest
123
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
124
+ }): ResolvedConnection | undefined {
125
+ const { source, target, sourceHandle } = params.connection
126
+ if (!source || !target || source === target) return undefined
127
+
128
+ const sourceRef = parseNamespacedId(source)
129
+ const targetRef = parseNamespacedId(target)
130
+
131
+ let targetValue: string
132
+ if (targetRef.flowKey === sourceRef.flowKey) {
133
+ targetValue = targetRef.nodeId
134
+ } else {
135
+ const targetGraph = params.graphs[targetRef.flowKey]
136
+ if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return undefined
137
+ targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
138
+ }
139
+
140
+ return {
141
+ flowKey: sourceRef.flowKey,
142
+ nodeId: sourceRef.nodeId,
143
+ handle: sourceHandle ?? 'next',
144
+ targetValue,
145
+ }
146
+ }
147
+
148
+ /** Aplica a conexão resolvida no nó. `next` string para saída única, objeto para ramificação. */
149
+ export function applyConnection(node: FlowNodeData, resolved: ResolvedConnection): FlowNodeData {
150
+ const currentNext = typeof node.next === 'object' && node.next ? node.next : undefined
151
+
152
+ if (resolved.handle === 'next') return { ...node, next: resolved.targetValue }
153
+
154
+ if (resolved.handle === '__default') {
155
+ return { ...node, next: { byAnswer: currentNext?.byAnswer ?? {}, default: resolved.targetValue } }
156
+ }
157
+
158
+ return {
159
+ ...node,
160
+ next: {
161
+ byAnswer: { ...(currentNext?.byAnswer ?? {}), [resolved.handle]: resolved.targetValue },
162
+ default: currentNext?.default ?? '',
163
+ },
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Um fluxo está sujo quando o rascunho difere do publicado.
169
+ *
170
+ * Comparação estrutural por JSON: é grosseira, e é suficiente porque o grafo é dado serializável sem
171
+ * ordem significativa de chave — o servidor devolve o que gravou. Comparar campo a campo daria a
172
+ * mesma resposta com mais código para errar.
173
+ */
174
+ export function isGraphDirty(working: FlowGraphData | undefined, published: FlowGraphData | undefined): boolean {
175
+ if (!working || !published) return false
176
+ return JSON.stringify(working) !== JSON.stringify(published)
177
+ }
@@ -372,7 +372,7 @@ export function findCollectionChains(graph: FlowGraphData): CollectionChain[] {
372
372
  }
373
373
 
374
374
  // Gera um id de nó único e legível a partir do rótulo (ex.: "Qual sua renda?" → "qual_sua_renda").
375
- export function slugifyNodeId(label: string, existing: Set<string>): string {
375
+ export function slugifyNodeId(label: string, existing: ReadonlySet<string>): string {
376
376
  const base =
377
377
  label
378
378
  .toLowerCase()
@@ -55,3 +55,19 @@ export type { FlowPortalNodeData } from './FlowPortalNode'
55
55
  export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
56
56
  export type { FlowNodePanelProps } from './FlowNodePanel'
57
57
  export type { FlowWhatsAppPreviewProps } from './FlowWhatsAppPreview'
58
+
59
+ /**
60
+ * Operações do EDITOR de fluxo, puras. Vinham soltas dentro da página de 973 linhas do
61
+ * financiamento; são a base do `FlowsWorkspace` (ADR 0002) e a parte que carrega o risco de perder
62
+ * trabalho de quem edita.
63
+ */
64
+ export {
65
+ applyConnection,
66
+ isGraphDirty,
67
+ mergedFlowKeysFrom,
68
+ namespaceNodeId,
69
+ parseNamespacedId,
70
+ removeNodeAndCleanRefs,
71
+ resolveConnection,
72
+ } from './flowEditorOps'
73
+ export type { ConnectionRequest, ResolvedConnection } from './flowEditorOps'
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * O subpath `/flows` precisa exportar a TELA, não só as peças.
5
+ *
6
+ * Existe porque a ausência disso já custou: exportando apenas `FlowMapCanvas`, `FlowPalette` e
7
+ * `FlowNodePanel`, o financiamento montou a tela por conta — 973 linhas de página mais um fork local
8
+ * dos componentes, que ficou atrás do pacote. O quickcart, para ter a mesma tela, teria que copiar o
9
+ * arquivo. É exatamente a divergência que `pluggable-module.md` §4 proíbe.
10
+ *
11
+ * O teste é de superfície, não de comportamento: não prova que o canvas desenha certo, prova que
12
+ * existe UM lugar onde a tela mora, e que ela aceita customização por contrato em vez de por fork.
13
+ */
14
+
15
+ import { describe, expect, it } from 'bun:test'
16
+
17
+ import * as flows from './index'
18
+ import { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
19
+
20
+ const WORKSPACE_SOURCE = `${import.meta.dir}/FlowsWorkspace.tsx`
21
+
22
+ describe('superfície composta', () => {
23
+ it('exporta a tela inteira', () => {
24
+ expect(typeof flows.FlowsWorkspace).toBe('function')
25
+ })
26
+
27
+ it('exporta as peças também — quem precisa de layout próprio não fica sem saída', () => {
28
+ // Workspace é o caminho recomendado, não uma prisão: um produto com layout radicalmente
29
+ // diferente compõe as peças, e isso é melhor que forkar o pacote.
30
+ for (const piece of ['FlowMapCanvas', 'FlowPalette', 'FlowNodePanel', 'FlowWhatsAppPreview']) {
31
+ expect(typeof (flows as Record<string, unknown>)[piece], piece).toBe('function')
32
+ }
33
+ })
34
+
35
+ it('as operações de grafo saem puras, sem passar pela tela', () => {
36
+ // São as que a tela consome de verdade (ver os imports em `FlowsWorkspace.tsx`). Exportá-las sem
37
+ // usá-las seria pior que não ter teste: teste verde sobre código que não roda em produção.
38
+ for (const operation of ['resolveConnection', 'applyConnection', 'removeNodeAndCleanRefs', 'mergedFlowKeysFrom']) {
39
+ expect(typeof (flows as Record<string, unknown>)[operation], operation).toBe('function')
40
+ }
41
+ })
42
+ })
43
+
44
+ describe('contrato de customização', () => {
45
+ it('nenhuma capacidade em forma de flag booleana `hasX`', async () => {
46
+ /**
47
+ * Capacidade opcional é por AUSÊNCIA de prop. `hasDelete` seria um segundo jeito de dizer o que
48
+ * `deletableFlowKeys` já diz, e dois jeitos divergem — alguém liga a flag sem a lista e a tela
49
+ * desenha um botão que não exclui nada.
50
+ */
51
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
52
+
53
+ expect(content).not.toMatch(/readonly has[A-Z]/)
54
+ })
55
+
56
+ it('aceita labels, className e os slots de render', async () => {
57
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
58
+
59
+ expect(content).toContain('labels?: Partial<')
60
+ // `className` é o que deixa o produto posicionar a tela no layout dele sem tocar no pacote.
61
+ expect(content).toContain('className?: string')
62
+ expect(content).toContain('renderMediaPicker?:')
63
+ })
64
+
65
+ it('nenhum texto visível escrito no componente — tudo passa por labels', async () => {
66
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
67
+ /**
68
+ * Texto entre tags JSX que não seja `{...}`, que é o que `web.md` §6 proíbe.
69
+ *
70
+ * O `\s*` nas pontas não é detalhe: a primeira versão deste regex no notification-ui exigia o
71
+ * texto colado nas tags, e o Prettier põe o conteúdo em linha própria — o teste passava com
72
+ * `>\n Configurações\n<` no meio do componente, provando nada.
73
+ */
74
+ const hardcoded = content.match(/>\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]{3,}\s*</g)
75
+
76
+ expect(hardcoded, `texto fixo: ${hardcoded?.join(' | ')}`).toBeNull()
77
+ })
78
+
79
+ it('o produto sobrescreve UM texto sem perder os outros do mesmo grupo', () => {
80
+ const merged = mergeFlowEditorLabels({ workspace: { ...DEFAULT_FLOW_EDITOR_LABELS.workspace, title: 'Jornadas' } })
81
+
82
+ expect(merged.workspace.title).toBe('Jornadas')
83
+ expect(merged.workspace.saveGraph).toBe(DEFAULT_FLOW_EDITOR_LABELS.workspace.saveGraph)
84
+ })
85
+
86
+ it('grupo novo de label entra no merge profundo', () => {
87
+ // Esquecer o grupo no `mergeFlowEditorLabels` deixa o override apagar os irmãos dele, e o
88
+ // sintoma é texto sumindo da tela — não erro.
89
+ for (const group of ['workspace', 'flowManager', 'validation', 'collectionChain'] as const) {
90
+ const merged = mergeFlowEditorLabels({ [group]: {} })
91
+
92
+ expect(Object.keys(merged[group]).length, group).toBe(Object.keys(DEFAULT_FLOW_EDITOR_LABELS[group]).length)
93
+ }
94
+ })
95
+ })