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": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@uiw/react-codemirror": "^4.25.7",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
@@ -3272,6 +3273,39 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"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": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@uiw/react-codemirror": "^4.25.7",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ type Props = {
|
|||||||
insertTagsLabel?: string
|
insertTagsLabel?: string
|
||||||
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
|
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
|
||||||
nodeMenuExtraContent?: React.ReactNode
|
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 ctx = useContext(FlowContext)
|
||||||
const nodes = ctx?.nodes ?? []
|
const nodes = ctx?.nodes ?? []
|
||||||
const setNodes = ctx?.setNodes
|
const setNodes = ctx?.setNodes
|
||||||
@@ -140,6 +142,16 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</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 && (
|
{inputsMenuContent != null && (
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
|
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '../base/BaseNode'
|
||||||
import { Code2, ScrollText, Variable } from 'lucide-react'
|
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarSeparator,
|
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 as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||||
[nodes, sourceIds]
|
[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(
|
const onChange = useCallback(
|
||||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||||
@@ -150,12 +154,20 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[insertAt]
|
[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 variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
||||||
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
||||||
const configTitles = useMemo(
|
const configTitles = useMemo(
|
||||||
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
|
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
|
||||||
[connectedConfigNodes],
|
[connectedConfigNodes],
|
||||||
)
|
)
|
||||||
|
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
const lang =
|
const lang =
|
||||||
configTypeId === 'wireframe'
|
configTypeId === 'wireframe'
|
||||||
@@ -166,11 +178,11 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
return [
|
return [
|
||||||
lang,
|
lang,
|
||||||
autocompletion({
|
autocompletion({
|
||||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
|
||||||
activateOnTyping: true,
|
activateOnTyping: true,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
|
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
|
||||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||||
|
|
||||||
const insertBlocksContent = useMemo(() => {
|
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>
|
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</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>
|
<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]
|
[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
|
const RENDER_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,6 +283,20 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
if (!configIdsUsed.has(e.target)) continue
|
if (!configIdsUsed.has(e.target)) continue
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
const src = nodes.find((n: any) => n.id === e.source)
|
||||||
if (src?.type === 'variable') setVarInContext(src)
|
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
|
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
|
||||||
const functionIdsToRegister = new Set<string>()
|
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.
|
// 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 =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
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 },
|
render: { width: 384, height: 320 },
|
||||||
variable: { width: 224, height: 180 },
|
variable: { width: 224, height: 180 },
|
||||||
function: { width: 288, height: 260 },
|
function: { width: 288, height: 260 },
|
||||||
|
data: { width: 360, height: 280 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
export type NodeType = 'config' | 'render' | 'variable' | 'function'
|
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data'
|
||||||
|
|
||||||
export type NodeHelpEntry = {
|
export type NodeHelpEntry = {
|
||||||
title: string
|
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 {
|
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 { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
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 AppNode = Node<AppNodeData>
|
||||||
export type AppEdge = Edge
|
export type AppEdge = Edge
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function nunjucksCompletionSource(
|
|||||||
variableIds: string[],
|
variableIds: string[],
|
||||||
configTitles?: string[],
|
configTitles?: string[],
|
||||||
functionIds?: string[],
|
functionIds?: string[],
|
||||||
|
dataIds?: string[],
|
||||||
): (context: CompletionContext) => CompletionResult | null {
|
): (context: CompletionContext) => CompletionResult | null {
|
||||||
return (context: CompletionContext) => {
|
return (context: CompletionContext) => {
|
||||||
const { state, pos } = context
|
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
|
if (options.length === 0) return null
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
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 { registerNodeType } from './nodeRegistry'
|
||||||
import { NODE_HELP } from './nodeHelp'
|
import { NODE_HELP } from './nodeHelp'
|
||||||
import ConfigNode from '../components/nodes/ConfigNode'
|
import ConfigNode from '../components/nodes/ConfigNode'
|
||||||
import RenderingNode from '../components/nodes/RenderingNode'
|
import RenderingNode from '../components/nodes/RenderingNode'
|
||||||
import VariableNode from '../components/nodes/VariableNode'
|
import VariableNode from '../components/nodes/VariableNode'
|
||||||
import FunctionNode from '../components/nodes/FunctionNode'
|
import FunctionNode from '../components/nodes/FunctionNode'
|
||||||
|
import DataNode from '../components/nodes/DataNode'
|
||||||
|
|
||||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function registerBuiltinNodes(): void {
|
|||||||
hasInput: true,
|
hasInput: true,
|
||||||
hasOutput: true,
|
hasOutput: true,
|
||||||
classification: 'psyche',
|
classification: 'psyche',
|
||||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
allowedSourceTypes: ['config', 'variable', 'function', 'data'],
|
||||||
allowedTargetTypes: ['config', 'render'],
|
allowedTargetTypes: ['config', 'render'],
|
||||||
help: NODE_HELP.config,
|
help: NODE_HELP.config,
|
||||||
menuLabel: 'Config',
|
menuLabel: 'Config',
|
||||||
@@ -73,6 +74,22 @@ export function registerBuiltinNodes(): void {
|
|||||||
menuIcon: <Variable className={ICON_CLASS} />,
|
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({
|
registerNodeType({
|
||||||
id: 'function',
|
id: 'function',
|
||||||
component: FunctionNode,
|
component: FunctionNode,
|
||||||
|
|||||||
Reference in New Issue
Block a user