Skip to content

Commit

Permalink
feat(ui): Implement drag and drop logic
Browse files Browse the repository at this point in the history
  • Loading branch information
topher-lo committed Mar 2, 2024
1 parent 0a1dac3 commit 0734f0f
Show file tree
Hide file tree
Showing 4 changed files with 347 additions and 106 deletions.
97 changes: 97 additions & 0 deletions frontend/src/components/action-node.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from "react"
import { Handle, NodeProps, Position } from "reactflow"

import {
ChevronsDownIcon,
CircleIcon,
GanttChartIcon,
PlayIcon,
TestTubeIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export interface ActionNodeData {
title: string
name: string
status: "online" | "error" | "offline"
numberOfEvents: number
// Generic metadata
}

const handleStyle = { width: 8, height: 8 }

export default React.memo(function ActionNode({
data: { title, name, status, numberOfEvents },
}: NodeProps<ActionNodeData>) {
return (
<Card>
<CardHeader className="grid grid-cols-[1fr_110px] items-start gap-4 space-y-0">
<div className="space-y-1">
<CardTitle>{title}</CardTitle>
<CardDescription>{name}</CardDescription>
</div>
<div className="flex items-center space-x-1 rounded-md bg-secondary text-secondary-foreground">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="px-2 shadow-none">
<ChevronsDownIcon className="h-4 w-4 text-secondary-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
alignOffset={-5}
className="w-[200px]"
forceMount
>
<DropdownMenuItem>
<PlayIcon className="mr-2 h-4 w-4" /> Run
</DropdownMenuItem>
<DropdownMenuItem>
<TestTubeIcon className="mr-2 h-4 w-4" /> Test action
</DropdownMenuItem>
<DropdownMenuItem>
<GanttChartIcon className="mr-2 h-4 w-4" /> View events
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="flex space-x-4 text-sm text-muted-foreground">
<div className="flex items-center">
<CircleIcon className="mr-1 h-3 w-3 fill-sky-400 text-sky-400" /> {status}
</div>
<div className="flex items-center">
<GanttChartIcon className="mr-1 h-3 w-3" /> {numberOfEvents} events
</div>
</div>
</CardContent>

<Handle
type="target"
position={Position.Top}
className="w-16 !bg-gray-500"
style={handleStyle}
/>
<Handle
type="source"
position={Position.Bottom}
className="w-16 !bg-gray-500"
style={handleStyle}
/>
</Card>
)
})
71 changes: 47 additions & 24 deletions frontend/src/components/action-tiles.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import Link from "next/link"
import { DragEvent } from "react"
import { LucideIcon } from "lucide-react"

import { cn } from "@/lib/utils"
Expand All @@ -13,72 +13,95 @@ import {

interface ActionTilesProps {
isCollapsed: boolean
links: {
tiles: {
title: string
label?: string
icon: LucideIcon
variant: "default" | "ghost"
}[]
}

export function ActionTiles({ links, isCollapsed }: ActionTilesProps) {
export function ActionTiles({ tiles, isCollapsed }: ActionTilesProps) {

const onDragStart = (
event: DragEvent<HTMLDivElement>,
tile: { title: string; label?: string; icon: LucideIcon; variant: "default" | "ghost" }
) => {

const actionNodeData = {
title: tile.title,
name: tile.label || "",
status: "offline",
numberOfEvents: 0
};
event.dataTransfer.setData("application/reactflow", "action")
event.dataTransfer.setData("application/json", JSON.stringify(actionNodeData))
event.dataTransfer.effectAllowed = "move";
};

return (
<div
data-collapsed={isCollapsed}
className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
>
<nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{links.map((link, index) =>
{tiles.map((tile, index) =>
isCollapsed ? (
<Tooltip key={index} delayDuration={0}>
<TooltipTrigger asChild>
<Link
href="#"
<div
className={cn(
buttonVariants({ variant: link.variant, size: "icon" }),
buttonVariants({ variant: tile.variant, size: "icon" }),
"h-9 w-9",
link.variant === "default" &&
tile.variant === "default" &&
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white"
)}
draggable
onMouseOver={(e) => e.currentTarget.style.cursor = "grab"}
onMouseOut={(e) => e.currentTarget.style.cursor = ""}
onDragStart={(event) => onDragStart(event, tile)}
>
<link.icon className="h-4 w-4" />
<span className="sr-only">{link.title}</span>
</Link>
<tile.icon className="h-4 w-4" />
<span className="sr-only">{tile.title}</span>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-4">
{link.title}
{link.label && (
{tile.title}
{tile.label && (
<span className="ml-auto text-muted-foreground">
{link.label}
{tile.label}
</span>
)}
</TooltipContent>
</Tooltip>
) : (
<Link
<div
key={index}
href="#"
className={cn(
buttonVariants({ variant: link.variant, size: "sm" }),
link.variant === "default" &&
buttonVariants({ variant: tile.variant, size: "sm" }),
tile.variant === "default" &&
"dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white",
"justify-start"
)}
draggable
onMouseOver={(e) => e.currentTarget.style.cursor = "grab"}
onMouseOut={(e) => e.currentTarget.style.cursor = ""}
onDragStart={(event) => onDragStart(event, tile)}
>
<link.icon className="mr-2 h-4 w-4" />
{link.title}
{link.label && (
<tile.icon className="mr-2 h-4 w-4" />
{tile.title}
{tile.label && (
<span
className={cn(
"ml-auto",
link.variant === "default" &&
tile.variant === "default" &&
"text-background dark:text-white"
)}
>
{link.label}
{tile.label}
</span>
)}
</Link>
</div>
)
)}
</nav>
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/components/canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useCallback, useRef, useState } from "react"
import ReactFlow, {
Background,
Connection,
Controls,
Edge,
MarkerType,
Node,
OnConnect,
ReactFlowInstance,
ReactFlowProvider,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
} from "reactflow"

import "reactflow/dist/style.css"
import { useToast } from "@/components/ui/use-toast"
import ActionNode, { ActionNodeData } from "@/components/action-node"

let id = 0
const getActionNodeId = (): string => `node_${id++}`

const nodeTypes = {
action: ActionNode,
}

const defaultEdgeOptions = {
markerEnd: {
type: MarkerType.ArrowClosed,
},
style: { strokeWidth: 3 },
}

const Workflow: React.FC = () => {
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [reactFlowInstance, setReactFlowInstance] =
useState<ReactFlowInstance | null>(null)
const { toast } = useToast()

const onConnect = useCallback(
(params: Edge | Connection) => {
setEdges((eds) => addEdge(params, eds))
},
[toast, edges, setEdges]
)

const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
}, [])

const onDrop = (event: React.DragEvent) => {
event.preventDefault()

// Limit total number of nodes
if (nodes.length >= 50) {
toast({
title: "Invalid action",
description: "Maximum 50 nodes allowed.",
})
return
}

const reactFlowNodeType = event.dataTransfer.getData("application/reactflow");
const actionNodeData = JSON.parse(
event.dataTransfer.getData("application/json")
) as ActionNodeData

if (!actionNodeData || !reactFlowNodeType || !reactFlowInstance) return

const reactFlowNodePosition = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
})

const newNode = {
id: getActionNodeId(),
type: reactFlowNodeType,
position: reactFlowNodePosition,
data: actionNodeData,
} as Node<ActionNodeData>

setNodes((nds) => nds.concat(newNode))
}

return (
<div ref={reactFlowWrapper} style={{ height: "100%" }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
defaultEdgeOptions={defaultEdgeOptions}
onConnect={onConnect as OnConnect}
onInit={setReactFlowInstance}
onDrop={onDrop}
onDragOver={onDragOver}
nodeTypes={nodeTypes}
fitViewOptions={{ maxZoom: 1 }}
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls />
</ReactFlow>
</div>
)
}

const WorkflowBuilder = ReactFlowProvider
const useWorkflowBuilder = useReactFlow

export { WorkflowBuilder, useWorkflowBuilder }
export default Workflow
Loading

0 comments on commit 0734f0f

Please sign in to comment.