388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
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<ConfigNodeData>
|
|
|
|
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<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 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 }: InsertBlock) => (
|
|
<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 }: InsertBlock) => (
|
|
<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>
|
|
{getConfigTypes().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
|