Node Search
A search bar component that can be used to search for nodes in the flow.
It uses the Command component from shadcn ui.
By default, it will check for lowercase string inclusion in the node’s label, and select
the node and fit the view to the node when it is selected. You can override this behavior
by passing a custom onSearch function. You can also override the default onSelectNode
function to customize the behavior when a node is selected.
UI Component: node-search
index.tsx
import { useCallback, useState } from "react";
import {
BuiltInEdge,
useReactFlow,
type Node,
type PanelProps,
} from "@xyflow/react";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
export interface NodeSearchProps extends Omit<PanelProps, "children"> {
// The function to search for nodes, should return an array of nodes that match the search string
// By default, it will check for lowercase string inclusion.
onSearch?: (searchString: string) => Node[];
// The function to select a node, should set the node as selected and fit the view to the node
// By default, it will set the node as selected and fit the view to the node.
onSelectNode?: (node: Node) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function NodeSearchInternal({
onSearch,
onSelectNode,
open,
onOpenChange,
}: NodeSearchProps) {
const [searchResults, setSearchResults] = useState<Node[]>([]);
const [searchString, setSearchString] = useState<string>("");
const { getNodes, fitView, setNodes } = useReactFlow<Node, BuiltInEdge>();
const defaultOnSearch = useCallback(
(searchString: string) => {
const nodes = getNodes();
return nodes.filter((node) =>
(node.data.label as string)
.toLowerCase()
.includes(searchString.toLowerCase()),
);
},
[getNodes],
);
const onChange = useCallback(
(searchString: string) => {
setSearchString(searchString);
if (searchString.length > 0) {
onOpenChange?.(true);
const results = (onSearch || defaultOnSearch)(searchString);
setSearchResults(results);
}
},
[defaultOnSearch, onOpenChange, onSearch],
);
const defaultOnSelectNode = useCallback(
(node: Node) => {
setNodes((nodes) =>
nodes.map((n) => (n.id === node.id ? { ...n, selected: true } : n)),
);
fitView({ nodes: [node], duration: 500 });
},
[fitView, setNodes],
);
const onSelect = useCallback(
(node: Node) => {
(onSelectNode || defaultOnSelectNode)?.(node);
setSearchString("");
onOpenChange?.(false);
},
[onSelectNode, defaultOnSelectNode, onOpenChange],
);
return (
<>
<CommandInput
placeholder="Search nodes..."
onValueChange={onChange}
value={searchString}
onFocus={() => onOpenChange?.(true)}
/>
{open && (
<CommandList>
{searchResults.length === 0 ? (
<CommandEmpty>No results found. {searchString}</CommandEmpty>
) : (
<CommandGroup heading="Nodes">
{searchResults.map((node) => {
return (
<CommandItem key={node.id} onSelect={() => onSelect(node)}>
<span>{node.data.label as string}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
)}
</>
);
}
export function NodeSearch({
className,
onSearch,
onSelectNode,
...props
}: NodeSearchProps) {
const [open, setOpen] = useState(false);
return (
<Command
shouldFilter={false}
className="rounded-lg border shadow-md md:min-w-[450px]"
>
<NodeSearchInternal
className={className}
onSearch={onSearch}
onSelectNode={onSelectNode}
open={open}
onOpenChange={setOpen}
{...props}
/>
</Command>
);
}
export interface NodeSearchDialogProps extends NodeSearchProps {
title?: string;
}
export function NodeSearchDialog({
className,
onSearch,
onSelectNode,
open,
onOpenChange,
...props
}: NodeSearchDialogProps) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<NodeSearchInternal
className={className}
onSearch={onSearch}
onSelectNode={onSelectNode}
open={open}
onOpenChange={onOpenChange}
{...props}
/>
</CommandDialog>
);
}