@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.
- package/dist/chunk-DXPSPUWF.js +110 -0
- package/dist/chunk-GY472G6E.js +2316 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +311 -4
- package/dist/flows/index.js +1322 -55
- package/dist/index.d.ts +1074 -42
- package/dist/index.js +3571 -742
- package/dist/preview/index.d.ts +328 -8
- package/dist/preview/index.js +902 -115
- package/dist/styles.css +819 -0
- package/dist/types-De5aN-E_.d.ts +502 -0
- package/package.json +3 -3
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +42 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +98 -22
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +164 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +543 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +35 -8
- package/src/flows/FlowNodePanel.tsx +149 -38
- package/src/flows/FlowPalette.tsx +13 -3
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1003 -0
- package/src/flows/flowCanvasModel.test.ts +293 -0
- package/src/flows/flowCanvasModel.ts +342 -0
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +6 -6
- package/src/flows/index.ts +40 -1
- package/src/flows/labels.ts +141 -0
- package/src/flows/workspaceContract.test.ts +95 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +114 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +199 -13
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +33 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +468 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
- package/src/styles.css +783 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +346 -0
- package/src/workspace/index.ts +12 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-N7B24WYD.js +0 -719
- package/dist/chunk-NV2RZ5KT.js +0 -56
- 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
|
+
}
|
package/src/flows/flowGraph.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
375
|
+
export function slugifyNodeId(label: string, existing: ReadonlySet<string>): string {
|
|
376
376
|
const base =
|
|
377
377
|
label
|
|
378
378
|
.toLowerCase()
|
package/src/flows/index.ts
CHANGED
|
@@ -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'
|