diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index 003ac49..c232552 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -131,9 +131,13 @@ export async function GET(request: NextRequest, { params }: { params: { graph: s } const query = request.nextUrl.searchParams.get("query") + const create = request.nextUrl.searchParams.get("create") if (!query) throw new Error("Missing parameter 'query'") - + if (create === "false") { + const type = await client.connection.type(graphId) + if (type === "none") return NextResponse.json({}, { status: 200 }) + } const graph = client.selectGraph(graphId) const result = await graph.query(query) diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index ed782d3..a5f454b 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -22,12 +22,12 @@ const COLORS_ORDER_NAME = [ ] const COLORS_ORDER_VALUE = [ - "#F2EB47", - "#99E4E5", - "#EF8759", - "#89D86D", + "#7167F6", "#ED70B1", - "#7167F6" + "#EF8759", + "#99E4E5", + "#F2EB47", + "#89D86D" ] const NODE_RESERVED_KEYS = ["parent", "id", "position"] @@ -53,13 +53,17 @@ function edgeSafeKey(key: string): string { } export function getCategoryColorValue(index = 0): string { - const colorIndex = index % COLORS_ORDER_VALUE.length - return COLORS_ORDER_VALUE[colorIndex] + return COLORS_ORDER_VALUE[index % COLORS_ORDER_VALUE.length] } export function getCategoryColorName(index = 0): string { - const colorIndex = index % COLORS_ORDER_NAME.length - return COLORS_ORDER_NAME[colorIndex] + return COLORS_ORDER_NAME[index % COLORS_ORDER_NAME.length] +} + +export function getCategoryColorNameFromValue(colorValue: string): string { + const colorIndex = COLORS_ORDER_VALUE.findIndex((c) => c === colorValue) + + return COLORS_ORDER_NAME[colorIndex % COLORS_ORDER_NAME.length] } export interface ExtractedData { @@ -116,10 +120,34 @@ export class Graph { return this.categories; } + set Categories(categories: Category[]) { + this.categories = categories; + } + + get CategoriesMap(): Map { + return this.categoriesMap; + } + get Labels(): Category[] { return this.labels; } + set Labels(labels: Category[]) { + this.labels = labels; + } + + get LabelsMap(): Map { + return this.labelsMap; + } + + get NodesMap(): Map { + return this.nodesMap; + } + + get EdgesMap(): Map { + return this.edgesMap; + } + get Elements(): ElementDefinition[] { return this.elements; } @@ -150,7 +178,7 @@ export class Graph { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - public extendNode(cell: any, newElements: ElementDefinition[]) { + public extendNode(cell: any) { // check if category already exists in categories let category = this.categoriesMap.get(cell.labels[0]) if (!category) { @@ -174,8 +202,10 @@ export class Graph { }); this.nodesMap.set(cell.id, node) this.elements.push({ data: node }) - newElements.push({ data: node }) - } else if (currentNode.category === "") { + return node + } + + if (currentNode.category === "") { // set values in a fake node currentNode.id = cell.id.toString(); currentNode.name = cell.id.toString(); @@ -184,14 +214,13 @@ export class Graph { Object.entries(cell.properties).forEach(([key, value]) => { currentNode[nodeSafeKey(key)] = value as string; }); - newElements.push({ data: currentNode }) } - return newElements + return currentNode } // eslint-disable-next-line @typescript-eslint/no-explicit-any - public extendEdge(cell: any, newElements: ElementDefinition[]) { + public extendEdge(cell: any) { let label = this.labelsMap.get(cell.relationshipType) if (!label) { @@ -205,7 +234,7 @@ export class Graph { const sourceId = cell.sourceId.toString(); const destinationId = cell.destinationId.toString() const edge: EdgeDataDefinition = { - _id: cell.id, + id: `_${cell.id}`, source: sourceId, target: destinationId, label: cell.relationshipType, @@ -216,8 +245,6 @@ export class Graph { }); this.edgesMap.set(cell.id, edge) this.elements.push({ data: edge }) - newElements.push({ data: edge }) - // creates a fakeS node for the source and target let source = this.nodesMap.get(cell.sourceId) if (!source) { @@ -229,7 +256,6 @@ export class Graph { } this.nodesMap.set(cell.sourceId, source) this.elements.push({ data: source }) - newElements.push({ data: source }) } let destination = this.nodesMap.get(cell.destinationId) @@ -242,16 +268,16 @@ export class Graph { } this.nodesMap.set(cell.destinationId, destination) this.elements.push({ data: destination }) - newElements.push({ data: destination }) } + return edge } - return newElements + return currentEdge } // eslint-disable-next-line @typescript-eslint/no-explicit-any public extend(results: any): ElementDefinition[] { - const newElements: ElementDefinition[] = [] + if (results?.data?.length) { if (results.data[0] instanceof Object) { this.columns = Object.keys(results.data[0]) @@ -268,16 +294,16 @@ export class Graph { if (cell.nodes) { // eslint-disable-next-line @typescript-eslint/no-explicit-any cell.nodes.forEach((node: any) => { - this.extendNode(node, newElements) + newElements.push({ data: this.extendNode(node) }) }) // eslint-disable-next-line @typescript-eslint/no-explicit-any cell.edges.forEach((edge: any) => { - this.extendEdge(edge, newElements) + newElements.push({ data: this.extendEdge(edge) }) }) } else if (cell.relationshipType) { - this.extendEdge(cell, newElements) + newElements.push({ data: this.extendEdge(cell) }) } else if (cell.labels) { - this.extendNode(cell, newElements) + newElements.push({ data: this.extendNode(cell) }) } } }) @@ -285,4 +311,18 @@ export class Graph { return newElements } + + public updateCategories(category: string, type: string) { + if (type === "node" && !this.elements.find(e => e.data.category === category)) { + const i = this.categories.findIndex(({ name }) => name === category) + this.categories.splice(i, 1) + this.categoriesMap.delete(category) + } + + if (type === "edge" && !this.elements.find(e => e.data.label === category)) { + const i = this.labels.findIndex(({ name }) => name === category) + this.labels.splice(i, 1) + this.labelsMap.delete(category) + } + } } \ No newline at end of file diff --git a/app/api/schema/[schema]/route.ts b/app/api/schema/[schema]/route.ts deleted file mode 100644 index 0b785a4..0000000 --- a/app/api/schema/[schema]/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getClient } from "../../auth/[...nextauth]/options"; - -// eslint-disable-next-line import/prefer-default-export -export async function GET(_request: NextRequest, { params }: { params: {schema: string} }) { - const client = await getClient() - if (client instanceof NextResponse) { - return client - } - - const schemaId = params.schema; - - try { - if (!schemaId) throw new Error("Missing SchemaID") - - const graph = client.selectGraph(schemaId); - - const query = `MATCh (n)-[e]-(m) return n,e,m` - - const result = await graph.query(query) - - return NextResponse.json({ result }) - } catch (err: unknown) { - return NextResponse.json({ message: (err as Error).message }, { status: 400 }) - } -} \ No newline at end of file diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 1c61246..f6b49fb 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -27,29 +27,27 @@ export default function Header({ inCreate = false, onSetGraphName }: Props) { const pathname = usePathname() const [userStatus, setUserStatus] = useState() const [graphName, setGraphName] = useState("") - - // const createGraph = async () => { - // const result = await securedFetch(`api/graph/${newName}`) - // } + const type = pathname.includes("/schema") ? "Schema" : "Graph" const handelCreateGraph = async (e: FormEvent) => { - if (!onSetGraphName) return + e.preventDefault() + const name = `${graphName}${type === "Schema" ? "_schema" : ""}` + const q = `RETURN 1` - const result = await securedFetch(`api/graph/${graphName}/?query=${prepareArg(q)}`, { + const result = await securedFetch(`api/graph/${name}/?query=${prepareArg(q)}`, { method: "GET" }) if (result.ok) { - Toast(`Graph ${graphName} created successfully!`, "Success") + Toast(`${type} ${graphName} created successfully!`, "Success") onSetGraphName(graphName) setCreateOpen(false) setGraphName("") } - } return ( @@ -68,14 +66,14 @@ export default function Header({ inCreate = false, onSetGraphName }: Props) {
@@ -86,11 +84,11 @@ export default function Header({ inCreate = false, onSetGraphName }: Props) {
} diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 7ce3b4e..25ef90d 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Toast, defaultQuery, prepareArg, securedFetch } from "@/lib/utils"; import GraphView from "./GraphView"; import Selector from "./Selector"; @@ -9,15 +9,43 @@ import { Graph, Query } from "../api/graph/model"; export default function Page() { + const [edgesCount, setEdgesCount] = useState(0) + const [nodesCount, setNodesCount] = useState(0) const [graphName, setGraphName] = useState("") const [graph, setGraph] = useState(Graph.empty()) const [queries, setQueries] = useState([]) const [historyQuery, setHistoryQuery] = useState("") - - const handleGraphChange = (selectedGraphName: string) => { + + const fetchCount = useCallback(async () => { + if (!graphName) return + const q = [ + "MATCH (n) RETURN COUNT(n) as nodes", + "MATCH ()-[e]->() RETURN COUNT(e) as edges" + ] - setGraphName(selectedGraphName) - } + const nodes = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q[0]}`, { + method: "GET" + })).json() + + const edges = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q[1]}`, { + method: "GET" + })).json() + + if (!edges || !nodes) return + + setEdgesCount(edges.result?.data[0].edges) + setNodesCount(nodes.result?.data[0].nodes) + }, [graphName]) + + useEffect(() => { + if (graphName !== graph.Id) { + setGraph(Graph.empty()) + } + }, [graph.Id, graphName]) + + useEffect(() => { + fetchCount() + }, [fetchCount, graphName]) const run = async (query: string) => { if (!graphName) { @@ -28,21 +56,21 @@ export default function Page() { const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(defaultQuery(query))}`, { method: "GET" }) - + if (!result.ok) return null const json = await result.json() - - return json.result + fetchCount() + return json.result } - + const runQuery = async (query: string) => { const result = await run(query) if (!result) return setQueries(prev => [...prev, { text: defaultQuery(query), metadata: result.metadata }]) setGraph(Graph.create(graphName, result)) } - + const runHistoryQuery = async (query: string, setQueriesOpen: (open: boolean) => void) => { const result = await run(query) if (!result) return @@ -54,10 +82,19 @@ export default function Page() { return (
-
+
- - + +
) diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index a098561..ebd4cfe 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -33,17 +33,19 @@ export default function Toolbar({disabled, chartRef, onDeleteElement, onAddEntit
+

{attributes.length} Attributes

+
+
+ + + + Name + Type + Description + Unique + Unique + + + + { + attributes.map(([key, val], index) => ( + setHover(`${index}`)} + onMouseLeave={() => setHover("")} + > + + { + hover === `${index}` && +
+ { + type === "edge" && +
+
+
+

{selectedNodes[0]?.category}

+
+ +
+

{selectedNodes[1]?.category}

+
+
+
+
+
+ } +
+
+
+ + ) +} \ No newline at end of file diff --git a/app/schema/SchemaDataPanel.tsx b/app/schema/SchemaDataPanel.tsx new file mode 100644 index 0000000..c249a9c --- /dev/null +++ b/app/schema/SchemaDataPanel.tsx @@ -0,0 +1,414 @@ +'use client' + +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useState } from "react"; +import { cn, Toast } from "@/lib/utils"; +import { ChevronRight, Trash2 } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { EdgeDataDefinition, NodeDataDefinition } from "cytoscape"; +import Input from "../components/ui/Input"; +import Button from "../components/ui/Button"; +import Combobox from "../components/ui/combobox"; + +export const OPTIONS = ["String", "Integer", "Float", "Geospatial", "Boolean"] + +export type Type = "String" | "Integer" | "Float" | "Geospatial" | "Boolean" | undefined +export type Attribute = [Type, string, boolean, boolean] + +const excludedProperties = new Set([ + "category", + "color", + "_id", + "id", + "label", + "target", + "source", +]); + +interface Props { + obj: NodeDataDefinition | EdgeDataDefinition + onExpand: () => void + onDelete: () => void + onSetAttribute: (key: string, val: Attribute) => Promise + onRemoveAttribute: (key: string) => Promise + onSetLabel: (label: string) => Promise +} + +const emptyAttribute = (): Attribute => [undefined, "", false, false] + +export default function SchemaCreateElement({ obj, onExpand, onDelete, onSetAttribute, onRemoveAttribute, onSetLabel }: Props) { + + const [attribute, setAttribute] = useState(emptyAttribute()) + const [newVal, setVal] = useState() + const [newKey, setNewKey] = useState() + const [labelEditable, setLabelEditable] = useState(false) + const [editable, setEditable] = useState("") + const [hover, setHover] = useState("") + const [isAddValue, setIsAddValue] = useState(false) + const [attributes, setAttributes] = useState<[string, Attribute][]>(Object.entries(obj).filter(([k, v]) => !excludedProperties.has(k) && !(k === "name" && v === obj.id)).map(([k, v]) => [k, Array.isArray(v) ? v : v.split(",")] as [string, Attribute])) + const [label, setLabel] = useState(obj.source ? obj.label : obj.category) + const [newLabel, setNewLabel] = useState() + + const handelAddAttribute = async (e: React.KeyboardEvent) => { + if (e.code === "Escape") { + e.preventDefault() + setAttribute(emptyAttribute()) + return + } + + if (e.key !== 'Enter') return + + e.preventDefault() + if (!newKey || !attribute[0] || !attribute[1]) { + Toast('Please fill all the fields') + return + } + + const success = await onSetAttribute(newKey, attribute) + + if (!success) return + + setAttributes(prev => [...prev, [newKey, attribute]]) + setAttribute(emptyAttribute()) + setNewKey("") + setIsAddValue(false) + } + + const handelSetAttribute = async (e: React.KeyboardEvent) => { + if (e.code === "Escape") { + e.preventDefault() + setVal("") + setEditable("") + return + } + + if (e.key !== 'Enter') return + + e.preventDefault() + + const [index, i] = editable.split("-") + const isKey = i === "key" + + if (isKey ? !newKey : !newVal) { + Toast("Please fill the field") + return + } + + const attr = attributes[Number(index)][1] + + const success = await onSetAttribute(isKey ? newKey as string : attributes[Number(index)][0] , [attr[0], isKey ? attr[1] : newVal as string, attr[2], attr[3]]) + + if (!success) return + + setAttributes(prev => { + const p = [...prev] + + if (i === "key") { + p[Number(index)][0] = newKey as string + } + + p[Number(index)][1][Number(i)] = newVal + + return p + }) + setVal(undefined) + setNewKey("") + setEditable("") + } + + const handelLabelCancel = () => { + setNewLabel(undefined) + setLabelEditable(false) + } + + const handelCancel = () => { + setVal("") + setEditable("") + } + + const handelSetLabel = async (e: React.KeyboardEvent) => { + + if (e.key === "Escape") { + handelLabelCancel() + } + + if (e.key !== "Enter") return + + if (!newLabel) { + Toast("Label can't be empty") + return + } + + const success = await onSetLabel(newLabel) + + if (!success) return + + setLabel(newLabel) + setNewLabel("") + setLabelEditable(false) + } + + return ( +
+
+
+
+

{attributes.length} Attributes

+
+
+ + { + (attributes.length > 0 || isAddValue) && + + + Name + Type + Description + Unique + Unique + + + } +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/schema/SchemaView.tsx b/app/schema/SchemaView.tsx index fe42873..33a2ea5 100644 --- a/app/schema/SchemaView.tsx +++ b/app/schema/SchemaView.tsx @@ -3,25 +3,23 @@ import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from "@/components/ui/resizable" import CytoscapeComponent from "react-cytoscapejs" import { ChevronLeft } from "lucide-react" -import cytoscape, { EdgeDataDefinition, EventObject, NodeDataDefinition } from "cytoscape" +import cytoscape, { EdgeDataDefinition, EdgeSingular, EventObject, NodeDataDefinition } from "cytoscape" import { ImperativePanelHandle } from "react-resizable-panels" -import { useEffect, useRef, useState } from "react" +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react" import fcose from "cytoscape-fcose"; -import { cn } from "@/lib/utils" +import { ElementDataDefinition, Toast, cn, prepareArg, securedFetch } from "@/lib/utils" import Toolbar from "../graph/toolbar" -import DataPanel from "../graph/DataPanel" +import SchemaDataPanel, { Attribute } from "./SchemaDataPanel" import Labels from "../graph/labels" -import { Category, Graph } from "../api/graph/model" +import { Category, getCategoryColorValue, Graph } from "../api/graph/model" import Button from "../components/ui/Button" +import CreateElement from "./SchemaCreateElement" /* eslint-disable react/require-default-props */ interface Props { schema: Graph - onAddEntity?: () => void - onAddRelation?: () => void - onDelete?: (selectedValue: NodeDataDefinition | EdgeDataDefinition) => Promise - removeProperty?: (selectedValue: NodeDataDefinition | EdgeDataDefinition, key: string) => Promise - setProperty?: (selectedValue: NodeDataDefinition | EdgeDataDefinition, key: string, newVal: string[]) => Promise + setNodesCount: Dispatch> + setEdgesCount: Dispatch> } const LAYOUT = { @@ -55,7 +53,7 @@ function getStyle() { selector: "node", style: { label: "data(category)", - "color": "black", + "color": "white", "text-valign": "center", "text-halign": "center", "text-wrap": "ellipsis", @@ -77,20 +75,14 @@ function getStyle() { "overlay-opacity": 0, // hide gray box around active node }, }, - { - selector: "node:selected", - style: { - "border-width": 0.7, - } - }, { selector: "edge", style: { width: 1, - "line-color": "black", + "line-color": "white", "line-opacity": 0.7, "arrow-scale": 0.7, - "target-arrow-color": "black", + "target-arrow-color": "white", "target-arrow-shape": "triangle", 'curve-style': 'straight', }, @@ -100,49 +92,57 @@ function getStyle() { style: { "overlay-opacity": 0, }, - }, - { - selector: "edge:selected", - style: { - width: 2, - "line-opacity": 1, - "arrow-scale": 1, - } - }, + } ] return style } -export default function SchemaView({ schema, onAddEntity, onAddRelation, onDelete, removeProperty, setProperty }: Props) { +const getElementId = (element: NodeDataDefinition | EdgeDataDefinition) => element.source ? { id: element.id?.slice(1), query: "()-[e]-()" } : { id: element.id, query: "(e)" } + +const getCreateQuery = (type: string, selectedNodes: NodeDataDefinition[], attributes: [string, Attribute][], label?: string) => { + if (type === "node") { + return `CREATE (n${label ? `:${label}` : ""}${attributes?.length > 0 ? ` {${attributes.map(([k, [t, d, u, un]]) => `${k}: ["${t}", "${d}", "${u}", "${un}"]`).join(",")}}` : ""}) RETURN n` + } + return `MATCH (a), (b) WHERE ID(a) = ${selectedNodes[0].id} AND ID(b) = ${selectedNodes[1].id} CREATE (a)-[e${label ? `:${label}` : ""}${attributes?.length > 0 ? ` {${attributes.map(([k, [t, d, u, un]]) => `${k}: ["${t}", "${d}", "${u}", "${un}"]`).join(",")}}` : ""}]->(b) RETURN e` +} - const [selectedElement, setSelectedElement] = useState(); +export default function SchemaView({ schema, setNodesCount, setEdgesCount }: Props) { + const [selectedElement, setSelectedElement] = useState(); + const [selectedNodes, setSelectedNodes] = useState([]); const [isCollapsed, setIsCollapsed] = useState(false); const chartRef = useRef(null); const dataPanel = useRef(null); + const [isAddRelation, setIsAddRelation] = useState(false) + const [isAddEntity, setIsAddEntity] = useState(false) useEffect(() => { dataPanel.current?.collapse() }, []) useEffect(() => { - if (chartRef.current) { - const layout = chartRef.current.layout(LAYOUT); - layout.run(); - } - }, [schema.Elements]); + setSelectedElement(undefined) + }, [schema.Id]) + + useEffect(() => { + setSelectedNodes([]) + }, [isAddRelation]) + + useEffect(() => { + chartRef?.current?.elements().layout(LAYOUT).run(); + }, [schema.Elements.length]); const onCategoryClick = (category: Category) => { const chart = chartRef.current if (chart) { - const elements = chart.elements(`node[category = "${category.name}"]`) + const nodes = chart.elements(`node[category = "${category.name}"]`) // eslint-disable-next-line no-param-reassign category.show = !category.show if (category.show) { - elements.style({ display: 'element' }) + nodes.style({ display: 'element' }) } else { - elements.style({ display: 'none' }) + nodes.style({ display: 'none' }) } chart.elements().layout(LAYOUT).run(); } @@ -151,26 +151,87 @@ export default function SchemaView({ schema, onAddEntity, onAddRelation, onDelet const onLabelClick = (label: Category) => { const chart = chartRef.current if (chart) { - const elements = chart.elements(`edge[label = "${label.name}"]`) + const edges = chart.elements(`edge[label = "${label.name}"]`) // eslint-disable-next-line no-param-reassign label.show = !label.show if (label.show) { - elements.style({ display: 'element' }) + edges.style({ display: 'element' }) } else { - elements.style({ display: 'none' }) + edges.style({ display: 'none' }) } chart.elements().layout(LAYOUT).run(); } } - const handleTap = (e: EventObject) => { - const element = e.target.json().data + const handelSetSelectedElement = (element?: ElementDataDefinition) => { setSelectedElement(element) - dataPanel.current?.expand() + if (isAddRelation || isAddEntity) return + if (element) { + dataPanel.current?.expand() + } else dataPanel.current?.collapse() + } + + const handleTap = (evt: EventObject) => { + const obj: ElementDataDefinition = evt.target.json().data; + setSelectedNodes(prev => prev.length >= 2 ? [prev[prev.length - 1], obj as NodeDataDefinition] : [...prev, obj as NodeDataDefinition]) } + const handleSelected = (evt: EventObject) => { + if (isAddRelation) return + + const { target } = evt + + if (target.isEdge()) { + const { color } = target.data() + target.style("line-color", color); + target.style("target-arrow-color", color); + target.style("line-opacity", 0.5); + target.style("width", 2); + target.style("arrow-scale", 1); + } else { + target.style("border-width", 0.7) + }; + + const obj: ElementDataDefinition = target.json().data + + handelSetSelectedElement(obj); + } + + const handleUnselected = (evt: EventObject) => { + const { target } = evt + + if (target.isEdge()) { + target.style("line-color", "white"); + target.style("target-arrow-color", "white"); + target.style("line-opacity", 1); + target.style("width", 1); + target.style("arrow-scale", 0.7); + } else target.style("border-width", 0.3); + + handelSetSelectedElement(); + } + + const handleMouseOver = (evt: EventObject) => { + const edge: EdgeSingular = evt.target; + const { color } = edge.data(); + + edge.style("line-color", color); + edge.style("target-arrow-color", color); + edge.style("line-opacity", 0.5); + }; + + const handleMouseOut = async (evt: EventObject) => { + const edge: EdgeSingular = evt.target; + + if (edge.selected()) return + + edge.style("line-color", "white"); + edge.style("target-arrow-color", "white"); + edge.style("line-opacity", 1); + }; + const onExpand = () => { if (!dataPanel.current) return const panel = dataPanel.current @@ -181,14 +242,196 @@ export default function SchemaView({ schema, onAddEntity, onAddRelation, onDelet } } + const handelDelete = async () => { + if (!selectedElement) return + + const type = !selectedElement.source + const { id, query } = getElementId(selectedElement) + const q = `MATCH ${query} WHERE ID(e) = ${id} delete e` + const result = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)} `, { + method: "GET" + }) + + if (!result.ok) return + + schema.Elements.splice(schema.Elements.findIndex(e => e.data.id === id), 1) + + if (type) { + schema.NodesMap.delete(Number(id)) + chartRef.current?.remove(`#${id} `) + setNodesCount(prev => prev - 1) + } else { + schema.EdgesMap.delete(Number(id)) + chartRef.current?.remove(`#_${id} `) + setEdgesCount(prev => prev - 1) + } + + schema.updateCategories(type ? selectedElement.category : selectedElement.label, type ? "node" : "edge") + setSelectedElement(undefined) + onExpand() + + } + + const handelSetAttribute = async (key: string, newVal: Attribute) => { + if (!selectedElement) return false + + const { id, query } = getElementId(selectedElement) + const q = `MATCH ${query} WHERE ID(e) = ${id} SET e.${key} = "${newVal}"` + const { ok } = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)}`, { + method: "GET" + }) + + if (ok) { + schema.Elements.forEach(e => { + if (e.data.id !== selectedElement.id) return + e.data[key] = newVal + }) + } else { + Toast("Failed to set property") + } + + return ok + } + + const handelSetLabel = async (label: string) => { + if (!selectedElement) return false + + const type = selectedElement.source ? "edge" : "node" + const { id, query } = getElementId(selectedElement) + const q = `MATCH ${query} WHERE ID(e) = ${id}${type === "node" ? ` REMOVE e:${selectedElement.category}` : ""} SET e:${label}` + const success = (await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)}`, { + method: "GET" + })).ok + + if (success) { + schema.Elements.forEach(({ data }) => { + if (data.id !== id) return + + if (type === "node") { + // eslint-disable-next-line no-param-reassign + data.category = label + let category = schema.CategoriesMap.get(label) + + if (!category) { + category = { name: label, index: schema.CategoriesMap.size, show: true } + schema.CategoriesMap.set(label, category) + schema.Categories.push(category) + } + + chartRef.current?.elements().forEach(n => { + if (n.data().id === id) { + // eslint-disable-next-line no-param-reassign + n.data().label = label + // eslint-disable-next-line no-param-reassign + n.data().color = getCategoryColorValue(category.index) + } + }); + chartRef.current?.elements().layout(LAYOUT).run(); + } else { + // eslint-disable-next-line no-param-reassign + data.label = label + let category = schema.LabelsMap.get(label) + + if (!category) { + category = { name: label, index: schema.LabelsMap.size, show: true } + schema.LabelsMap.set(label, category) + schema.Labels.push(category) + } + + chartRef.current?.elements().forEach(r => { + if (r.data().id === selectedElement.id) { + // eslint-disable-next-line no-param-reassign + r.data().label = label + // eslint-disable-next-line no-param-reassign + r.data().color = getCategoryColorValue(category.index) + } + }); + chartRef.current?.elements().layout(LAYOUT).run(); + } + }) + schema.updateCategories(type === "node" ? selectedElement.category : selectedElement.label, type) + } + + return success + } + + const handelRemoveProperty = async (key: string) => { + if (!selectedElement) return false + + const { id, query } = getElementId(selectedElement) + const q = `MATCH ${query} WHERE ID(e) = ${id} SET e.${key} = null` + const { ok } = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)}`, { + method: "GET" + }) + + if (!ok) return ok + + const s = schema + s.Elements = schema.Elements.map(e => { + if (e.data.id === id) { + const updatedElement = e + delete updatedElement.data[key] + return updatedElement + } + return e + }) + + return ok + } + + const onCreateElement = async (attributes: [string, Attribute][], label?: string) => { + const type = isAddEntity ? "node" : "" + + const result = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${getCreateQuery(type, selectedNodes, attributes, label)}`, { + method: "GET" + }) + + if (result.ok) { + const json = await result.json() + + if (type === "node") { + chartRef?.current?.add({ data: schema.extendNode(json.result.data[0].n) }) + setNodesCount(prev => prev + 1) + setIsAddEntity(false) + } else { + chartRef?.current?.add({ data: schema.extendEdge(json.result.data[0].e) }) + setEdgesCount(prev => prev + 1) + setIsAddRelation(false) + } + onExpand() + } else Toast("Failed to create element") + + return result.ok + } + + return (
- onDelete && selectedElement && await onDelete(selectedElement)} chartRef={chartRef} /> + { + setIsAddEntity(true) + setIsAddRelation(false) + setSelectedElement(undefined) + if (dataPanel.current?.isExpanded()) return + onExpand() + }} + onAddRelation={() => { + setIsAddRelation(true) + setIsAddEntity(false) + setSelectedElement(undefined) + if (dataPanel.current?.isExpanded()) return + onExpand() + }} + onDeleteElement={handelDelete} + chartRef={chartRef} + /> { isCollapsed &&