@adatechnology/conversations-ui 0.1.0-rc.3 → 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.
Files changed (143) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/chunk-GY472G6E.js +2316 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +311 -4
  5. package/dist/flows/index.js +1322 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3571 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +819 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +543 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1003 -0
  70. package/src/flows/flowCanvasModel.test.ts +293 -0
  71. package/src/flows/flowCanvasModel.ts +342 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +40 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +783 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Estas funções decidem o que acontece com o fluxo que alguém desenhou, e o modo de falhar delas é
5
+ * silencioso: aresta apontando para nó apagado não dá erro no editor — dá conversa travada no meio
6
+ * para o cliente. Salto para nó do meio de outro fluxo não dá erro — o motor simplesmente ignora, e
7
+ * o atendimento para sem ninguém entender.
8
+ *
9
+ * São puras de propósito: rodam sem navegador, sem React e sem estado, o que é exatamente o que
10
+ * faltava enquanto elas viviam dentro de uma página de 973 linhas.
11
+ */
12
+
13
+ import { describe, expect, it } from 'bun:test'
14
+ import type { FlowGraphData, FlowNodeData } from '@adatechnology/meta-whatsapp-contracts'
15
+
16
+ import {
17
+ applyConnection,
18
+ isGraphDirty,
19
+ mergedFlowKeysFrom,
20
+ namespaceNodeId,
21
+ parseNamespacedId,
22
+ removeNodeAndCleanRefs,
23
+ resolveConnection,
24
+ } from './flowEditorOps'
25
+
26
+ /** Nó mínimo VÁLIDO pelo contrato — sem `as`, para o teste falhar se a forma do nó mudar. */
27
+ function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
28
+ return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
29
+ }
30
+
31
+ /**
32
+ * Grafo mínimo VÁLIDO. `key` e `version` são obrigatórios no contrato, e sem `as` o teste passa a
33
+ * quebrar quando o contrato ganhar campo novo — que é exatamente o drift que o comentário do
34
+ * `flowGraph.ts` registra ter acontecido antes com o `version`.
35
+ */
36
+ function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
37
+ return {
38
+ key: params.key,
39
+ label: params.key,
40
+ version: 1,
41
+ startNodeId: params.start,
42
+ nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
43
+ }
44
+ }
45
+
46
+ describe('id no canvas mesclado', () => {
47
+ it('vai e volta', () => {
48
+ expect(parseNamespacedId(namespaceNodeId('menu', 'inicio'))).toEqual({ flowKey: 'menu', nodeId: 'inicio' })
49
+ })
50
+
51
+ it('nó com separador no próprio id não parte errado', () => {
52
+ // O separador aparece no PRIMEIRO índice: um id que contenha `::` continua íntegro.
53
+ expect(parseNamespacedId('menu::passo::dois')).toEqual({ flowKey: 'menu', nodeId: 'passo::dois' })
54
+ })
55
+
56
+ it('id sem prefixo devolve fluxo vazio, em vez de adivinhar', () => {
57
+ expect(parseNamespacedId('inicio')).toEqual({ flowKey: '', nodeId: 'inicio' })
58
+ })
59
+ })
60
+
61
+ describe('apagar nó limpa quem apontava para ele', () => {
62
+ it('saída única que ia para o apagado fica vazia, não pendurada', () => {
63
+ const nodes = { a: node('a', 'b'), b: node('b') }
64
+
65
+ const result = removeNodeAndCleanRefs(nodes, 'b')
66
+
67
+ expect(Object.keys(result)).toEqual(['a'])
68
+ // Vazio e não `'b'`: aresta para nó inexistente trava a conversa no motor do bot.
69
+ expect(result.a?.next).toBe('')
70
+ })
71
+
72
+ it('ramificação perde o destino mas MANTÉM a opção', () => {
73
+ const nodes = {
74
+ pergunta: node('pergunta', { byAnswer: { sim: 'aprovado', nao: 'recusado' }, default: 'aprovado' }),
75
+ aprovado: node('aprovado'),
76
+ recusado: node('recusado'),
77
+ }
78
+
79
+ const result = removeNodeAndCleanRefs(nodes, 'aprovado')
80
+ const next = result.pergunta?.next as { byAnswer: Record<string, string>; default: string }
81
+
82
+ // A chave `sim` continua existindo com destino vazio: removê-la esconderia da tela que a opção
83
+ // existe e não leva a lugar nenhum, e alguém publicaria assim.
84
+ expect(next.byAnswer).toEqual({ sim: '', nao: 'recusado' })
85
+ expect(next.default).toBe('')
86
+ })
87
+
88
+ it('não mexe em quem não apontava para o apagado', () => {
89
+ const nodes = { a: node('a', 'c'), b: node('b'), c: node('c') }
90
+
91
+ expect(removeNodeAndCleanRefs(nodes, 'b').a?.next).toBe('c')
92
+ })
93
+
94
+ it('nó sem next segue sem next, e não ganha um vazio', () => {
95
+ expect(removeNodeAndCleanRefs({ a: node('a'), b: node('b') }, 'b').a?.next).toBeUndefined()
96
+ })
97
+ })
98
+
99
+ describe('fecho transitivo dos fluxos abertos juntos', () => {
100
+ const graphs = {
101
+ menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'flow:simulacao')] }),
102
+ simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1', 'flow:documentos')] }),
103
+ documentos: graph({ key: 'documentos', start: 'd1', nodes: [node('d1')] }),
104
+ orfao: graph({ key: 'orfao', start: 'o1', nodes: [node('o1')] }),
105
+ }
106
+
107
+ it('alcança em profundidade e ignora quem ninguém referencia', () => {
108
+ expect([...mergedFlowKeysFrom('menu', graphs)].sort()).toEqual(['documentos', 'menu', 'simulacao'])
109
+ })
110
+
111
+ it('ciclo não estoura — menu que volta ao menu é o caso mais comum que existe', () => {
112
+ const cyclic = {
113
+ menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:sub')] }),
114
+ sub: graph({ key: 'sub', start: 's', nodes: [node('s', 'flow:menu')] }),
115
+ }
116
+
117
+ expect([...mergedFlowKeysFrom('menu', cyclic)].sort()).toEqual(['menu', 'sub'])
118
+ })
119
+
120
+ it('referência para fluxo que não existe é ignorada em vez de abrir aba vazia', () => {
121
+ const dangling = { menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:apagado')] }) }
122
+
123
+ expect(mergedFlowKeysFrom('menu', dangling)).toEqual(['menu'])
124
+ })
125
+ })
126
+
127
+ describe('conectar aresta', () => {
128
+ const graphs = {
129
+ menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio'), node('meio')] }),
130
+ simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1'), node('s2')] }),
131
+ }
132
+
133
+ it('dentro do mesmo fluxo grava o id local', () => {
134
+ const resolved = resolveConnection({
135
+ connection: { source: 'menu::inicio', target: 'menu::meio' },
136
+ graphs,
137
+ })
138
+
139
+ expect(resolved).toMatchObject({ flowKey: 'menu', nodeId: 'inicio', targetValue: 'meio' })
140
+ })
141
+
142
+ it('para o nó INICIAL de outro fluxo grava o salto `flow:`', () => {
143
+ const resolved = resolveConnection({
144
+ connection: { source: 'menu::inicio', target: 'simulacao::s1' },
145
+ graphs,
146
+ })
147
+
148
+ expect(resolved?.targetValue).toBe('flow:simulacao')
149
+ })
150
+
151
+ it('para o MEIO de outro fluxo é RECUSADO — o motor não sabe pular para lá', () => {
152
+ // Recusa silenciosa é melhor que gravar um salto que o bot ignora em produção: o sintoma seria
153
+ // conversa parada, longe de quem editou.
154
+ const resolved = resolveConnection({
155
+ connection: { source: 'menu::inicio', target: 'simulacao::s2' },
156
+ graphs,
157
+ })
158
+
159
+ expect(resolved).toBeUndefined()
160
+ })
161
+
162
+ it('nó em si mesmo é recusado', () => {
163
+ expect(
164
+ resolveConnection({ connection: { source: 'menu::inicio', target: 'menu::inicio' }, graphs }),
165
+ ).toBeUndefined()
166
+ })
167
+
168
+ it('fluxo de destino inexistente é recusado', () => {
169
+ expect(resolveConnection({ connection: { source: 'menu::inicio', target: 'sumiu::x' }, graphs })).toBeUndefined()
170
+ })
171
+ })
172
+
173
+ describe('aplicar a conexão no nó', () => {
174
+ it('handle `next` vira saída única', () => {
175
+ const result = applyConnection(node('a'), { flowKey: 'menu', nodeId: 'a', handle: 'next', targetValue: 'b' })
176
+
177
+ expect(result.next).toBe('b')
178
+ })
179
+
180
+ it('handle de resposta acrescenta no byAnswer e PRESERVA o resto', () => {
181
+ const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
182
+
183
+ const result = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: 'nao', targetValue: 'z' })
184
+ const next = result.next as { byAnswer: Record<string, string>; default: string }
185
+
186
+ expect(next.byAnswer).toEqual({ sim: 'x', nao: 'z' })
187
+ // O default não pode ser perdido ao ligar um ramo: seria mudança silenciosa de comportamento.
188
+ expect(next.default).toBe('y')
189
+ })
190
+
191
+ it('handle `__default` troca só o default', () => {
192
+ const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
193
+
194
+ const next = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: '__default', targetValue: 'z' })
195
+ .next as { byAnswer: Record<string, string>; default: string }
196
+
197
+ expect(next).toEqual({ byAnswer: { sim: 'x' }, default: 'z' })
198
+ })
199
+
200
+ it('ramificar um nó que tinha saída única não carrega a antiga como default', () => {
201
+ // A saída única vira ramificação: o default nasce vazio, e a validação do grafo cobra. Herdar a
202
+ // antiga faria o ramo não configurado seguir para onde o nó ia antes, sem ninguém pedir.
203
+ const next = applyConnection(node('a', 'antigo'), {
204
+ flowKey: 'menu',
205
+ nodeId: 'a',
206
+ handle: 'sim',
207
+ targetValue: 'novo',
208
+ }).next as { byAnswer: Record<string, string>; default: string }
209
+
210
+ expect(next).toEqual({ byAnswer: { sim: 'novo' }, default: '' })
211
+ })
212
+ })
213
+
214
+ describe('rascunho sujo', () => {
215
+ const published = graph({ key: 'menu', start: 'i', nodes: [node('i', 'b'), node('b')] })
216
+
217
+ it('igual ao publicado não está sujo', () => {
218
+ expect(isGraphDirty(structuredClone(published), published)).toBe(false)
219
+ })
220
+
221
+ it('texto mudado está sujo', () => {
222
+ const working = structuredClone(published)
223
+ working.nodes.i!.question = 'outro'
224
+
225
+ expect(isGraphDirty(working, published)).toBe(true)
226
+ })
227
+
228
+ it('posição mudada TAMBÉM está sujo — arrastar card é edição que se publica', () => {
229
+ const working = structuredClone(published)
230
+ working.nodes.i!.position = { x: 10, y: 20 }
231
+
232
+ expect(isGraphDirty(working, published)).toBe(true)
233
+ })
234
+
235
+ it('sem rascunho ou sem publicado não está sujo, em vez de sujo por ausência', () => {
236
+ // Durante o carregamento os dois lados chegam separados; sujo aqui faria a tela pedir
237
+ // confirmação de descarte antes de o usuário ter tocado em nada.
238
+ expect(isGraphDirty(undefined, published)).toBe(false)
239
+ expect(isGraphDirty(published, undefined)).toBe(false)
240
+ })
241
+ })
@@ -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
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  FlowNodeData,
14
14
  FlowGraphData,
