import React, { useCallback, useContext, useMemo, useRef } from 'react' import { autocompletion } from '@codemirror/autocomplete' import CodeMirror from '@uiw/react-codemirror' import { javascript } from '@codemirror/lang-javascript' import { markdown } from '@codemirror/lang-markdown' import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, type FlowNode, } from '@/lib/graph/abstractNode' import { useResizeHeight } from '@/hooks/useResizeHeight' import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete' import { plantumlLanguage } from '@/lib/plantumlLanguage' import { useTheme } from '@/lib/themeContext' import { getConfigTypes, getConfigContent, getConfigType, getConfigTypeId, isGroup, type ConfigTypeId, type InsertBlockGroup, type InsertBlock, } from '@/lib/graph/configTypes' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, } from '@/components/graph/BaseNode' import { Code2, Database, ScrollText, Variable } from 'lucide-react' import { MenubarItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, } from '@/components/ui/menubar' import { Kbd } from '@/components/ui/kbd' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle' import { NodeMenubar } from '@/components/graph/NodeMenubar' import { FlowUIContext } from '@/lib/graph/flowContext' import { getNodeType } from '@/lib/graph/nodeRegistry' export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string } type Props = AbstractNodeProps function ConfigNodeComponent({ id, data, width, height, selected }: Props) { const flowUIContext = useContext(FlowUIContext) const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId const supportsFullscreen = getNodeType('config')?.supportsFullscreen const configTypeId = getConfigTypeId(data ?? {}) const configType = getConfigType(configTypeId) const content = getConfigContent(data ?? {}) const { theme } = useTheme() const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const editorRef = useRef(null) const connectedConfigNodes = useMemo( () => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'), [nodes, sourceIds] ) const connectedVariableNodes = useMemo( () => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'), [nodes, sourceIds] ) const connectedFunctionNodes = useMemo( () => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'), [nodes, sourceIds] ) const connectedDataNodes = useMemo( () => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'data'), [nodes, sourceIds] ) const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0 const onChange = useCallback( (val: string) => updateData({ content: val, configType: configTypeId }), [updateData, configTypeId] ) const setConfigType = useCallback( (newTypeId: ConfigTypeId) => { if (newTypeId === configTypeId) return updateData({ configType: newTypeId, content: getConfigContent({ ...data, configType: newTypeId }) ?? '', }) }, [configTypeId, data, updateData] ) const insertAt = useCallback( (insertText: string, mode: 'prepend' | 'append' | 'cursor') => { const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null if (ref?.view) { const view = ref.view const doc = view.state.doc const len = doc.length let from: number if (mode === 'prepend') { from = 0 } else if (mode === 'append') { from = len } else { const main = view.state.selection.main from = main.from } view.dispatch({ changes: { from, to: from, insert: insertText } }) const newVal = view.state.doc.toString() onChange(newVal) return } if (mode === 'prepend') { onChange(insertText + content) } else { onChange(content + insertText) } }, [onChange, content] ) const insertExtendsFromNode = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{% extends "${sourceNode.id}" %}\n`, mode) }, [insertAt] ) const insertIncludeFromNode = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{% include "${sourceNode.id}" %}\n`, mode) }, [insertAt] ) const insertImportFromNode = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{% import "${sourceNode.id}" as ${sourceNode.id} %}\n`, mode) }, [insertAt] ) const insertVariableReference = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{{ ${sourceNode.id} }}`, mode) }, [insertAt] ) const insertFunctionCall = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{{ '' | ${sourceNode.id} }}`, mode) }, [insertAt] ) const insertDataReference = useCallback( (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { insertAt(`{% for row in ${sourceNode.id} %}\n \n{% endfor %}`, mode) }, [insertAt] ) const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes]) const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes]) const configTitles = useMemo( () => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id), [connectedConfigNodes], ) const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes]) const extensions = useMemo(() => { const lang = configTypeId === 'wireframe' ? javascript() : configType.language === 'plantuml' ? plantumlLanguage.extension : markdown() return [ lang, autocompletion({ override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)], activateOnTyping: true, }), ] }, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds]) const [editorHeight, editorContainerRef] = useResizeHeight(180) const insertBlocksContent = useMemo(() => { const templatingGroup = configType.insertBlocks.find( (b): b is InsertBlockGroup => isGroup(b) && b.label === 'Templating' ) const typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating')) const insertShortcut = Insert const typeItems = typeBlocks.flatMap((block) => isGroup(block) ? block.items.map(({ label, snippet }: InsertBlock) => ( insertAt(snippet, 'cursor')} > {label} {insertShortcut} )) : [ insertAt(block.snippet, 'cursor')} > {block.label} {insertShortcut} , ] ) const templatingItems = templatingGroup?.items.map(({ label, snippet }: InsertBlock) => ( insertAt(snippet, 'cursor')} > {label} Insert )) ?? [] return ( <> {typeItems} {templatingItems.length > 0 && ( <> {templatingItems} )} ) }, [configType.insertBlocks, insertAt]) const dimensions = width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined return ( }> } title={} right={ } onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} />
{connectedConfigNodes.map((n: any) => ( {n.data?.title ?? n.id} insertExtendsFromNode(n, 'cursor')} > Extend Insert insertIncludeFromNode(n, 'cursor')} > Include Insert insertImportFromNode(n, 'cursor')} > Import Insert ))} {connectedVariableNodes.map((n: any) => ( insertVariableReference(n, 'cursor')} > {n.id} Insert ))} {connectedFunctionNodes.map((n: any) => ( insertFunctionCall(n, 'cursor')} > {n.id} Insert ))} {connectedDataNodes.map((n: any) => ( insertDataReference(n, 'cursor')} > {n.id} Insert ))} ) : ( Connect nodes to insert references ) } insertMenuLabel="Blocks" insertContentDirect insertTagsContent={insertBlocksContent} />
} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
{configType.label} ยท {content ? `${content.length} chars` : 'none'}
) } export const ConfigNode = createAbstractNodeComponent( 'ConfigNode', ConfigNodeComponent ) export default ConfigNode