Skip to content

Commit

Permalink
feat(ui): Fuzzy selector node search (#440)
Browse files Browse the repository at this point in the history
  • Loading branch information
daryllimyt authored Nov 6, 2024
1 parent 5b17947 commit 579fe4c
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 61 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"date-fns": "^2.30.0",
"fuzzysort": "^3.1.0",
"install": "^0.13.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.359.0",
Expand Down
8 changes: 8 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

190 changes: 129 additions & 61 deletions frontend/src/components/workbench/canvas/selector-node.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from "react"
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import { RegistryActionRead } from "@/client"
import { useWorkflowBuilder } from "@/providers/builder"
import fuzzysort from "fuzzysort"
import { CloudOffIcon, XIcon } from "lucide-react"
import { Handle, Node, NodeProps, Position, useNodeId } from "reactflow"

Expand All @@ -26,24 +27,23 @@ import {
isEphemeral,
} from "@/components/workbench/canvas/canvas"

const TOP_LEVEL_GROUP = "__TOP_LEVEL__" as const
export const SelectorTypename = "selector" as const

const groupByDisplayGroup = (
actions: RegistryActionRead[]
): Record<string, RegistryActionRead[]> => {
const groups = {} as Record<string, RegistryActionRead[]>
actions.forEach((action) => {
const displayGroup = (action.display_group || TOP_LEVEL_GROUP).toString()
if (!groups[displayGroup]) {
groups[displayGroup] = []
}
groups[displayGroup].push(action)
const highlight = true
const SEARCH_KEYS = [
"action",
"default_title",
"display_group",
] as (keyof RegistryActionRead)[]

function filterActions(actions: RegistryActionRead[], search: string) {
const results = fuzzysort.go<RegistryActionRead>(search, actions, {
all: true,
keys: SEARCH_KEYS,
})
return groups
return results
}

export const SelectorTypename = "selector" as const

export interface SelectorNodeData {
type: "selector"
}
Expand Down Expand Up @@ -84,15 +84,19 @@ export default React.memo(function SelectorNode({
setNodes((nodes) => nodes.filter((node) => !isEphemeral(node)))
setEdges((edges) => edges.filter((edge) => edge.target !== id))
}
const [inputValue, setInputValue] = React.useState("")

if (!workflowId || !id) {
console.error("Workflow or node ID not found")
return null
}

return (
<div>
<Command className="h-96 w-72 rounded-lg border shadow-sm">
<div onWheelCapture={(e) => e.stopPropagation()}>
<Command
className="h-96 w-72 rounded-lg border shadow-sm"
shouldFilter={false}
>
<div className="w-full bg-muted-foreground/5 px-3 py-[2px]">
<Label className="flex items-center text-xs text-muted-foreground">
<span className="font-medium">Add a node</span>
Expand All @@ -113,6 +117,7 @@ export default React.memo(function SelectorNode({
ref={inputRef}
className="!py-0 text-xs"
placeholder="Start typing to search for an action..."
onValueChange={(value) => setInputValue(value)}
autoFocus
/>
<CommandList className="border-b">
Expand All @@ -121,7 +126,7 @@ export default React.memo(function SelectorNode({
No results found.
</span>
</CommandEmpty>
<ActionCommandSelector nodeId={id} />
<ActionCommandSelector nodeId={id} inputValue={inputValue} />
</CommandList>
</Command>
<Handle
Expand All @@ -136,27 +141,23 @@ export default React.memo(function SelectorNode({
)
})

function ActionCommandSelector({ nodeId }: { nodeId: string }) {
function ActionCommandSelector({
nodeId,
inputValue,
}: {
nodeId: string
inputValue: string
}) {
const { registryActions, registryActionsIsLoading, registryActionsError } =
useWorkbenchRegistryActions()
const scrollAreaRef = useRef<HTMLDivElement>(null)

// Add effect to reset scroll position when search results change
useEffect(() => {
const handleScroll = (event: Event) => {
event.stopPropagation()
}

const scrollArea = scrollAreaRef.current
if (scrollArea) {
scrollArea.addEventListener("wheel", handleScroll)
}

return () => {
if (scrollArea) {
scrollArea.removeEventListener("wheel", handleScroll)
}
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0
}
}, [])
}, [inputValue])

if (!registryActions || registryActionsIsLoading) {
return <CenteredSpinner />
Expand All @@ -170,35 +171,41 @@ function ActionCommandSelector({ nodeId }: { nodeId: string }) {
)
}

const grouped = groupByDisplayGroup(registryActions)
return (
<ScrollArea ref={scrollAreaRef} className="h-full">
{Object.entries(grouped)
.sort(([groupA], [groupB]) => groupA.localeCompare(groupB))
.map(([group, actions], idx) => (
<ActionCommandGroup
key={`${group}-${idx}`}
group={group === TOP_LEVEL_GROUP ? "Core" : group}
registryActions={actions}
nodeId={nodeId}
/>
))}
<ScrollArea className="h-full" ref={scrollAreaRef}>
<ActionCommandGroup
group="Suggestions"
nodeId={nodeId}
registryActions={registryActions}
inputValue={inputValue}
/>
</ScrollArea>
)
}

function ActionCommandGroup({
group,
registryActions: actions,
nodeId,
registryActions,
inputValue,
}: {
group: string
registryActions: RegistryActionRead[]
nodeId: string
registryActions: RegistryActionRead[]
inputValue: string
}) {
const { workspaceId, workflowId, reactFlow } = useWorkflowBuilder()
const { getNode, setNodes, setEdges } = reactFlow

// Move sortedActions and filterResults logic here
const sortedActions = useMemo(() => {
return [...registryActions].sort((a, b) => a.action.localeCompare(b.action))
}, [registryActions])

const filterResults = useMemo(() => {
return filterActions(sortedActions, inputValue)
}, [sortedActions, inputValue])

const handleSelect = useCallback(
async (action: RegistryActionRead) => {
if (!workflowId) {
Expand Down Expand Up @@ -255,22 +262,83 @@ function ActionCommandGroup({
return // Abort
}
},
[getNode, nodeId]
[getNode, nodeId, workflowId, workspaceId, setNodes, setEdges]
)

// Add ref for the command group
const commandGroupRef = useRef<HTMLDivElement>(null)

// Reset scroll position when filter results change
useEffect(() => {
if (commandGroupRef.current) {
commandGroupRef.current.scrollIntoView({ block: "start" })
}
}, [filterResults])

return (
<CommandGroup heading={group} className="text-xs">
{actions.map((action) => (
<CommandItem
key={action.action}
className="text-xs"
onSelect={async () => await handleSelect(action)}
>
{getIcon(action.action, {
className: "size-5 mr-2",
})}
<span className="text-xs">{action.default_title}</span>
</CommandItem>
))}
<CommandGroup heading={group} className="text-xs" ref={commandGroupRef}>
{filterResults.map((result) => {
const action = result.obj

if (highlight) {
const highlighted = SEARCH_KEYS.reduce(
(acc, key, index) => {
const currRes = result[index]
acc[key] = currRes.highlight() || String(action[key])
return acc
},
{} as Record<keyof RegistryActionRead, string>
)

return (
<CommandItem
key={action.action}
className="text-xs"
onSelect={async () => await handleSelect(action)}
>
<div className="flex-col">
<div className="flex items-center justify-start">
{getIcon(action.action, {
className: "size-5 mr-2",
})}
<span
className="text-xs"
dangerouslySetInnerHTML={{
__html: highlighted.default_title,
}}
/>
</div>
<span
className="text-xs text-muted-foreground"
dangerouslySetInnerHTML={{
__html: highlighted.action,
}}
/>
</div>
</CommandItem>
)
} else {
return (
<CommandItem
key={action.action}
className="text-xs"
onSelect={async () => await handleSelect(action)}
>
<div className="flex-col">
<div className="flex items-center justify-start">
{getIcon(action.action, {
className: "size-5 mr-2",
})}
<span className="text-xs">{action.default_title}</span>
</div>
<span className="text-xs text-muted-foreground">
{action.action}
</span>
</div>
</CommandItem>
)
}
})}
</CommandGroup>
)
}

0 comments on commit 579fe4c

Please sign in to comment.