@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1
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/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- 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 +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- 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 +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- 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 +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- 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 +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -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 +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- 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 +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -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 +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- 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 +423 -0
- package/src/workspace/index.ts +17 -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/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
}
|
|
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
|
+
}
|
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,16 +29,25 @@ 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
|
|
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
|
-
?
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
export
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
|
@@ -372,7 +397,7 @@ export function findCollectionChains(graph: FlowGraphData): CollectionChain[] {
|
|
|
372
397
|
}
|
|
373
398
|
|
|
374
399
|
// 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:
|
|
400
|
+
export function slugifyNodeId(label: string, existing: ReadonlySet<string>): string {
|
|
376
401
|
const base =
|
|
377
402
|
label
|
|
378
403
|
.toLowerCase()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* O caso que originou isto: o submenu "Ação" aberto perto do rodapé mostrava metade da lista, com
|
|
5
|
+
* "Enviar catálogo de produtos" cortado pela borda — e sem rolagem, o item era inalcançável.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { placeFloatingPanel } from './flowMenuPlacement'
|
|
11
|
+
|
|
12
|
+
const VIEWPORT = { width: 1200, height: 800 }
|
|
13
|
+
const rect = (left: number, top: number, width = 0, height = 0) => ({
|
|
14
|
+
left,
|
|
15
|
+
top,
|
|
16
|
+
right: left + width,
|
|
17
|
+
bottom: top + height,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('menu abaixo do ponto clicado', () => {
|
|
21
|
+
it('fica onde foi pedido quando há espaço', () => {
|
|
22
|
+
const placement = placeFloatingPanel({
|
|
23
|
+
anchor: rect(100, 100),
|
|
24
|
+
panel: { width: 256, height: 300 },
|
|
25
|
+
viewport: VIEWPORT,
|
|
26
|
+
prefer: 'below',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(placement.left).toBeGreaterThanOrEqual(100)
|
|
30
|
+
expect(placement.top).toBe(100)
|
|
31
|
+
expect(placement.maxHeight).toBe(300)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('encosta na borda direita em vez de sair dela', () => {
|
|
35
|
+
const placement = placeFloatingPanel({
|
|
36
|
+
anchor: rect(1150, 100),
|
|
37
|
+
panel: { width: 256, height: 300 },
|
|
38
|
+
viewport: VIEWPORT,
|
|
39
|
+
prefer: 'below',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(placement.left + 256).toBeLessThanOrEqual(VIEWPORT.width)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('sobe quando não cabe para baixo, em vez de vazar pelo rodapé', () => {
|
|
46
|
+
const placement = placeFloatingPanel({
|
|
47
|
+
anchor: rect(100, 700),
|
|
48
|
+
panel: { width: 256, height: 300 },
|
|
49
|
+
viewport: VIEWPORT,
|
|
50
|
+
prefer: 'below',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
54
|
+
expect(placement.top).toBeLessThan(700)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('submenu ao lado do item', () => {
|
|
59
|
+
it('abre à direita quando cabe', () => {
|
|
60
|
+
const placement = placeFloatingPanel({
|
|
61
|
+
anchor: rect(300, 200, 256, 36),
|
|
62
|
+
panel: { width: 256, height: 240 },
|
|
63
|
+
viewport: VIEWPORT,
|
|
64
|
+
prefer: 'side',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(placement.left).toBeGreaterThanOrEqual(300 + 256)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('vira para a esquerda quando não cabe à direita', () => {
|
|
71
|
+
const placement = placeFloatingPanel({
|
|
72
|
+
anchor: rect(900, 200, 256, 36),
|
|
73
|
+
panel: { width: 256, height: 240 },
|
|
74
|
+
viewport: VIEWPORT,
|
|
75
|
+
prefer: 'side',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(placement.left).toBeLessThan(900)
|
|
79
|
+
expect(placement.left).toBeGreaterThanOrEqual(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('lista longa perto do rodapé ganha rolagem em vez de ser cortada', () => {
|
|
83
|
+
// O caso da captura: submenu de ações mais alto que o espaço abaixo do item.
|
|
84
|
+
const placement = placeFloatingPanel({
|
|
85
|
+
anchor: rect(300, 620, 256, 36),
|
|
86
|
+
panel: { width: 256, height: 520 },
|
|
87
|
+
viewport: VIEWPORT,
|
|
88
|
+
prefer: 'side',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
92
|
+
expect(placement.maxHeight).toBeLessThanOrEqual(520)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('painel mais alto que a tela cabe inteiro na tela, rolando por dentro', () => {
|
|
96
|
+
const placement = placeFloatingPanel({
|
|
97
|
+
anchor: rect(300, 400, 256, 36),
|
|
98
|
+
panel: { width: 256, height: 2000 },
|
|
99
|
+
viewport: VIEWPORT,
|
|
100
|
+
prefer: 'side',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(placement.top).toBeGreaterThanOrEqual(0)
|
|
104
|
+
expect(placement.maxHeight).toBeLessThan(VIEWPORT.height)
|
|
105
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('os menus do editor usam a conta acima', () => {
|
|
110
|
+
/**
|
|
111
|
+
* A função pura pode estar correta e ninguém chamá-la. Estes dois amarram o uso: sem eles a
|
|
112
|
+
* correção some no primeiro refactor e o menu volta a encostar na borda.
|
|
113
|
+
*/
|
|
114
|
+
it('o menu do "+" mede antes de posicionar, em vez de usar a âncora crua', async () => {
|
|
115
|
+
const content = await Bun.file(`${import.meta.dir}/FlowsWorkspace.tsx`).text()
|
|
116
|
+
|
|
117
|
+
expect(content).toContain('placeFloatingPanel')
|
|
118
|
+
expect(content).not.toContain('left: quickAddFrom.anchor.x + 12')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('o submenu não fica mais preso ao item por CSS', async () => {
|
|
122
|
+
const content = await Bun.file(`${import.meta.dir}/FlowPalette.tsx`).text()
|
|
123
|
+
|
|
124
|
+
expect(content).toContain('placeFloatingPanel')
|
|
125
|
+
// Verifica o uso em JSX, não a menção: o comentário do arquivo cita a classe antiga para
|
|
126
|
+
// explicar o que mudou, e casar com o texto solto reprovaria a própria explicação.
|
|
127
|
+
expect(content).not.toContain('className="absolute left-full')
|
|
128
|
+
expect(content).toContain('overflowY')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Onde um menu flutuante do editor pode aparecer sem sair da tela.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque tanto o menu do "+" quanto os submenus dele eram posicionados por uma âncora crua:
|
|
7
|
+
* o menu em `fixed` na coordenada do clique, os submenus em `left-full top-0`. Card perto da borda
|
|
8
|
+
* direita jogava o menu para fora; item de ação perto do rodapé cortava a lista no meio, e não havia
|
|
9
|
+
* como rolar nem alcançar o resto — o menu ficava preso contra a borda.
|
|
10
|
+
*
|
|
11
|
+
* A conta é pura de propósito: é ela que decide se o operador consegue clicar na opção, e testá-la
|
|
12
|
+
* exige apenas números.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Retângulo do gatilho. Um clique é um retângulo de tamanho zero. */
|
|
16
|
+
export type AnchorRect = {
|
|
17
|
+
readonly left: number
|
|
18
|
+
readonly top: number
|
|
19
|
+
readonly right: number
|
|
20
|
+
readonly bottom: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PanelSize = {
|
|
24
|
+
readonly width: number
|
|
25
|
+
readonly height: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ViewportSize = {
|
|
29
|
+
readonly width: number
|
|
30
|
+
readonly height: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FloatingPlacement = {
|
|
34
|
+
readonly left: number
|
|
35
|
+
readonly top: number
|
|
36
|
+
/** Teto de altura: acima disso o painel rola por dentro em vez de vazar pelo rodapé. */
|
|
37
|
+
readonly maxHeight: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PlaceFloatingPanelParams = {
|
|
41
|
+
readonly anchor: AnchorRect
|
|
42
|
+
readonly panel: PanelSize
|
|
43
|
+
readonly viewport: ViewportSize
|
|
44
|
+
/** `side`: submenu sai ao lado do item; `below`: menu sai abaixo do ponto clicado. */
|
|
45
|
+
readonly prefer: 'side' | 'below'
|
|
46
|
+
readonly gap?: number
|
|
47
|
+
readonly margin?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_GAP = 4
|
|
51
|
+
const DEFAULT_MARGIN = 8
|
|
52
|
+
|
|
53
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
54
|
+
// `maximum` menor que `minimum` acontece com painel maior que a viewport: a margem de cima manda,
|
|
55
|
+
// porque cortar em cima esconde o começo da lista.
|
|
56
|
+
return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function placeFloatingPanel({
|
|
60
|
+
anchor,
|
|
61
|
+
panel,
|
|
62
|
+
viewport,
|
|
63
|
+
prefer,
|
|
64
|
+
gap = DEFAULT_GAP,
|
|
65
|
+
margin = DEFAULT_MARGIN,
|
|
66
|
+
}: PlaceFloatingPanelParams): FloatingPlacement {
|
|
67
|
+
const maxHeight = Math.min(panel.height, viewport.height - margin * 2)
|
|
68
|
+
|
|
69
|
+
let left: number
|
|
70
|
+
if (prefer === 'side') {
|
|
71
|
+
// Vira para a esquerda quando não cabe à direita — o inverso de escolher sempre um lado.
|
|
72
|
+
const toTheRight = anchor.right + gap
|
|
73
|
+
const toTheLeft = anchor.left - gap - panel.width
|
|
74
|
+
left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft
|
|
75
|
+
} else {
|
|
76
|
+
left = anchor.left + gap
|
|
77
|
+
}
|
|
78
|
+
left = clamp(left, margin, viewport.width - margin - panel.width)
|
|
79
|
+
|
|
80
|
+
const top = prefer === 'side' ? anchor.top : anchor.bottom
|
|
81
|
+
return {
|
|
82
|
+
left,
|
|
83
|
+
top: clamp(top, margin, viewport.height - margin - maxHeight),
|
|
84
|
+
maxHeight,
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/flows/index.ts
CHANGED
|
@@ -6,15 +6,19 @@ 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'
|
|
14
|
+
export { FlowsWorkspace } from './FlowsWorkspace'
|
|
12
15
|
|
|
13
16
|
export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
|
|
14
17
|
|
|
15
18
|
export {
|
|
16
19
|
CONDITION_OPERATORS,
|
|
17
20
|
BUILT_IN_ACTION_KINDS,
|
|
21
|
+
PASS_THROUGH_ACTION_KINDS,
|
|
18
22
|
NODE_CARD_WIDTH,
|
|
19
23
|
WHATSAPP_LIMITS,
|
|
20
24
|
CROSS_FLOW_PREFIX,
|
|
@@ -43,7 +47,15 @@ export type {
|
|
|
43
47
|
CollectionChain,
|
|
44
48
|
} from './flowGraph'
|
|
45
49
|
|
|
46
|
-
export type { FlowEditorLabels } from './labels'
|
|
50
|
+
export type { FlowEditorLabels, FlowValidationLabels } from './labels'
|
|
51
|
+
export type {
|
|
52
|
+
FlowsWorkspaceProps,
|
|
53
|
+
FlowsWorkspaceApi,
|
|
54
|
+
FlowLivePosition,
|
|
55
|
+
FlowLiveNodeCount,
|
|
56
|
+
FlowLivePositionInput,
|
|
57
|
+
CreateFlowInput,
|
|
58
|
+
} from './FlowsWorkspace'
|
|
47
59
|
export type { FlowNodeCardData } from './FlowNodeCard'
|
|
48
60
|
export type { FlowMapNodeData } from './FlowMapNode'
|
|
49
61
|
export type { FlowMapCanvasProps } from './FlowMapCanvas'
|
|
@@ -53,3 +65,40 @@ export type { FlowPortalNodeData } from './FlowPortalNode'
|
|
|
53
65
|
export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
|
|
54
66
|
export type { FlowNodePanelProps } from './FlowNodePanel'
|
|
55
67
|
export type { FlowWhatsAppPreviewProps } from './FlowWhatsAppPreview'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Operações do EDITOR de fluxo, puras. Vinham soltas dentro da página de 973 linhas do
|
|
71
|
+
* financiamento; são a base do `FlowsWorkspace` (ADR 0002) e a parte que carrega o risco de perder
|
|
72
|
+
* trabalho de quem edita.
|
|
73
|
+
*/
|
|
74
|
+
export {
|
|
75
|
+
applyConnection,
|
|
76
|
+
isGraphDirty,
|
|
77
|
+
mergedFlowKeysFrom,
|
|
78
|
+
namespaceNodeId,
|
|
79
|
+
parseNamespacedId,
|
|
80
|
+
removeNodeAndCleanRefs,
|
|
81
|
+
resolveConnection,
|
|
82
|
+
} from './flowEditorOps'
|
|
83
|
+
export type { ConnectionRequest, ResolvedConnection } from './flowEditorOps'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Modelo do canvas: posições, arestas, contagem ao vivo e o nó novo da paleta.
|
|
87
|
+
*
|
|
88
|
+
* Exportado porque é consumido pelo `FlowsWorkspace` e tem teste próprio — as decisões daqui não dão
|
|
89
|
+
* erro quando erram, dão conversa parada e card fora da tela.
|
|
90
|
+
*/
|
|
91
|
+
export {
|
|
92
|
+
buildFlowEdges,
|
|
93
|
+
chainFrameBounds,
|
|
94
|
+
chainFrameNodeId,
|
|
95
|
+
computeMergedLayout,
|
|
96
|
+
countLiveByNode,
|
|
97
|
+
detachedNodeIds,
|
|
98
|
+
newNodeFromSpec,
|
|
99
|
+
portalNodeId,
|
|
100
|
+
GROUP_HEADER_NODE_ID,
|
|
101
|
+
} from './flowCanvasModel'
|
|
102
|
+
// Os formatos de posição viva já saem pelo `FlowsWorkspace`, que é onde o produto os encontra ao
|
|
103
|
+
// implementar a api — exportar de novo aqui daria dois caminhos para o mesmo tipo.
|
|
104
|
+
export type { FlowEdgeKind, FlowEdgeSpec, FlowNodePosition } from './flowCanvasModel'
|