feat: add initial data node
This commit is contained in:
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@wireweave/core": "^2.6.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
@@ -3272,6 +3273,39 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@wireweave/core": "^2.6.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
|
||||
@@ -35,9 +35,11 @@ type Props = {
|
||||
insertTagsLabel?: string
|
||||
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
|
||||
nodeMenuExtraContent?: React.ReactNode
|
||||
/** When set, renders a top-level "Data" menu with this content (e.g. data nodes) */
|
||||
dataMenuContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) {
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent, dataMenuContent }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
@@ -140,6 +142,16 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
{dataMenuContent != null && (
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
||||
Data
|
||||
</MenubarTrigger>
|
||||
<MenubarContent className="min-w-[12rem]">
|
||||
{dataMenuContent}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
)}
|
||||
{inputsMenuContent != null && (
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
|
||||
|
||||
@@ -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
|
||||
|
||||
54
frontend/src/lib/csvParse.ts
Normal file
54
frontend/src/lib/csvParse.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Parse a CSV string into an array of row objects (first row = headers).
|
||||
* Handles quoted fields with commas.
|
||||
*/
|
||||
export function parseCsvToRows(csvText: string): { columns: string[]; rows: Record<string, string>[] } {
|
||||
const lines = csvText.trim().split(/\r?\n/)
|
||||
if (lines.length === 0) return { columns: [], rows: [] }
|
||||
|
||||
const parseRow = (line: string): string[] => {
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
while (i < line.length) {
|
||||
if (line[i] === '"') {
|
||||
let cell = ''
|
||||
i += 1
|
||||
while (i < line.length) {
|
||||
if (line[i] === '"') {
|
||||
i += 1
|
||||
if (line[i] === '"') {
|
||||
cell += '"'
|
||||
i += 1
|
||||
} else break
|
||||
} else {
|
||||
cell += line[i]
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
out.push(cell)
|
||||
if (line[i] === ',') i += 1
|
||||
} else {
|
||||
const comma = line.indexOf(',', i)
|
||||
const value = comma === -1 ? line.slice(i) : line.slice(i, comma)
|
||||
out.push(value.trim())
|
||||
i = comma === -1 ? line.length : comma + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const headers = parseRow(lines[0])
|
||||
const columns = headers.map((h) => h.trim() || `Column${headers.indexOf(h) + 1}`)
|
||||
const rows: Record<string, string>[] = []
|
||||
|
||||
for (let r = 1; r < lines.length; r++) {
|
||||
const cells = parseRow(lines[r])
|
||||
const row: Record<string, string> = {}
|
||||
for (let c = 0; c < columns.length; c++) {
|
||||
row[columns[c]] = cells[c] ?? ''
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
return { columns, rows }
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
||||
render: { width: 384, height: 320 },
|
||||
variable: { width: 224, height: 180 },
|
||||
function: { width: 288, height: 260 },
|
||||
data: { width: 360, height: 280 },
|
||||
}
|
||||
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type NodeType = 'config' | 'render' | 'variable' | 'function'
|
||||
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
@@ -77,6 +77,19 @@ export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
data: {
|
||||
title: 'Data node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Data nodes hold CSV data. Drop a .csv file onto the node to load it; the table is shown in the node. Connect this node to a Config node to use the data in Nunjucks templates.</p>
|
||||
</Section>
|
||||
<Section title="Using in templates">
|
||||
<p>In a Config template connected to this data node, the variable <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> is an array of row objects (one per CSV row). Each row has keys from the CSV header. Example: <Code>{'{% for row in data_001 %}{{ row.name }}, {{ row.value }}{% endfor %}'}</Code></p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
|
||||
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
||||
import type { DataNodeData } from '@/components/nodes/DataNode'
|
||||
|
||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
|
||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData
|
||||
export type AppNode = Node<AppNodeData>
|
||||
export type AppEdge = Edge
|
||||
|
||||
@@ -43,6 +43,7 @@ export function nunjucksCompletionSource(
|
||||
variableIds: string[],
|
||||
configTitles?: string[],
|
||||
functionIds?: string[],
|
||||
dataIds?: string[],
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext) => {
|
||||
const { state, pos } = context
|
||||
@@ -81,6 +82,11 @@ export function nunjucksCompletionSource(
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dataId of dataIds ?? []) {
|
||||
if (!word || dataId.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: dataId, type: 'variable', info: 'Data (array of rows)' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ScrollText, Sparkles, Variable, Code2 } from 'lucide-react'
|
||||
import { ScrollText, Sparkles, Variable, Code2, Database } from 'lucide-react'
|
||||
import { registerNodeType } from './nodeRegistry'
|
||||
import { NODE_HELP } from './nodeHelp'
|
||||
import ConfigNode from '../components/nodes/ConfigNode'
|
||||
import RenderingNode from '../components/nodes/RenderingNode'
|
||||
import VariableNode from '../components/nodes/VariableNode'
|
||||
import FunctionNode from '../components/nodes/FunctionNode'
|
||||
import DataNode from '../components/nodes/DataNode'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
@@ -24,7 +25,7 @@ export function registerBuiltinNodes(): void {
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
classification: 'psyche',
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedSourceTypes: ['config', 'variable', 'function', 'data'],
|
||||
allowedTargetTypes: ['config', 'render'],
|
||||
help: NODE_HELP.config,
|
||||
menuLabel: 'Config',
|
||||
@@ -73,6 +74,22 @@ export function registerBuiltinNodes(): void {
|
||||
menuIcon: <Variable className={ICON_CLASS} />,
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'data',
|
||||
component: DataNode,
|
||||
defaultStyle: { width: 360, height: 280 },
|
||||
defaultData: { rows: [], columns: [], fileName: '' },
|
||||
idPrefix: 'data_',
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
classification: 'physis',
|
||||
allowedTargetTypes: ['config'],
|
||||
help: NODE_HELP.data,
|
||||
menuLabel: 'Data',
|
||||
menuIcon: <Database className={ICON_CLASS} />,
|
||||
connectionLabel: 'data source',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'function',
|
||||
component: FunctionNode,
|
||||
|
||||
Reference in New Issue
Block a user