refactor: nodes as builder pattern

This commit is contained in:
2026-03-12 11:01:35 +01:00
parent 302107e710
commit 86238b7efc
28 changed files with 1130 additions and 611 deletions

View File

@@ -0,0 +1,385 @@
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/abstractNode'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { useTheme } from '@/lib/themeContext'
import {
CONFIG_TYPES,
getConfigContent,
getConfigType,
getConfigTypeId,
isGroup,
type ConfigTypeId,
} from '@/lib/configTypes'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/base/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/base/NodeHandles'
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
import { NodeMenubar } from '@/components/base/NodeMenubar'
import FlowContext from '@/lib/flowContext'
import { getNodeType } from '@/lib/nodeRegistry'
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.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<ConfigNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(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 import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
)
const typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating'))
const insertShortcut = <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
const typeItems = typeBlocks.flatMap((block) =>
isGroup(block)
? block.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
{insertShortcut}
</MenubarItem>
))
: [
<MenubarItem
key={block.label}
className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
{insertShortcut}
</MenubarItem>,
]
)
const templatingItems =
templatingGroup?.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
)) ?? []
return (
<>
{typeItems}
{templatingItems.length > 0 && (
<>
<MenubarSeparator />
{templatingItems}
</>
)}
</>
)
}, [configType.insertBlocks, insertAt])
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow
icon={<ScrollText className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
right={
<Select value={configTypeId} onValueChange={(v) => setConfigType(v as ConfigTypeId)}>
<SelectTrigger className="h-7 w-[7rem] text-xs font-normal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((t) => (
<SelectItem key={t.id} value={t.id} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar
nodeId={id}
nodeType="config"
inputsMenuContent={
hasDependencies ? (
<>
{connectedConfigNodes.map((n: any) => (
<MenubarSub key={`${n.id}`}>
<MenubarSubTrigger className="text-xs flex items-center gap-2">
<ScrollText className="size-3.5 shrink-0" />
{n.data?.title ?? n.id}
</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem
className="text-xs flex items-center group"
onClick={() => insertExtendsFromNode(n, 'cursor')}
>
Extend
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
<MenubarItem
className="text-xs flex items-center group"
onClick={() => insertIncludeFromNode(n, 'cursor')}
>
Include
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
<MenubarItem
className="text-xs flex items-center group"
onClick={() => insertImportFromNode(n, 'cursor')}
>
Import
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
))}
{connectedVariableNodes.map((n: any) => (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertVariableReference(n, 'cursor')}
>
<Variable className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
{connectedFunctionNodes.map((n: any) => (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertFunctionCall(n, 'cursor')}
>
<Code2 className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
{connectedDataNodes.map((n: any) => (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertDataReference(n, 'cursor')}
>
<Database className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
</>
) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
)
}
insertMenuLabel="Blocks"
insertContentDirect
insertTagsContent={insertBlocksContent}
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
value={content}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
/>
</div>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
{configType.label} · {content ? `${content.length} chars` : 'none'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
'ConfigNode',
ConfigNodeComponent
)
export default ConfigNode