15
15
  } from '@adatechnology/meta-whatsapp-contracts'
16
+ import { FLOW_ACTION_KIND } from '@adatechnology/meta-whatsapp-contracts'
16
17
 
17
18
  export type {
18
19
  FlowNodeType,
@@ -28,11 +29,10 @@ export const CONDITION_OPERATORS: FlowConditionOperator[] = ['>', '>=', '<', '<=
28
29
 
29
30
  // Kinds de ação genéricos que o pacote conhece de fábrica — o host pode registrar quaisquer
30
31
  // outros via `actionKindLabels`/`actionKinds` nos componentes (ver FlowPalette, labels.ts).
31
- export const BUILT_IN_ACTION_KINDS = {
32
- HANDOFF: 'handoff',
33
- RATE_LIMITED_HANDOFF: 'rate_limited_handoff',
34
- SEND_PRODUCT_LIST: 'send_product_list',
35
- } as const
32
+ // Reexporta o vocabulário do contrato em vez de redeclarar os literais: o editor oferece na
33
+ // paleta exatamente os `actionKind` que o backend sabe interpretar, e duas listas separadas
34
+ // divergiriam em silêncio (um nó publicável que nenhum handler atende).
35
+ export const BUILT_IN_ACTION_KINDS = FLOW_ACTION_KIND
36
36
 
37
37
  // Destinos "flow:<key>" são saltos para outro fluxo, resolvidos pelo motor do host. Duplicado
38
38
  // (em vez de importado do contracts) de propósito: são três linhas triviais e importá-las como
@@ -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()
@@ -9,6 +9,7 @@ export { FlowPortalNode, flowPortalNodeTypes } from './FlowPortalNode'
9
9
  export { FlowPalette } from './FlowPalette'
10
10
  export { FlowNodePanel } from './FlowNodePanel'
11
11
  export { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
12
+ export { FlowsWorkspace } from './FlowsWorkspace'
12
13
 
13
14
  export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
14
15
 
@@ -43,7 +44,8 @@ export type {
43
44
  CollectionChain,
44
45
  } from './flowGraph'
45
46
 
46
- export type { FlowEditorLabels } from './labels'
47
+ export type { FlowEditorLabels, FlowValidationLabels } from './labels'
48
+ export type { FlowsWorkspaceProps, FlowsWorkspaceApi, FlowLivePosition, CreateFlowInput } from './FlowsWorkspace'
47
49
  export type { FlowNodeCardData } from './FlowNodeCard'
48
50
  export type { FlowMapNodeData } from './FlowMapNode'
49
51
  export type { FlowMapCanvasProps } from './FlowMapCanvas'
@@ -53,3 +55,40 @@ export type { FlowPortalNodeData } from './FlowPortalNode'
53
55
  export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
54
56
  export type { FlowNodePanelProps } from './FlowNodePanel'
55
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'
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'