diff --git a/package-lock.json b/package-lock.json index 460e85f..45411dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-context-menu": "^2.2.16", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "js-yaml": "^4.1.0", @@ -2375,6 +2376,38 @@ "vite": "^4.1.0-beta.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index c8f8696..94e3193 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-context-menu": "^2.2.16", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "js-yaml": "^4.1.0", diff --git a/src/components/ConfigNode.tsx b/src/components/ConfigNode.tsx index dcc9b98..6e171c9 100644 --- a/src/components/ConfigNode.tsx +++ b/src/components/ConfigNode.tsx @@ -1,10 +1,17 @@ -import React from 'react' +import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Handle, Position } from 'reactflow' import Editor, { loader } from '@monaco-editor/react' import FlowContext from '../lib/flowContext' +import { + BaseNode, + BaseNodeContent, + BaseNodeFooter, + BaseNodeHeader, + BaseNodeHeaderTitle, +} from './base-node' +import { Pencil } from 'lucide-react' // Ensure Monaco can load its web workers under Vite by pointing to a CDN -// This avoids the editor hanging while trying to locate worker scripts locally. loader.config({ paths: { vs: 'https://unpkg.com/monaco-editor@latest/min/vs' } }) type Props = { @@ -12,31 +19,31 @@ type Props = { data: any } -export default function ConfigNode({ id, data }: Props) { - const [value, setValue] = React.useState(data?.yaml ?? "# Enter YAML here\n") - const ctx = React.useContext(FlowContext) +export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { + const [value, setValue] = useState(data?.yaml ?? '# Enter YAML here\n') + + const ctx = useContext(FlowContext) const setNodes = ctx?.setNodes const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? '' - const editorRef = React.useRef(null) - const monacoRef = React.useRef(null) + + const editorRef = useRef(null) + const monacoRef = useRef(null) const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? [] const incomingIds = incomingEdges.map((e: any) => e.source).sort() const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config') - React.useEffect(() => { - // keep node data in sync on mount + useEffect(() => { if (setNodes) { setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: value } } : n))) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const onChange = React.useCallback( + const onChange = useCallback( (val?: string) => { const v = val ?? '' setValue(v) - // debug log to help trace updates // eslint-disable-next-line no-console console.debug('ConfigNode:onChange', id, v.substring(0, 60)) if (setNodes) { @@ -46,12 +53,12 @@ export default function ConfigNode({ id, data }: Props) { [id, setNodes] ) - const handleEditorMount = React.useCallback((editor: any, monaco: any) => { + const handleEditorMount = useCallback((editor: any, monaco: any) => { editorRef.current = editor monacoRef.current = monaco }, []) - const insertIncludeFromNode = React.useCallback( + const insertIncludeFromNode = useCallback( (sourceNode: any) => { const ref = `${sourceNode.id}.yaml` const includeText = `!include ${ref}\n` @@ -85,61 +92,58 @@ export default function ConfigNode({ id, data }: Props) { ) return ( -
-
-
{id}.yml
-
CONFIG
-
+ + + + {id}.yml + - {connectedConfigNodes.length > 0 && ( -
-
Connected configs
-
- {connectedConfigNodes.map((n: any) => ( -
-
{n.data?.title ?? n.id}
-
- + + {connectedConfigNodes.length > 0 && ( +
+
Connected configs
+
+ {connectedConfigNodes.map((n: any) => ( +
+
{n.data?.title ?? n.id}
+
+ +
-
- ))} + ))} +
+ )} + +
+
- )} + -
- -
-
- Stored YAML: {storedYaml ? `${storedYaml.length} chars` : 'none'} -
+ +
Stored YAML: {storedYaml ? `${storedYaml.length} chars` : 'none'}
+
- + - -
+ + ) -} +}) + +ConfigNode.displayName = 'ConfigNode' + +export default ConfigNode diff --git a/src/components/RenderingNode.tsx b/src/components/RenderingNode.tsx index 7d1c9a3..0752404 100644 --- a/src/components/RenderingNode.tsx +++ b/src/components/RenderingNode.tsx @@ -1,56 +1,68 @@ -import React from 'react' +import { memo, useContext, useEffect, useMemo, useState } from 'react' import { Handle, Position } from 'reactflow' import yaml from 'js-yaml' import FlowContext from '../lib/flowContext' +import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from './ui/empty' +import { + BaseNode, + BaseNodeContent, + BaseNodeFooter, + BaseNodeHeader, + BaseNodeHeaderTitle, +} from './base-node' +import { Rocket } from 'lucide-react' type Props = { id: string - data: any + data?: any } -export default function RenderingNode({ id }: Props) { - const [output, setOutput] = React.useState('') +export const RenderingNode = memo(function RenderingNode({ id }: Props) { + const [output, setOutput] = useState('') + const [error, setError] = useState(null) - const ctx = React.useContext(FlowContext) + const ctx = useContext(FlowContext) const nodes = ctx?.nodes ?? [] const edges = ctx?.edges ?? [] + const setNodes = ctx?.setNodes + const setEdges = ctx?.setEdges - const incomingEdges = edges.filter((e: any) => e.target === id) - const incomingIds = incomingEdges.map((e: any) => e.source).sort() + const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id]) + const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges]) const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = nodes.find((n: any) => n.id === srcId) const yamlText = srcNode && srcNode.type === 'config' ? srcNode?.data?.yaml ?? '' : '' + const srcData = srcNode?.data ?? {} - React.useEffect(() => { + useEffect(() => { // debug // eslint-disable-next-line no-console - console.debug('RenderingNode:selection', { id, incomingIds, srcNode: srcNode ? { id: srcNode.id, type: srcNode.type, yaml: (srcNode.data?.yaml ?? '').slice(0, 60) } : null }) + console.debug('RenderingNode:selection', { + id, + incomingIds, + srcNode: srcNode ? { id: srcNode.id, type: srcNode.type, yaml: (srcNode.data?.yaml ?? '').slice(0, 60) } : null, + }) if (!yamlText) { if (incomingIds.length === 0) { - setOutput(`No configuration connected (nodes: ${nodes.length}, edges: ${edges.length})`) + setOutput('') + setError(null) return } - setOutput('No YAML found on connected configuration node') + setOutput('') + setError({ kind: 'no-yaml', message: 'No YAML found on connected configuration node' }) return } try { - // resolve !include directives by inlining YAML from reachable config nodes const resolveIncludes = (text: string, visited = new Set()): string => { const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm - return text.replace(includeRegex, (match, indent, ref) => { + return text.replace(includeRegex, (match: string, indent: string, ref: string) => { const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref - // try to find a node by id or by title - const refNode = nodes.find((n: any) => n.id === refName || (n.data?.title === refName)) - if (!refNode) { - throw new Error(`Included node not found: ${ref}`) - } - if (visited.has(refNode.id)) { - throw new Error(`Circular include detected: ${ref}`) - } + const refNode = nodes.find((n: any) => n.id === refName || n.data?.title === refName) + if (!refNode) throw new Error(`Included node not found: ${ref}`) + if (visited.has(refNode.id)) throw new Error(`Circular include detected: ${ref}`) - // ensure refNode can reach this rendering node const isReachable = (startId: string, targetId: string) => { const q: string[] = [startId] const seen = new Set([startId]) @@ -67,19 +79,16 @@ export default function RenderingNode({ id }: Props) { return false } - if (!isReachable(refNode.id, id)) { - throw new Error(`Included node not connected to renderer: ${ref}`) - } + if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`) visited.add(refNode.id) const includedRaw = String(refNode.data?.yaml ?? '') const resolved = resolveIncludes(includedRaw, visited) visited.delete(refNode.id) - // indent included content to match include position const indented = resolved .split('\n') - .map((line: string, idx: number) => (line === '' ? '' : indent + line)) + .map((line: string) => (line === '' ? '' : indent + line)) .join('\n') return indented }) @@ -88,34 +97,75 @@ export default function RenderingNode({ id }: Props) { const resolvedYaml = resolveIncludes(yamlText) const parsed = yaml.load(resolvedYaml) const newOutput = JSON.stringify(parsed, null, 2) + setError(null) setOutput((prev) => (prev === newOutput ? prev : newOutput)) } catch (err: any) { const msg = 'YAML parse/include error: ' + err.message - setOutput((prev) => (prev === msg ? prev : msg)) + setOutput('') + setError({ kind: 'parse', message: msg }) } }, [id, incomingIds.join(','), yamlText, nodes.length, edges.length]) return ( -
-
-
{id}
-
RENDERER
-
-
{output}
+ + + + {id} + - + + {incomingIds.length === 0 ? ( + + + + + + No configuration connected + Connect a Configuration node or create one. The renderer will display parsed YAML. + + + + + + ) : error ? ( + srcData?.renderError ? ( + srcData.renderError(error) + ) : srcData?.errorHtml ? ( +
+ ) : ( +
{error.message}
+ ) + ) : ( +
{output}
+ )} + - -
+ +
Parsed YAML output
+
+ + + +
) -} +}) + +RenderingNode.displayName = 'RenderingNode' + +export default RenderingNode \ No newline at end of file diff --git a/src/components/base-node.tsx b/src/components/base-node.tsx new file mode 100644 index 0000000..10240b3 --- /dev/null +++ b/src/components/base-node.tsx @@ -0,0 +1,88 @@ +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +export function BaseNode({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +/** + * A container for a consistent header layout intended to be used inside the + * `` component. + */ +export function BaseNodeHeader({ + className, + ...props +}: ComponentProps<"header">) { + return ( +
` component. + className, + )} + /> + ); +} + +/** + * The title text for the node. To maintain a native application feel, the title + * text is not selectable. + */ +export function BaseNodeHeaderTitle({ + className, + ...props +}: ComponentProps<"h3">) { + return ( +

+ ); +} + +export function BaseNodeContent({ + className, + ...props +}: ComponentProps<"div">) { + return ( +
+ ); +} + +export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..7166763 --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,104 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +}