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

@@ -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",

View File

@@ -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",

View File

@@ -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}>

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

View 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 }
}

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,