@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,1003 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
Controls,
|
|
6
|
+
MiniMap,
|
|
7
|
+
MarkerType,
|
|
8
|
+
applyNodeChanges,
|
|
9
|
+
type Node,
|
|
10
|
+
type NodeChange,
|
|
11
|
+
type Edge,
|
|
12
|
+
type Connection,
|
|
13
|
+
type ReactFlowInstance,
|
|
14
|
+
} from '@xyflow/react'
|
|
15
|
+
import '@xyflow/react/dist/style.css'
|
|
16
|
+
import { Plus, Trash2, LayoutGrid, AlertTriangle, AlertCircle, Save, Undo2, Map as MapIcon, Workflow } from 'lucide-react'
|
|
17
|
+
|
|
18
|
+
import { useIsDarkTheme } from '../useDarkMode'
|
|
19
|
+
import { flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
|
|
20
|
+
import { flowPortalNodeTypes, type FlowPortalNodeData } from './FlowPortalNode'
|
|
21
|
+
import { flowGroupHeaderNodeTypes, type FlowGroupHeaderData } from './FlowGroupHeader'
|
|
22
|
+
import { flowGroupFrameNodeTypes, type FlowGroupFrameData } from './FlowGroupFrame'
|
|
23
|
+
import { FlowNodePanel } from './FlowNodePanel'
|
|
24
|
+
import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
|
|
25
|
+
import { FlowMapCanvas } from './FlowMapCanvas'
|
|
26
|
+
import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
|
|
27
|
+
// Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
|
|
28
|
+
// erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
|
|
29
|
+
import {
|
|
30
|
+
buildFlowEdges,
|
|
31
|
+
chainFrameBounds,
|
|
32
|
+
chainFrameNodeId,
|
|
33
|
+
computeMergedLayout,
|
|
34
|
+
countLiveByNode,
|
|
35
|
+
newNodeFromSpec,
|
|
36
|
+
portalNodeId,
|
|
37
|
+
GROUP_HEADER_NODE_ID,
|
|
38
|
+
type FlowEdgeSpec,
|
|
39
|
+
type FlowLivePosition,
|
|
40
|
+
} from './flowCanvasModel'
|
|
41
|
+
import {
|
|
42
|
+
applyConnection,
|
|
43
|
+
mergedFlowKeysFrom,
|
|
44
|
+
namespaceNodeId,
|
|
45
|
+
parseNamespacedId,
|
|
46
|
+
removeNodeAndCleanRefs,
|
|
47
|
+
resolveConnection,
|
|
48
|
+
} from './flowEditorOps'
|
|
49
|
+
import {
|
|
50
|
+
computeAutoLayout,
|
|
51
|
+
targetsOf,
|
|
52
|
+
validateGraph,
|
|
53
|
+
isCrossFlowTarget,
|
|
54
|
+
crossFlowKey,
|
|
55
|
+
findCollectionChains,
|
|
56
|
+
estimateNodeHeight,
|
|
57
|
+
NODE_CARD_WIDTH,
|
|
58
|
+
type FlowGraphData,
|
|
59
|
+
type FlowNodeData,
|
|
60
|
+
type GraphIssue,
|
|
61
|
+
} from './flowGraph'
|
|
62
|
+
import { TooltipLayer } from '../Tooltip'
|
|
63
|
+
|
|
64
|
+
const RF_NODE_TYPES = {
|
|
65
|
+
...flowNodeTypes,
|
|
66
|
+
...flowPortalNodeTypes,
|
|
67
|
+
...flowGroupHeaderNodeTypes,
|
|
68
|
+
...flowGroupFrameNodeTypes,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CHAIN_FRAME_PADDING = 36
|
|
72
|
+
const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
|
|
73
|
+
|
|
74
|
+
const EDGE_COLOR_LINEAR = '#94a3b8'
|
|
75
|
+
const EDGE_COLOR_BRANCH = '#8b5cf6'
|
|
76
|
+
const EDGE_COLOR_FALLBACK = '#cbd5e1'
|
|
77
|
+
const EDGE_COLOR_LIVE = '#3b82f6'
|
|
78
|
+
const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
|
|
79
|
+
const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
|
|
80
|
+
const BACKGROUND_COLOR_DARK = '#334155'
|
|
81
|
+
|
|
82
|
+
// `FlowLivePosition` mora no modelo, que é quem conta — e é reexportado aqui porque faz parte da api
|
|
83
|
+
// que o produto implementa. Declarar nos dois lugares faria as duas formas divergirem em silêncio.
|
|
84
|
+
export type { FlowLivePosition } from './flowCanvasModel'
|
|
85
|
+
|
|
86
|
+
export interface CreateFlowInput {
|
|
87
|
+
key: string
|
|
88
|
+
label: string
|
|
89
|
+
showInMenu: boolean
|
|
90
|
+
/** Ausente quando `showInMenu` é falso — não há opção de menu para rotular. */
|
|
91
|
+
menuOptionLabel?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Backend de fluxos do host. Funções cruas em vez de um cliente HTTP: o pacote roda em produtos com
|
|
96
|
+
* axios, fetch e react-query, e nenhum deles precisa entrar como dependência daqui.
|
|
97
|
+
*/
|
|
98
|
+
export interface FlowsWorkspaceApi {
|
|
99
|
+
getGraphs(): Promise<Record<string, FlowGraphData>>
|
|
100
|
+
saveGraph(key: string, graph: FlowGraphData): Promise<void>
|
|
101
|
+
/**
|
|
102
|
+
* Criar e excluir fluxo são **opcionais por capacidade**: produto cujos fluxos vêm de um seed
|
|
103
|
+
* versionado não expõe rota para isso, e a tela simplesmente não desenha os botões — em vez de
|
|
104
|
+
* oferecer uma ação que estoura no clique.
|
|
105
|
+
*/
|
|
106
|
+
createFlow?(input: CreateFlowInput): Promise<void>
|
|
107
|
+
deleteFlow?(key: string): Promise<void>
|
|
108
|
+
/** Contagem de conversas vivas por nó. Ausente, os cards não pulsam e nada é consultado. */
|
|
109
|
+
getLivePositions?(): Promise<FlowLivePosition[]>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface FlowsWorkspaceProps {
|
|
113
|
+
readonly api: FlowsWorkspaceApi
|
|
114
|
+
/** Fluxo raiz — o que abre por padrão e o único que não pode ser excluído. */
|
|
115
|
+
readonly rootFlowKey?: string
|
|
116
|
+
readonly labels?: Partial<FlowEditorLabels>
|
|
117
|
+
/** Kinds de ação do produto oferecidos na paleta (`trigger_simulation`, `abrir_comanda`…). */
|
|
118
|
+
readonly actionOptions?: readonly FlowPaletteActionOption[]
|
|
119
|
+
/** Seletor de arquivos do nó `send_media` — a biblioteca é do host, então entra por slot. */
|
|
120
|
+
// Recebe o grafo junto do nó: com fluxos fundidos, o nó em edição pode pertencer a um fluxo que
|
|
121
|
+
// não é o raiz, e o seletor do host precisa da chave dele para saber onde gravar.
|
|
122
|
+
readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
|
|
123
|
+
/** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
|
|
124
|
+
readonly livePollIntervalMs?: number
|
|
125
|
+
readonly className?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Traduz o papel da aresta em traço, cor e seta.
|
|
130
|
+
*
|
|
131
|
+
* O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
|
|
132
|
+
* conversa do cliente parar; cor errada aparece na primeira olhada.
|
|
133
|
+
*/
|
|
134
|
+
function styleEdge(spec: FlowEdgeSpec): Edge {
|
|
135
|
+
const color = spec.crossFlow
|
|
136
|
+
? EDGE_COLOR_CROSS_FLOW
|
|
137
|
+
: spec.kind === 'fallback'
|
|
138
|
+
? EDGE_COLOR_FALLBACK
|
|
139
|
+
: spec.live
|
|
140
|
+
? EDGE_COLOR_LIVE
|
|
141
|
+
: spec.kind === 'branch'
|
|
142
|
+
? EDGE_COLOR_BRANCH
|
|
143
|
+
: EDGE_COLOR_LINEAR
|
|
144
|
+
const baseWidth = spec.kind === 'branch' ? 1.75 : 1.5
|
|
145
|
+
const dash = spec.crossFlow ? '3 3' : spec.kind === 'fallback' ? '5 4' : undefined
|
|
146
|
+
const markerSize = spec.kind === 'fallback' ? 16 : 18
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: spec.id,
|
|
150
|
+
source: spec.source,
|
|
151
|
+
target: spec.target,
|
|
152
|
+
...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
|
|
153
|
+
type: 'bezier',
|
|
154
|
+
animated: spec.live,
|
|
155
|
+
style: {
|
|
156
|
+
stroke: color,
|
|
157
|
+
strokeWidth: spec.live && !spec.crossFlow ? 2.5 : baseWidth,
|
|
158
|
+
...(dash === undefined ? {} : { strokeDasharray: dash }),
|
|
159
|
+
},
|
|
160
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: markerSize, height: markerSize },
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
function extractErrorMessage(error: unknown): string | undefined {
|
|
167
|
+
if (error instanceof Error) return error.message
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const OUTLINE_BUTTON =
|
|
172
|
+
'inline-flex items-center gap-1.5 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 hover:border-blue-300 disabled:opacity-40 disabled:cursor-not-allowed'
|
|
173
|
+
const PRIMARY_BUTTON =
|
|
174
|
+
'inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed'
|
|
175
|
+
const DIALOG_INPUT =
|
|
176
|
+
'w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Editor de fluxograma completo — barra de ações, abas de fluxo, paleta, canvas com fusão
|
|
180
|
+
* editável, painel de nó, mapa de fluxos e diálogos de criar/excluir.
|
|
181
|
+
*
|
|
182
|
+
* É a tela inteira, não as peças: cada produto que remontava esse grid à mão acabava com uma
|
|
183
|
+
* versão diferente do mesmo editor. Customização entra por `labels`, `actionOptions` e
|
|
184
|
+
* `renderMediaPicker` — nunca por cópia do arquivo.
|
|
185
|
+
*/
|
|
186
|
+
export function FlowsWorkspace({
|
|
187
|
+
api,
|
|
188
|
+
rootFlowKey = 'menu',
|
|
189
|
+
labels: labelsOverride,
|
|
190
|
+
actionOptions,
|
|
191
|
+
renderMediaPicker,
|
|
192
|
+
livePollIntervalMs = 5000,
|
|
193
|
+
className,
|
|
194
|
+
}: FlowsWorkspaceProps) {
|
|
195
|
+
const labels = useMemo(() => mergeFlowEditorLabels(labelsOverride), [labelsOverride])
|
|
196
|
+
const isDark = useIsDarkTheme()
|
|
197
|
+
|
|
198
|
+
const [graphs, setGraphs] = useState<Record<string, FlowGraphData> | undefined>(undefined)
|
|
199
|
+
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
200
|
+
const [livePositions, setLivePositions] = useState<FlowLivePosition[] | undefined>(undefined)
|
|
201
|
+
const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
|
|
202
|
+
const [openFlowKeys, setOpenFlowKeys] = useState<readonly string[]>([rootFlowKey])
|
|
203
|
+
const [hasAutoMerged, setHasAutoMerged] = useState(false)
|
|
204
|
+
const [workingGraphs, setWorkingGraphs] = useState<Record<string, FlowGraphData>>({})
|
|
205
|
+
const [editingRef, setEditingRef] = useState<{ flowKey: string; nodeId: string } | null>(null)
|
|
206
|
+
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'success' | 'error'>('idle')
|
|
207
|
+
const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
|
|
208
|
+
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
|
209
|
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
210
|
+
const [newFlow, setNewFlow] = useState({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
|
|
211
|
+
const [flowMutationState, setFlowMutationState] = useState<{ pending: boolean; error?: string }>({ pending: false })
|
|
212
|
+
const [rfNodes, setRfNodes] = useState<Node[]>([])
|
|
213
|
+
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
|
|
214
|
+
const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
|
|
215
|
+
|
|
216
|
+
const reloadGraphs = useCallback(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const loaded = await api.getGraphs()
|
|
219
|
+
setGraphs(loaded)
|
|
220
|
+
setLoadState('ready')
|
|
221
|
+
return loaded
|
|
222
|
+
} catch {
|
|
223
|
+
setLoadState('error')
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
}, [api])
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
void reloadGraphs()
|
|
230
|
+
}, [reloadGraphs])
|
|
231
|
+
|
|
232
|
+
// Polling das posições vivas. `active` corta a resposta que chega depois do desmonte — o
|
|
233
|
+
// intervalo é longo o bastante para uma resposta lenta atravessar a troca de tela.
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const fetchLive = api.getLivePositions
|
|
236
|
+
if (!fetchLive) return
|
|
237
|
+
let active = true
|
|
238
|
+
async function poll(): Promise<void> {
|
|
239
|
+
try {
|
|
240
|
+
const positions = await fetchLive!()
|
|
241
|
+
if (active) setLivePositions(positions)
|
|
242
|
+
} catch {
|
|
243
|
+
// Contagem viva é enfeite: falhar aqui não pode derrubar o editor.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
void poll()
|
|
247
|
+
const timer = setInterval(() => void poll(), livePollIntervalMs)
|
|
248
|
+
return () => {
|
|
249
|
+
active = false
|
|
250
|
+
clearInterval(timer)
|
|
251
|
+
}
|
|
252
|
+
}, [api, livePollIntervalMs])
|
|
253
|
+
|
|
254
|
+
const primaryFlowKey = openFlowKeys[0] ?? rootFlowKey
|
|
255
|
+
const primaryGraph = workingGraphs[primaryFlowKey]
|
|
256
|
+
|
|
257
|
+
// Assim que os fluxos carregam pela primeira vez, mescla automaticamente todo o fecho
|
|
258
|
+
// transitivo referenciado a partir da raiz — "o fluxo completo" aparece de cara, sem precisar
|
|
259
|
+
// clicar em cada portal. Só roda uma vez (hasAutoMerged); depois disso, focar/mesclar/fechar
|
|
260
|
+
// fica inteiramente sob controle do usuário.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!graphs || hasAutoMerged) return
|
|
263
|
+
setOpenFlowKeys(mergedFlowKeysFrom(rootFlowKey, graphs))
|
|
264
|
+
setHasAutoMerged(true)
|
|
265
|
+
}, [graphs, hasAutoMerged, rootFlowKey])
|
|
266
|
+
|
|
267
|
+
// Semeia o rascunho local de qualquer fluxo recém-aberto (seleção inicial, foco ou fusão) —
|
|
268
|
+
// nunca sobrescreve um fluxo que já tem rascunho (preserva edições não publicadas mesmo em
|
|
269
|
+
// recargas de fundo).
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!graphs) return
|
|
272
|
+
setWorkingGraphs((prev) => {
|
|
273
|
+
let changed = false
|
|
274
|
+
const next = { ...prev }
|
|
275
|
+
for (const key of openFlowKeys) {
|
|
276
|
+
if (!next[key] && graphs[key]) {
|
|
277
|
+
next[key] = graphs[key]
|
|
278
|
+
changed = true
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return changed ? next : prev
|
|
282
|
+
})
|
|
283
|
+
}, [graphs, openFlowKeys])
|
|
284
|
+
|
|
285
|
+
const isFlowDirty = useCallback(
|
|
286
|
+
(key: string): boolean => {
|
|
287
|
+
const working = workingGraphs[key]
|
|
288
|
+
const server = graphs?.[key]
|
|
289
|
+
if (!working || !server) return false
|
|
290
|
+
return JSON.stringify(working) !== JSON.stringify(server)
|
|
291
|
+
},
|
|
292
|
+
[workingGraphs, graphs],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const issuesByFlow = useMemo<Record<string, GraphIssue[]>>(() => {
|
|
296
|
+
const map: Record<string, GraphIssue[]> = {}
|
|
297
|
+
for (const key of openFlowKeys) {
|
|
298
|
+
const graph = workingGraphs[key]
|
|
299
|
+
if (graph) map[key] = validateGraph(graph, labels.validation)
|
|
300
|
+
}
|
|
301
|
+
return map
|
|
302
|
+
}, [openFlowKeys, workingGraphs, labels])
|
|
303
|
+
|
|
304
|
+
const errorCount = Object.values(issuesByFlow).reduce(
|
|
305
|
+
(sum, list) => sum + list.filter((issue) => issue.severity === 'error').length,
|
|
306
|
+
0,
|
|
307
|
+
)
|
|
308
|
+
const warningCount = Object.values(issuesByFlow).reduce(
|
|
309
|
+
(sum, list) => sum + list.filter((issue) => issue.severity === 'warning').length,
|
|
310
|
+
0,
|
|
311
|
+
)
|
|
312
|
+
const dirtyKeys = openFlowKeys.filter(isFlowDirty)
|
|
313
|
+
const isDirty = dirtyKeys.length > 0
|
|
314
|
+
|
|
315
|
+
const updateFlow = useCallback((flowKey: string, updater: (graph: FlowGraphData) => FlowGraphData) => {
|
|
316
|
+
setWorkingGraphs((prev) => {
|
|
317
|
+
const graph = prev[flowKey]
|
|
318
|
+
if (!graph) return prev
|
|
319
|
+
return { ...prev, [flowKey]: updater(graph) }
|
|
320
|
+
})
|
|
321
|
+
}, [])
|
|
322
|
+
|
|
323
|
+
// "Focar": troca o fluxo primário e re-mescla o fecho transitivo dele (o fluxo completo
|
|
324
|
+
// referenciado a partir dele) — não isola mais num único fluxo sozinho, já que o padrão
|
|
325
|
+
// agora é sempre mostrar tudo que está conectado.
|
|
326
|
+
const focusFlow = useCallback(
|
|
327
|
+
(key: string) => {
|
|
328
|
+
const others = openFlowKeys.filter((each) => each !== key)
|
|
329
|
+
if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
|
|
330
|
+
setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key])
|
|
331
|
+
if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
|
|
332
|
+
},
|
|
333
|
+
[openFlowKeys, isFlowDirty, editingRef, graphs, labels],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
// "Fechar": remove um fluxo mesclado sem trocar o foco do primário.
|
|
337
|
+
const closeFlow = useCallback(
|
|
338
|
+
(key: string) => {
|
|
339
|
+
if (isFlowDirty(key) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
|
|
340
|
+
setOpenFlowKeys((prev) => prev.filter((each) => each !== key))
|
|
341
|
+
setWorkingGraphs((prev) => {
|
|
342
|
+
const { [key]: _removed, ...rest } = prev
|
|
343
|
+
return rest
|
|
344
|
+
})
|
|
345
|
+
if (editingRef?.flowKey === key) setEditingRef(null)
|
|
346
|
+
},
|
|
347
|
+
[isFlowDirty, editingRef, labels],
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Fusão editável: traz os nós de verdade do fluxo alvo para o mesmo canvas, no lugar do portal.
|
|
352
|
+
*
|
|
353
|
+
* Não calcula posição. Abrir um segundo fluxo liga o layout mesclado, que posiciona TODOS os nós
|
|
354
|
+
* junto e em coordenadas absolutas — um deslocamento calculado aqui seria descartado no desenho e
|
|
355
|
+
* ainda assim subtraído ao gravar, que era como a posição do card ia parar errada no grafo.
|
|
356
|
+
*/
|
|
357
|
+
const mergeFlow = useCallback((targetFlowKey: string) => {
|
|
358
|
+
setOpenFlowKeys((prev) => (prev.includes(targetFlowKey) ? prev : [...prev, targetFlowKey]))
|
|
359
|
+
}, [])
|
|
360
|
+
|
|
361
|
+
// Mais de um fluxo aberto = layout global (ignora node.position individual, recalcula tudo
|
|
362
|
+
// junto pra nunca sobrepor); um só fluxo aberto = comportamento de sempre (respeita posição
|
|
363
|
+
// salva/arrastada, com fallback pro auto-layout daquele fluxo isolado).
|
|
364
|
+
const isMerged = openFlowKeys.length > 1
|
|
365
|
+
const mergedPositions = useMemo(
|
|
366
|
+
() => (isMerged ? computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey }) : null),
|
|
367
|
+
[isMerged, openFlowKeys, workingGraphs, primaryFlowKey],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
// Última posição em que cada nó foi desenhado, e ela manda sobre o layout calculado.
|
|
371
|
+
//
|
|
372
|
+
// O layout mesclado é recalculado a cada mudança de topologia: ligar ou desligar um fio mexia
|
|
373
|
+
// no rank de todo mundo e o canvas inteiro saltava de lugar — o card que perdeu a ligação ia
|
|
374
|
+
// parar na faixa dos órfãos e os demais mudavam de coluna, o que se lê como "o card sumiu".
|
|
375
|
+
// Aqui o layout vira só a semente de quem ainda não tem lugar; o resto fica onde está até o
|
|
376
|
+
// usuário arrastar ou pedir "Organizar".
|
|
377
|
+
const renderedPositionsRef = useRef(new Map<string, { x: number; y: number }>())
|
|
378
|
+
|
|
379
|
+
const derivedNodes = useMemo<Node[]>(() => {
|
|
380
|
+
const allNodes: Node[] = []
|
|
381
|
+
for (const flowKey of openFlowKeys) {
|
|
382
|
+
const graph = workingGraphs[flowKey]
|
|
383
|
+
if (!graph) continue
|
|
384
|
+
const fallbackPositions = mergedPositions ? {} : computeAutoLayout(graph)
|
|
385
|
+
const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
|
|
386
|
+
const flowIssues = issuesByFlow[flowKey] ?? []
|
|
387
|
+
const isPrimary = flowKey === primaryFlowKey
|
|
388
|
+
|
|
389
|
+
// Quem recebe fio de alguém neste fluxo. O que sobra (fora o nó inicial) está solto: o card
|
|
390
|
+
// ganha contorno tracejado e pulsa, para desconectar ou criar um nó ficar visivelmente
|
|
391
|
+
// "falta ligar isto aqui" em vez de silencioso.
|
|
392
|
+
const connectedTargets = new Set<string>()
|
|
393
|
+
for (const candidate of Object.values(graph.nodes)) {
|
|
394
|
+
for (const { target } of targetsOf(candidate)) {
|
|
395
|
+
if (!isCrossFlowTarget(target)) connectedTargets.add(target)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolvePosition(nodeId: string): { x: number; y: number } {
|
|
400
|
+
if (mergedPositions) {
|
|
401
|
+
const nsId = namespaceNodeId(flowKey, nodeId)
|
|
402
|
+
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
|
|
403
|
+
}
|
|
404
|
+
// Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
|
|
405
|
+
return graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const node of Object.values(graph.nodes)) {
|
|
409
|
+
const position = resolvePosition(node.id)
|
|
410
|
+
allNodes.push({
|
|
411
|
+
id: namespaceNodeId(flowKey, node.id),
|
|
412
|
+
type: 'flowNode',
|
|
413
|
+
position,
|
|
414
|
+
draggable: true,
|
|
415
|
+
data: {
|
|
416
|
+
node,
|
|
417
|
+
liveCount: liveCounts[node.id] ?? 0,
|
|
418
|
+
isStart: node.id === graph.startNodeId,
|
|
419
|
+
isSelected: editingRef?.flowKey === flowKey && editingRef?.nodeId === node.id,
|
|
420
|
+
isDetached: node.id !== graph.startNodeId && !connectedTargets.has(node.id),
|
|
421
|
+
issues: flowIssues,
|
|
422
|
+
labels,
|
|
423
|
+
onSelect: (nodeId: string) => setEditingRef({ flowKey, nodeId }),
|
|
424
|
+
} satisfies FlowNodeCardData,
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// Um portal por (nó de origem, fluxo alvo único) — só para saltos cujo fluxo alvo AINDA
|
|
428
|
+
// não está mesclado no canvas (hoje raro, já que abrir um fluxo já mescla tudo que ele
|
|
429
|
+
// referencia — mas serve de rede de segurança pra um fluxo criado depois da fusão
|
|
430
|
+
// inicial); se já estiver mesclado, buildAllEdges liga direto ao nó real.
|
|
431
|
+
const crossFlowTargets = [...new Set(targetsOf(node).map((edge) => edge.target).filter(isCrossFlowTarget))]
|
|
432
|
+
crossFlowTargets.forEach((target, index) => {
|
|
433
|
+
const targetFlowKey = crossFlowKey(target)
|
|
434
|
+
if (openFlowKeys.includes(targetFlowKey)) return
|
|
435
|
+
allNodes.push({
|
|
436
|
+
id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
|
|
437
|
+
type: 'flowPortal',
|
|
438
|
+
draggable: false,
|
|
439
|
+
selectable: false,
|
|
440
|
+
position: { x: position.x + 320, y: position.y + index * 70 },
|
|
441
|
+
data: {
|
|
442
|
+
label: graphs?.[targetFlowKey]?.label ?? targetFlowKey,
|
|
443
|
+
onNavigate: () => mergeFlow(targetFlowKey),
|
|
444
|
+
} satisfies FlowPortalNodeData,
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Moldura decorativa por trás de cada cadeia de perguntas lineares que alimenta uma ação —
|
|
450
|
+
// puramente derivada da topologia do grafo, sem precisar marcar manualmente quais perguntas
|
|
451
|
+
// "pertencem" ao cálculo.
|
|
452
|
+
for (const chain of findCollectionChains(graph)) {
|
|
453
|
+
const chainNodeIds = [...chain.nodeIds, chain.actionNodeId]
|
|
454
|
+
const positions = chainNodeIds.map((id) => resolvePosition(id))
|
|
455
|
+
const minX = Math.min(...positions.map((point) => point.x))
|
|
456
|
+
const maxX = Math.max(...positions.map((point) => point.x)) + NODE_CARD_WIDTH
|
|
457
|
+
const minY = Math.min(...positions.map((point) => point.y))
|
|
458
|
+
const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]!)
|
|
459
|
+
allNodes.push({
|
|
460
|
+
id: namespaceNodeId(flowKey, `__chain__${chain.actionNodeId}`),
|
|
461
|
+
type: 'flowGroupFrame',
|
|
462
|
+
draggable: false,
|
|
463
|
+
selectable: false,
|
|
464
|
+
zIndex: -1,
|
|
465
|
+
position: { x: minX - CHAIN_FRAME_PADDING, y: minY - CHAIN_FRAME_PADDING - 24 },
|
|
466
|
+
style: {
|
|
467
|
+
width: maxX - minX + CHAIN_FRAME_PADDING * 2,
|
|
468
|
+
height: maxY - minY + CHAIN_FRAME_PADDING * 2 + 24,
|
|
469
|
+
},
|
|
470
|
+
data: {
|
|
471
|
+
label: labels.collectionChain.feeds(nodeLabel(graph.nodes[chain.actionNodeId], labels)),
|
|
472
|
+
} satisfies FlowGroupFrameData,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Cabeçalho flutuante só para fluxos mesclados (o primário já tem controles na barra
|
|
477
|
+
// de cima — paleta, organizar, publicar, excluir).
|
|
478
|
+
if (!isPrimary) {
|
|
479
|
+
const startPosition = resolvePosition(graph.startNodeId)
|
|
480
|
+
allNodes.push({
|
|
481
|
+
id: namespaceNodeId(flowKey, '__group_header__'),
|
|
482
|
+
type: 'flowGroupHeader',
|
|
483
|
+
draggable: false,
|
|
484
|
+
selectable: false,
|
|
485
|
+
position: { x: startPosition.x, y: startPosition.y - 60 },
|
|
486
|
+
data: {
|
|
487
|
+
label: graph.label,
|
|
488
|
+
onFocus: () => focusFlow(flowKey),
|
|
489
|
+
onClose: () => closeFlow(flowKey),
|
|
490
|
+
} satisfies FlowGroupHeaderData,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return allNodes
|
|
495
|
+
}, [
|
|
496
|
+
openFlowKeys,
|
|
497
|
+
workingGraphs,
|
|
498
|
+
mergedPositions,
|
|
499
|
+
livePositions,
|
|
500
|
+
issuesByFlow,
|
|
501
|
+
primaryFlowKey,
|
|
502
|
+
rootFlowKey,
|
|
503
|
+
editingRef,
|
|
504
|
+
graphs,
|
|
505
|
+
mergeFlow,
|
|
506
|
+
focusFlow,
|
|
507
|
+
closeFlow,
|
|
508
|
+
labels,
|
|
509
|
+
])
|
|
510
|
+
|
|
511
|
+
const edges = useMemo(
|
|
512
|
+
() =>
|
|
513
|
+
buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map(styleEdge),
|
|
514
|
+
[openFlowKeys, workingGraphs, rootFlowKey, livePositions],
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
setRfNodes(derivedNodes)
|
|
519
|
+
for (const node of derivedNodes) {
|
|
520
|
+
if (node.type === 'flowNode') renderedPositionsRef.current.set(node.id, node.position)
|
|
521
|
+
}
|
|
522
|
+
}, [derivedNodes])
|
|
523
|
+
|
|
524
|
+
// Nó recém-criado ainda não tem ligação, e o layout manda todo órfão para uma faixa abaixo de
|
|
525
|
+
// tudo — num canvas com vários fluxos mesclados isso cai longe da área visível, e o card parece
|
|
526
|
+
// ter sumido. Espera ele existir no canvas e leva a viewport até lá.
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
if (!pendingFocusNodeId || !flowInstance) return
|
|
529
|
+
const target = rfNodes.find((node) => node.id === pendingFocusNodeId)
|
|
530
|
+
if (!target) return
|
|
531
|
+
flowInstance.setCenter(target.position.x + NODE_CARD_WIDTH / 2, target.position.y, { zoom: 1, duration: 400 })
|
|
532
|
+
setPendingFocusNodeId(null)
|
|
533
|
+
}, [pendingFocusNodeId, flowInstance, rfNodes])
|
|
534
|
+
|
|
535
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
536
|
+
setRfNodes((current) => applyNodeChanges(changes, current))
|
|
537
|
+
}, [])
|
|
538
|
+
|
|
539
|
+
const onNodeDragStop = useCallback(
|
|
540
|
+
(_event: unknown, node: Node) => {
|
|
541
|
+
renderedPositionsRef.current.set(node.id, node.position)
|
|
542
|
+
const { flowKey, nodeId } = parseNamespacedId(node.id)
|
|
543
|
+
if (!openFlowKeys.includes(flowKey)) return
|
|
544
|
+
|
|
545
|
+
// A posição do card É a posição do grafo: não há mais deslocamento por fluxo a desfazer aqui.
|
|
546
|
+
updateFlow(flowKey, (graph) =>
|
|
547
|
+
graph.nodes[nodeId]
|
|
548
|
+
? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: node.position } } }
|
|
549
|
+
: graph,
|
|
550
|
+
)
|
|
551
|
+
},
|
|
552
|
+
[openFlowKeys, updateFlow],
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
const onConnect = useCallback(
|
|
556
|
+
(connection: Connection) => {
|
|
557
|
+
// Traduzir o arraste e aplicar no nó são as duas decisões que mandam a conversa do cliente
|
|
558
|
+
// para o lugar certo ou errado, sem erro no meio — por isso vivem em `flowEditorOps`, testadas.
|
|
559
|
+
const resolved = resolveConnection({
|
|
560
|
+
connection: { source: connection.source, target: connection.target, sourceHandle: connection.sourceHandle },
|
|
561
|
+
graphs: workingGraphs,
|
|
562
|
+
})
|
|
563
|
+
if (!resolved) return
|
|
564
|
+
|
|
565
|
+
updateFlow(resolved.flowKey, (graph) => {
|
|
566
|
+
const node = graph.nodes[resolved.nodeId]
|
|
567
|
+
if (!node) return graph
|
|
568
|
+
return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
|
|
569
|
+
})
|
|
570
|
+
},
|
|
571
|
+
[workingGraphs, updateFlow],
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
function handleAddNode(spec: NewNodeSpec) {
|
|
575
|
+
if (!primaryGraph) return
|
|
576
|
+
const newNode = newNodeFromSpec(spec, new Set(Object.keys(primaryGraph.nodes)))
|
|
577
|
+
const maxY = Math.max(0, ...Object.values(primaryGraph.nodes).map((node) => node.position?.y ?? 0))
|
|
578
|
+
newNode.position = { x: 0, y: maxY + 170 }
|
|
579
|
+
updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }))
|
|
580
|
+
setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id })
|
|
581
|
+
setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function handleNodePanelChange(updated: FlowNodeData) {
|
|
585
|
+
if (!editingRef) return
|
|
586
|
+
updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [updated.id]: updated } }))
|
|
587
|
+
setEditingRef(null)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function handleNodeDelete(nodeId: string) {
|
|
591
|
+
if (!editingRef) return
|
|
592
|
+
updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: removeNodeAndCleanRefs(graph.nodes, nodeId) }))
|
|
593
|
+
setEditingRef(null)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Único caminho que move card sozinho — as posições são estáveis no resto do tempo, então
|
|
597
|
+
// reorganizar virou uma ação explícita, inclusive com vários fluxos mesclados no canvas.
|
|
598
|
+
function handleOrganize() {
|
|
599
|
+
if (!primaryGraph) return
|
|
600
|
+
renderedPositionsRef.current.clear()
|
|
601
|
+
|
|
602
|
+
if (isMerged) {
|
|
603
|
+
const positions = computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey })
|
|
604
|
+
for (const key of openFlowKeys) {
|
|
605
|
+
updateFlow(key, (graph) => ({
|
|
606
|
+
...graph,
|
|
607
|
+
nodes: Object.fromEntries(
|
|
608
|
+
Object.entries(graph.nodes).map(([id, node]) => {
|
|
609
|
+
const position = positions.get(namespaceNodeId(key, id))
|
|
610
|
+
return [id, position ? { ...node, position } : node]
|
|
611
|
+
}),
|
|
612
|
+
),
|
|
613
|
+
}))
|
|
614
|
+
}
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const positions = computeAutoLayout(primaryGraph)
|
|
619
|
+
updateFlow(primaryFlowKey, (graph) => ({
|
|
620
|
+
...graph,
|
|
621
|
+
nodes: Object.fromEntries(
|
|
622
|
+
Object.entries(graph.nodes).map(([id, node]) => [id, { ...node, position: positions[id] ?? node.position }]),
|
|
623
|
+
),
|
|
624
|
+
}))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Descarta o rascunho local e volta ao que está publicado. Sem isso, a única saída de uma
|
|
628
|
+
// edição indesejada era recarregar a página no susto — e recarregar também perde o resto.
|
|
629
|
+
function handleDiscardChanges() {
|
|
630
|
+
if (!graphs || !isDirty) return
|
|
631
|
+
if (!window.confirm(labels.workspace.discardConfirm)) return
|
|
632
|
+
setWorkingGraphs((prev) => {
|
|
633
|
+
const next = { ...prev }
|
|
634
|
+
for (const key of dirtyKeys) {
|
|
635
|
+
const published = graphs[key]
|
|
636
|
+
if (published) next[key] = published
|
|
637
|
+
}
|
|
638
|
+
return next
|
|
639
|
+
})
|
|
640
|
+
renderedPositionsRef.current.clear()
|
|
641
|
+
setEditingRef(null)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function handlePublish() {
|
|
645
|
+
if (dirtyKeys.length === 0 || errorCount > 0) return
|
|
646
|
+
setSaveState('saving')
|
|
647
|
+
setSaveErrorMessage(undefined)
|
|
648
|
+
try {
|
|
649
|
+
await Promise.all(dirtyKeys.map((key) => api.saveGraph(key, workingGraphs[key]!)))
|
|
650
|
+
await reloadGraphs()
|
|
651
|
+
setSaveState('success')
|
|
652
|
+
setTimeout(() => setSaveState('idle'), 3000)
|
|
653
|
+
} catch (error) {
|
|
654
|
+
setSaveState('error')
|
|
655
|
+
setSaveErrorMessage(extractErrorMessage(error))
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function handleCreateFlow() {
|
|
660
|
+
const createFlow = api.createFlow
|
|
661
|
+
if (!createFlow) return
|
|
662
|
+
setFlowMutationState({ pending: true })
|
|
663
|
+
try {
|
|
664
|
+
await createFlow({
|
|
665
|
+
key: newFlow.key,
|
|
666
|
+
label: newFlow.label,
|
|
667
|
+
showInMenu: newFlow.showInMenu,
|
|
668
|
+
...(newFlow.showInMenu ? { menuOptionLabel: newFlow.menuOptionLabel || newFlow.label } : {}),
|
|
669
|
+
})
|
|
670
|
+
await reloadGraphs()
|
|
671
|
+
setOpenFlowKeys([newFlow.key])
|
|
672
|
+
setShowCreateDialog(false)
|
|
673
|
+
setNewFlow({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
|
|
674
|
+
setFlowMutationState({ pending: false })
|
|
675
|
+
} catch (error) {
|
|
676
|
+
setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.createError })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function handleDeleteFlow() {
|
|
681
|
+
const deleteFlow = api.deleteFlow
|
|
682
|
+
if (!deleteFlow || !primaryGraph) return
|
|
683
|
+
setFlowMutationState({ pending: true })
|
|
684
|
+
try {
|
|
685
|
+
await deleteFlow(primaryGraph.key)
|
|
686
|
+
const reloaded = await reloadGraphs()
|
|
687
|
+
setOpenFlowKeys(reloaded ? mergedFlowKeysFrom(rootFlowKey, reloaded) : [rootFlowKey])
|
|
688
|
+
setShowDeleteDialog(false)
|
|
689
|
+
setFlowMutationState({ pending: false })
|
|
690
|
+
} catch (error) {
|
|
691
|
+
setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.deleteError })
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const editingGraph = editingRef ? workingGraphs[editingRef.flowKey] : null
|
|
696
|
+
const editingNode = editingRef && editingGraph ? editingGraph.nodes[editingRef.nodeId] : null
|
|
697
|
+
const otherFlows = useMemo(
|
|
698
|
+
() =>
|
|
699
|
+
Object.values(graphs ?? {})
|
|
700
|
+
.filter((graph) => graph.key !== editingRef?.flowKey)
|
|
701
|
+
.map((graph) => ({ key: graph.key, label: graph.label })),
|
|
702
|
+
[graphs, editingRef?.flowKey],
|
|
703
|
+
)
|
|
704
|
+
const keyIsValid = FLOW_KEY_PATTERN.test(newFlow.key)
|
|
705
|
+
const canCreateFlow = Boolean(api.createFlow)
|
|
706
|
+
const canDeleteFlow = Boolean(api.deleteFlow) && primaryFlowKey !== rootFlowKey
|
|
707
|
+
|
|
708
|
+
return (
|
|
709
|
+
<div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
|
|
710
|
+
<TooltipLayer />
|
|
711
|
+
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
712
|
+
<div>
|
|
713
|
+
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
|
|
714
|
+
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{labels.workspace.subtitle}</p>
|
|
715
|
+
</div>
|
|
716
|
+
<div className="flex items-center gap-3">
|
|
717
|
+
{saveState === 'success' && <span className="text-sm text-green-600">{labels.workspace.saveSuccess}</span>}
|
|
718
|
+
{saveState === 'error' && (
|
|
719
|
+
<span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
|
|
720
|
+
)}
|
|
721
|
+
<button
|
|
722
|
+
data-cv-tooltip={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap} aria-label={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
|
|
723
|
+
type="button"
|
|
724
|
+
onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
|
|
725
|
+
className={OUTLINE_BUTTON}
|
|
726
|
+
>
|
|
727
|
+
{viewMode === 'map' ? <Workflow size={14} aria-hidden="true" /> : <MapIcon size={14} aria-hidden="true" />}
|
|
728
|
+
{viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
|
|
729
|
+
</button>
|
|
730
|
+
{viewMode === 'detail' && (
|
|
731
|
+
<>
|
|
732
|
+
<button
|
|
733
|
+
type="button"
|
|
734
|
+
onClick={handleOrganize}
|
|
735
|
+
className={OUTLINE_BUTTON}
|
|
736
|
+
data-cv-tooltip={labels.workspace.organizeTooltip} aria-label={labels.workspace.organizeTooltip}
|
|
737
|
+
disabled={!primaryGraph}
|
|
738
|
+
>
|
|
739
|
+
<LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
|
|
740
|
+
</button>
|
|
741
|
+
<button
|
|
742
|
+
type="button"
|
|
743
|
+
onClick={handleDiscardChanges}
|
|
744
|
+
className={OUTLINE_BUTTON}
|
|
745
|
+
data-cv-tooltip={labels.workspace.discardTooltip} aria-label={labels.workspace.discardTooltip}
|
|
746
|
+
disabled={!isDirty || saveState === 'saving'}
|
|
747
|
+
>
|
|
748
|
+
<Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
|
|
749
|
+
</button>
|
|
750
|
+
<button
|
|
751
|
+
data-cv-tooltip={labels.workspace.saveGraph} aria-label={labels.workspace.saveGraph}
|
|
752
|
+
type="button"
|
|
753
|
+
onClick={() => void handlePublish()}
|
|
754
|
+
className={PRIMARY_BUTTON}
|
|
755
|
+
disabled={!isDirty || errorCount > 0 || saveState === 'saving'}
|
|
756
|
+
>
|
|
757
|
+
<Save size={14} aria-hidden="true" />
|
|
758
|
+
{saveState === 'saving' ? labels.workspace.saving : labels.workspace.saveGraph}
|
|
759
|
+
</button>
|
|
760
|
+
</>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
{viewMode === 'detail' && (
|
|
766
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
767
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
768
|
+
{graphs &&
|
|
769
|
+
Object.values(graphs).map((graph) => (
|
|
770
|
+
<button
|
|
771
|
+
data-cv-tooltip={graph.label} aria-label={graph.label}
|
|
772
|
+
key={graph.key}
|
|
773
|
+
type="button"
|
|
774
|
+
onClick={() => focusFlow(graph.key)}
|
|
775
|
+
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
|
776
|
+
primaryFlowKey === graph.key
|
|
777
|
+
? 'bg-blue-600 text-white border-blue-600'
|
|
778
|
+
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-blue-300'
|
|
779
|
+
}`}
|
|
780
|
+
>
|
|
781
|
+
{graph.label}
|
|
782
|
+
</button>
|
|
783
|
+
))}
|
|
784
|
+
{canCreateFlow && (
|
|
785
|
+
<button
|
|
786
|
+
data-cv-tooltip={labels.flowManager.newFlow} aria-label={labels.flowManager.newFlow}
|
|
787
|
+
type="button"
|
|
788
|
+
onClick={() => {
|
|
789
|
+
setFlowMutationState({ pending: false })
|
|
790
|
+
setShowCreateDialog(true)
|
|
791
|
+
}}
|
|
792
|
+
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border border-dashed border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-blue-400 hover:text-blue-600"
|
|
793
|
+
>
|
|
794
|
+
<Plus size={12} aria-hidden="true" /> {labels.flowManager.newFlow}
|
|
795
|
+
</button>
|
|
796
|
+
)}
|
|
797
|
+
</div>
|
|
798
|
+
<div className="flex items-center gap-2">
|
|
799
|
+
{primaryGraph && (
|
|
800
|
+
<FlowPalette
|
|
801
|
+
onAdd={handleAddNode}
|
|
802
|
+
labels={labels}
|
|
803
|
+
{...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
|
|
804
|
+
/>
|
|
805
|
+
)}
|
|
806
|
+
{primaryGraph && canDeleteFlow && (
|
|
807
|
+
<button
|
|
808
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
809
|
+
type="button"
|
|
810
|
+
onClick={() => {
|
|
811
|
+
setFlowMutationState({ pending: false })
|
|
812
|
+
setShowDeleteDialog(true)
|
|
813
|
+
}}
|
|
814
|
+
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-900 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
|
|
815
|
+
>
|
|
816
|
+
<Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
|
|
817
|
+
</button>
|
|
818
|
+
)}
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
)}
|
|
822
|
+
|
|
823
|
+
{viewMode === 'detail' && (errorCount > 0 || warningCount > 0) && (
|
|
824
|
+
<div className="flex items-center gap-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 px-3 py-2 text-xs">
|
|
825
|
+
<span className="font-medium text-gray-600 dark:text-gray-300">{labels.validation.title}:</span>
|
|
826
|
+
{errorCount > 0 && (
|
|
827
|
+
<span className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
|
|
828
|
+
<AlertCircle size={13} aria-hidden="true" /> {labels.validation.errors(errorCount)}
|
|
829
|
+
</span>
|
|
830
|
+
)}
|
|
831
|
+
{warningCount > 0 && (
|
|
832
|
+
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
|
833
|
+
<AlertTriangle size={13} aria-hidden="true" /> {labels.validation.warnings(warningCount)}
|
|
834
|
+
</span>
|
|
835
|
+
)}
|
|
836
|
+
</div>
|
|
837
|
+
)}
|
|
838
|
+
|
|
839
|
+
<div className="flex-1 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
|
840
|
+
{loadState === 'loading' && <p className="text-center text-gray-400 py-12">{labels.workspace.loading}</p>}
|
|
841
|
+
{loadState === 'error' && <p className="text-center text-red-500 py-12">{labels.workspace.loadError}</p>}
|
|
842
|
+
{loadState === 'ready' && graphs && viewMode === 'map' && (
|
|
843
|
+
<FlowMapCanvas
|
|
844
|
+
graphs={graphs}
|
|
845
|
+
rootKey={rootFlowKey}
|
|
846
|
+
labels={labels}
|
|
847
|
+
onOpenFlow={(key) => {
|
|
848
|
+
focusFlow(key)
|
|
849
|
+
setViewMode('detail')
|
|
850
|
+
}}
|
|
851
|
+
/>
|
|
852
|
+
)}
|
|
853
|
+
{loadState === 'ready' && viewMode === 'detail' && primaryGraph && (
|
|
854
|
+
<ReactFlow
|
|
855
|
+
nodes={rfNodes}
|
|
856
|
+
edges={edges}
|
|
857
|
+
nodeTypes={RF_NODE_TYPES}
|
|
858
|
+
onNodesChange={onNodesChange}
|
|
859
|
+
onNodeDragStop={onNodeDragStop}
|
|
860
|
+
onConnect={onConnect}
|
|
861
|
+
onInit={setFlowInstance}
|
|
862
|
+
fitView
|
|
863
|
+
proOptions={{ hideAttribution: true }}
|
|
864
|
+
colorMode={isDark ? 'dark' : 'light'}
|
|
865
|
+
>
|
|
866
|
+
<Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
|
|
867
|
+
<Controls />
|
|
868
|
+
<MiniMap pannable zoomable className="!bg-white dark:!bg-gray-800" />
|
|
869
|
+
</ReactFlow>
|
|
870
|
+
)}
|
|
871
|
+
</div>
|
|
872
|
+
|
|
873
|
+
{/* `key` por nó: o painel guarda um rascunho local em estado, e sem remontar ao trocar de nó
|
|
874
|
+
selecionado ele seguia mostrando (e salvando) os campos do nó anterior. */}
|
|
875
|
+
{editingNode && editingGraph && (
|
|
876
|
+
<FlowNodePanel
|
|
877
|
+
key={`${editingRef?.flowKey}:${editingRef?.nodeId}`}
|
|
878
|
+
graph={editingGraph}
|
|
879
|
+
node={editingNode}
|
|
880
|
+
issues={editingRef ? (issuesByFlow[editingRef.flowKey] ?? []) : []}
|
|
881
|
+
otherFlows={otherFlows}
|
|
882
|
+
labels={labels}
|
|
883
|
+
onClose={() => setEditingRef(null)}
|
|
884
|
+
onChange={handleNodePanelChange}
|
|
885
|
+
onDelete={handleNodeDelete}
|
|
886
|
+
{...(renderMediaPicker ? { renderMediaPicker } : {})}
|
|
887
|
+
/>
|
|
888
|
+
)}
|
|
889
|
+
|
|
890
|
+
{showCreateDialog && canCreateFlow && (
|
|
891
|
+
<FlowDialog title={labels.flowManager.createTitle} onClose={() => setShowCreateDialog(false)}>
|
|
892
|
+
<div className="space-y-3">
|
|
893
|
+
<div>
|
|
894
|
+
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.label}</label>
|
|
895
|
+
<input
|
|
896
|
+
value={newFlow.label}
|
|
897
|
+
onChange={(event) => setNewFlow((prev) => ({ ...prev, label: event.target.value }))}
|
|
898
|
+
className={`mt-1 ${DIALOG_INPUT}`}
|
|
899
|
+
/>
|
|
900
|
+
</div>
|
|
901
|
+
<div>
|
|
902
|
+
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.key}</label>
|
|
903
|
+
<input
|
|
904
|
+
value={newFlow.key}
|
|
905
|
+
onChange={(event) => setNewFlow((prev) => ({ ...prev, key: event.target.value.toLowerCase() }))}
|
|
906
|
+
className={`mt-1 ${DIALOG_INPUT}`}
|
|
907
|
+
/>
|
|
908
|
+
<p className="text-[11px] text-gray-400 mt-1">
|
|
909
|
+
{newFlow.key && !keyIsValid ? labels.flowManager.keyInvalid : labels.flowManager.keyHint}
|
|
910
|
+
</p>
|
|
911
|
+
</div>
|
|
912
|
+
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
913
|
+
<input
|
|
914
|
+
type="checkbox"
|
|
915
|
+
checked={newFlow.showInMenu}
|
|
916
|
+
onChange={(event) => setNewFlow((prev) => ({ ...prev, showInMenu: event.target.checked }))}
|
|
917
|
+
/>
|
|
918
|
+
{labels.flowManager.showInMenu}
|
|
919
|
+
</label>
|
|
920
|
+
{newFlow.showInMenu && (
|
|
921
|
+
<div>
|
|
922
|
+
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
923
|
+
{labels.flowManager.menuOptionLabel}
|
|
924
|
+
</label>
|
|
925
|
+
<input
|
|
926
|
+
value={newFlow.menuOptionLabel}
|
|
927
|
+
onChange={(event) => setNewFlow((prev) => ({ ...prev, menuOptionLabel: event.target.value }))}
|
|
928
|
+
placeholder={newFlow.label}
|
|
929
|
+
className={`mt-1 ${DIALOG_INPUT}`}
|
|
930
|
+
/>
|
|
931
|
+
</div>
|
|
932
|
+
)}
|
|
933
|
+
{flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
|
|
934
|
+
</div>
|
|
935
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
936
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
|
|
937
|
+
{labels.nodePanel.cancel}
|
|
938
|
+
</button>
|
|
939
|
+
<button
|
|
940
|
+
data-cv-tooltip={labels.flowManager.create} aria-label={labels.flowManager.create}
|
|
941
|
+
type="button"
|
|
942
|
+
className={PRIMARY_BUTTON}
|
|
943
|
+
onClick={() => void handleCreateFlow()}
|
|
944
|
+
disabled={!keyIsValid || !newFlow.label || flowMutationState.pending}
|
|
945
|
+
>
|
|
946
|
+
{flowMutationState.pending ? labels.flowManager.creating : labels.flowManager.create}
|
|
947
|
+
</button>
|
|
948
|
+
</div>
|
|
949
|
+
</FlowDialog>
|
|
950
|
+
)}
|
|
951
|
+
|
|
952
|
+
{showDeleteDialog && canDeleteFlow && (
|
|
953
|
+
<FlowDialog title={labels.flowManager.deleteFlow} onClose={() => setShowDeleteDialog(false)}>
|
|
954
|
+
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
955
|
+
{primaryGraph ? labels.flowManager.deleteConfirm(primaryGraph.label) : ''}
|
|
956
|
+
</p>
|
|
957
|
+
{flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
|
|
958
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
959
|
+
<button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
|
|
960
|
+
{labels.nodePanel.cancel}
|
|
961
|
+
</button>
|
|
962
|
+
<button
|
|
963
|
+
data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
|
|
964
|
+
type="button"
|
|
965
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
|
|
966
|
+
onClick={() => void handleDeleteFlow()}
|
|
967
|
+
disabled={flowMutationState.pending}
|
|
968
|
+
>
|
|
969
|
+
<Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
|
|
970
|
+
</button>
|
|
971
|
+
</div>
|
|
972
|
+
</FlowDialog>
|
|
973
|
+
)}
|
|
974
|
+
</div>
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Diálogo próprio em vez de depender do `Dialog` do produto: o pacote roda em três apps com
|
|
979
|
+
// bibliotecas de UI diferentes, e exigir uma delas transformaria a tela composta num acoplamento.
|
|
980
|
+
function FlowDialog({
|
|
981
|
+
title,
|
|
982
|
+
onClose,
|
|
983
|
+
children,
|
|
984
|
+
}: {
|
|
985
|
+
title: string
|
|
986
|
+
onClose: () => void
|
|
987
|
+
children: ReactNode
|
|
988
|
+
}) {
|
|
989
|
+
return (
|
|
990
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
|
|
991
|
+
<div className="absolute inset-0" onClick={onClose} aria-hidden="true" />
|
|
992
|
+
<div
|
|
993
|
+
role="dialog"
|
|
994
|
+
aria-modal="true"
|
|
995
|
+
aria-label={title}
|
|
996
|
+
className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-5 shadow-xl"
|
|
997
|
+
>
|
|
998
|
+
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">{title}</h3>
|
|
999
|
+
{children}
|
|
1000
|
+
</div>
|
|
1001
|
+
</div>
|
|
1002
|
+
)
|
|
1003
|
+
}
|