feat: add initial data node

This commit is contained in:
2026-03-10 23:49:28 +01:00
parent dd1de7cb1e
commit 38e1ae3692
12 changed files with 550 additions and 10 deletions

View File

@@ -27,7 +27,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} from '../base/BaseNode'
import { Code2, ScrollText, Variable } from 'lucide-react'
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
import {
MenubarItem,
MenubarSeparator,
@@ -67,7 +67,11 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
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 }),
@@ -150,12 +154,20 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[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'
@@ -166,11 +178,11 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
return [
lang,
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
activateOnTyping: true,
}),
]
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => {
@@ -313,6 +325,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
<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>

View File

@@ -0,0 +1,354 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '../../lib/abstractNode'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '../base/BaseNode'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar'
import { OutputHandle } from '../base/NodeHandles'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'
import { parseCsvToRows } from '../../lib/csvParse'
import { ArrowDown, ArrowUp, ArrowUpDown, Database, FileUp } from 'lucide-react'
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { Button } from '../ui/button'
import { Input } from '@/components/ui/input'
import { ContextMenuCheckboxItem } from '../ui/context-menu'
import {
MenubarCheckboxItem,
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
} from '../ui/menubar'
export type DataNodeData = {
rows?: Record<string, string>[]
columns?: string[]
fileName?: string
/** Column ids (header names) that are hidden; hidden columns are excluded from template output */
hiddenColumns?: string[]
}
type Props = AbstractNodeProps<DataNodeData>
function DataNodeComponent({ id, data, width, height, selected }: Props) {
const { updateData } = useAbstractNode<DataNodeData>(id, data ?? {})
const rows = data?.rows ?? []
const columns = data?.columns ?? []
const hiddenColumns = data?.hiddenColumns ?? []
const [isDragging, setIsDragging] = useState(false)
const [globalFilter, setGlobalFilter] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const hasData = rows.length > 0 && columns.length > 0
const onFile = useCallback(
(file: File) => {
if (!file.name.toLowerCase().endsWith('.csv')) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = (reader.result as string) ?? ''
const { columns: cols, rows: parsed } = parseCsvToRows(text)
updateData({ rows: parsed, columns: cols, fileName: file.name, hiddenColumns: [] })
} catch {
updateData({ rows: [], columns: [], fileName: '', hiddenColumns: [] })
}
}
reader.readAsText(file)
},
[updateData]
)
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer?.files?.[0]
if (file) onFile(file)
},
[onFile]
)
const onDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setIsDragging(true)
}, [])
const onDragLeave = useCallback(() => setIsDragging(false), [])
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) onFile(file)
e.target.value = ''
},
[onFile]
)
const openFileDialog = useCallback(() => {
fileInputRef.current?.click()
}, [])
const clearData = useCallback(() => {
updateData({ rows: [], columns: [], fileName: '', hiddenColumns: [] })
}, [updateData])
const columnVisibility = useMemo(
() => Object.fromEntries(columns.map((col) => [col, !hiddenColumns.includes(col)])),
[columns, hiddenColumns]
)
const onColumnVisibilityChange = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const next = updater(columnVisibility)
const newHidden = columns.filter((col) => !next[col])
updateData({ hiddenColumns: newHidden })
},
[columnVisibility, columns, updateData]
)
const tableColumns = useMemo<ColumnDef<Record<string, string>>[]>(() => {
return columns.map((key) => ({
accessorKey: key,
header: ({ column }: { column: { getIsSorted: () => false | 'asc' | 'desc'; toggleSorting: (desc: boolean) => void } }) => (
<Button
variant="ghost"
size="sm"
className="-ml-2 h-8 data-[state=open]:bg-accent"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{key}
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="ml-1.5 size-3.5" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="ml-1.5 size-3.5" />
) : (
<ArrowUpDown className="ml-1.5 size-3.5" />
)}
</Button>
),
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => (
<span className="truncate max-w-[200px] block" title={String(row.getValue(key) ?? '')}>
{String(row.getValue(key) ?? '')}
</span>
),
}))
}, [columns])
const table = useReactTable({
data: rows,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnVisibilityChange,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
columnVisibility,
globalFilter,
},
globalFilterFn: (row, _columnIds, filterValue) => {
const str = String(filterValue ?? '').toLowerCase()
if (!str) return true
const obj = row.original as Record<string, unknown>
for (const v of Object.values(obj)) {
if (String(v ?? '').toLowerCase().includes(str)) return true
}
return false
},
})
const fileInputId = `data-node-file-${id}`
const dataMenuContent = (
<>
<MenubarItem className="text-xs" onClick={openFileDialog}>
Upload new dataset
</MenubarItem>
<MenubarItem className="text-xs" onClick={clearData}>
Clear dataset
</MenubarItem>
{hasData && (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger className="text-xs">Columns</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
{table
.getAllColumns()
.filter((col) => typeof col.accessorFn !== 'undefined' && col.getCanHide())
.map((col) => (
<MenubarCheckboxItem
key={col.id}
className="text-xs capitalize"
checked={col.getIsVisible()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
>
{col.id}
</MenubarCheckboxItem>
))}
</MenubarSubContent>
</MenubarSub>
<MenubarSub>
<MenubarSubTrigger className="text-xs">Rows per page</MenubarSubTrigger>
<MenubarSubContent className="min-w-[8rem]">
{[10, 20, 30, 50].map((size) => (
<ContextMenuCheckboxItem
key={size}
className="text-xs"
checked={table.getState().pagination.pageSize === size}
onCheckedChange={() => table.setPageSize(size)}
>
{size}
</ContextMenuCheckboxItem>
))}
</MenubarSubContent>
</MenubarSub>
</>
)}
</>
)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return (
<BaseNode className="min-w-[320px] min-h-[200px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<OutputHandle id="out" />}>
<input
id={fileInputId}
ref={fileInputRef}
type="file"
accept=".csv"
onChange={onInputChange}
className="sr-only"
aria-label="Upload CSV file"
/>
<BaseNodeHeaderRow icon={<Database className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="data" dataMenuContent={dataMenuContent} />
</div>
{!hasData ? (
<label
htmlFor={fileInputId}
className={`flex-1 min-h-[140px] flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 m-2 transition-colors nodrag nopan cursor-pointer ${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<FileUp className="size-8 text-muted-foreground" />
<p className="text-xs text-muted-foreground text-center">
Drop a .csv file here or click to browse
</p>
</label>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-2 pt-2 nodrag nopan">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-2">
<Input
placeholder="Search..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 max-w-[180px] text-xs"
/>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-16 text-center text-muted-foreground text-xs">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="data">
{hasData
? `${table.getFilteredRowModel().rows.length} of ${rows.length} rows · ${table.getVisibleLeafColumns().length} cols`
: 'Drop CSV'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const DataNode = createAbstractNodeComponent<DataNodeData>('DataNode', DataNodeComponent)
export default DataNode

View File

@@ -168,6 +168,16 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
[nodes, connectedNodeIds]
)
const dataSignature = useMemo(
() =>
nodes
.filter((n: any) => n.type === 'data' && connectedNodeIds.has(n.id))
.map((n: any) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const RENDER_DEBOUNCE_MS = 250
useEffect(() => {
@@ -273,6 +283,20 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (!configIdsUsed.has(e.target)) continue
const src = nodes.find((n: any) => n.id === e.source)
if (src?.type === 'variable') setVarInContext(src)
if (src?.type === 'data') {
const rows = (src.data?.rows as Record<string, string>[] | undefined) ?? []
const hidden = (src.data?.hiddenColumns as string[] | undefined) ?? []
const visibleCols = (src.data?.columns as string[] | undefined) ?? []
.filter((c) => !hidden.includes(c))
const filteredRows = rows.map((row) => {
const out: Record<string, string> = {}
for (const col of visibleCols) {
if (col in row) out[col] = row[col]
}
return out
})
nunjucksContext[src.id] = filteredRows
}
}
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
const functionIdsToRegister = new Set<string>()
@@ -505,7 +529,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
}
}
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount])
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount])
const dimensions =
width != null && height != null && width > 0 && height > 0