feat: add initial data node
This commit is contained in:
@@ -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>
|
||||
|
||||
354
frontend/src/components/nodes/DataNode.tsx
Normal file
354
frontend/src/components/nodes/DataNode.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user