diff --git a/app/components/combobox.tsx b/app/components/combobox.tsx index 71c7f37..019c486 100644 --- a/app/components/combobox.tsx +++ b/app/components/combobox.tsx @@ -1,9 +1,15 @@ "use client" import { useState, Dispatch, createRef } from "react" -import { Check, ChevronsUpDown } from "lucide-react" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" - +import { Check, ChevronsUpDown, Trash2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -20,20 +26,32 @@ import { } from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { Input } from "@/components/ui/input" - +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + AlertDialogFooter +} from "@/components/ui/alert-dialog" /* eslint-disable react/require-default-props */ interface ComboboxProps { className?: string, type?: string, options: string[], - addOption?: Dispatch|null, + addOption?: Dispatch | null, + deleteOption?: (graphName :string) => void, selectedValue: string, setSelectedValue: Dispatch } -export default function Combobox({ className='', type='', options, addOption=null, selectedValue, setSelectedValue }: ComboboxProps) { +export default function Combobox({ className = '', type = '', options, addOption = null, deleteOption, selectedValue, setSelectedValue }: ComboboxProps) { const [open, setOpen] = useState(false) + const [deleteGraph, setDeleteGraph] = useState("") const inputRef = createRef() // read the text in the create input box and add it to the list of options @@ -47,6 +65,13 @@ export default function Combobox({ className='', type='', options, addOption=nul } } + const onDeleteOption = (graphName: string) => { + setOpen(false) + if (deleteOption) { + deleteOption(graphName) + } + } + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { onAddOption(); @@ -55,65 +80,87 @@ export default function Combobox({ className='', type='', options, addOption=nul const entityType = type ?? "" return ( - - - - - - - - No framework found. - - {options.map((option) => ( - { - if (currentValue !== selectedValue) { - setSelectedValue(option) + + + + + + + + + No framework found. + + {options.map((option) => ( + { + if (currentValue !== selectedValue) { + setSelectedValue(option) + } + setOpen(false) + }} + > +
+ + {option} +
+ { + deleteOption && + setDeleteGraph(option)}> + + } - setOpen(false) - }} - > - - {option} -
- ))} - - - {addOption && - - - Create new {entityType}... - - - - Create a new {entityType}? - - - - - - - - } -
-
-
-
+
+ ))} + + {addOption && + + + Create new {entityType}... + + + + Create a new {entityType}? + + + + + + + + } +
+
+
+
+ + + Are you absolutely sure? + + Are you sure you want to delete {deleteGraph}? + + + + Cancel + onDeleteOption(deleteGraph)}>Delete + + + ) } \ No newline at end of file diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx index 2b482fc..3af69da 100644 --- a/app/graph/DataPanel.tsx +++ b/app/graph/DataPanel.tsx @@ -1,4 +1,4 @@ -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface Props { @@ -17,9 +17,10 @@ const excludedProperties = new Set([ // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function DataPanel({ object }: Props) { const rowClass = "dark:hover:bg-slate-700 hover:bg-gray-400 border-y-[1px] border-y-gray-700" - + const type = object.source ? "edge" : "node" return ( + {type} properties Field @@ -38,10 +39,10 @@ export default function DataPanel({ object }: Props) { const text = cellIndex === 1 ? JSON.parse(strCell) : strCell return ( // eslint-disable-next-line react/no-array-index-key - + - + {text} diff --git a/app/graph/GraphList.tsx b/app/graph/GraphList.tsx index 7575c91..eb57769 100644 --- a/app/graph/GraphList.tsx +++ b/app/graph/GraphList.tsx @@ -1,13 +1,13 @@ import { useState, useEffect, Dispatch, SetStateAction } from 'react'; -import { useToast } from "@/components/ui/use-toast" +import { useToast } from "@/components/ui/use-toast" import Combobox from '../components/combobox'; interface Props { onSelectedGraph: Dispatch>, - onDelete: boolean, + onDelete: () => void, } // A component that renders an input box for Cypher queries -export default function GraphsList({onSelectedGraph, onDelete}: Props) { +export default function GraphsList({ onSelectedGraph, onDelete }: Props) { const [graphs, setGraphs] = useState([]); const [selectedGraph, setSelectedGraph] = useState(""); @@ -33,10 +33,21 @@ export default function GraphsList({onSelectedGraph, onDelete}: Props) { }) }, [toast]) - useEffect(() => { - setGraphs((prevGraphs: string[]) => [...prevGraphs.filter((graph) => graph !== selectedGraph)]) - setSelectedGraph('') - }, [onDelete]) + const handelDelete = (graphName: string) => { + fetch(`/api/graph/${encodeURIComponent(graphName)}`, { + method: 'DELETE', + }).then(() => + toast({ + title: 'Graph Deleted', + description: `Graph ${graphName} deleted`, + }) + ).catch((error) => { + toast({ + title: "Error", + description: error.message, + }) + }) + } const setSelectedValue = (graph: string) => { setSelectedGraph(graph) @@ -48,7 +59,14 @@ export default function GraphsList({onSelectedGraph, onDelete}: Props) { setSelectedValue(newGraph) } + const deleteOption = (graphName: string) => { + setGraphs((prevGraphs: string[]) => [...prevGraphs.filter(graph => graph !== graphName)]); + setSelectedValue("") + handelDelete(graphName) + onDelete() + } + return ( - + ) } diff --git a/app/graph/GraphView.tsx b/app/graph/GraphView.tsx index 2b54a9e..df7934b 100644 --- a/app/graph/GraphView.tsx +++ b/app/graph/GraphView.tsx @@ -1,7 +1,7 @@ import CytoscapeComponent from "react-cytoscapejs"; import { toast } from "@/components/ui/use-toast"; import cytoscape, { ElementDefinition, EventObject, NodeDataDefinition } from "cytoscape"; -import { useRef, useState, useImperativeHandle, forwardRef, useEffect } from "react"; +import { useRef, useState, useImperativeHandle, forwardRef } from "react"; import { signOut } from "next-auth/react"; import fcose from 'cytoscape-fcose'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; @@ -98,18 +98,12 @@ const GraphView = forwardRef(({ graph, darkmode }: GraphViewProps, ref) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const [selectedObject, setSelectedObject] = useState(null); - const [isOpen, setIsOpen] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); // A reference to the chart container to allowing zooming and editing const chartRef = useRef(null) const dataPanel = useRef(null) - useEffect(() => { - if (isOpen) { - dataPanel.current?.expand() - } else dataPanel.current?.collapse() - }, [isOpen]) - useImperativeHandle(ref, () => ({ expand: (elements: ElementDefinition[]) => { const chart = chartRef.current @@ -121,6 +115,16 @@ const GraphView = forwardRef(({ graph, darkmode }: GraphViewProps, ref) => { } })) + const onExpand = () => { + if (dataPanel.current) { + if (dataPanel.current.isCollapsed()) { + dataPanel.current.expand() + } else { + dataPanel.current.collapse() + } + } + } + // Send the user query to the server to expand a node async function onFetchNode(node: NodeDataDefinition) { const result = await fetch(`/api/graph/${graph.Id}/${node.id}`, { @@ -177,7 +181,7 @@ const GraphView = forwardRef(({ graph, darkmode }: GraphViewProps, ref) => { const handleTap = (evt: EventObject) => { const object = evt.target.json().data; setSelectedObject(object); - setIsOpen(true); + dataPanel.current?.expand() } return ( @@ -212,16 +216,23 @@ const GraphView = forwardRef(({ graph, darkmode }: GraphViewProps, ref) => { { selectedObject && - } - { - isOpen && - - {selectedObject && } - - } + { setIsCollapsed(true) }} + onExpand={() => { setIsCollapsed(false) }} + collapsible + defaultSize={selectedObject ? 20 : 0} + className="bg-gray-100 dark:bg-gray-800" + > + {selectedObject && } + ) }); diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 804c428..dec6bbf 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -62,7 +62,7 @@ export default function Page() { // Proposed abstraction for improved modularity if (!validateGraphSelection(state.graphName)) return false; - const q = state.query.trim() || "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN n,e,m limit 100"; + const q = state.query?.trim() || "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN n,e,m limit 100"; const result = await fetch(`/api/graph?graph=${prepareArg(state.graphName)}&query=${prepareArg(q)}`, { method: 'GET', @@ -97,7 +97,7 @@ export default function Page() { className="border rounded-lg border-gray-300 p-2" onSubmit={runQuery} onQueryUpdate={(state) => { queryState.current = state }} - onDeleteGraph={() => setGraph(Graph.empty())} + onDelete={() => setGraph(Graph.empty())} />
{ diff --git a/app/graph/query.tsx b/app/graph/query.tsx index 5825afd..c4d5e73 100644 --- a/app/graph/query.tsx +++ b/app/graph/query.tsx @@ -1,127 +1,102 @@ -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { useState } from "react"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { Menu, Play, Trash2 } from "lucide-react"; -import { useToast } from "@/components/ui/use-toast"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Maximize, Play } from "lucide-react"; import Editor from "@monaco-editor/react"; import { useTheme } from "next-themes"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import GraphsList from "./GraphList"; export class QueryState { constructor( - public query: string, + public query: string | undefined, public graphName: string, ) { } } -export function Query({ onSubmit, onQueryUpdate, onDeleteGraph, className = "" }: { +export function Query({ onSubmit, onQueryUpdate, onDelete, className = "" }: { onSubmit: (event: React.FormEvent) => Promise, onQueryUpdate: (state: QueryState) => void, - onDeleteGraph: () => void, + onDelete: () => void, className: string }) { - const [query, setQuery] = useState(''); + const lineHeight = 40 + const [query, setQuery] = useState(); const [graphName, setGraphName] = useState(''); - const [onDelete, setOnDelete] = useState(false); const { theme, systemTheme } = useTheme() const darkmode = theme === "dark" || (theme === "system" && systemTheme === "dark") - const { toast } = useToast(); - onQueryUpdate(new QueryState(query, graphName)) - - const handleDelete = () => { - fetch(`/api/graph/${encodeURIComponent(graphName)}`, { - method: 'DELETE', - }).then(res => res.json()).then((data) => { - toast({ - title: "Delete graph", - description: data.message, - }) - setOnDelete(prev => !prev) - setGraphName('') - onDeleteGraph() - }).catch(err => { - toast({ - title: "Error", - description: (err as Error).message, - }) - }) + const getHeight = () => { + if (!query) return lineHeight + switch (query.split("\n").length) { + case 1: return lineHeight + case 2: return lineHeight * 2 + case 3: return lineHeight * 3 + default: return lineHeight * 4 + } } + const height = getHeight(); + + onQueryUpdate(new QueryState(query, graphName)) + return ( -
-
- + + -
-
- (val || val === "") && setQuery(val)} - theme={`${darkmode ? "vs-dark" : "light"}`} - language="cypher" - options={{ - suggest: { - showKeywords: true, - }, - minimap: { enabled: false }, - wordWrap: "on", - lineNumbers: "off", - lineHeight: 40, - fontSize: 30, - }} - /> - - - - - - -

Run Query

-
-
-
- - - - - - - Actions - - {graphName && - - - - Delete graph - - - } - - - - - Are you absolutely sure you? - - Are you absolutely sure you want to delete {graphName}? - - - - Cancel - handleDelete()}>Delete - - - -
- +
+ +
+
+ + + + + + + +
+ + ) } \ No newline at end of file diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index 07768fb..def50ac 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -1,4 +1,3 @@ -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { CircleDot, ZoomIn, ZoomOut } from "lucide-react"; import { cn } from "@/lib/utils" @@ -22,39 +21,25 @@ export default function Toolbar({ chartRef, className = "" }: { } return ( - -
    -
  • - - handleZoomClick(1.1)}> - - - -

    Zoom In

    -
    -
    -
  • -
  • - - handleZoomClick(0.9)}> - - - -

    Zoom Out

    -
    -
    -
  • -
  • - - - - - -

    Center

    -
    -
    -
  • -
-
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
) } diff --git a/app/providers.tsx b/app/providers.tsx index f95b842..cbc1e2a 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,17 +1,18 @@ "use client"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from 'next-themes' import { useEffect, useRef, useState } from "react"; import { ImperativePanelHandle } from "react-resizable-panels"; import Navbar from "@/components/custom/navbar"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import useScreenSize from "./useScreenSize"; export default function NextAuthProvider({ children }: { children: React.ReactNode }) { const { screenSize } = useScreenSize(); - const isSmallScreen = screenSize === 'sm' || screenSize === 'xs' + const isSmallScreen = screenSize === 'sm' || screenSize === 'xs' || screenSize === 'md' const [isCollapsed, setCollapsed] = useState(isSmallScreen) const navPanel = useRef(null) @@ -25,7 +26,6 @@ export default function NextAuthProvider({ children }: { children: React.ReactNo } }, [isSmallScreen]) - const onExpand = () => { if (navPanel.current) { if (navPanel.current.isCollapsed()) { @@ -35,8 +35,8 @@ export default function NextAuthProvider({ children }: { children: React.ReactNo } } } - const panelSize = 9 - const collapsedSize = 3 + const panelSize = isSmallScreen ? 7 : 9 + const collapsedSize = isSmallScreen ? 7 : 3 return ( @@ -51,9 +51,11 @@ export default function NextAuthProvider({ children }: { children: React.ReactNo minSize={panelSize} onCollapse={() => { setCollapsed(true) }} onExpand={() => { setCollapsed(false) }}> - + + - {children} diff --git a/components/custom/navbar.tsx b/components/custom/navbar.tsx index 1d40872..c16c6d1 100644 --- a/components/custom/navbar.tsx +++ b/components/custom/navbar.tsx @@ -1,16 +1,13 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ -import { Activity, Info, LogOut, Menu, Settings, Waypoints } from "lucide-react"; +import { Activity, Info, LogOut, Moon, Sun, Waypoints } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils" -import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; -import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; import GithubMark from "./GithubMark"; -import { DropdownMenu, DropdownMenuContent } from "../ui/dropdown-menu"; export interface LinkDefinition { name: string, @@ -46,116 +43,90 @@ const linksDown: LinkDefinition[] = [ }, ] -export default function Navbar({ collapsed, onExpand }: { collapsed: boolean, onExpand: () => void }) { +export default function Navbar({ collapsed }: { collapsed: boolean }) { const { status } = useSession() const { theme, setTheme, systemTheme } = useTheme() const [mounted, setMounted] = useState(false) const pathName = usePathname() + const darkmode = theme === "dark" || (theme === "system" && systemTheme === "dark") + useEffect(() => { setMounted(true) }, []) - const setDarkMode = (val: boolean) => { - if (val) { - setTheme("dark") - } - else { - setTheme("light") - } - } - const darkmode = theme === "dark" || (theme === "system" && systemTheme === "dark") return ( - ) } \ No newline at end of file