diff --git a/.eslintrc.json b/.eslintrc.json index ee1b3b13..ce391e3f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,6 +11,8 @@ "plugin:react/recommended" ], "rules": { + "semi": ["error", "always"], + "@typescript-eslint/semi": ["error", "always"], "react/react-in-jsx-scope": "off", "import/no-extraneous-dependencies": [ "error", @@ -41,6 +43,9 @@ "*.js", "*.jsx" ], + "excludedFiles": [ + "*.d.ts" + ], "parserOptions": { "project": [ "./tsconfig.json" diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index 0b8bc2c8..c39964e3 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: "22" + node-version: "24" cache: ${{ steps.detect-package-manager.outputs.manager }} - name: Restore cache diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0752ddd2..057d48aa 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Cache node modules id: cache-npm @@ -89,7 +89,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -155,7 +155,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -221,7 +221,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -291,7 +291,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -360,7 +360,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -422,7 +422,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 @@ -555,7 +555,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 cache: 'npm' - name: Restore dependencies cache uses: actions/cache/restore@v4 diff --git a/Dockerfile b/Dockerfile index a692a3a2..96a63c44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG CYPHER_VERSION=latest -FROM node:22-alpine AS base +FROM node:24-alpine AS base FROM falkordb/text-to-cypher:${CYPHER_VERSION} AS cypher diff --git a/app/GTM.tsx b/app/GTM.tsx index 889963ea..f2da2f93 100644 --- a/app/GTM.tsx +++ b/app/GTM.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; import { useEffect } from "react"; import TagManager from "react-gtm-module"; export default function GTM() { useEffect(() => { - const gtmId = process.env.NEXT_PUBLIC_GTM_ID + const gtmId = process.env.NEXT_PUBLIC_GTM_ID; if (gtmId) { TagManager.initialize({ gtmId }); } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index e49d16d9..274f3889 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,6 @@ import NextAuth from "next-auth"; import authOptions from "./options"; -const handler = NextAuth(authOptions) +const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; \ No newline at end of file diff --git a/app/api/chat/route.tsx b/app/api/chat/route.tsx index 7b41e641..d4395d84 100644 --- a/app/api/chat/route.tsx +++ b/app/api/chat/route.tsx @@ -5,22 +5,22 @@ import { chatRequest, validateBody } from "../validate-body"; export async function GET() { try { - const session = await getClient() + const session = await getClient(); if (session instanceof NextResponse) { - throw new Error(await session.text()) + throw new Error(await session.text()); } // Return empty object to allow chat to be displayed // The actual model configuration is provided by the user in the frontend - return NextResponse.json({}, { status: 200 }) + return NextResponse.json({}, { status: 200 }); } catch (error) { - console.error(error) - return NextResponse.json({ error: (error as Error).message }, { status: 500 }) + console.error(error); + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } } -export type EventType = "Status" | "Schema" | "CypherQuery" | "CypherResult" | "ModelOutputChunk" | "Result" | "Error" +export type EventType = "Status" | "Schema" | "CypherQuery" | "CypherResult" | "ModelOutputChunk" | "Result" | "Error"; /** * Build FalkorDB connection URL from user session @@ -41,26 +41,26 @@ function buildFalkorDBConnection(user: { host: string; port: number; url?: strin // eslint-disable-next-line import/prefer-default-export export async function POST(request: NextRequest) { - const encoder = new TextEncoder() - const { readable, writable } = new TransformStream() - const writer = writable.getWriter() + const encoder = new TextEncoder(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); try { // Verify authentication via getClient - const session = await getClient() + const session = await getClient(); if (session instanceof NextResponse) { - throw new Error(await session.text()) + throw new Error(await session.text()); } - const body = await request.json() + const body = await request.json(); // Validate request body const validation = validateBody(chatRequest, body); if (!validation.success) { - writer.write(encoder.encode(`event: error status: ${400} data: ${JSON.stringify(validation.error)}\n\n`)) - writer.close() + writer.write(encoder.encode(`event: error status: ${400} data: ${JSON.stringify(validation.error)}\n\n`)); + writer.close(); return new Response(readable, { headers: { @@ -68,15 +68,15 @@ export async function POST(request: NextRequest) { "Cache-Control": "no-cache", Connection: "keep-alive", }, - }) + }); } - const { messages, graphName, key, model } = validation.data + const { messages, graphName, key, model } = validation.data; // Validate required parameters if (!key) { - writer.write(encoder.encode(`event: error status: ${400} data: "API key is required. Please configure it in Settings."\n\n`)) - writer.close() + writer.write(encoder.encode(`event: error status: ${400} data: "API key is required. Please configure it in Settings."\n\n`)); + writer.close(); return new Response(readable, { headers: { @@ -84,7 +84,7 @@ export async function POST(request: NextRequest) { "Cache-Control": "no-cache", Connection: "keep-alive", }, - }) + }); } try { @@ -125,9 +125,9 @@ export async function POST(request: NextRequest) { writer.write(encoder.encode(`event: Result data: ${JSON.stringify(result.answer)}\n\n`)); } - writer.close() + writer.close(); } catch (error) { - console.error(error) + console.error(error); const errorMessage = (error as Error).message; // Check if it's an API key error @@ -138,18 +138,18 @@ export async function POST(request: NextRequest) { userFriendlyMessage = 'API key error. Please verify your API key in Settings.'; } - writer.write(encoder.encode(`event: error status: ${400} data: ${JSON.stringify(userFriendlyMessage)}\n\n`)) - writer.close() + writer.write(encoder.encode(`event: error status: ${400} data: ${JSON.stringify(userFriendlyMessage)}\n\n`)); + writer.close(); } } catch (error) { - console.error(error) - writer.write(encoder.encode(`event: error status: ${500} data: ${JSON.stringify((error as Error).message)}\n\n`)) - writer.close() + console.error(error); + writer.write(encoder.encode(`event: error status: ${500} data: ${JSON.stringify((error as Error).message)}\n\n`)); + writer.close(); } request.signal.addEventListener("abort", () => { - writer.close() - }) + writer.close(); + }); return new Response(readable, { headers: { @@ -157,5 +157,5 @@ export async function POST(request: NextRequest) { "Cache-Control": "no-cache", Connection: "keep-alive", }, - }) + }); } \ No newline at end of file diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index f7633584..beac1b4b 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -23,7 +23,9 @@ export async function DELETE( await graph.delete(); - return NextResponse.json({ message: `${graphId} graph deleted` }); + return NextResponse.json( + { message: `${graphId} graph deleted` }, + ); } } catch (error) { console.error(error); diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 7925795a..a5587e5b 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -3,8 +3,6 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { LinkObject, NodeObject } from "react-force-graph-2d"; - export type Value = string | number | boolean; export type HistoryQuery = { @@ -55,36 +53,33 @@ const getSchemaValue = (value: string): string[] => { // Constant for empty display name export const EMPTY_DISPLAY_NAME: [string, string] = ['', '']; -export type Node = NodeObject<{ +export type Node = { id: number; labels: string[]; color: string; visible: boolean; expand: boolean; collapsed: boolean; - displayName: [string, string]; + size?: number; + caption?: string; data: { [key: string]: any; }; -}>; - -export type Link = LinkObject< - Node, - { - id: number; - relationship: string; - color: string; - source: Node; - target: Node; - visible: boolean; - expand: boolean; - collapsed: boolean; - curve: number; - data: { - [key: string]: any; - }; - } ->; +}; + +export type Link = { + id: number; + relationship: string; + color: string; + source: number; + target: number; + visible: boolean; + expand: boolean; + collapsed: boolean; + data: { + [key: string]: any; + }; +}; export type GraphData = { nodes: Node[]; @@ -117,7 +112,7 @@ export type DataRow = { export type Data = DataRow[]; -export type MemoryValue = number | Map +export type MemoryValue = number | Map; export const DEFAULT_COLORS = [ "hsl(246, 100%, 70%)", @@ -155,30 +150,33 @@ export const STYLE_COLORS = [ ]; // Size options for node customization (relative to base NODE_SIZE) -export const NODE_SIZE_OPTIONS = [0.5, 0.7, 0.85, 1, 1.15, 1.3, 1.5, 1.7, 2, 2.3, 2.6]; +export const NODE_SIZE_OPTIONS = [3, 4.2, 5.1, 6, 6.9, 7.8, 9, 10.2, 12, 13.8, 15.6]; -export interface InfoLabel { - name: string; +export interface LinkStyle { color: string; - show: boolean; } -export interface LabelStyle { - customColor?: string; // Custom color override - customSize?: number; // Custom size multiplier (1 = default) - customCaption?: string; // Custom property to display as caption +export interface LabelStyle extends LinkStyle { + size?: number; + caption?: string; +} + +export interface InfoLabel { + name: string; + style: LabelStyle; + show: boolean; } export interface Label extends InfoLabel { elements: Node[]; textWidth?: number; textHeight?: number; - style?: LabelStyle; // Style customization + style: LabelStyle; } export interface InfoRelationship { name: string; - color: string; + style: LinkStyle; show: boolean; } @@ -254,21 +252,19 @@ export class GraphInfo { new Map(this.labels), new Map(this.relationships), new Map(this.memoryUsage), - [...this.colors] + this.colors ); } public static empty( propertyKeys?: string[], memoryUsage?: Map, - colors?: string[] ): GraphInfo { return new GraphInfo( propertyKeys || [], new Map(), new Map(), new Map(memoryUsage), - colors ); } @@ -277,9 +273,8 @@ export class GraphInfo { labels: string[], relationships: string[], memoryUsage: Map, - colors?: string[] ): GraphInfo { - const graphInfo = GraphInfo.empty(propertyKeys, memoryUsage, colors); + const graphInfo = GraphInfo.empty(propertyKeys, memoryUsage); graphInfo.createLabel(labels); relationships.forEach((relationship) => graphInfo.createRelationship(relationship) @@ -295,7 +290,9 @@ export class GraphInfo { if (!c) { c = { name: label, - color: this.getLabelColorValue(this.colorsCounter), + style: { + color: this.getLabelColorValue(this.colorsCounter), + }, show: true, }; @@ -313,8 +310,10 @@ export class GraphInfo { if (!c) { c = { name: relationship, - color: this.getLabelColorValue(this.colorsCounter), show: true, + style: { + color: this.getLabelColorValue(this.colorsCounter), + }, }; this.relationships.set(relationship, c); @@ -329,9 +328,7 @@ export class GraphInfo { return this.colors[index]; } - const newColor = `hsl(${ - (index - Math.min(DEFAULT_COLORS.length, this.colors.length)) * 20 - }, 100%, 70%)`; + const newColor = `hsl(${(index - DEFAULT_COLORS.length) * 20}, 100%, 70%)`; this.colors.push(newColor); @@ -496,7 +493,11 @@ export class Graph { currentLimit: number, graphInfo?: GraphInfo ): Graph { - const graph = Graph.empty(undefined, currentLimit, graphInfo); + const graph = Graph.empty( + undefined, + currentLimit, + graphInfo + ); graph.extend(results, isCollapsed, isSchema); graph.id = id; return graph; @@ -505,22 +506,22 @@ export class Graph { public calculateLinkCurve(link: Link, existingLinks: Link[] = []): number { const start = link.source; const end = link.target; - + // Find all links between the same nodes (including new links being added) const allLinks = [...this.elements.links, ...existingLinks]; const sameNodesLinks = allLinks.filter( (l) => - (l.source.id === start.id && l.target.id === end.id) || - (l.target.id === start.id && l.source.id === end.id) + (l.source === start && l.target === end) || + (l.target === start && l.source === end) ); - + let index = sameNodesLinks.findIndex((l) => l.id === link.id); index = index === -1 ? sameNodesLinks.length : index; - + const even = index % 2 === 0; let curve; - if (start.id === end.id) { + if (start === end) { if (even) { curve = Math.floor(-(index / 2)) - 3; } else { @@ -550,11 +551,10 @@ export class Graph { const node: Node = { id: cell.id, labels: labels.map((l) => l.name), - color: isColor ? getLabelWithFewestElements(labels).color : "", + color: isColor ? getLabelWithFewestElements(labels).style.color : "", visible: true, expand: false, collapsed, - displayName: ["", ""], data: {}, }; Object.entries(cell.properties).forEach(([key, value]) => { @@ -572,7 +572,7 @@ export class Graph { currentNode.id = cell.id; currentNode.labels = labels.map((l) => l.name); currentNode.color = isColor - ? getLabelWithFewestElements(labels).color + ? getLabelWithFewestElements(labels).style.color : ""; currentNode.expand = false; currentNode.collapsed = collapsed; @@ -623,11 +623,10 @@ export class Graph { source = { id: cell.sourceId, labels: [label.name], - color: isColor ? label.color : "", + color: isColor ? label.style.color : "", expand: false, collapsed, visible: true, - displayName: ["", ""], data: {}, }; @@ -638,14 +637,13 @@ export class Graph { link = { id: cell.id, - source, - target: source, + source: cell.sourceId, + target: cell.destinationId, relationship: cell.relationshipType, - color: relation.color, + color: relation.style.color, expand: false, collapsed, visible: true, - curve: 0, data: {}, }; } else { @@ -660,11 +658,10 @@ export class Graph { source = { id: cell.sourceId, labels: [label!.name], - color: isColor ? label!.color : "", + color: isColor ? label!.style.color : "", expand: false, collapsed, visible: true, - displayName: ["", ""], data: {}, }; @@ -677,11 +674,10 @@ export class Graph { target = { id: cell.destinationId, labels: [label!.name], - color: isColor ? label!.color : "", + color: isColor ? label!.style.color : "", expand: false, collapsed, visible: true, - displayName: ["", ""], data: {}, }; @@ -692,14 +688,13 @@ export class Graph { link = { id: cell.id, - source, - target, + source: cell.sourceId, + target: cell.destinationId, relationship: cell.relationshipType, - color: relation.color, + color: relation.style.color, expand: false, collapsed, visible: true, - curve: 0, data: {}, }; } @@ -783,9 +778,8 @@ export class Graph { }); }); - newElements.filter((element): element is Link => !!element.source).forEach((link) => { - link.curve = this.calculateLinkCurve(link); - }); + this.nodesMap = new Map(this.elements.nodes.map((n) => [n.id, n])); + this.linksMap = new Map(this.elements.links.map((l) => [l.id, l])); newElements .filter((element): element is Node => "labels" in element) @@ -796,7 +790,7 @@ export class Graph { ) ); // Use custom color if available, otherwise use default label color - node.color = label.style?.customColor || label.color; + node.color = label.style.color; }); // remove empty category if there are no more empty nodes category @@ -841,16 +835,11 @@ export class Graph { const storageKey = `labelStyle_${label.name}`; const savedStyle = localStorage.getItem(storageKey); - + if (savedStyle) { try { const style = JSON.parse(savedStyle); label.style = style; - - // Apply custom color if present - if (style.customColor) { - label.color = style.customColor; - } } catch (e) { // Ignore invalid JSON } @@ -878,10 +867,8 @@ export class Graph { if ( this.RelationshipsMap.get(link.relationship)!.show && visible && - this.elements.nodes.map((n) => n.id).includes(link.source.id) && - link.source.visible && - this.elements.nodes.map((n) => n.id).includes(link.target.id) && - link.target.visible + this.nodesMap.get(link.source)?.visible && + this.nodesMap.get(link.target)?.visible ) { // eslint-disable-next-line no-param-reassign link.visible = true; @@ -889,10 +876,10 @@ export class Graph { if ( !visible && - ((this.elements.nodes.map((n) => n.id).includes(link.source.id) && - !link.source.visible) || - (this.elements.nodes.map((n) => n.id).includes(link.target.id) && - !link.target.visible)) + (this.nodesMap.get(link.source)?.visible === + false || + this.nodesMap.get(link.target)?.visible === + false) ) { // eslint-disable-next-line no-param-reassign link.visible = false; @@ -902,17 +889,17 @@ export class Graph { public removeLinks(ids: number[] = []): Relationship[] { const links = this.elements.links.filter( - (link) => ids.includes(link.source.id) || ids.includes(link.target.id) + (link) => ids.includes(link.source) || ids.includes(link.target) ); this.elements = { nodes: this.elements.nodes, links: this.elements.links - .map((link) => { - if ( + .map((link) => { + if ( (ids.length !== 0 && !links.includes(link)) || - (this.elements.nodes.map((n) => n.id).includes(link.source.id) && - this.elements.nodes.map((n) => n.id).includes(link.target.id)) + (this.nodesMap.has(link.source) && + this.nodesMap.has(link.target)) ) { return link; } @@ -946,13 +933,14 @@ export class Graph { public removeElements(elements: (Node | Link)[]) { elements.forEach((element) => { const { id } = element; - const type = !element.source; + const type = "labels" in element; if (type) { this.elements.nodes.splice( this.elements.nodes.findIndex((n) => n.id === id), 1 ); + this.nodesMap.delete(id); const category = this.labelsMap.get(element.labels[0]); if (category) { @@ -970,6 +958,7 @@ export class Graph { this.elements.links.findIndex((l) => l.id === id), 1 ); + this.linksMap.delete(id); const category = this.relationshipsMap.get(element.relationship); if (category) { @@ -985,8 +974,8 @@ export class Graph { } }); - const nodes = elements.filter((n): n is Node => !n.source); - const links = elements.filter((l): l is Link => l.source); + const nodes = elements.filter((n): n is Node => "labels" in n); + const links = elements.filter((l): l is Link => "source" in l); this.elements = { nodes: this.elements.nodes.filter( @@ -1046,11 +1035,13 @@ export class Graph { category.elements = category.elements.filter( (element) => element.id !== selectedElement.id ); + if (category.elements.length === 0) { this.Labels.splice( this.Labels.findIndex((c) => c.name === category.name), 1 ); + this.LabelsMap.delete(category.name); } } @@ -1063,12 +1054,20 @@ export class Graph { if (selectedElement.labels.length === 0) { const [emptyCategory] = this.createLabel([""], selectedElement); selectedElement.labels.push(emptyCategory.name); - selectedElement.color = emptyCategory.color; + const { color, size, caption } = emptyCategory.style; + selectedElement.color = color; + selectedElement.size = size; + selectedElement.caption = caption; } else { // Update node color to reflect the remaining label - const remainingLabel = this.LabelsMap.get(selectedElement.labels[0]); + const remainingLabel = this.LabelsMap.get(getLabelWithFewestElements(selectedElement.labels.map(l => this.LabelsMap.get(l)).filter(l => !!l)).name); + if (remainingLabel) { - selectedElement.color = remainingLabel.color; + const { color, size, caption } = remainingLabel.style; + + selectedElement.color = color; + selectedElement.size = size; + selectedElement.caption = caption; } } } @@ -1110,13 +1109,16 @@ export class Graph { selectedElement ); selectedElement.labels.splice(emptyCategoryIndex, 1); - selectedElement.color = category.color; + + const emptyCategory = this.labelsMap.get(""); + if (emptyCategory) { emptyCategory.elements = emptyCategory.elements.filter( (e) => e.id !== selectedElement.id ); + if (emptyCategory.elements.length === 0) { this.labels.splice( this.labels.findIndex((c) => c.name === emptyCategory.name), @@ -1129,8 +1131,12 @@ export class Graph { selectedElement.labels.push(label); - // Update node color to reflect the new label - selectedElement.color = category.color; + const { color, size, caption } = category.style; + + selectedElement.color = color; + selectedElement.size = size; + selectedElement.caption = caption; + return this.labels; } diff --git a/app/api/monitor/route.ts b/app/api/monitor/route.ts index dc5207b6..65992ceb 100644 --- a/app/api/monitor/route.ts +++ b/app/api/monitor/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; -const fileds = ["used_memory", "used_memory_rss"]; +const fields = ["used_memory", "used_memory_rss"]; // eslint-disable-next-line import/prefer-default-export export async function GET() { try { @@ -24,7 +24,7 @@ export async function GET() { return { name, series }; }) .filter((item: { name: string; series: string }) => - fileds.find((filed) => filed === item.name) + fields.find((field) => field === item.name) ); const dataGraph: { name: string; series: number }[] = []; for (let i = 0; i < infoGraph.length; i += 2) { diff --git a/app/api/schema/[schema]/[element]/utils.ts b/app/api/schema/[schema]/[element]/utils.ts index a4e60845..fa28d408 100644 --- a/app/api/schema/[schema]/[element]/utils.ts +++ b/app/api/schema/[schema]/[element]/utils.ts @@ -1,10 +1,10 @@ export const formatAttribute = (att: [string, string[]]) => { - const [key, [t, d, u, r]] = att - let val = `${t}` - if (u === "true") val += "!" - if (r === "true") val += "*" - if (d) val += `-${d}` - return [key, val] -} + const [key, [t, d, u, r]] = att; + let val = `${t}`; + if (u === "true") val += "!"; + if (r === "true") val += "*"; + if (d) val += `-${d}`; + return [key, val]; +}; -export const formatAttributes = (attributes: [string, string[]][]) => attributes.map((att) => formatAttribute(att)) \ No newline at end of file +export const formatAttributes = (attributes: [string, string[]][]) => attributes.map((att) => formatAttribute(att)); \ No newline at end of file diff --git a/app/api/utils.ts b/app/api/utils.ts index debfa714..6db23ec4 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -5,4 +5,4 @@ import { Role } from "next-auth"; export const runQuery = async (graph: Graph, query: string, role: Role) => { const result = role === "Read-Only" ? await graph.roQuery(query) : await graph.query(query); return result; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/CloseDialog.tsx b/app/components/CloseDialog.tsx index 8eeeb730..c7db90c2 100644 --- a/app/components/CloseDialog.tsx +++ b/app/components/CloseDialog.tsx @@ -25,5 +25,5 @@ export default function CloseDialog({ className, label, children, ...props }: Pr {children} - ) + ); } \ No newline at end of file diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx index 402584bc..4a0591cf 100644 --- a/app/components/CreateGraph.tsx +++ b/app/components/CreateGraph.tsx @@ -1,17 +1,17 @@ /* eslint-disable react/require-default-props */ -"use client" +"use client"; -import React, { useState, useContext, useEffect } from "react" -import { InfoIcon, PlusCircle } from "lucide-react" -import { prepareArg, securedFetch } from "@/lib/utils" -import { useToast } from "@/components/ui/use-toast" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import DialogComponent from "./DialogComponent" -import Button from "./ui/Button" -import CloseDialog from "./CloseDialog" -import Input from "./ui/Input" -import { IndicatorContext } from "./provider" +import React, { useState, useContext, useEffect } from "react"; +import { InfoIcon, PlusCircle } from "lucide-react"; +import { prepareArg, securedFetch } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import DialogComponent from "./DialogComponent"; +import Button from "./ui/Button"; +import CloseDialog from "./CloseDialog"; +import Input from "./ui/Input"; +import { IndicatorContext } from "./provider"; interface Props { onSetGraphName: (name: string) => void @@ -37,59 +37,59 @@ export default function CreateGraph({ ), }: Props) { - const { indicator, setIndicator } = useContext(IndicatorContext) + const { indicator, setIndicator } = useContext(IndicatorContext); - const { toast } = useToast() + const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false) - const [graphName, setGraphName] = useState("") - const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false); + const [graphName, setGraphName] = useState(""); + const [open, setOpen] = useState(false); useEffect(() => { if (!open) { - setGraphName("") - setIsLoading(false) + setGraphName(""); + setIsLoading(false); } - }, [open]) + }, [open]); const handleCreateGraph = async (e: React.FormEvent) => { - e.preventDefault() + e.preventDefault(); try { - setIsLoading(true) - const name = graphName.trim() + setIsLoading(true); + const name = graphName.trim(); if (!name) { toast({ title: "Error", description: `${type} name cannot be empty`, variant: "destructive" - }) - return + }); + return; } if (graphNames.includes(name)) { toast({ title: "Error", description: `${type} name already exists`, variant: "destructive" - }) - return + }); + return; } const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}`, { method: "POST", - }, toast, setIndicator) + }, toast, setIndicator); - if (!result.ok) return + if (!result.ok) return; - onSetGraphName(name) - setGraphName("") - setOpen(false) + onSetGraphName(name); + setGraphName(""); + setOpen(false); toast({ title: `${type} created successfully`, description: `The ${type.toLowerCase()} has been created successfully`, - }) + }); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return ( - ) + ); } \ No newline at end of file diff --git a/app/components/DialogComponent.tsx b/app/components/DialogComponent.tsx index 648ea208..43eb5573 100644 --- a/app/components/DialogComponent.tsx +++ b/app/components/DialogComponent.tsx @@ -62,7 +62,7 @@ export default function DialogComponent({ {children} - ) + ); } DialogComponent.defaultProps = { @@ -72,4 +72,4 @@ DialogComponent.defaultProps = { label: "", preventOutsideClose: undefined, className: undefined, -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/EditorComponent.tsx b/app/components/EditorComponent.tsx index 7ab1c072..3b071117 100644 --- a/app/components/EditorComponent.tsx +++ b/app/components/EditorComponent.tsx @@ -4,8 +4,8 @@ "use client"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; -import { Editor, Monaco } from "@monaco-editor/react" -import { SetStateAction, Dispatch, useEffect, useRef, useState, useContext, useMemo } from "react" +import { Editor, Monaco } from "@monaco-editor/react"; +import { SetStateAction, Dispatch, useEffect, useRef, useState, useContext, useMemo } from "react"; import * as monaco from "monaco-editor"; import { Minimize2, X } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; @@ -42,8 +42,8 @@ export const setTheme = (monacoI: Monaco, themeName: string, backgroundColor: st }, }); - monacoI.editor.setTheme(themeName) -} + monacoI.editor.setTheme(themeName); +}; interface Props { graph: Graph @@ -57,9 +57,9 @@ interface Props { isQueryLoading: boolean } -const MAX_HEIGHT = 20 -const LINE_HEIGHT = 22 -const PLACEHOLDER = "Type your query here to start" +const MAX_HEIGHT = 20; +const LINE_HEIGHT = 22; +const PLACEHOLDER = "Type your query here to start"; const monacoOptions: monaco.editor.IStandaloneEditorConstructionOptions = { renderLineHighlight: "none", glyphMargin: false, @@ -112,7 +112,7 @@ const KEYWORDS = [ "FOREACH", "CALL", "YIELD", -] +]; const FUNCTIONS = [ "all", @@ -207,7 +207,7 @@ const FUNCTIONS = [ "vecf32", "vec.euclideanDistance", "vec.cosineDistance", -] +]; const SUGGESTIONS: monaco.languages.CompletionItem[] = [ ...KEYWORDS.map(key => ({ @@ -225,104 +225,104 @@ const SUGGESTIONS: monaco.languages.CompletionItem[] = [ range: new monaco.Range(1, 1, 1, 1), detail: "(function)" })) -] +]; export default function EditorComponent({ graph, graphName, historyQuery, maximize, setMaximize, runQuery, setHistoryQuery, editorKey, isQueryLoading }: Props) { - const { indicator, setIndicator } = useContext(IndicatorContext) - const { tutorialOpen } = useContext(BrowserSettingsContext) - - const { toast } = useToast() - const { theme } = useTheme() - const editorRef = useRef(null) - const dialogEditorRef = useRef(null) - const placeholderRef = useRef(null) - const submitQuery = useRef(null) - const containerRef = useRef(null) - const indicatorRef = useRef(indicator) - const graphIdRef = useRef(graph.Id) - const graphNameRef = useRef(graphName) - const queryRef = useRef(historyQuery.query) - const tutorialOpenRef = useRef(tutorialOpen) - - const [monacoEditor, setMonacoEditor] = useState(null) - const [sugDisposed, setSugDisposed] = useState() - const [lineNumber, setLineNumber] = useState(1) - const [blur, setBlur] = useState(false) - - const { background, currentTheme } = getTheme(theme) + const { indicator, setIndicator } = useContext(IndicatorContext); + const { tutorialOpen } = useContext(BrowserSettingsContext); + + const { toast } = useToast(); + const { theme } = useTheme(); + const editorRef = useRef(null); + const dialogEditorRef = useRef(null); + const placeholderRef = useRef(null); + const submitQuery = useRef(null); + const containerRef = useRef(null); + const indicatorRef = useRef(indicator); + const graphIdRef = useRef(graph.Id); + const graphNameRef = useRef(graphName); + const queryRef = useRef(historyQuery.query); + const tutorialOpenRef = useRef(tutorialOpen); + + const [monacoEditor, setMonacoEditor] = useState(null); + const [sugDisposed, setSugDisposed] = useState(); + const [lineNumber, setLineNumber] = useState(1); + const [blur, setBlur] = useState(false); + + const { background, currentTheme } = getTheme(theme); const editorHeight = useMemo(() => blur ? LINE_HEIGHT : Math.min(lineNumber * LINE_HEIGHT, document.body.clientHeight / 100 * MAX_HEIGHT), - [blur, lineNumber]) + [blur, lineNumber]); useEffect(() => { - tutorialOpenRef.current = tutorialOpen - }, [tutorialOpen]) + tutorialOpenRef.current = tutorialOpen; + }, [tutorialOpen]); useEffect(() => { - graphNameRef.current = graphName - }, [graphName]) + graphNameRef.current = graphName; + }, [graphName]); useEffect(() => { - queryRef.current = historyQuery.query - }, [historyQuery.query]) + queryRef.current = historyQuery.query; + }, [historyQuery.query]); useEffect(() => { - indicatorRef.current = indicator - }, [indicator]) + indicatorRef.current = indicator; + }, [indicator]); useEffect(() => { if (historyQuery.query && placeholderRef.current) { - placeholderRef.current.style.display = "none" + placeholderRef.current.style.display = "none"; } else if (!historyQuery.query && placeholderRef.current && blur) { - placeholderRef.current.style.display = "block" + placeholderRef.current.style.display = "block"; } - }, [historyQuery.query]) + }, [historyQuery.query]); useEffect(() => { - graphIdRef.current = graph.Id - }, [graph.Id]) + graphIdRef.current = graph.Id; + }, [graph.Id]); useEffect(() => () => { - sugDisposed?.dispose() - }, [sugDisposed]) + sugDisposed?.dispose(); + }, [sugDisposed]); useEffect(() => { - if (!containerRef.current) return + if (!containerRef.current) return; const handleResize = () => { - editorRef.current?.layout() - } + editorRef.current?.layout(); + }; - window.addEventListener("resize", handleResize) + window.addEventListener("resize", handleResize); - const observer = new ResizeObserver(handleResize) + const observer = new ResizeObserver(handleResize); - observer.observe(containerRef.current) + observer.observe(containerRef.current); return () => { - window.removeEventListener("resize", handleResize) - observer.disconnect() - } - }, [containerRef.current]) + window.removeEventListener("resize", handleResize); + observer.disconnect(); + }; + }, [containerRef.current]); useEffect(() => { - setLineNumber(historyQuery.query.split("\n").length) - }, [historyQuery.query]) + setLineNumber(historyQuery.query.split("\n").length); + }, [historyQuery.query]); const fetchSuggestions = async (detail: string): Promise => { - if (indicator === "offline") return [] + if (indicator === "offline") return []; const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}`, { method: 'GET', - }, toast, setIndicator) + }, toast, setIndicator); - if (!result) return [] + if (!result) return []; - const json = await result.json() + const json = await result.json(); - if (json.result.data.length === 0) return [] + if (json.result.data.length === 0) return []; return json.result.data.map(({ info }: { info: string }) => ({ insertTextRules: detail === '(function)' ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined, @@ -340,15 +340,15 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi })(), range: new monaco.Range(1, 1, 1, 1), detail - })) - } + })); + }; const getSuggestions = async () => (await Promise.all([ fetchSuggestions('(function)'), fetchSuggestions('(property key)'), fetchSuggestions('(label)'), fetchSuggestions('(relationship type)') - ])).flat() + ])).flat(); const addSuggestions = async (monacoI: Monaco) => { const sug = [ @@ -356,17 +356,17 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi ...(graphIdRef.current ? await getSuggestions() : []) ]; - const functions = sug.filter(({ detail }) => detail === "(function)") + const functions = sug.filter(({ detail }) => detail === "(function)"); const namespaces = new Set( functions .filter(({ label }) => (label as string).includes(".")) .map(({ label }) => { - const newNamespaces = (label as string).split(".") - newNamespaces.pop() - return newNamespaces + const newNamespaces = (label as string).split("."); + newNamespaces.pop(); + return newNamespaces; }).flat() - ) + ); monacoI.languages.setMonarchTokensProvider('custom-language', { tokenizer: { @@ -376,10 +376,10 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi [ new RegExp(`\\b(${functions.map(({ label }) => { if ((label as string).includes(".")) { - const labels = (label as string).split(".") - return labels[labels.length - 1] + const labels = (label as string).split("."); + return labels[labels.length - 1]; } - return label + return label; }).join('|')})\\b`), "function" ], @@ -411,25 +411,25 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi ], }, ignoreCase: true, - }) + }); - return sug - } + return sug; + }; useEffect(() => { if (monacoEditor) { - addSuggestions(monacoEditor) + addSuggestions(monacoEditor); } - }, [monacoEditor, graphIdRef.current]) + }, [monacoEditor, graphIdRef.current]); const handleSubmit = async () => { - runQuery(historyQuery.query.trim()) - } + runQuery(historyQuery.query.trim()); + }; const handleEditorWillMount = async (monacoI: Monaco) => { - setMonacoEditor(monacoI) + setMonacoEditor(monacoI); - monacoI.languages.register({ id: "custom-language" }) + monacoI.languages.register({ id: "custom-language" }); monacoI.languages.setMonarchTokensProvider('custom-language', { tokenizer: { @@ -454,9 +454,9 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi ], }, ignoreCase: true, - }) + }); - setTheme(monacoI, "editor-theme", background, currentTheme === "dark") + setTheme(monacoI, "editor-theme", background, currentTheme === "dark"); monacoI.languages.setLanguageConfiguration('custom-language', { brackets: [ @@ -480,20 +480,20 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi ] }); - addSuggestions(monacoI) + addSuggestions(monacoI); const provider = monacoI.languages.registerCompletionItemProvider("custom-language", { provideCompletionItems: async (model, position) => { - const word = model.getWordUntilPosition(position) - const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) + const word = model.getWordUntilPosition(position); + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); return { suggestions: (await addSuggestions(monacoI)).map(s => ({ ...s, range })) - } + }; }, - }) + }); - setSugDisposed(provider) - } + setSugDisposed(provider); + }; const handleEditorDidMount = (e: monaco.editor.IStandaloneCodeEditor) => { const updatePlaceholderVisibility = () => { @@ -508,13 +508,13 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi placeholderRef.current.style.display = 'none'; } - setBlur(false) + setBlur(false); }); e.onDidBlurEditorText(() => { updatePlaceholderVisibility(); - setBlur(true) + setBlur(true); }); updatePlaceholderVisibility(); @@ -537,7 +537,7 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi const textarea = domNode.querySelector('textarea'); if (textarea) (textarea as HTMLTextAreaElement).blur(); } - }) + }); // eslint-disable-next-line no-bitwise e.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { @@ -546,7 +546,7 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi // eslint-disable-next-line no-bitwise e.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { - if (indicatorRef.current === "offline" || !queryRef.current || !graphNameRef.current || tutorialOpenRef.current) return + if (indicatorRef.current === "offline" || !queryRef.current || !graphNameRef.current || tutorialOpenRef.current) return; submitQuery.current?.click(); }); @@ -557,8 +557,8 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi keybindings: [monaco.KeyCode.Enter], contextMenuOrder: 1.5, run: async () => { - if (indicatorRef.current === "offline" || !queryRef.current || !graphNameRef.current || tutorialOpenRef.current) return - submitQuery.current?.click() + if (indicatorRef.current === "offline" || !queryRef.current || !graphNameRef.current || tutorialOpenRef.current) return; + submitQuery.current?.click(); }, precondition: '!suggestWidgetVisible', }); @@ -582,8 +582,8 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi return { ...prev, counter - } - }) + }; + }); }, precondition: 'isFirstLine && !suggestWidgetVisible', }); @@ -595,21 +595,21 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi contextMenuOrder: 1.5, run: async () => { setHistoryQuery(prev => { - if (prev.queries.length === 0) return prev + if (prev.queries.length === 0) return prev; - let counter + let counter; if (prev.counter) { - counter = prev.counter + 1 > prev.queries.length ? 0 : prev.counter + 1 + counter = prev.counter + 1 > prev.queries.length ? 0 : prev.counter + 1; } else { - counter = 0 + counter = 0; } return { ...prev, counter - } - }) + }; + }); }, precondition: 'isLastLine && !suggestWidgetVisible', }); @@ -617,13 +617,13 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi // Override the default Ctrl + F keybinding // eslint-disable-next-line no-bitwise e.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => { }); - } + }; const getLabel = () => { - if (!graphName) return "Select a graph first" - if (!historyQuery.query) return "You need to type a query first" - return "Press Enter to run the query" - } + if (!graphName) return "Select a graph first"; + if (!historyQuery.query) return "You need to type a query first"; + return "Press Enter to run the query"; + }; return (
@@ -648,19 +648,19 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi text: val || "", }, query: val || "", - })) + })); } else { setHistoryQuery(prev => ({ ...prev, query: val || "", - })) + })); } }} theme="editor-theme" beforeMount={handleEditorWillMount} onMount={(e) => { - handleEditorDidMount(e) - editorRef.current = e + handleEditorDidMount(e); + editorRef.current = e; }} /> @@ -677,8 +677,8 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi setHistoryQuery(prev => ({ ...prev, query: "", - })) - editorRef.current?.focus() + })); + editorRef.current?.focus(); }} > @@ -720,8 +720,8 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi setHistoryQuery(prev => ({ ...prev, query: "", - })) - dialogEditorRef.current?.focus() + })); + dialogEditorRef.current?.focus(); }} > @@ -744,8 +744,8 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi key={`${editorKey}-${currentTheme}`} className="w-full h-full" onMount={(e) => { - handleEditorDidMount(e) - dialogEditorRef.current = e + handleEditorDidMount(e); + dialogEditorRef.current = e; }} theme="editor-theme" options={{ @@ -764,7 +764,7 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi setHistoryQuery(prev => ({ ...prev, query: val || "" - })) + })); } else { setHistoryQuery(prev => ({ ...prev, @@ -773,7 +773,7 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi ...prev.currentQuery, text: val || "", }, - })) + })); } }} language="custom-language" @@ -782,5 +782,5 @@ export default function EditorComponent({ graph, graphName, historyQuery, maximi
- ) + ); } diff --git a/app/components/ExportGraph.tsx b/app/components/ExportGraph.tsx index 607c3b9b..18f3e934 100644 --- a/app/components/ExportGraph.tsx +++ b/app/components/ExportGraph.tsx @@ -1,10 +1,10 @@ -import React, { useContext, useEffect, useState } from "react" -import { prepareArg, securedFetch } from "@/lib/utils" -import { useToast } from "@/components/ui/use-toast" -import DialogComponent from "./DialogComponent" -import Button from "./ui/Button" -import CloseDialog from "./CloseDialog" -import { IndicatorContext } from "./provider" +import React, { useContext, useEffect, useState } from "react"; +import { prepareArg, securedFetch } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; +import DialogComponent from "./DialogComponent"; +import Button from "./ui/Button"; +import CloseDialog from "./CloseDialog"; +import { IndicatorContext } from "./provider"; interface Props { selectedValues: string[] @@ -13,51 +13,51 @@ interface Props { export default function ExportGraph({ selectedValues, type }: Props) { - const [open, setOpen] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() - const { indicator, setIndicator } = useContext(IndicatorContext) + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const { indicator, setIndicator } = useContext(IndicatorContext); useEffect(() => { if (!open) { - setIsLoading(false) + setIsLoading(false); } - }, [open]) + }, [open]); const handleExport = async () => { try { - setIsLoading(true) + setIsLoading(true); await Promise.all(selectedValues.map(async value => { - const name = `${value}${type === "Schema" ? "_schema" : ""}` + const name = `${value}${type === "Schema" ? "_schema" : ""}`; const result = await securedFetch(`api/graph/${prepareArg(name)}/export`, { method: "GET" - }, toast, setIndicator) + }, toast, setIndicator); - if (!result.ok) return + if (!result.ok) return; - const blob = await result.blob() - const url = window.URL.createObjectURL(blob) + const blob = await result.blob(); + const url = window.URL.createObjectURL(blob); try { - const link = document.createElement('a') - link.href = url - link.setAttribute('download', `${name}.dump`) - document.body.appendChild(link) - link.click() - link.parentNode?.removeChild(link) - window.URL.revokeObjectURL(url) - setOpen(false) + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${name}.dump`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + setOpen(false); } catch (e) { toast({ title: "Error", description: "Error while exporting data", variant: "destructive" - }) + }); } - })) + })); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return ( @@ -94,5 +94,5 @@ export default function ExportGraph({ selectedValues, type }: Props) { /> - ) + ); } \ No newline at end of file diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx index 767971cb..10cf78ed 100644 --- a/app/components/ForceGraph.tsx +++ b/app/components/ForceGraph.tsx @@ -1,343 +1,114 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react/require-default-props */ /* eslint-disable no-param-reassign */ -"use client" +"use client"; -import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" -import ForceGraph2D from "react-force-graph-2d" -import { securedFetch, GraphRef, handleZoomToFit, getTheme, Tab, ViewportState, getNodeDisplayText, getContrastTextColor } from "@/lib/utils" -import { useToast } from "@/components/ui/use-toast" -import * as d3 from "d3" -import { useTheme } from "next-themes" -import { Link, Node, Relationship, Graph, getLabelWithFewestElements, GraphData, EMPTY_DISPLAY_NAME } from "../api/graph/model" -import { BrowserSettingsContext, IndicatorContext } from "./provider" -import Spinning from "./ui/spinning" +import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import type { Data, GraphLink, GraphNode, GraphData as CanvasData, ViewportState } from "@falkordb/canvas"; +import { securedFetch, getTheme, GraphRef } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; +import { Link, Node, Relationship, Graph, GraphData } from "../api/graph/model"; +import { IndicatorContext } from "./provider"; interface Props { graph: Graph data: GraphData setData: Dispatch> - chartRef: GraphRef + graphData: CanvasData | undefined + setGraphData: Dispatch> + canvasRef: GraphRef selectedElements: (Node | Link)[] setSelectedElements: (el?: (Node | Link)[]) => void - type?: "schema" | "graph" setRelationships: Dispatch> - parentHeight: number - parentWidth: number - setParentHeight: Dispatch> - setParentWidth: Dispatch> isLoading: boolean - handleCooldown: (ticks?: 0, isSetLoading?: boolean) => void + setIsLoading: (loading: boolean) => void cooldownTicks: number | undefined - currentTab?: Tab + type?: "schema" | "graph" + handleCooldown: (ticks?: 0) => void viewport?: ViewportState setViewport?: Dispatch> - isSaved?: boolean } -const NODE_SIZE = 6 -const PADDING = 2; - -/** - * Wraps text into two lines with ellipsis handling for circular nodes - * @param ctx Canvas context for text measurement - * @param text The text to wrap - * @param maxRadius Maximum radius of the circular node for text fitting - * @returns Tuple of [line1, line2] with proper ellipsis handling - */ -const wrapTextForCircularNode = (ctx: CanvasRenderingContext2D, text: string, maxRadius: number): [string, string] => { - const ellipsis = '...'; - const ellipsisWidth = ctx.measureText(ellipsis).width; - - // Use fixed text height - it's essentially constant for a given font - const halfTextHeight = 1.125; // Fixed value based on font size (1.5px * 1.5 spacing / 2) - - - const availableRadius = Math.sqrt(Math.max(0, maxRadius * maxRadius - halfTextHeight * halfTextHeight)); - - const lineWidth = availableRadius * 2; - - const words = text.split(/\s+/); - let line1 = ''; - let line2 = ''; - - // Build first line - try to fit as many words as possible - for (let i = 0; i < words.length; i += 1) { - const word = words[i]; - const testLine = line1 ? `${line1} ${word}` : word; - const testWidth = ctx.measureText(testLine).width; - - if (testWidth <= lineWidth) { - line1 = testLine; - } else if (!line1) { - // If first word is too long, break it in the middle - let partialWord = word; - while (partialWord.length > 0 && ctx.measureText(partialWord).width > lineWidth) { - partialWord = partialWord.slice(0, -1); - } - line1 = partialWord; - // Put remaining part of word and other words in line2 - const remainingWords = [word.slice(partialWord.length), ...words.slice(i + 1)]; - line2 = remainingWords.join(' '); - break; - } else { - // Put remaining words in line2 - line2 = words.slice(i).join(' '); - break; - } - } - - // Truncate line2 if needed - if (line2 && ctx.measureText(line2).width > lineWidth) { - while (line2.length > 0 && ctx.measureText(line2).width + ellipsisWidth > lineWidth) { - line2 = line2.slice(0, -1); - } - line2 += ellipsis; - } - - return [line1, line2 || '']; -}; - -const LINK_DISTANCE = 50; -const MAX_LINK_DISTANCE = 80; // Maximum distance only when clusters would overlap -const LINK_STRENGTH = 0.5; -const MIN_LINK_STRENGTH = 0.3; // Minimum strength for very high-degree nodes -const COLLISION_STRENGTH = 1.35; -const CHARGE_STRENGTH = -5; // Stronger repulsion to maintain circular arrangement -const CENTER_STRENGTH = 0.4; -const COLLISION_BASE_RADIUS = NODE_SIZE * 2; -const HIGH_DEGREE_PADDING = 1.25; -const DEGREE_STRENGTH_DECAY = 15; // Degree at which strength starts significantly decreasing -const CROWDING_THRESHOLD = 20; // Degree threshold where we start adding distance to prevent overlap - -const getEndpointId = (endpoint: Node | number | string | undefined): Node["id"] | undefined => { - if (endpoint === undefined || endpoint === null) return undefined; - if (typeof endpoint === "object") return endpoint.id; - if (typeof endpoint === "number") return endpoint; - - const parsed = Number(endpoint); - return Number.isNaN(parsed) ? undefined : parsed; -}; +const convertToCanvasData = (graphData: GraphData): Data => ({ + nodes: graphData.nodes.map(({ id, labels, color, visible, data }) => ({ + id, + labels, + color, + visible, + data + })), + links: graphData.links.map(({ id, relationship, color, visible, source, target, data }) => ({ + id, + relationship, + color, + visible, + source, + target, + data + })) +}); export default function ForceGraph({ graph, data, setData, - chartRef, + graphData, + setGraphData, + canvasRef, selectedElements, setSelectedElements, - type = "graph", setRelationships, - parentHeight, - parentWidth, - setParentHeight, - setParentWidth, isLoading, - handleCooldown, + setIsLoading, cooldownTicks, - currentTab = "Graph", - viewport, - setViewport, - isSaved + handleCooldown, + type = "graph", + viewport = undefined, + setViewport = undefined, }: Props) { - const { indicator, setIndicator } = useContext(IndicatorContext) - const { settings: { graphInfo: { displayTextPriority } } } = useContext(BrowserSettingsContext) - - const { theme } = useTheme() - const { toast } = useToast() - const { background, foreground } = getTheme(theme) - - const lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) - const parentRef = useRef(null) + const { setIndicator } = useContext(IndicatorContext); - const [hoverElement, setHoverElement] = useState() + const { theme } = useTheme(); + const { toast } = useToast(); + const { background, foreground } = getTheme(theme); - const nodeDegreeMap = useMemo(() => { - const degree = new Map(); + const lastClick = useRef<{ date: Date, id: number }>({ date: new Date(), id: -1 }); - data.nodes.forEach(node => degree.set(node.id, 0)); - - data.links.forEach(link => { - const sourceId = getEndpointId(link.source as Node | number | string); - const targetId = getEndpointId(link.target as Node | number | string); - - if (sourceId !== undefined) { - degree.set(sourceId, (degree.get(sourceId) || 0) + 1); - } - if (targetId !== undefined) { - degree.set(targetId, (degree.get(targetId) || 0) + 1); - } - }); - - return degree; - }, [data.links, data.nodes]); + const [hoverElement, setHoverElement] = useState(); + const [canvasLoaded, setCanvasLoaded] = useState(false); + // Load falkordb-canvas web component on client side only useEffect(() => { - setData({ ...graph.Elements }) - }, [graph, setData]) + import('@falkordb/canvas').then(() => { + setCanvasLoaded(true); + }); + }, []); // Load saved viewport on mount useEffect(() => { - if (isSaved && viewport) { - const { zoom, centerX, centerY } = viewport; - setTimeout(() => { - if (chartRef.current) { - chartRef.current.zoom(zoom, 0); - chartRef.current.centerAt(centerX, centerY, 0); - } - }, 100); - } else if (currentTab === "Graph" && graph.Elements.nodes.length > 0) { - handleCooldown() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chartRef, graph.Id, currentTab, graph.Elements.nodes.length, isSaved]) + if (!viewport || !canvasRef.current || !canvasLoaded) return; + + canvasRef.current.setViewport(viewport); + }, [canvasRef, viewport, canvasLoaded]); // Save viewport on unmount useEffect(() => { - const chart = chartRef.current; + const canvas = canvasRef.current; return () => { - if (chart && setViewport) { - const zoom = chart.zoom(); - const centerPos = chart.centerAt(); - - if (centerPos) { - setViewport({ - zoom, - centerX: centerPos.x, - centerY: centerPos.y, - }); + if (canvas && setViewport && canvasLoaded) { + const savedData = canvas.getGraphData(); + if (savedData.nodes.length !== 0) { + setViewport(canvas.getViewport()); + setGraphData(savedData); } } }; - }, [chartRef, graph.Id, setViewport]) - - useEffect(() => { - if (!parentRef.current) return; - - const canvas = parentRef.current.querySelector('canvas') as HTMLCanvasElement; - - if (!canvas) return; - - canvas.setAttribute('data-engine-status', 'stop'); - }, []) - - useEffect(() => { - const handleResize = () => { - if (!parentRef.current) return - setParentWidth(parentRef.current.clientWidth) - setParentHeight(parentRef.current.clientHeight) - } - - window.addEventListener('resize', handleResize) - - const observer = new ResizeObserver(handleResize) - - if (parentRef.current) { - observer.observe(parentRef.current) - } - - return () => { - window.removeEventListener('resize', handleResize) - observer.disconnect() - } - }, [parentRef, setParentHeight, setParentWidth]) - - useEffect(() => { - if (!chartRef.current) return; - - const linkForce = chartRef.current.d3Force('link'); - - if (linkForce) { - linkForce - .distance((link: Link) => { - const sourceId = getEndpointId(link.source as Node | number | string); - const targetId = getEndpointId(link.target as Node | number | string); - const sourceDegree = sourceId !== undefined ? (nodeDegreeMap.get(sourceId) || 0) : 0; - const targetDegree = targetId !== undefined ? (nodeDegreeMap.get(targetId) || 0) : 0; - const maxDegree = Math.max(sourceDegree, targetDegree); - - // Use regular link distance for all links - // Only increase distance when degree is very high to prevent cluster overlap - if (maxDegree >= CROWDING_THRESHOLD) { - // Gradually increase distance for very high-degree nodes to prevent crowding - const extraDistance = Math.min(MAX_LINK_DISTANCE - LINK_DISTANCE, (maxDegree - CROWDING_THRESHOLD) * 1.5); - return LINK_DISTANCE + extraDistance; - } - - // For normal links and moderate high-degree links, use base distance - return LINK_DISTANCE; - }) - .strength((link: Link) => { - const sourceId = getEndpointId(link.source as Node | number | string); - const targetId = getEndpointId(link.target as Node | number | string); - const sourceDegree = sourceId !== undefined ? (nodeDegreeMap.get(sourceId) || 0) : 0; - const targetDegree = targetId !== undefined ? (nodeDegreeMap.get(targetId) || 0) : 0; - - // Use the maximum degree of the two endpoints - const maxDegree = Math.max(sourceDegree, targetDegree); - - // Gradually reduce link strength as degree increases - // This allows high-degree nodes to still pull, but not as aggressively - if (maxDegree <= DEGREE_STRENGTH_DECAY) { - return LINK_STRENGTH; - } - - // Scale strength down gradually: strength decreases as degree increases - // Formula: MIN + (BASE - MIN) * exp(-(degree - threshold) / threshold) - const strengthReduction = Math.max(0, (maxDegree - DEGREE_STRENGTH_DECAY) / DEGREE_STRENGTH_DECAY); - const scaledStrength = MIN_LINK_STRENGTH + (LINK_STRENGTH - MIN_LINK_STRENGTH) * Math.exp(-strengthReduction); - - return Math.max(MIN_LINK_STRENGTH, scaledStrength); - }); - } - - // Add collision force to prevent node overlap (scale radius by node degree and custom size) - chartRef.current.d3Force('collision', d3.forceCollide((node: Node) => { - const degree = nodeDegreeMap.get(node.id) || 0; - const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0])); - const customSize = label.style?.customSize || 1; - return (COLLISION_BASE_RADIUS * customSize) + Math.sqrt(degree) * HIGH_DEGREE_PADDING; - }).strength(COLLISION_STRENGTH).iterations(2)); - - // Center force to keep graph centered - const centerForce = chartRef.current.d3Force('center'); - - if (centerForce) { - centerForce.strength(CENTER_STRENGTH); - } - - // Add charge force to repel nodes - const chargeForce = chartRef.current.d3Force('charge'); - - if (chargeForce) { - chargeForce - .strength(CHARGE_STRENGTH) - .distanceMax(300); // Increased to help maintain circular arrangement - } - - // Reheat the simulation - chartRef.current.d3ReheatSimulation(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chartRef, graph.Elements.links.length, graph.Elements.nodes.length, graph, nodeDegreeMap]) - - // Clear cached display names when displayTextPriority changes - useEffect(() => { - data.nodes.forEach(node => { - // eslint-disable-next-line no-param-reassign - node.displayName = [...EMPTY_DISPLAY_NAME]; - }); - // Force a re-render by reheating the simulation - if (chartRef.current) { - chartRef.current.d3ReheatSimulation(); - } - }, [displayTextPriority, chartRef, data.nodes]); + }, [canvasRef, graph.Id, setGraphData, setViewport, canvasLoaded]); - const handleGetNodeDisplayText = useCallback((node: Node) => getNodeDisplayText(node, displayTextPriority), [displayTextPriority]) - - const onFetchNode = async (node: Node) => { + const onFetchNode = useCallback(async (node: Node) => { const result = await securedFetch(`/api/${type}/${graph.Id}/${node.id}`, { method: 'GET', headers: { @@ -346,349 +117,202 @@ export default function ForceGraph({ }, toast, setIndicator); if (result.ok) { - const json = await result.json() - const elements = graph.extend(json.result, true) + const json = await result.json(); + const elements = graph.extend(json.result, true); if (elements.length === 0) { toast({ title: `No neighbors found`, description: `No neighbors found`, - }) + }); + } else { + setData({ ...graph.Elements }); } } - } + }, [type, graph, toast, setIndicator, setData]); - const deleteNeighbors = (nodes: Node[]) => { + const deleteNeighbors = useCallback((nodes: Node[]) => { if (nodes.length === 0) return; - const expandedNodes: Node[] = [] + const expandedNodes: Node[] = []; graph.Elements = { nodes: graph.Elements.nodes.filter(node => { - if (!node.collapsed) return true + if (!node.collapsed) return true; - const isTarget = graph.Elements.links.some(link => link.target.id === node.id && nodes.some(n => n.id === link.source.id)); + const isTarget = graph.Elements.links.some(link => { + const targetId = link.target; + const sourceId = link.source; + return targetId === node.id && nodes.some(n => n.id === sourceId); + }); - if (!isTarget) return true + if (!isTarget) return true; - const deleted = graph.NodesMap.delete(Number(node.id)) + const deleted = graph.NodesMap.delete(Number(node.id)); if (deleted && node.expand) { - expandedNodes.push(node) + expandedNodes.push(node); } - return false + return false; }), links: graph.Elements.links - } + }; - deleteNeighbors(expandedNodes) + deleteNeighbors(expandedNodes); - setRelationships(graph.removeLinks(nodes.map(n => n.id))) - } + setRelationships(graph.removeLinks(nodes.map(n => n.id))); + setData({ ...graph.Elements }); + }, [graph, setRelationships, setData]); - const handleNodeClick = async (node: Node) => { - const now = new Date() - const { date, name } = lastClick.current - lastClick.current = { date: now, name: handleGetNodeDisplayText(node) } + const handleNodeClick = useCallback(async (node: GraphNode) => { + const fullNode = graph.NodesMap.get(node.id); + if (!fullNode) return; - if (now.getTime() - date.getTime() < 1000 && name === handleGetNodeDisplayText(node)) { - if (!node.expand) { - await onFetchNode(node) + const now = new Date(); + const { date, id: name } = lastClick.current; + lastClick.current = { date: now, id: node.id }; + + if (now.getTime() - date.getTime() < 1000 && name === node.id) { + if (!fullNode.expand) { + await onFetchNode(fullNode); } else { - deleteNeighbors([node]) + deleteNeighbors([fullNode]); } - node.expand = !node.expand - setData({ ...graph.Elements }) - handleCooldown(undefined, false) + fullNode.expand = !fullNode.expand; + setData({ ...graph.Elements }); + } + }, [graph, onFetchNode, deleteNeighbors, setData]); + + const handleHover = useCallback((element: GraphNode | GraphLink | null) => { + if (element === null) { + setHoverElement(undefined); + return; + } + + // Find the full element from the graph + if ('source' in element) { + const fullLink = graph.LinksMap.get(element.id); + if (fullLink) setHoverElement(fullLink); + } else { + const fullNode = graph.NodesMap.get(element.id); + if (fullNode) setHoverElement(fullNode); + } + }, [graph]); + + const handleRightClick = useCallback((element: GraphNode | GraphLink, evt: MouseEvent) => { + // Find the full element from the graph + let fullElement: Node | Link | undefined; + if ('source' in element) { + fullElement = graph.LinksMap.get(element.id); + } else { + fullElement = graph.NodesMap.get(element.id); } - } - const handleHover = (element: Node | Link | null) => { - setHoverElement(element === null ? undefined : element) - } + if (!fullElement) return; - const handleRightClick = (element: Node | Link, evt: MouseEvent) => { if (evt.ctrlKey) { - if (selectedElements.includes(element)) { - setSelectedElements(selectedElements.filter((el) => el !== element)) + if (selectedElements.find(e => (("source" in e && "source" in fullElement) || (!("source" in e) && !("source" in fullElement))) && e.id === fullElement.id)) { + setSelectedElements(selectedElements.filter((el) => el !== fullElement)); } else { - setSelectedElements([...selectedElements, element]) + setSelectedElements([...selectedElements, fullElement]); } } else { - setSelectedElements([element]) + setSelectedElements([fullElement]); } - } + }, [graph, selectedElements, setSelectedElements]); + + const handleUnselected = useCallback((evt?: MouseEvent) => { + if (evt?.ctrlKey || selectedElements.length === 0) return; + setSelectedElements([]); + }, [selectedElements, setSelectedElements]); + + const checkIsNodeSelected = useCallback((node: GraphNode) => + selectedElements.some(el => el.id === node.id && !('source' in el)) || + (!!hoverElement && !('source' in hoverElement) && hoverElement.id === node.id) + , [selectedElements, hoverElement]); + + const checkIsLinkSelected = useCallback((link: GraphLink) => + selectedElements.some(el => el.id === link.id && 'source' in el) || + (!!hoverElement && 'source' in hoverElement && hoverElement.id === link.id) + , [selectedElements, hoverElement]); - const handleUnselected = (evt?: MouseEvent) => { - if (evt?.ctrlKey || selectedElements.length === 0) return - setSelectedElements([]) - } + const handleEngineStop = useCallback(() => { + const canvas = canvasRef.current; - const isLinkSelected = (link: Link) => (selectedElements.length > 0 && selectedElements.some(el => el.id === link.id && el.source)) - || (hoverElement && hoverElement.source && hoverElement.id === link.id) + if (!canvas) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)[type] = () => canvas.getGraphData(); + + if (cooldownTicks === 0) return; + + handleCooldown(0); + }, [canvasRef, cooldownTicks, handleCooldown, type]); + + const handleLoadingChange = useCallback((loading: boolean) => { + setIsLoading(loading); + }, [setIsLoading]); + + // Update colors + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return; + canvasRef.current.setBackgroundColor(background); + }, [canvasRef, background, canvasLoaded]); + + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return; + canvasRef.current.setForegroundColor(foreground); + }, [canvasRef, foreground, canvasLoaded]); + + // Update loading state + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return; + canvasRef.current.setIsLoading(isLoading); + }, [canvasRef, isLoading, canvasLoaded]); + + // Update cooldown ticks + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return; + canvasRef.current.setCooldownTicks(cooldownTicks); + }, [canvasRef, cooldownTicks, canvasLoaded]); + + // Update event handlers and selection functions + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return; + canvasRef.current.setConfig({ + onNodeClick: handleNodeClick, + onNodeRightClick: handleRightClick, + onLinkRightClick: handleRightClick, + onNodeHover: handleHover, + onLinkHover: handleHover, + onBackgroundClick: handleUnselected, + isNodeSelected: checkIsNodeSelected, + isLinkSelected: checkIsLinkSelected, + onEngineStop: handleEngineStop, + onLoadingChange: handleLoadingChange + }); + }, [handleNodeClick, handleRightClick, handleHover, handleUnselected, checkIsNodeSelected, checkIsLinkSelected, handleEngineStop, handleLoadingChange, canvasRef, canvasLoaded]); + + // Update canvas data + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || !canvasLoaded) return; + + if (graphData) { + canvas.setGraphData(graphData); + setGraphData(undefined); + } else { + const canvasData = convertToCanvasData(data); + + canvas.setData(canvasData); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canvasRef, data, setGraphData, canvasLoaded]); return ( -
- { - isLoading && -
- -
- } - link.relationship} - nodeLabel={(node) => type === "graph" ? handleGetNodeDisplayText(node) : node.labels[0]} - graphData={data} - nodeRelSize={NODE_SIZE} - nodeVal={(node) => { - // Return the square of customSize because library uses Math.sqrt(nodeVal) - const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0])); - const customSize = label.style?.customSize || 1; - return customSize * customSize; // Squared because library will sqrt it - }} - nodeCanvasObjectMode={() => 'replace'} - linkCanvasObjectMode={() => 'after'} - linkDirectionalArrowLength={(link) => { - let length = 0; - - if (link.source !== link.target) { - length = isLinkSelected(link) ? 4 : 2 - } - - return length; - }} - linkDirectionalArrowRelPos={1} - linkDirectionalArrowColor={(link) => link.color} - linkWidth={(link) => isLinkSelected(link) ? 2 : 1} - nodeCanvasObject={(node, ctx) => { - - if (!node.x || !node.y) { - node.x = 0 - node.y = 0 - } - - // Get label style customization - const label = getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l) || graph.createLabel([l])[0])); - const customSize = label.style?.customSize || 1; - const nodeSize = NODE_SIZE * customSize; - - // Draw the node circle with custom color and size - ctx.fillStyle = node.color; - ctx.beginPath(); - ctx.arc(node.x, node.y, nodeSize, 0, 2 * Math.PI, false); - ctx.fill(); - - // Draw the border - ctx.lineWidth = ((selectedElements.length > 0 && selectedElements.some(el => el.id === node.id && !el.source))) - || (hoverElement && !hoverElement.source && hoverElement.id === node.id) - ? 1.5 : 0.5 - ctx.strokeStyle = foreground; - ctx.stroke(); - - // Set text color based on node background color for better contrast - ctx.fillStyle = getContrastTextColor(node.color); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = `400 2px SofiaSans`; - ctx.letterSpacing = '0.1px' - - let [line1, line2] = node.displayName; - - // If displayName is empty or invalid, generate new text wrapping - if (!line1 && !line2) { - let text = ''; - - if (type === "graph") { - // Check if label has custom caption property - const customCaption = label.style?.customCaption; - if (customCaption) { - if (customCaption === "Description") { - text = handleGetNodeDisplayText(node); - } else if (customCaption === "id") { - text = String(node.id); - } else if (node.data[customCaption]) { - text = String(node.data[customCaption]); - } else { - text = handleGetNodeDisplayText(node); - } - } else { - text = handleGetNodeDisplayText(node); - } - } else { - text = label.name; - } - - // Calculate text wrapping for circular node - const textRadius = nodeSize - PADDING / 2; // Leave some padding inside the circle - [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius); - - // Cache the result - node.displayName = [line1, line2]; - } - - const textMetrics = ctx.measureText(line1); - const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; - const halfTextHeight = textHeight / 2 * 1.5; - - // Draw the text lines - if (line1) { - ctx.fillText(line1, node.x, line2 ? node.y - halfTextHeight : node.y); - } - if (line2) { - ctx.fillText(line2, node.x, node.y + halfTextHeight); - } - }} - linkCanvasObject={(link, ctx) => { - const start = link.source; - const end = link.target; - - if (!start.x || !start.y || !end.x || !end.y) { - start.x = 0 - start.y = 0 - end.x = 0 - end.y = 0 - } - - let textX; - let textY; - let angle; - - if (start.id === end.id) { - const radius = NODE_SIZE * link.curve * 6.2; - const angleOffset = -Math.PI / 4; // 45 degrees offset for text alignment - textX = start.x + radius * Math.cos(angleOffset); - textY = start.y + radius * Math.sin(angleOffset); - angle = -angleOffset; - } else { - // Calculate the control point for the quadratic Bézier curve - const dx = end.x - start.x; - const dy = end.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Calculate perpendicular vector for curve offset - const perpX = dy / distance; - const perpY = -dx / distance; - - // Control point with larger offset to match the actual curve - const curvature = link.curve || 0; - const controlX = (start.x + end.x) / 2 + perpX * curvature * distance * 1.0; - const controlY = (start.y + end.y) / 2 + perpY * curvature * distance * 1.0; - - // Calculate point on Bézier curve at t = 0.5 (midpoint) - const t = 0.5; - const oneMinusT = 1 - t; - textX = oneMinusT * oneMinusT * start.x + 2 * oneMinusT * t * controlX + t * t * end.x; - textY = oneMinusT * oneMinusT * start.y + 2 * oneMinusT * t * controlY + t * t * end.y; - - // Calculate tangent angle at t = 0.5 - const tangentX = 2 * oneMinusT * (controlX - start.x) + 2 * t * (end.x - controlX); - const tangentY = 2 * oneMinusT * (controlY - start.y) + 2 * t * (end.y - controlY); - angle = Math.atan2(tangentY, tangentX); - - // maintain label vertical orientation for legibility - if (angle > Math.PI / 2) angle = -(Math.PI - angle); - if (angle < -Math.PI / 2) angle = -(-Math.PI - angle); - } - - // Get text width - ctx.font = '400 2px SofiaSans'; - ctx.letterSpacing = '0.1px' - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - let textWidth; - let textHeight; - let textAscent; - let textDescent; - - const relationship = graph.RelationshipsMap.get(link.relationship) - - if (relationship) { - ({ textWidth, textHeight, textAscent, textDescent } = relationship) - } - - if ( - textWidth === undefined || - textHeight === undefined || - textAscent === undefined || - textDescent === undefined - ) { - const { - width, - actualBoundingBoxAscent, - actualBoundingBoxDescent - } = ctx.measureText(link.relationship) - - textWidth = width - textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent - textAscent = actualBoundingBoxAscent - textDescent = actualBoundingBoxDescent - if (relationship) { - graph.RelationshipsMap.set(link.relationship, { - ...relationship, - textWidth, - textHeight, - textAscent, - textDescent - }) - } - } - - if ( - textWidth === undefined || - textHeight === undefined || - textAscent === undefined || - textDescent === undefined - ) { - return - } - - // Use single save/restore for both background and text - ctx.save(); - ctx.translate(textX, textY); - ctx.rotate(angle); - - // Draw background rectangle (rotated) - ctx.fillStyle = background; - const backgroundWidth = textWidth * 0.7; - const backgroundHeight = textHeight * 0.7; - ctx.fillRect( - -backgroundWidth / 2, - -backgroundHeight / 2, - backgroundWidth, - backgroundHeight - ); - - // Draw text - ctx.fillStyle = foreground; - ctx.textBaseline = 'middle'; - ctx.fillText(link.relationship, 0, 0); - ctx.restore(); - }} - onNodeClick={indicator === "offline" ? undefined : handleNodeClick} - onNodeHover={handleHover} - onLinkHover={handleHover} - onNodeRightClick={handleRightClick} - onLinkRightClick={handleRightClick} - onBackgroundClick={handleUnselected} - onBackgroundRightClick={handleUnselected} - onEngineStop={async () => { - if (cooldownTicks === 0) return - - handleZoomToFit(chartRef, undefined, data.nodes.length < 2 ? 4 : undefined) - setTimeout(() => handleCooldown(0), 1000) - }} - linkCurvature="curve" - nodeVisibility="visible" - linkVisibility="visible" - cooldownTicks={cooldownTicks} - cooldownTime={1000} - backgroundColor={background} - /> -
- ) + + ); } \ No newline at end of file diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index bf0664d0..e69b3e06 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -1,20 +1,20 @@ /* eslint-disable react/no-array-index-key */ /* eslint-disable no-param-reassign */ -"use client" +"use client"; -import { useState } from "react" -import { EyeIcon, EyeOffIcon, InfoIcon } from "lucide-react" -import { cn } from "@/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import Button from "./ui/Button" -import Combobox from "./ui/combobox" -import Input from "./ui/Input" +import { useState } from "react"; +import { EyeIcon, EyeOffIcon, InfoIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import Button from "./ui/Button"; +import Combobox from "./ui/combobox"; +import Input from "./ui/Input"; export type Error = { message: string condition: (value: string, password?: string) => boolean -} +}; export type DefaultField = { value: string @@ -25,26 +25,26 @@ export type DefaultField = { description?: string errors?: Error[] info?: string -} +}; export type SelectField = DefaultField & { type: "select" options: string[] selectType: "Role" onChange: (value: string) => void -} +}; export type PasswordField = DefaultField & { onChange: (e: React.ChangeEvent) => void type: "password" -} +}; export type TextField = DefaultField & { onChange: (e: React.ChangeEvent) => void type: "text" -} +}; -export type Field = SelectField | PasswordField | TextField +export type Field = SelectField | PasswordField | TextField; interface Props { handleSubmit: (e: React.FormEvent) => Promise @@ -64,34 +64,34 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, const [isLoading, setIsLoading] = useState(false); const onHandleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + e.preventDefault(); - const newErrors: { [key: string]: boolean } = {} + const newErrors: { [key: string]: boolean } = {}; fields.forEach(field => { if (field.errors) { - newErrors[field.label] = field.errors.some(err => err.condition(field.value)) + newErrors[field.label] = field.errors.some(err => err.condition(field.value)); } - }) + }); - setErrors(newErrors) + setErrors(newErrors); if (Object.values(newErrors).some(value => value)) { - return + return; } try { - setIsLoading(true) - await handleSubmit(e) + setIsLoading(true); + await handleSubmit(e); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return (
{ fields.map((field) => { - const passwordType = show[field.label] ? "text" : "password" + const passwordType = show[field.label] ? "text" : "password"; return (
@@ -117,7 +117,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, setShow(prev => ({ ...prev, [field.label]: !prev[field.label] - })) + })); }} > { @@ -143,21 +143,21 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, placeholder={field.placeholder} value={field.value} onChange={(e) => { - field.onChange(e) + field.onChange(e); if (field.type === "password") { - const confirmPasswordField = fields.find(f => f.label === "Confirm Password") + const confirmPasswordField = fields.find(f => f.label === "Confirm Password"); if (confirmPasswordField && confirmPasswordField.errors) { setErrors(prev => ({ ...prev, "Confirm Password": confirmPasswordField.errors!.some(err => err.condition(confirmPasswordField.value, e.target.value)) - })) + })); } } if (field.errors) { setErrors(prev => ({ ...prev, [field.label]: field.errors!.some(err => err.condition(e.target.value)) - })) + })); } }} /> } @@ -170,7 +170,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined,
- ) + ); }) } {children} @@ -188,7 +188,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, />
- ) + ); } FormComponent.defaultProps = { @@ -196,4 +196,4 @@ FormComponent.defaultProps = { error: undefined, submitButtonLabel: "Submit", className: "" -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/GithubMark.tsx b/app/components/GithubMark.tsx index 716ec130..fb31407c 100644 --- a/app/components/GithubMark.tsx +++ b/app/components/GithubMark.tsx @@ -1,17 +1,17 @@ -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; export default function GithubMark({darkMode, className=""}: {darkMode: boolean, className: string}) { - const [color, setColor] = useState("#24292f") + const [color, setColor] = useState("#24292f"); // Setting the color post render to avoid warning about mismatched client/server content useEffect(() => { - setColor(darkMode ? "#ffffff" : "#24292f") - }, [darkMode]) + setColor(darkMode ? "#ffffff" : "#24292f"); + }, [darkMode]); return ( - ) + ); } \ No newline at end of file diff --git a/app/components/GoogleAnalytics.tsx b/app/components/GoogleAnalytics.tsx index 955ac32c..ab7b8ca8 100644 --- a/app/components/GoogleAnalytics.tsx +++ b/app/components/GoogleAnalytics.tsx @@ -19,6 +19,6 @@ function GoogleAnalytics({ ga_id }: { ga_id: string }) { `, }} /> - + ; } export default GoogleAnalytics; \ No newline at end of file diff --git a/app/components/Header.tsx b/app/components/Header.tsx index b435a181..09766af7 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/require-default-props */ -'use client' +'use client'; import { ArrowUpRight, Database, FileCode, LogOut, Monitor, Moon, Settings, Sun } from "lucide-react"; import { useCallback, useContext, useState, useEffect } from "react"; @@ -30,54 +30,54 @@ interface Props { } function getPathType(pathname: string): "Schema" | "Graph" | undefined { - if (pathname.includes("/schema")) return "Schema" - if (pathname.includes("/graph")) return "Graph" - return undefined + if (pathname.includes("/schema")) return "Schema"; + if (pathname.includes("/graph")) return "Graph"; + return undefined; } -const iconSize = 30 +const iconSize = 30; export default function Header({ onSetGraphName, graphNames, graphName, onOpenGraphInfo, navigateToSettings }: Props) { - const { indicator } = useContext(IndicatorContext) - const { setPanel } = useContext(PanelContext) - const { hasChanges, saveSettings, resetSettings, settings: { chatSettings: { model, secretKey, displayChat } } } = useContext(BrowserSettingsContext) + const { indicator } = useContext(IndicatorContext); + const { setPanel } = useContext(PanelContext); + const { hasChanges, saveSettings, resetSettings, settings: { chatSettings: { model, secretKey, displayChat } } } = useContext(BrowserSettingsContext); - const { theme, setTheme } = useTheme() - const { currentTheme } = getTheme(theme) - const { data: session } = useSession() - const pathname = usePathname() - const router = useRouter() - const { toast } = useToast() + const { theme, setTheme } = useTheme(); + const { currentTheme } = getTheme(theme); + const { data: session } = useSession(); + const pathname = usePathname(); + const router = useRouter(); + const { toast } = useToast(); - const [mounted, setMounted] = useState(false) + const [mounted, setMounted] = useState(false); - const type = getPathType(pathname) - const showCreate = type && session?.user.role && session.user.role !== "Read-Only" + const type = getPathType(pathname); + const showCreate = type && session?.user.role && session.user.role !== "Read-Only"; useEffect(() => { - setMounted(true) - }, []) + setMounted(true); + }, []); const navigateBack = useCallback(() => { if (hasChanges) { getQuerySettingsNavigationToast(toast, () => { - saveSettings() - router.back() + saveSettings(); + router.back(); }, () => { - resetSettings() - router.back() - }) + resetSettings(); + router.back(); + }); } else { - router.back() + router.back(); } - }, [hasChanges, resetSettings, saveSettings, router, toast]) + }, [hasChanges, resetSettings, saveSettings, router, toast]); const handleSetCurrentPanel = useCallback((newPanel: Panel) => { - setPanel(prev => prev === newPanel ? undefined : newPanel) - }, [setPanel]) + setPanel(prev => prev === newPanel ? undefined : newPanel); + }, [setPanel]); - const separator =
+ const separator =
; return (
@@ -148,14 +148,14 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenGr label="CHAT" onClick={() => { if (navigateToSettings && (!model || !secretKey)) { - router.push("/settings") + router.push("/settings"); toast({ title: "Incomplete Chat Settings", description: "Please complete the chat settings to use the chat feature.", variant: "destructive", - }) + }); } else { - handleSetCurrentPanel("chat") + handleSetCurrentPanel("chat"); } }} /> @@ -249,11 +249,11 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenGr data-testid="themeToggle" title={`Toggle theme current theme: ${theme}`} onClick={() => { - let newTheme = "" - if (theme === "dark") newTheme = "light" - else if (theme === "light") newTheme = "system" - else newTheme = "dark" - setTheme(newTheme) + let newTheme = ""; + if (theme === "dark") newTheme = "light"; + else if (theme === "light") newTheme = "system"; + else newTheme = "dark"; + setTheme(newTheme); }} > {theme === "dark" && } @@ -288,5 +288,5 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenGr
- ) + ); } diff --git a/app/components/PaginationList.tsx b/app/components/PaginationList.tsx index db09ceed..44824914 100644 --- a/app/components/PaginationList.tsx +++ b/app/components/PaginationList.tsx @@ -1,12 +1,12 @@ -import { cn } from "@/lib/utils" -import { Fragment, KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react" -import { Check, Circle, Loader2, X } from "lucide-react" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import Button from "./ui/Button" -import { Query } from "../api/graph/model" -import Input from "./ui/Input" +import { cn } from "@/lib/utils"; +import { Fragment, KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; +import { Check, Circle, Loader2, X } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import Button from "./ui/Button"; +import { Query } from "../api/graph/model"; +import Input from "./ui/Input"; -type Item = string | Query +type Item = string | Query; type ElementItem = { content: React.ReactNode; @@ -25,34 +25,34 @@ const getLastRun = (timestamp: number) => { } return date.toLocaleString([], { hour12: false }); -} +}; -const getExecutionTime = (metadata: string[]) => metadata.find(value => value.startsWith("Query internal execution time:"))?.split(":")[1].replace(" milliseconds", "ms") +const getExecutionTime = (metadata: string[]) => metadata.find(value => value.startsWith("Query internal execution time:"))?.split(":")[1].replace(" milliseconds", "ms"); const getItemClassName = (selected: boolean, deleteSelected: boolean, hover: boolean, prefix: "text" | "bg" = "text") => { - if (selected) return `${prefix}-primary border-primary` - if (deleteSelected) return `${prefix}-destructive border-destructive` - if (hover) return `${prefix}-foreground border-foreground` - return `${prefix}-border border-border` -} + if (selected) return `${prefix}-primary border-primary`; + if (deleteSelected) return `${prefix}-destructive border-destructive`; + if (hover) return `${prefix}-foreground border-foreground`; + return `${prefix}-border border-border`; +}; const getSeparator = (selected: boolean, deleteSelected: boolean, hover: boolean) => (
-) +); const getStatusIcon = (status: Query["status"]) => { - const size = 20 + const size = 20; switch (status) { case "Empty": - return + return ; case "Failed": - return + return ; default: - return + return ; } -} +}; const getQueryElement = (item: Query, selected: boolean, deleteSelected: boolean, hover: boolean) => { const executionTime = getExecutionTime(item.metadata); @@ -116,7 +116,7 @@ const getQueryElement = (item: Query, selected: boolean, deleteSelected: boolean ))}
); -} +}; interface Props { list: T[] @@ -134,74 +134,74 @@ interface Props { export default function PaginationList({ list, onClick, dataTestId, afterSearchCallback, isSelected, isDeleteSelected, label, isLoading, className, children, searchRef }: Props) { - const [filteredList, setFilteredList] = useState([...list]) - const [hoverIndex, setHoverIndex] = useState(0) - const [stepCounter, setStepCounter] = useState(0) - const [pageCount, setPageCount] = useState(0) - const [search, setSearch] = useState("") - const [itemsPerPage, setItemsPerPage] = useState(1) + const [filteredList, setFilteredList] = useState([...list]); + const [hoverIndex, setHoverIndex] = useState(0); + const [stepCounter, setStepCounter] = useState(0); + const [pageCount, setPageCount] = useState(0); + const [search, setSearch] = useState(""); + const [itemsPerPage, setItemsPerPage] = useState(1); - const containerRef = useRef(null) + const containerRef = useRef(null); - const startIndex = stepCounter * itemsPerPage - const endIndex = Math.min(startIndex + itemsPerPage, filteredList.length) - const items = filteredList.slice(startIndex, endIndex) - const itemHeight = typeof items[0] === "string" ? 30 : 50 + const startIndex = stepCounter * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, filteredList.length); + const items = filteredList.slice(startIndex, endIndex); + const itemHeight = typeof items[0] === "string" ? 30 : 50; useEffect(() => { - setStepCounter(0) - }, [filteredList]) + setStepCounter(0); + }, [filteredList]); useEffect(() => { - const newPageCount = Math.ceil(filteredList.length / itemsPerPage) - setPageCount(newPageCount) - if (newPageCount < stepCounter + 1) setStepCounter(0) - }, [filteredList, itemsPerPage, stepCounter]) + const newPageCount = Math.ceil(filteredList.length / itemsPerPage); + setPageCount(newPageCount); + if (newPageCount < stepCounter + 1) setStepCounter(0); + }, [filteredList, itemsPerPage, stepCounter]); useEffect(() => { const calculateItemsPerPage = () => { if (containerRef.current) { - const containerHeight = containerRef.current.clientHeight - const calculatedItems = Math.floor(containerHeight / itemHeight) - setItemsPerPage(Math.max(1, calculatedItems)) + const containerHeight = containerRef.current.clientHeight; + const calculatedItems = Math.floor(containerHeight / itemHeight); + setItemsPerPage(Math.max(1, calculatedItems)); } - } + }; - calculateItemsPerPage() + calculateItemsPerPage(); const resizeObserver = new ResizeObserver(() => { - calculateItemsPerPage() - }) + calculateItemsPerPage(); + }); if (containerRef.current) { - resizeObserver.observe(containerRef.current) + resizeObserver.observe(containerRef.current); } return () => { - resizeObserver.disconnect() - } - }, [itemHeight, items, items.length]) + resizeObserver.disconnect(); + }; + }, [itemHeight, items, items.length]); useEffect(() => { const timeout = setTimeout(() => { - const newFilteredList = list.filter((item) => !search || (typeof item === "string" ? item.toLowerCase().includes(search.toLowerCase()) : item.text.toLowerCase().includes(search.toLowerCase()))) || [] + const newFilteredList = list.filter((item) => !search || (typeof item === "string" ? item.toLowerCase().includes(search.toLowerCase()) : item.text.toLowerCase().includes(search.toLowerCase()))) || []; if (JSON.stringify(newFilteredList) !== JSON.stringify(filteredList)) { - setFilteredList([...newFilteredList]) - afterSearchCallback([...newFilteredList]) - setStepCounter(0) - setHoverIndex(0) + setFilteredList([...newFilteredList]); + afterSearchCallback([...newFilteredList]); + setStepCounter(0); + setHoverIndex(0); } - }, 500) + }, 500); return () => { - clearTimeout(timeout) - } - }, [afterSearchCallback, list, search, filteredList]) + clearTimeout(timeout); + }; + }, [afterSearchCallback, list, search, filteredList]); const handleSetStepCounter = (callback: ((prev: number) => number) | number) => { - setStepCounter(callback) - searchRef.current?.focus() - } + setStepCounter(callback); + searchRef.current?.focus(); + }; return (
@@ -216,25 +216,25 @@ export default function PaginationList({ list, onClick, dataTest onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { - e.preventDefault() - setSearch("") + e.preventDefault(); + setSearch(""); } if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab" && hoverIndex > 0)) { - e.preventDefault() + e.preventDefault(); - setHoverIndex(prev => prev ? prev - 1 : prev) + setHoverIndex(prev => prev ? prev - 1 : prev); } if (e.key === "ArrowDown" || (!e.shiftKey && e.key === "Tab" && hoverIndex < items.length - 1)) { - e.preventDefault() + e.preventDefault(); - setHoverIndex(prev => prev < items.length - 1 ? prev + 1 : prev) + setHoverIndex(prev => prev < items.length - 1 ? prev + 1 : prev); } if (e.key === "Enter") { - e.preventDefault() - onClick(typeof items[hoverIndex] === "string" ? items[hoverIndex] : items[hoverIndex].text, e) + e.preventDefault(); + onClick(typeof items[hoverIndex] === "string" ? items[hoverIndex] : items[hoverIndex].text, e); } }} onFocus={() => setHoverIndex(0)} @@ -249,11 +249,11 @@ export default function PaginationList({ list, onClick, dataTest > { items.map((item, index) => { - const selected = isSelected ? isSelected(item) : false - const deleteSelected = isDeleteSelected ? isDeleteSelected(item) : false - const hover = hoverIndex === index - const isString = typeof item === "string" - const text = isString ? item : item.text + const selected = isSelected ? isSelected(item) : false; + const deleteSelected = isDeleteSelected ? isDeleteSelected(item) : false; + const hover = hoverIndex === index; + const isString = typeof item === "string"; + const text = isString ? item : item.text; const content = ( <> @@ -263,7 +263,7 @@ export default function PaginationList({ list, onClick, dataTest }

{text}

- ) + ); return (
  • ({ list, onClick, dataTest data-testid={`${dataTestId}${text}Button`} title={text} onClick={(e) => { - onClick(text, e) + onClick(text, e); }} onContextMenu={(e) => { - e.preventDefault() + e.preventDefault(); const syntheticEvent = { ...e, type: "rightclick" as const - } as typeof e & { type: "rightclick" } - onClick(text, syntheticEvent) + } as typeof e & { type: "rightclick" }; + onClick(text, syntheticEvent); }} tabIndex={-1} > @@ -301,7 +301,7 @@ export default function PaginationList({ list, onClick, dataTest : content }
  • - ) + ); }) } @@ -329,7 +329,7 @@ export default function PaginationList({ list, onClick, dataTest label={`[${index + 1}]`} title={`Page ${index + 1}`} onClick={() => { - handleSetStepCounter(index) + handleSetStepCounter(index); }} /> @@ -341,7 +341,7 @@ export default function PaginationList({ list, onClick, dataTest
    - ) + ); } PaginationList.defaultProps = { @@ -349,4 +349,4 @@ PaginationList.defaultProps = { children: undefined, isLoading: undefined, isDeleteSelected: undefined, -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 753746d4..7e423e27 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -5,10 +5,10 @@ /* eslint-disable no-nested-ternary */ /* eslint-disable react/require-default-props */ -"use client" +"use client"; import { Checkbox } from "@/components/ui/checkbox"; -import { JSONTree } from "react-json-tree" +import { JSONTree } from "react-json-tree"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Cell, cn, getTheme, Row } from "@/lib/utils"; import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -84,34 +84,34 @@ export default function TableComponent({ onExpandChange }: Props) { - const { indicator } = useContext(IndicatorContext) + const { indicator } = useContext(IndicatorContext); - const { theme } = useTheme() - const { currentTheme } = getTheme(theme) + const { theme } = useTheme(); + const { currentTheme } = getTheme(theme); - const searchRef = useRef(null) - const headerRef = useRef(null) - const tableRef = useRef(null) - const scrollContainerRef = useRef(null) + const searchRef = useRef(null); + const headerRef = useRef(null); + const tableRef = useRef(null); + const scrollContainerRef = useRef(null); const loadAttemptedRef = useRef>(new Set()); const abortControllersRef = useRef>(new Map()); - const [hasRestored, setHasRestored] = useState(false) - const [search, setSearch] = useState("") - const [editable, setEditable] = useState("") - const [hover, setHover] = useState("") - const [newValue, setNewValue] = useState("") - const [filteredRows, setFilteredRows] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [scrollTop, setScrollTop] = useState(0) - const [topFakeRowHeight, setTopFakeRowHeight] = useState(0) - const [bottomFakeRowHeight, setBottomFakeRowHeight] = useState(0) - const [visibleRows, setVisibleRows] = useState([]) - const [loadingCells, setLoadingCells] = useState>(new Set()) - const [loadedCells, setLoadedCells] = useState>(new Set()) - const [expandArr, setExpandArr] = useState(new Map(initialExpand)) - - const height = useMemo(() => expandArr.size === 0 ? itemHeight : itemHeight * 2, [expandArr.size, itemHeight]) + const [hasRestored, setHasRestored] = useState(false); + const [search, setSearch] = useState(""); + const [editable, setEditable] = useState(""); + const [hover, setHover] = useState(""); + const [newValue, setNewValue] = useState(""); + const [filteredRows, setFilteredRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [scrollTop, setScrollTop] = useState(0); + const [topFakeRowHeight, setTopFakeRowHeight] = useState(0); + const [bottomFakeRowHeight, setBottomFakeRowHeight] = useState(0); + const [visibleRows, setVisibleRows] = useState([]); + const [loadingCells, setLoadingCells] = useState>(new Set()); + const [loadedCells, setLoadedCells] = useState>(new Set()); + const [expandArr, setExpandArr] = useState(new Map(initialExpand)); + + const height = useMemo(() => expandArr.size === 0 ? itemHeight : itemHeight * 2, [expandArr.size, itemHeight]); const handleLoadLazyCell = useCallback((rowName: string, cellIndex: number, loadFn: () => Promise) => { // Use row name for stable cell key @@ -190,37 +190,37 @@ export default function TableComponent({ // Clean up abort controller abortControllersRef.current.delete(cellKey); }); - }, [loadingCells, loadedCells, rows, setRows]) + }, [loadingCells, loadedCells, rows, setRows]); useEffect(() => { - const newStartIndex = Math.max(0, Math.floor((scrollTop - (height * itemsPerPage)) / height)) - const newEndIndex = Math.min(filteredRows.length, Math.floor((scrollTop + (height * (itemsPerPage * 2))) / height)) - const newTopFakeRowHeight = newStartIndex * height - const newBottomFakeRowHeight = (filteredRows.length - newEndIndex) * height - const newVisibleRows = [...filteredRows].slice(newStartIndex, newEndIndex) + const newStartIndex = Math.max(0, Math.floor((scrollTop - (height * itemsPerPage)) / height)); + const newEndIndex = Math.min(filteredRows.length, Math.floor((scrollTop + (height * (itemsPerPage * 2))) / height)); + const newTopFakeRowHeight = newStartIndex * height; + const newBottomFakeRowHeight = (filteredRows.length - newEndIndex) * height; + const newVisibleRows = [...filteredRows].slice(newStartIndex, newEndIndex); - setTopFakeRowHeight(newTopFakeRowHeight) - setBottomFakeRowHeight(newBottomFakeRowHeight) - setVisibleRows(newVisibleRows) - }, [scrollTop, itemHeight, itemsPerPage, filteredRows, height]) + setTopFakeRowHeight(newTopFakeRowHeight); + setBottomFakeRowHeight(newBottomFakeRowHeight); + setVisibleRows(newVisibleRows); + }, [scrollTop, itemHeight, itemsPerPage, filteredRows, height]); useEffect(() => { if (searchRef.current) { - searchRef.current.focus() + searchRef.current.focus(); } - }, []) + }, []); useEffect(() => { if (inputRef && inputRef.current && editable) { - inputRef.current.focus() + inputRef.current.focus(); } - }, [inputRef, editable]) + }, [inputRef, editable]); useEffect(() => { if (searchRef.current) { - searchRef.current.focus() + searchRef.current.focus(); } - }, []) + }, []); const handleSearchFilter = useCallback((cell: Cell): boolean => { if (!cell.value) return false; @@ -239,24 +239,24 @@ export default function TableComponent({ } return cell.value.toString().toLowerCase().includes(searchLower); - }, [search]) + }, [search]); useEffect(() => { if (!search) { - setFilteredRows([...rows]) - return undefined + setFilteredRows([...rows]); + return undefined; } const timeout = setTimeout(() => { setFilteredRows([...rows].filter((row) => row.cells.some(cell => handleSearchFilter(cell) - ))) - }, 500) + ))); + }, 500); return () => { - clearTimeout(timeout) - } - }, [search, rows, handleSearchFilter]) + clearTimeout(timeout); + }; + }, [search, rows, handleSearchFilter]); // Clean up when rows change and cells no longer have values useEffect(() => { @@ -304,28 +304,28 @@ export default function TableComponent({ }); return newSet; }); - }, [rows]) + }, [rows]); useEffect(() => { // Restore scroll position on mount - if (hasRestored || filteredRows.length === 0) return () => { } + if (hasRestored || filteredRows.length === 0) return () => { }; // Use setTimeout to ensure virtual scroll content is rendered const timer = setTimeout(() => { if (initialScrollPosition && scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = initialScrollPosition - setScrollTop(initialScrollPosition) + scrollContainerRef.current.scrollTop = initialScrollPosition; + setScrollTop(initialScrollPosition); } if (initialSearch) { - setSearch(initialSearch) + setSearch(initialSearch); } - setHasRestored(true) - }, 0) + setHasRestored(true); + }, 0); - return () => clearTimeout(timer) - }, [hasRestored, initialScrollPosition, filteredRows.length, initialSearch]) + return () => clearTimeout(timer); + }, [hasRestored, initialScrollPosition, filteredRows.length, initialSearch]); // Cleanup: abort all pending requests on unmount useEffect(() => () => { @@ -333,38 +333,38 @@ export default function TableComponent({ controller.abort(); }); abortControllersRef.current.clear(); - }, []) + }, []); const handleSetEditable = (editValue: string, value: string) => { - setEditable(editValue) - setNewValue(value) - } + setEditable(editValue); + setNewValue(value); + }; const handleScroll = (e: React.UIEvent) => { - const newScrollTop = (e.target as HTMLDivElement).scrollTop - setScrollTop(newScrollTop) + const newScrollTop = (e.target as HTMLDivElement).scrollTop; + setScrollTop(newScrollTop); if (onScrollChange) { - onScrollChange(newScrollTop) + onScrollChange(newScrollTop); } - } + }; const stripSVG = useMemo(() => encodeURIComponent( ` ` - ), [itemHeight]) - const stripBackground = useMemo(() => `url("data:image/svg+xml,${stripSVG}")`, [stripSVG]) + ), [itemHeight]); + const stripBackground = useMemo(() => `url("data:image/svg+xml,${stripSVG}")`, [stripSVG]); const columnCount = (setRows ? headers.length + 1 : headers.length) + 1; const renderValue = (v: any) => ( {v} - ) + ); const renderLabel = (l: any) => ( {l[0]}: - ) + ); - const getClassName = (index: number, level?: number) => cn("text-border rounded-lg", expandArr.get(index) === level && "bg-background text-foreground") + const getClassName = (index: number, level?: number) => cn("text-border rounded-lg", expandArr.get(index) === level && "bg-background text-foreground"); return (
    @@ -379,17 +379,17 @@ export default function TableComponent({ placeholder={`Search for${entityName ? ` a ${entityName}` : ""}`} onKeyDown={(e) => { if (e.key === "Escape") { - e.preventDefault() - setSearch("") + e.preventDefault(); + setSearch(""); } - if (e.key !== "Enter") return - e.preventDefault() + if (e.key !== "Enter") return; + e.preventDefault(); }} onChange={(e) => { - const val = e.target.value - setSearch(val) - if (onSearchChange) onSearchChange(val) + const val = e.target.value; + setSearch(val); + if (onSearchChange) onSearchChange(val); }} />
    @@ -404,11 +404,11 @@ export default function TableComponent({ className="w-6 h-6 rounded-full bg-background border-primary data-[state=checked]:bg-primary" checked={rows.length > 0 && rows.every(row => row.checked)} onCheckedChange={() => { - const checked = rows.every(row => row.checked) + const checked = rows.every(row => row.checked); setRows(rows.map((row) => { - row.checked = !checked - return row - })) + row.checked = !checked; + return row; + })); }} /> @@ -434,10 +434,10 @@ export default function TableComponent({ className={getClassName(i, 1)} title="Expand Root" onClick={() => { - const newExpandArr = new Map(expandArr).set(i, 1) - setExpandArr(newExpandArr) + const newExpandArr = new Map(expandArr).set(i, 1); + setExpandArr(newExpandArr); - if (onExpandChange) onExpandChange(newExpandArr) + if (onExpandChange) onExpandChange(newExpandArr); }} > @@ -446,10 +446,10 @@ export default function TableComponent({ title="Expand All" className={getClassName(i, -1)} onClick={() => { - const newExpandArr = new Map(expandArr).set(i, -1) - setExpandArr(newExpandArr) + const newExpandArr = new Map(expandArr).set(i, -1); + setExpandArr(newExpandArr); - if (onExpandChange) onExpandChange(newExpandArr) + if (onExpandChange) onExpandChange(newExpandArr); }} > @@ -458,11 +458,11 @@ export default function TableComponent({ title="Collapse All" className={getClassName(i)} onClick={() => { - const newExpandArr = new Map(expandArr) - newExpandArr.delete(i) - setExpandArr(newExpandArr) + const newExpandArr = new Map(expandArr); + newExpandArr.delete(i); + setExpandArr(newExpandArr); - if (onExpandChange) onExpandChange(newExpandArr) + if (onExpandChange) onExpandChange(newExpandArr); }} > @@ -496,8 +496,8 @@ export default function TableComponent({ } { visibleRows.map((row, index) => { - const actualIndex = topFakeRowHeight / height + index - const rowTestID = `${label}${row.name}` + const actualIndex = topFakeRowHeight / height + index; + const rowTestID = `${label}${row.name}`; return ( { setRows(rows.map((r) => { if (r.name === row.name) { - r.checked = !r.checked + r.checked = !r.checked; } - return r - })) + return r; + })); }} /> @@ -613,9 +613,9 @@ export default function TableComponent({ inTable options={cell.options} setSelectedValue={async (value) => { - const result = await cell.onChange(value) + const result = await cell.onChange(value); if (result) { - handleSetEditable("", "") + handleSetEditable("", ""); } }} label={cell.selectType} @@ -630,17 +630,17 @@ export default function TableComponent({ onChange={(e) => setNewValue(e.target.value)} onKeyDown={async (e) => { if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - handleSetEditable("", "") + e.preventDefault(); + e.stopPropagation(); + handleSetEditable("", ""); } - if (e.key !== "Enter") return + if (e.key !== "Enter") return; - e.preventDefault() - const result = await cell.onChange(newValue) + e.preventDefault(); + const result = await cell.onChange(newValue); if (result) { - handleSetEditable("", "") + handleSetEditable("", ""); } }} /> @@ -653,13 +653,13 @@ export default function TableComponent({ title="Save" onClick={async () => { try { - setIsLoading(true) - const result = await cell.onChange(newValue) + setIsLoading(true); + const result = await cell.onChange(newValue); if (result) { - handleSetEditable("", "") + handleSetEditable("", ""); } } finally { - setIsLoading(false) + setIsLoading(false); } }} isLoading={isLoading} @@ -673,7 +673,7 @@ export default function TableComponent({ data-testid={`cancelButton${label}`} title="Cancel" onClick={() => { - handleSetEditable("", "") + handleSetEditable("", ""); }} > @@ -708,11 +708,11 @@ export default function TableComponent({ }
    - ) + ); }) } - ) + ); }) } { @@ -745,5 +745,5 @@ export default function TableComponent({ - ) + ); } \ No newline at end of file diff --git a/app/components/ToastButton.tsx b/app/components/ToastButton.tsx index 654754ed..c2a55ae0 100644 --- a/app/components/ToastButton.tsx +++ b/app/components/ToastButton.tsx @@ -23,11 +23,11 @@ export default function ToastButton({ onClick, showUndo, label = "Undo", variant {showUndo && } - ) + ); } ToastButton.defaultProps = { variant: undefined, showUndo: undefined, label: "Undo", -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/Tutorial.tsx b/app/components/Tutorial.tsx index 3dc285a9..21c94d1b 100644 --- a/app/components/Tutorial.tsx +++ b/app/components/Tutorial.tsx @@ -122,7 +122,7 @@ const tutorialSteps: TutorialStep[] = [ title: "Graph Visualization", description: "Query results containing nodes and edges will be visualized here as an interactive graph. You can drag, zoom, and explore the relationships.", placementAxis: "x", - targetSelector: '.force-graph-container canvas', + targetSelector: 'falkordb-canvas', spotlightSelector: '[data-testid="graphView"]', forward: ["mousedown", "mouseup", "mousemove", "mouseenter", "mouseleave", "mouseover", "mouseout", "contextmenu", "pointerdown", "pointerup", "pointermove", "pointerenter", "pointerleave", "wheel"], hidePrev: true @@ -215,16 +215,16 @@ function TutorialPortal({ // Calculate position based on target element useEffect(() => { if (!targetSelector) { - setCurrentPosition({ ...position, transform: "translate(-50%, -50%)" }) - return () => { } + setCurrentPosition({ ...position, transform: "translate(-50%, -50%)" }); + return () => { }; } const element = document.querySelector(targetSelector); - const currentTooltip = tooltipRef.current + const currentTooltip = tooltipRef.current; if (!element || !currentTooltip) { - setCurrentPosition({ ...position, transform: "translate(-50%, -50%)" }) - return () => { } + setCurrentPosition({ ...position, transform: "translate(-50%, -50%)" }); + return () => { }; } // Get actual tooltip dimensions from ref @@ -306,7 +306,7 @@ function TutorialPortal({ setCurrentPosition(calculatedPosition); setDirection(computedDirection); - } + }; updatePosition(); @@ -327,7 +327,7 @@ function TutorialPortal({ }, [placementAxis, position, step, targetSelector]); useEffect(() => { - const forwardArr = [...(forward || []), advanceOn].filter(ev => !!ev) + const forwardArr = [...(forward || []), advanceOn].filter(ev => !!ev); // Highlight target element and add click listener if (targetSelector) { @@ -337,7 +337,8 @@ function TutorialPortal({ // Check if the element is disabled const isDisabled = element instanceof HTMLButtonElement || element instanceof HTMLInputElement ? element.disabled - : element.hasAttribute('disabled') || + : element.getAttribute('disabled') === 'true' || + element.getAttribute('aria-disabled') === 'true' || element.classList.contains('disabled') || window.getComputedStyle(element).pointerEvents === 'none'; @@ -386,17 +387,17 @@ function TutorialPortal({ overlay.style.height = `${bottom - top}px`; }; - const resizeObserver = new ResizeObserver(updateOverlayPosition) + const resizeObserver = new ResizeObserver(updateOverlayPosition); const cleanup = () => { element.classList.remove('tutorial-highlight'); - resizeObserver.disconnect() + resizeObserver.disconnect(); window.removeEventListener('resize', updateOverlayPosition); if (wheelHandler) { overlay.removeEventListener('wheel', wheelHandler, { passive: true } as EventListenerOptions); } overlay.remove(); - } + }; updateOverlayPosition(); resizeObserver.observe(element); @@ -438,8 +439,8 @@ function TutorialPortal({ // Advance the tutorial on the specified event type. Use a short delay // so the forwarded event can reach the underlying element's handlers first. setTimeout(() => { - onNext() - }, 200) + onNext(); + }, 200); } // Get the overlay and target element positions to adjust coordinates @@ -542,7 +543,7 @@ function TutorialPortal({ return () => { removeForwarders(); cleanup(); - } + }; } } @@ -723,12 +724,12 @@ function TutorialSpotlight({ targetSelector, spotlightSelector }: { targetSelect updateSpotlight(); // Update on window resize - const resizeObserver = new ResizeObserver(updateSpotlight) - resizeObserver.observe(element) + const resizeObserver = new ResizeObserver(updateSpotlight); + resizeObserver.observe(element); window.addEventListener('resize', updateSpotlight); return () => { - resizeObserver.disconnect() + resizeObserver.disconnect(); window.removeEventListener('resize', updateSpotlight); }; }, [targetSelector, spotlightSelector]); diff --git a/app/components/graph/DeleteGraph.tsx b/app/components/graph/DeleteGraph.tsx index 7985b284..1a6aced0 100644 --- a/app/components/graph/DeleteGraph.tsx +++ b/app/components/graph/DeleteGraph.tsx @@ -31,63 +31,63 @@ export default function DeleteGraph({ setGraphNames }: Props) { - const [open, setOpen] = useState(false) - const [closeManage, setCloseManage] = useState(false) - const { toast } = useToast() - const [isLoading, setIsLoading] = useState(false) - const { indicator, setIndicator } = useContext(IndicatorContext) + const [open, setOpen] = useState(false); + const [closeManage, setCloseManage] = useState(false); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + const { indicator, setIndicator } = useContext(IndicatorContext); useEffect(() => { if (!open && closeManage) { - setOpenMenage(false) - setCloseManage(false) + setOpenMenage(false); + setCloseManage(false); } - }, [open, closeManage, setOpenMenage]) + }, [open, closeManage, setOpenMenage]); useEffect(() => { if (!open) { - setIsLoading(false) + setIsLoading(false); } - }, [open]) + }, [open]); const handleDelete = async (deleteGraphNames: string[]) => { - setIsLoading(true) - let newGraphNames + setIsLoading(true); + let newGraphNames; try { const [failedDeletedGraphs, successDeletedGraphs] = await Promise.all(deleteGraphNames .map(async (name) => { const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}`, { method: "DELETE" - }, toast, setIndicator) + }, toast, setIndicator); - if (result.ok) return "" + if (result.ok) return ""; - return name + return name; - })).then(result => [result.filter(n => n !== ""), deleteGraphNames.filter(n => !result.includes(n))]) + })).then(result => [result.filter(n => n !== ""), deleteGraphNames.filter(n => !result.includes(n))]); - newGraphNames = graphNames.filter(n => !successDeletedGraphs.includes(n)) + newGraphNames = graphNames.filter(n => !successDeletedGraphs.includes(n)); - setGraphNames(newGraphNames) + setGraphNames(newGraphNames); if (successDeletedGraphs.includes(selectedValue)) { - setGraphName(successDeletedGraphs.length > 0 ? newGraphNames[successDeletedGraphs.length - 1] : "") - setGraph(Graph.empty()) + setGraphName(successDeletedGraphs.length > 0 ? newGraphNames[successDeletedGraphs.length - 1] : ""); + setGraph(Graph.empty()); } - handleSetRows(successDeletedGraphs) + handleSetRows(successDeletedGraphs); toast({ title: "Graph(s) deleted successfully", description: successDeletedGraphs.length > 0 && `The graph(s) ${successDeletedGraphs.join(", ")} have been deleted successfully${failedDeletedGraphs.length > 0 && `The graph(s) ${failedDeletedGraphs.join(", ")} have not been deleted`}`, - }) + }); } finally { - setIsLoading(false) - setOpen(false) + setIsLoading(false); + setOpen(false); if (typeof newGraphNames !== "undefined" && newGraphNames.length === 0) { - setCloseManage(true) + setCloseManage(true); } } - } + }; return ( { if (!open) { - setDuplicateName("") - setIsLoading(false) + setDuplicateName(""); + setIsLoading(false); } - }, [open]) + }, [open]); const handleDuplicate = async (e: FormEvent) => { - e.preventDefault() + e.preventDefault(); if (duplicateName === "") { toast({ title: "Error", description: "Graph name cannot be empty", - }) - return + }); + return; } try { - setIsLoading(true) + setIsLoading(true); const result = await securedFetch(`api/${type === "Graph" ? "graph" : "schema"}/${prepareArg(duplicateName)}/duplicate`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceName: selectedValue }) - }, toast, setIndicator) + }, toast, setIndicator); - if (!result.ok) return + if (!result.ok) return; - onDuplicate(duplicateName) - onOpenChange(false) + onDuplicate(duplicateName); + onOpenChange(false); toast({ title: `${type} duplicated successfully`, description: `The ${type} has been duplicated successfully`, - }) + }); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return ( - ) + ); } \ No newline at end of file diff --git a/app/components/graph/UploadGraph.tsx b/app/components/graph/UploadGraph.tsx index 7591e50d..3bf628ec 100644 --- a/app/components/graph/UploadGraph.tsx +++ b/app/components/graph/UploadGraph.tsx @@ -11,32 +11,32 @@ export default function UploadGraph({ disabled, open, onOpenChange }: { onOpenChange?: (open: boolean) => void }) { - const [files, setFiles] = useState([]) - const isControlled = typeof open === "boolean" && typeof onOpenChange === "function" - const [internalOpen, setInternalOpen] = useState(false) + const [files, setFiles] = useState([]); + const isControlled = typeof open === "boolean" && typeof onOpenChange === "function"; + const [internalOpen, setInternalOpen] = useState(false); const dialogOpen = useMemo( () => (isControlled ? (open as boolean) : internalOpen), [isControlled, open, internalOpen] - ) + ); const handleOpenChange = (nextOpen: boolean) => { if (onOpenChange) { - onOpenChange(nextOpen) + onOpenChange(nextOpen); } else { - setInternalOpen(nextOpen) + setInternalOpen(nextOpen); } - } + }; useEffect(() => { if (!dialogOpen) { - setFiles([]) + setFiles([]); } - }, [dialogOpen]) + }, [dialogOpen]); const onUploadData = () => { - if (!files.length) return - setFiles([]) - } + if (!files.length) return; + setFiles([]); + }; return ( @@ -66,5 +66,5 @@ export default function UploadGraph({ disabled, open, onOpenChange }: { - ) + ); } \ No newline at end of file diff --git a/app/components/provider.ts b/app/components/provider.ts index 1f7b9126..9bcdf091 100644 --- a/app/components/provider.ts +++ b/app/components/provider.ts @@ -1,5 +1,6 @@ import { createContext, Dispatch, SetStateAction } from "react"; -import { Panel, Tab, TextPriority, ViewportState } from "@/lib/utils"; +import { GraphRef, Panel, Tab } from "@/lib/utils"; +import type { GraphData as CanvasData, ViewportState } from "@falkordb/canvas"; import { Graph, GraphData, GraphInfo, HistoryQuery } from "../api/graph/model"; type BrowserSettingsContextType = { @@ -33,8 +34,6 @@ type BrowserSettingsContextType = { graphInfo: { newRefreshInterval: number; setNewRefreshInterval: Dispatch>; - newDisplayTextPriority: TextPriority[]; - setNewDisplayTextPriority: Dispatch>; }; }; settings: { @@ -72,8 +71,6 @@ type BrowserSettingsContextType = { showMemoryUsage: boolean; refreshInterval: number; setRefreshInterval: Dispatch>; - displayTextPriority: TextPriority[]; - setDisplayTextPriority: Dispatch>; }; }; hasChanges: boolean; @@ -104,6 +101,7 @@ type GraphContextType = { handleCooldown: (ticks?: 0, isSetLoading?: boolean) => void; cooldownTicks: number | undefined; isLoading: boolean; + setIsLoading: (loading: boolean) => void; }; type SchemaContextType = { @@ -135,12 +133,14 @@ type QueryLoadingContextType = { setIsQueryLoading: Dispatch>; }; -type ViewportContextType = { +type ForceGraphContextType = { + canvasRef: GraphRef; viewport: ViewportState; setViewport: Dispatch>; data: GraphData; setData: Dispatch>; - isSaved: boolean; + graphData: CanvasData | undefined; + setGraphData: Dispatch>; }; type TableViewContextType = { @@ -176,7 +176,10 @@ export const BrowserSettingsContext = createContext( newModel: "", setNewModel: () => {}, }, - graphInfo: { newRefreshInterval: 0, setNewRefreshInterval: () => {}, newDisplayTextPriority: [], setNewDisplayTextPriority: () => {} }, + graphInfo: { + newRefreshInterval: 0, + setNewRefreshInterval: () => {}, + }, }, settings: { limitSettings: { @@ -203,7 +206,11 @@ export const BrowserSettingsContext = createContext( navigateToSettings: false, displayChat: false, }, - graphInfo: { showMemoryUsage: false, refreshInterval: 0, setRefreshInterval: () => {}, displayTextPriority: [], setDisplayTextPriority: () => {} }, + graphInfo: { + showMemoryUsage: false, + refreshInterval: 0, + setRefreshInterval: () => {}, + }, }, hasChanges: false, setHasChanges: () => {}, @@ -234,6 +241,7 @@ export const GraphContext = createContext({ handleCooldown: () => {}, cooldownTicks: undefined, isLoading: false, + setIsLoading: () => {}, }); export const SchemaContext = createContext({ @@ -279,12 +287,14 @@ export const QueryLoadingContext = createContext({ setIsQueryLoading: () => {}, }); -export const ViewportContext = createContext({ +export const ForceGraphContext = createContext({ + canvasRef: { current: null }, viewport: { centerX: 0, centerY: 0, zoom: 0 }, setViewport: () => {}, data: { nodes: [], links: [] }, setData: () => {}, - isSaved: false, + graphData: { nodes: [], links: [] }, + setGraphData: () => {}, }); export const TableViewContext = createContext({ diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 9a75f7e7..247ec8ba 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -1,11 +1,11 @@ /* eslint-disable react/button-has-type */ /* eslint-disable react/jsx-props-no-spreading */ -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" -import { Loader2 } from "lucide-react" -import React, { forwardRef } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import React, { forwardRef } from "react"; -export type Variant = "Large" | "Primary" | "Secondary" | "Cancel" | "Delete" | "button" +export type Variant = "Large" | "Primary" | "Secondary" | "Cancel" | "Delete" | "button"; /* eslint-disable react/require-default-props */ export interface Props extends React.DetailedHTMLProps, HTMLButtonElement> { @@ -28,7 +28,7 @@ const getClassName = (variant: Variant, disable: boolean | undefined, open: bool open !== undefined && "gap-4", isLoading && "flex items-center justify-center", classN, - ) + ); switch (variant) { case "Primary": @@ -36,21 +36,21 @@ const getClassName = (variant: Variant, disable: boolean | undefined, open: bool "px-4 py-[10px] bg-primary", !disable && "hover:bg-primary", className - ) - break + ); + break; case "Secondary": - className = cn("px-12 py-2 bg-transparent border-2 border-primary", className) - break + className = cn("px-12 py-2 bg-transparent border-2 border-primary", className); + break; case "Cancel": - className = cn("px-12 py-2 bg-transparent border-2 border-border", className) - break + className = cn("px-12 py-2 bg-transparent border-2 border-border", className); + break; case "Delete": - className = cn("px-4 py-[10px] bg-transparent border-2 border-destructive", className) - break + className = cn("px-4 py-[10px] bg-transparent border-2 border-destructive", className); + break; default: } - return className -} + return className; +}; const Button = forwardRef(({ label, variant = "button", open, className, title, type = "button", disabled, children, isLoading = false, indicator, tooltipVariant = variant, tooltipSide, ...props }, ref) => title !== "" && (title || label || indicator === "offline") && variant !== "Cancel" ? ( @@ -103,8 +103,8 @@ const Button = forwardRef(({ label, variant = "button" {children} {isLoading ? : label} - )) + )); -Button.displayName = "Button" +Button.displayName = "Button"; -export default Button \ No newline at end of file +export default Button; \ No newline at end of file diff --git a/app/components/ui/Dropzone.tsx b/app/components/ui/Dropzone.tsx index ed19b332..22406bed 100644 --- a/app/components/ui/Dropzone.tsx +++ b/app/components/ui/Dropzone.tsx @@ -1,16 +1,16 @@ -'use client' +'use client'; -import { ArrowDownToLine } from 'lucide-react' -import React, { useCallback, useState } from 'react' -import { useDropzone } from 'react-dropzone' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { cn } from '@/lib/utils' +import { ArrowDownToLine } from 'lucide-react'; +import React, { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { cn } from '@/lib/utils'; type TableFile = { name: string size: number type: string -} +}; /* eslint-disable react/require-default-props */ interface Props { @@ -25,11 +25,11 @@ const FileProps = [ "Name", "Size", "Type", -] +]; function Dropzone({ filesCount = false, className = "", withTable = false, disabled = false, onFileDrop }: Props) { - const [files, setFiles] = useState([]) + const [files, setFiles] = useState([]); const onDrop = useCallback((acceptedFiles: File[]) => { const newFiles = acceptedFiles.map((file: File) => ({ @@ -37,11 +37,11 @@ function Dropzone({ filesCount = false, className = "", withTable = false, disab size: file.size, type: file.type, })); - setFiles(newFiles) - onFileDrop(acceptedFiles) - }, [onFileDrop]) + setFiles(newFiles); + onFileDrop(acceptedFiles); + }, [onFileDrop]); - const { getRootProps, getInputProps } = useDropzone({ onDrop, disabled }) + const { getRootProps, getInputProps } = useDropzone({ onDrop, disabled }); return (
    @@ -101,7 +101,7 @@ function Dropzone({ filesCount = false, className = "", withTable = false, disab
    } - ) + ); } -export default Dropzone \ No newline at end of file +export default Dropzone; \ No newline at end of file diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx index c5920d12..e3aa674a 100644 --- a/app/components/ui/Input.tsx +++ b/app/components/ui/Input.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/require-default-props */ /* eslint-disable react/jsx-props-no-spreading */ -"use client" +"use client"; import { cn } from "@/lib/utils"; import { forwardRef } from "react"; @@ -22,8 +22,8 @@ const Input = forwardRef(({ )} {...props} /> - )) + )); -Input.displayName = "Input" +Input.displayName = "Input"; -export default Input \ No newline at end of file +export default Input; \ No newline at end of file diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index 60ef8fc6..eceac3a9 100644 --- a/app/components/ui/combobox.tsx +++ b/app/components/ui/combobox.tsx @@ -1,13 +1,13 @@ -"use client" +"use client"; -import { Dialog } from "@/components/ui/dialog" -import { cn } from "@/lib/utils" -import { useContext, useEffect, useState } from "react" -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select" -import Button from "./Button" -import Input from "./Input" -import { IndicatorContext } from "../provider" +import { Dialog } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useContext, useEffect, useState } from "react"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select"; +import Button from "./Button"; +import Input from "./Input"; +import { IndicatorContext } from "../provider"; interface ComboboxProps { id?: string, @@ -21,44 +21,44 @@ interface ComboboxProps { className?: string, } -const STEP = 4 +const STEP = 4; export default function Combobox({ id, disabled = false, inTable = false, label, options, selectedValue, setSelectedValue, defaultOpen = false, className }: ComboboxProps) { - const { indicator } = useContext(IndicatorContext) + const { indicator } = useContext(IndicatorContext); - const [filteredOptions, setFilteredOptions] = useState([]) - const [openMenage, setOpenMenage] = useState(false) - const [maxOptions, setMaxOptions] = useState(STEP) - const [open, setOpen] = useState(defaultOpen) - const [search, setSearch] = useState("") + const [filteredOptions, setFilteredOptions] = useState([]); + const [openMenage, setOpenMenage] = useState(false); + const [maxOptions, setMaxOptions] = useState(STEP); + const [open, setOpen] = useState(defaultOpen); + const [search, setSearch] = useState(""); useEffect(() => { if (!open) { - setSearch("") - setMaxOptions(STEP) - setFilteredOptions([...options]) + setSearch(""); + setMaxOptions(STEP); + setFilteredOptions([...options]); } - }, [open, options]) + }, [open, options]); useEffect(() => { const timeout = setTimeout(() => { - setFilteredOptions(!search ? options : options.filter((option) => option.toLowerCase().includes(search.toLowerCase()))) - }, 500) + setFilteredOptions(!search ? options : options.filter((option) => option.toLowerCase().includes(search.toLowerCase()))); + }, 500); - return () => clearTimeout(timeout) - }, [options, search]) + return () => clearTimeout(timeout); + }, [options, search]); const getTitle = () => { switch (true) { case indicator === "offline": - return "The FalkorDB server is offline" + return "The FalkorDB server is offline"; case options.length === 0: - return `There are no ${label}s` + return `There are no ${label}s`; default: - return selectedValue || `Select ${label}` + return selectedValue || `Select ${label}`; } - } + }; return ( @@ -86,8 +86,8 @@ export default function Combobox({ id, disabled = false, inTab className="w-1 grow" placeholder={`Search for a ${label}`} onChange={(e) => { - setSearch(e.target.value) - setMaxOptions(5) + setSearch(e.target.value); + setMaxOptions(5); }} value={search} /> @@ -136,7 +136,7 @@ export default function Combobox({ id, disabled = false, inTab - ) + ); } Combobox.defaultProps = { @@ -145,4 +145,4 @@ Combobox.defaultProps = { inTable: false, defaultOpen: false, className: undefined, -} \ No newline at end of file +}; \ No newline at end of file diff --git a/app/components/ui/spinning.tsx b/app/components/ui/spinning.tsx index 285985be..c6a5bd79 100644 --- a/app/components/ui/spinning.tsx +++ b/app/components/ui/spinning.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton" +import { Skeleton } from "@/components/ui/skeleton"; export default function Spinning() { @@ -10,5 +10,5 @@ export default function Spinning() { - ) + ); } diff --git a/app/graph/Chat.tsx b/app/graph/Chat.tsx index 4c602b7d..21123da6 100644 --- a/app/graph/Chat.tsx +++ b/app/graph/Chat.tsx @@ -1,72 +1,72 @@ /* eslint-disable no-case-declarations */ /* eslint-disable react/no-array-index-key */ -import { cn, Message } from "@/lib/utils" -import { useContext, useEffect, useState } from "react" -import { ChevronDown, ChevronRight, CircleArrowUp, Copy, Loader2, Play, Search, X } from "lucide-react" -import { useToast } from "@/components/ui/use-toast" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import Button from "../components/ui/Button" -import Input from "../components/ui/Input" -import { GraphContext, IndicatorContext, QueryLoadingContext, BrowserSettingsContext } from "../components/provider" -import { EventType } from "../api/chat/route" +import { cn, Message } from "@/lib/utils"; +import { useContext, useEffect, useState } from "react"; +import { ChevronDown, ChevronRight, CircleArrowUp, Copy, Loader2, Play, Search, X } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import Button from "../components/ui/Button"; +import Input from "../components/ui/Input"; +import { GraphContext, IndicatorContext, QueryLoadingContext, BrowserSettingsContext } from "../components/provider"; +import { EventType } from "../api/chat/route"; interface Props { onClose: () => void } export default function Chat({ onClose }: Props) { - const { setIndicator } = useContext(IndicatorContext) - const { graphName, runQuery } = useContext(GraphContext) - const { isQueryLoading } = useContext(QueryLoadingContext) - const { settings: { chatSettings: { secretKey, model } } } = useContext(BrowserSettingsContext) + const { setIndicator } = useContext(IndicatorContext); + const { graphName, runQuery } = useContext(GraphContext); + const { isQueryLoading } = useContext(QueryLoadingContext); + const { settings: { chatSettings: { secretKey, model } } } = useContext(BrowserSettingsContext); - const { toast } = useToast() + const { toast } = useToast(); - const [messages, setMessages] = useState([]) - const [messagesList, setMessagesList] = useState<(Message | [Message[], boolean])[]>([]) - const [newMessage, setNewMessage] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [queryCollapse, setQueryCollapse] = useState<{ [key: string]: boolean }>({}) + const [messages, setMessages] = useState([]); + const [messagesList, setMessagesList] = useState<(Message | [Message[], boolean])[]>([]); + const [newMessage, setNewMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [queryCollapse, setQueryCollapse] = useState<{ [key: string]: boolean }>({}); useEffect(() => { - let statusGroup: Message[] + let statusGroup: Message[]; const newMessagesList = messages.map((message, i): Message | [Message[], boolean] | undefined => { if (message.type === "Status") { if (messages[i - 1]?.type !== "Status") { - if (messages[i + 1]?.type !== "Status") return message - statusGroup = [message] + if (messages[i + 1]?.type !== "Status") return message; + statusGroup = [message]; } else { - statusGroup.push(message) - if (messages[i + 1]?.type !== "Status") return [statusGroup, false] + statusGroup.push(message); + if (messages[i + 1]?.type !== "Status") return [statusGroup, false]; } } else { - return message + return message; } - return undefined - }).filter(m => !!m) + return undefined; + }).filter(m => !!m); - setMessagesList(newMessagesList) - }, [messages]) + setMessagesList(newMessagesList); + }, [messages]); const scrollToBottom = () => { - const chatContainer = document.querySelector(".chat-container") + const chatContainer = document.querySelector(".chat-container"); if (chatContainer) { - chatContainer.scrollTop = chatContainer.scrollHeight + chatContainer.scrollTop = chatContainer.scrollHeight; } - } + }; const handleSubmit = async (e?: React.FormEvent | React.MouseEvent) => { - e?.preventDefault() + e?.preventDefault(); if (isLoading) { toast({ title: "Please wait", description: "You are already sending a message", variant: "destructive", - }) - return + }); + return; } if (newMessage.trim() === "") { @@ -74,17 +74,17 @@ export default function Chat({ onClose }: Props) { title: "Please enter a message", description: "You cannot send an empty message", variant: "destructive", - }) - return + }); + return; } - setIsLoading(true) + setIsLoading(true); - const newMessages = [...messages, { role: "user", type: "Text", content: newMessage } as const] + const newMessages = [...messages, { role: "user", type: "Text", content: newMessage } as const]; - setMessages(newMessages) - setTimeout(scrollToBottom, 0) - setNewMessage("") + setMessages(newMessages); + setTimeout(scrollToBottom, 0); + setNewMessage(""); // eslint-disable-next-line @typescript-eslint/no-explicit-any const body: any = { @@ -93,14 +93,14 @@ export default function Chat({ onClose }: Props) { content })), graphName, - } + }; if (model) { - body.model = model + body.model = model; } if (secretKey) { - body.key = secretKey + body.key = secretKey; } try { @@ -129,24 +129,24 @@ export default function Chat({ onClose }: Props) { const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('event:').filter(line => line); - let isResult = false + let isResult = false; lines.forEach(line => { - const eventType: EventType | "error" = line.split(" ")[1] as EventType | "error" - const eventData = line.split("data:")[1] + const eventType: EventType | "error" = line.split(" ")[1] as EventType | "error"; + const eventData = line.split("data:")[1]; switch (eventType) { case "Status": const message = { role: "assistant" as const, content: eventData.trim(), type: eventType - } + }; - setMessages(prev => [...prev, message]) + setMessages(prev => [...prev, message]); break; case "CypherQuery": - setQueryCollapse(prev => ({ ...prev, [messages.length]: false })) + setQueryCollapse(prev => ({ ...prev, [messages.length]: false })); setMessages(prev => [ ...prev, { @@ -157,25 +157,6 @@ export default function Chat({ onClose }: Props) { ]); break; - case "ModelOutputChunk": - setMessages(prev => { - const lastMessage = prev[prev.length - 1] - - if (lastMessage.role === "assistant" && lastMessage.type === "Result") { - return [...prev.slice(0, -1), { - ...lastMessage, - content: `${lastMessage.content} ${eventData.trim()}` - }] - } - - return [...prev, { - role: "assistant", - type: "Result", - content: eventData.trim() - }] - }) - break; - case "Result": try { setMessages(prev => [ @@ -197,7 +178,7 @@ export default function Chat({ onClose }: Props) { } ]); } - isResult = true + isResult = true; break; case "Error": @@ -209,21 +190,21 @@ export default function Chat({ onClose }: Props) { type: eventType } ]); - isResult = true + isResult = true; break; case "error": - const statusCode = Number(line.split("status:")[1].split(" ")[0]) + const statusCode = Number(line.split("status:")[1].split(" ")[0]); - if (statusCode === 401 || statusCode >= 500) setIndicator("offline") + if (statusCode === 401 || statusCode >= 500) setIndicator("offline"); toast({ title: "Error", description: eventData, variant: "destructive", - }) + }); - isResult = true + isResult = true; break; case "Schema": @@ -231,15 +212,15 @@ export default function Chat({ onClose }: Props) { break; default: - throw new Error(`Unknown event type: ${eventType}`) + throw new Error(`Unknown event type: ${eventType}`); } }); - setTimeout(scrollToBottom, 0) + setTimeout(scrollToBottom, 0); if (!isResult) await processStream(); - setIsLoading(false) + setIsLoading(false); }; processStream(); @@ -248,9 +229,9 @@ export default function Chat({ onClose }: Props) { title: "Error", description: (error as Error).message, variant: "destructive", - }) + }); } - } + }; const getMessage = (message: Message, index?: number) => { switch (message.type) { @@ -261,7 +242,7 @@ export default function Chat({ onClose }: Props) { }

    {message.content}

    - + ; return index !== undefined ? (
  • @@ -271,15 +252,15 @@ export default function Chat({ onClose }: Props) {
    {content}
    - ) + ); case "CypherQuery": - const i = messages.findIndex(m => m === message) + const i = messages.findIndex(m => m === message); return (
    - ) + ); default: return (

    {message.content}

    - ) + ); } - } + }; return (
    @@ -351,9 +332,9 @@ export default function Chat({ onClose }: Props) { { messagesList.map((message, index) => { if (Array.isArray(message)) { - const [m, collapse] = message + const [m, collapse] = message; return ( -
  • +
  • {m.some(me => messages[messages.length - 1] === me) && !collapse ? @@ -361,13 +342,13 @@ export default function Chat({ onClose }: Props) {

    Status

  • - ) + ); } if (message.type === "Status") { return (
  • {getMessage(message)}
  • - ) + ); } - const isUser = message.role === "user" - const assistantBg = message.type === "Error" ? "bg-destructive" : "bg-secondary" + const isUser = message.role === "user"; + const assistantBg = message.type === "Error" ? "bg-destructive" : "bg-secondary"; const avatar =

    {message.role.charAt(0).toUpperCase()}

    -
    + ; return (
  • - ) + ); }) } @@ -436,5 +417,5 @@ export default function Chat({ onClose }: Props) { - ) + ); } \ No newline at end of file diff --git a/app/graph/CreateElementPanel.tsx b/app/graph/CreateElementPanel.tsx index 27cf8e42..ad0b584d 100644 --- a/app/graph/CreateElementPanel.tsx +++ b/app/graph/CreateElementPanel.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/destructuring-assignment */ -'use client' +'use client'; import { Fragment, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react"; @@ -8,12 +8,13 @@ import { ArrowRight, ArrowRightLeft, Check, Info, Pencil, Plus, Trash2, X } from import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/components/ui/use-toast"; import { Switch } from "@/components/ui/switch"; -import { cn, getNodeDisplayText } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import { getNodeDisplayText } from "@falkordb/canvas"; import Button from "../components/ui/Button"; import Input from "../components/ui/Input"; import Combobox from "../components/ui/combobox"; import { Node, Value } from "../api/graph/model"; -import { BrowserSettingsContext, IndicatorContext } from "../components/provider"; +import { IndicatorContext } from "../components/provider"; import AddLabel from "./addLabel"; import RemoveLabel from "./RemoveLabel"; import DialogComponent from "../components/DialogComponent"; @@ -33,7 +34,7 @@ type Props = type: false; }; -type ValueType = "string" | "number" | "boolean" +type ValueType = "string" | "number" | "boolean"; export default function CreateElementPanel(props: Props) { const { onCreate, onClose, type } = props; @@ -41,150 +42,149 @@ export default function CreateElementPanel(props: Props) { const selectedNodes = !type ? props.selectedNodes : undefined; const setSelectedNodes = !type ? props.setSelectedNodes : undefined; - const { indicator } = useContext(IndicatorContext) - const { settings: { graphInfo: { displayTextPriority } } } = useContext(BrowserSettingsContext) - const { toast } = useToast() - - const setInputRef = useRef(null) - const scrollableContainerRef = useRef(null) - - const [attributes, setAttributes] = useState<[string, Value][]>([]) - const [newKey, setNewKey] = useState("") - const [newVal, setNewVal] = useState("") - const [newType, setNewType] = useState("string") - const [editVal, setEditVal] = useState("") - const [editType, setEditType] = useState("string") - const [labels, setLabels] = useState([]) - const [labelsHover, setLabelsHover] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [hover, setHover] = useState("") - const [expandedAttributes, setExpandedAttributes] = useState>({}) - const [editable, setEditable] = useState("") - const valueParagraphRefs = useRef>({}) - const [valueOverflowMap, setValueOverflowMap] = useState>({}) + const { indicator } = useContext(IndicatorContext); + const { toast } = useToast(); + + const setInputRef = useRef(null); + const scrollableContainerRef = useRef(null); + + const [attributes, setAttributes] = useState<[string, Value][]>([]); + const [newKey, setNewKey] = useState(""); + const [newVal, setNewVal] = useState(""); + const [newType, setNewType] = useState("string"); + const [editVal, setEditVal] = useState(""); + const [editType, setEditType] = useState("string"); + const [labels, setLabels] = useState([]); + const [labelsHover, setLabelsHover] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hover, setHover] = useState(""); + const [expandedAttributes, setExpandedAttributes] = useState>({}); + const [editable, setEditable] = useState(""); + const valueParagraphRefs = useRef>({}); + const [valueOverflowMap, setValueOverflowMap] = useState>({}); const setValueParagraphRef = useCallback((key: string) => (el: HTMLParagraphElement | null) => { if (!el) { - delete valueParagraphRefs.current[key] - return + delete valueParagraphRefs.current[key]; + return; } - valueParagraphRefs.current[key] = el - }, []) + valueParagraphRefs.current[key] = el; + }, []); const measureValueOverflow = useCallback(() => { - if (typeof window === "undefined") return + if (typeof window === "undefined") return; - const nextMap: Record = {} + const nextMap: Record = {}; attributes.forEach(([key]) => { - const element = valueParagraphRefs.current[key] - if (!element) return + const element = valueParagraphRefs.current[key]; + if (!element) return; - const computedStyle = window.getComputedStyle(element) - let lineHeight = parseFloat(computedStyle.lineHeight) + const computedStyle = window.getComputedStyle(element); + let lineHeight = parseFloat(computedStyle.lineHeight); if (Number.isNaN(lineHeight)) { - const fontSize = parseFloat(computedStyle.fontSize) - lineHeight = Number.isNaN(fontSize) ? 16 : fontSize * 1.2 + const fontSize = parseFloat(computedStyle.fontSize); + lineHeight = Number.isNaN(fontSize) ? 16 : fontSize * 1.2; } - const collapsedHeight = lineHeight * 3 - nextMap[key] = element.scrollHeight - collapsedHeight > 1 - }) + const collapsedHeight = lineHeight * 3; + nextMap[key] = element.scrollHeight - collapsedHeight > 1; + }); setValueOverflowMap((prev) => { - const prevKeys = Object.keys(prev) - const nextKeys = Object.keys(nextMap) + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(nextMap); if (prevKeys.length === nextKeys.length && prevKeys.every((key) => prev[key] === nextMap[key])) { - return prev + return prev; } - return nextMap - }) - }, [attributes]) + return nextMap; + }); + }, [attributes]); useLayoutEffect(() => { - measureValueOverflow() - if (typeof window === "undefined") return undefined + measureValueOverflow(); + if (typeof window === "undefined") return undefined; - window.addEventListener("resize", measureValueOverflow) + window.addEventListener("resize", measureValueOverflow); return () => { - window.removeEventListener("resize", measureValueOverflow) - } - }, [measureValueOverflow]) + window.removeEventListener("resize", measureValueOverflow); + }; + }, [measureValueOverflow]); useLayoutEffect(() => { - if (typeof ResizeObserver === "undefined") return undefined - if (!scrollableContainerRef.current) return undefined + if (typeof ResizeObserver === "undefined") return undefined; + if (!scrollableContainerRef.current) return undefined; - const observer = new ResizeObserver(() => measureValueOverflow()) - observer.observe(scrollableContainerRef.current) + const observer = new ResizeObserver(() => measureValueOverflow()); + observer.observe(scrollableContainerRef.current); return () => { - observer.disconnect() - } - }, [measureValueOverflow]) + observer.disconnect(); + }; + }, [measureValueOverflow]); const handleClose = useCallback((e?: KeyboardEvent) => { - if (e && e.key !== "Escape") return - setAttributes([]) - setLabels([]) - setNewKey("") - setNewVal("") - setNewType("string") - setEditVal("") - setEditType("string") - setEditable("") - setHover("") - setExpandedAttributes({}) - onClose() - }, [onClose]) + if (e && e.key !== "Escape") return; + setAttributes([]); + setLabels([]); + setNewKey(""); + setNewVal(""); + setNewType("string"); + setEditVal(""); + setEditType("string"); + setEditable(""); + setHover(""); + setExpandedAttributes({}); + onClose(); + }, [onClose]); useEffect(() => { - window.addEventListener("keydown", handleClose) + window.addEventListener("keydown", handleClose); return () => { - window.removeEventListener("keydown", handleClose) - } - }, [handleClose]) + window.removeEventListener("keydown", handleClose); + }; + }, [handleClose]); useEffect(() => { if (setInputRef.current && editable) { - setInputRef.current.focus() + setInputRef.current.focus(); } - }, [editable]) + }, [editable]); - const handleGetNodeTextPriority = useCallback((node: Node) => getNodeDisplayText(node, displayTextPriority), [displayTextPriority]) + const handleGetNodeTextPriority = useCallback((node: Node) => getNodeDisplayText(node), []); const getDefaultVal = (t: ValueType): Value => { switch (t) { case "boolean": - return false + return false; case "number": - return 0 + return 0; default: - return "" + return ""; } - } + }; const getStringValue = (value: Value) => { switch (typeof value) { case "number": - return String(value) + return String(value); case "boolean": - return value ? "true" : "false" + return value ? "true" : "false"; case "object": - return String(value) + return String(value); default: - return value + return value; } - } + }; const handleSetEditable = (key: string, value?: Value) => { - setEditable(key) - setEditVal(value || "") - setEditType(typeof value === "undefined" ? "string" : typeof value as ValueType) - } + setEditable(key); + setEditVal(value || ""); + setEditType(typeof value === "undefined" ? "string" : typeof value as ValueType); + }; const handleUpdateAttribute = (oldKey: string) => { if (editVal === "") { @@ -192,37 +192,37 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Value cannot be empty", variant: "destructive" - }) - return + }); + return; } setAttributes(prev => prev.map(([key, val]) => key === oldKey ? [key, editVal] : [key, val] - )) + )); - setEditable("") - setEditVal("") - setEditType("string") + setEditable(""); + setEditVal(""); + setEditType("string"); setExpandedAttributes(prev => ({ ...prev, [oldKey]: false - })) - } + })); + }; const handleSetKeyDown = (e: React.KeyboardEvent, key: string) => { if (e.key === "Escape") { - e.preventDefault() - setEditable("") - setEditVal("") - setEditType("string") - return + e.preventDefault(); + setEditable(""); + setEditVal(""); + setEditType("string"); + return; } - if (e.key !== 'Enter') return + if (e.key !== 'Enter') return; - e.preventDefault() - handleUpdateAttribute(key) - } + e.preventDefault(); + handleUpdateAttribute(key); + }; const handleAddAttribute = () => { if (!newKey || newVal === "") { @@ -230,9 +230,9 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Key or value cannot be empty", variant: "destructive" - }) + }); - return + return; } if (attributes.some(([key]) => key === newKey)) { @@ -240,47 +240,47 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "An attribute with this key already exists", variant: "destructive" - }) + }); - return + return; } - setAttributes(prev => [...prev, [newKey, newVal]]) - setNewKey("") - setNewVal("") - setNewType("string") - } + setAttributes(prev => [...prev, [newKey, newVal]]); + setNewKey(""); + setNewVal(""); + setNewType("string"); + }; const handleAddKeyDown = (e: React.KeyboardEvent) => { if (e.code === "Escape") { - e.preventDefault() - setNewKey("") - setNewVal("") - setNewType("string") - return + e.preventDefault(); + setNewKey(""); + setNewVal(""); + setNewType("string"); + return; } - if (e.key !== 'Enter') return + if (e.key !== 'Enter') return; - e.preventDefault() - handleAddAttribute() - } + e.preventDefault(); + handleAddAttribute(); + }; - const valueNeedsExpansion = (key: string) => Boolean(valueOverflowMap[key]) + const valueNeedsExpansion = (key: string) => Boolean(valueOverflowMap[key]); const handleToggleValueExpansion = (key: string, event: ReactMouseEvent | ReactKeyboardEvent) => { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); setExpandedAttributes(prev => ({ ...prev, [key]: !prev[key] - })) - } + })); + }; const getCellEditableContent = (actionType: "set" | "add" = "add", key?: string) => { - const value = actionType === "set" ? editVal : newVal - const valueType = actionType === "set" ? editType : newType - const setValue = actionType === "set" ? setEditVal : setNewVal + const value = actionType === "set" ? editVal : newVal; + const valueType = actionType === "set" ? editType : newType; + const setValue = actionType === "set" ? setEditVal : setNewVal; switch (valueType) { case "boolean": @@ -288,18 +288,18 @@ export default function CreateElementPanel(props: Props) { className="data-[state=unchecked]:bg-border" checked={value as boolean} onCheckedChange={(checked) => setValue(checked)} - /> + />; case "number": return { - const num = Number(e.target.value) - if (!Number.isNaN(num)) setValue(num) + const num = Number(e.target.value); + if (!Number.isNaN(num)) setValue(num); }} onKeyDown={actionType === "set" ? (e) => handleSetKeyDown(e, key!) : handleAddKeyDown} - /> + />; default: return setValue(e.target.value)} onKeyDown={actionType === "set" ? (e) => handleSetKeyDown(e, key!) : handleAddKeyDown} - /> + />; } - } + }; const getNewTypeInput = (actionType: "set" | "add" = "add") => { - const valueType = actionType === "set" ? editType : newType - const setType = actionType === "set" ? setEditType : setNewType - const value = actionType === "set" ? editVal : newVal - const setValue = actionType === "set" ? setEditVal : setNewVal + const valueType = actionType === "set" ? editType : newType; + const setType = actionType === "set" ? setEditType : setNewType; + const value = actionType === "set" ? editVal : newVal; + const setValue = actionType === "set" ? setEditVal : setNewVal; return ( { - const t = val as ValueType - setType(t) - setValue(typeof value === t ? value : getDefaultVal(t)) + const t = val as ValueType; + setType(t); + setValue(typeof value === t ? value : getDefaultVal(t)); }} label="Type" /> - ) - } + ); + }; const isComplexType = (value: Value) => { - const valueType = typeof value - return valueType !== "string" && valueType !== "number" && valueType !== "boolean" - } + const valueType = typeof value; + return valueType !== "string" && valueType !== "number" && valueType !== "boolean"; + }; const handleAddLabel = async (newLabel: string) => { if (newLabel === "") { @@ -344,8 +344,8 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Label cannot be empty", variant: "destructive" - }) - return false + }); + return false; } if (labels.includes(newLabel)) { @@ -353,8 +353,8 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Label already exists", variant: "destructive" - }) - return false + }); + return false; } // For edges, only allow one label @@ -363,20 +363,20 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Edge can only have one label", variant: "destructive" - }) - return false + }); + return false; } - setLabels(prev => [...prev, newLabel]) + setLabels(prev => [...prev, newLabel]); - return true - } + return true; + }; const handleRemoveLabel = async (removeLabel: string) => { - setLabels(prev => prev.filter(l => l !== removeLabel)) + setLabels(prev => prev.filter(l => l !== removeLabel)); - return true - } + return true; + }; const handleOnCreate = async () => { if (!type) { @@ -385,28 +385,28 @@ export default function CreateElementPanel(props: Props) { title: "Error", description: "Edge must have a label (relationship type)", variant: "destructive" - }) - return + }); + return; } } try { - setIsLoading(true) - const ok = await onCreate(attributes, labels) - - if (!ok) return - - setAttributes([]) - setNewKey("") - setNewVal("") - setNewType("string") - setEditVal("") - setEditType("string") - setLabels([]) + setIsLoading(true); + const ok = await onCreate(attributes, labels); + + if (!ok) return; + + setAttributes([]); + setNewKey(""); + setNewVal(""); + setNewType("string"); + setEditVal(""); + setEditType("string"); + setLabels([]); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return (
    @@ -475,11 +475,11 @@ export default function CreateElementPanel(props: Props) {
    Type
    {attributes.map(([key, value]) => { - const isComplex = isComplexType(value) - const stringValue = getStringValue(value) - const isExpanded = expandedAttributes[key] - const shouldShowToggle = valueNeedsExpansion(key) - const rowHeightClass = editable === key ? "py-2 min-h-14" : "py-2 min-h-10" + const isComplex = isComplexType(value); + const stringValue = getStringValue(value); + const isExpanded = expandedAttributes[key]; + const shouldShowToggle = valueNeedsExpansion(key); + const rowHeightClass = editable === key ? "py-2 min-h-14" : "py-2 min-h-10"; return ( @@ -526,7 +526,7 @@ export default function CreateElementPanel(props: Props) { onClick={(event) => handleToggleValueExpansion(key, event)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { - handleToggleValueExpansion(key, event) + handleToggleValueExpansion(key, event); } }} > @@ -564,9 +564,9 @@ export default function CreateElementPanel(props: Props) { variant="button" title="Cancel" onClick={() => { - setEditable("") - setEditVal("") - setEditType("string") + setEditable(""); + setEditVal(""); + setEditType("string"); }} > @@ -620,7 +620,7 @@ export default function CreateElementPanel(props: Props) { }
    - ) + ); })}
    { - setNewKey("") - setNewVal("") - setNewType("string") + setNewKey(""); + setNewVal(""); + setNewType("string"); }} > @@ -756,5 +756,5 @@ export default function CreateElementPanel(props: Props) {
    - ) + ); } \ No newline at end of file diff --git a/app/graph/CustomizeStylePanel.tsx b/app/graph/CustomizeStylePanel.tsx index d3c4e06c..27cfca13 100644 --- a/app/graph/CustomizeStylePanel.tsx +++ b/app/graph/CustomizeStylePanel.tsx @@ -1,10 +1,12 @@ -'use client' +/* eslint-disable no-param-reassign */ + +'use client'; import { useContext, useState, useEffect, useCallback, useRef } from "react"; import { X, Palette } from "lucide-react"; import { cn } from "@/lib/utils"; -import { GraphContext, ViewportContext } from "@/app/components/provider"; -import { Label, STYLE_COLORS, NODE_SIZE_OPTIONS, LabelStyle, EMPTY_DISPLAY_NAME } from "@/app/api/graph/model"; +import { GraphContext, ForceGraphContext } from "@/app/components/provider"; +import { Label, STYLE_COLORS, NODE_SIZE_OPTIONS, LabelStyle, getLabelWithFewestElements, EMPTY_DISPLAY_NAME } from "@/app/api/graph/model"; import Button from "@/app/components/ui/Button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -15,31 +17,28 @@ interface Props { export default function CustomizeStylePanel({ label, onClose }: Props) { const { graph } = useContext(GraphContext); - const { setData } = useContext(ViewportContext); + const { canvasRef } = useContext(ForceGraphContext); // Get available properties from nodes with this label - const availableProperties = Array.from( + const captionOptions = Array.from( new Set( label.elements.flatMap(node => Object.keys(node.data || {})) ) ).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - // Add special options - const captionOptions = ["Description", ...availableProperties, "id"]; - // Store original values for comparison and cancel functionality - const [originalColor] = useState(label.style?.customColor || label.color); - const [originalSize] = useState(label.style?.customSize || 1); - const [originalCaption] = useState(label.style?.customCaption || "Description"); + const [originalColor] = useState(label.style.color); + const [originalSize] = useState(label.style.size || 6); + const [originalCaption] = useState(label.style.caption); const [selectedColor, setSelectedColor] = useState( - label.style?.customColor || label.color + label.style.color ); - const [selectedSize, setSelectedSize] = useState( - label.style?.customSize || 1 + const [selectedSize, setSelectedSize] = useState( + label.style.size || 6 ); - const [selectedCaption, setSelectedCaption] = useState( - label.style?.customCaption || "Description" + const [selectedCaption, setSelectedCaption] = useState( + label.style.caption ); // RGB Color Picker state @@ -58,36 +57,45 @@ export default function CustomizeStylePanel({ label, onClose }: Props) { localStorage.setItem(storageKey, JSON.stringify(style)); }, []); - const applyStylesToGraph = useCallback((color: string, size: number, caption: string) => { + const applyStylesToGraph = useCallback((color: string, size: number, caption?: string) => { const updatedLabel = graph.LabelsMap.get(label.name); - if (updatedLabel) { - // Update label color directly for sidebar badge - // eslint-disable-next-line no-param-reassign - updatedLabel.color = color; + if (updatedLabel) { updatedLabel.style = { ...updatedLabel.style, - customColor: color, - customSize: size, - customCaption: caption, + color, + size, + caption, }; // Update all nodes with this label updatedLabel.elements.forEach(n => { - // eslint-disable-next-line no-param-reassign - n.color = color; - // Clear cached display names to force recalculation - // eslint-disable-next-line no-param-reassign - n.displayName = [...EMPTY_DISPLAY_NAME]; + if (getLabelWithFewestElements(n.labels.map(l => graph.LabelsMap.get(l)).filter(Boolean) as Label[])?.name === label.name) { + n.color = color; + n.size = size; + n.caption = caption; + } }); - // Trigger canvas re-render - setData({ - nodes: [...graph.Elements.nodes], - links: graph.Elements.links - }); + const canvas = canvasRef.current; + + if (canvas) { + const currentData = canvas.getGraphData(); + + currentData.nodes.forEach(node => { + if (getLabelWithFewestElements(node.labels.map(l => graph.LabelsMap.get(l)).filter(Boolean) as Label[])?.name === label.name) { + node.color = color; + node.size = size; + node.caption = caption; + node.displayName = EMPTY_DISPLAY_NAME; + } + }); + + canvas.setGraphData(currentData); + } + } - }, [graph, label.name, setData]); + }, [canvasRef, graph.LabelsMap, label.name]); const handleColorSelect = (color: string) => { setSelectedColor(color); @@ -114,7 +122,7 @@ export default function CustomizeStylePanel({ label, onClose }: Props) { applyStylesToGraph(selectedColor, size, selectedCaption); }; - const handleCaptionSelect = (caption: string) => { + const handleCaptionSelect = (caption?: string) => { setSelectedCaption(caption); // Apply to graph immediately for preview (without saving to localStorage) applyStylesToGraph(selectedColor, selectedSize, caption); @@ -123,9 +131,9 @@ export default function CustomizeStylePanel({ label, onClose }: Props) { const handleSave = () => { // Save to localStorage saveStyleToStorage(label.name, { - customColor: selectedColor, - customSize: selectedSize, - customCaption: selectedCaption, + color: selectedColor, + size: selectedSize, + caption: selectedCaption, }); onClose(); }; @@ -141,9 +149,10 @@ export default function CustomizeStylePanel({ label, onClose }: Props) { }, [originalColor, originalSize, originalCaption, applyStylesToGraph]); const handleClose = useCallback(() => { + handleCancel(); // Just close the panel without reverting changes onClose(); - }, [onClose]); + }, [onClose, handleCancel]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -157,80 +166,80 @@ export default function CustomizeStylePanel({ label, onClose }: Props) { }, [handleCancel]); return ( -
    - {/* Scrollable Content Area */} -
    - - -
    -

    Style Settings

    - -
    -
    - {label.name} -
    + <> + +

    Style Settings

    +
    +
    + + +

    {label.name}

    +
    + + {label.name} + +
    +
    {/* Color Selection */} -
    +

    Color:

    -
    -
    - {/* First 15 preset colors */} - {STYLE_COLORS.slice(0, 15).map((color) => ( - - - + style={{ backgroundColor: color }} + onClick={() => handleColorSelect(color)} + aria-label={`Select color ${color}`} + /> - {showRgbPicker ? "Close Custom Color" : "Custom Color"} + {color} -
    + ))} + + {/* RGB Color Picker Button */} + + + + + + {showRgbPicker ? "Close Custom Color" : "Custom Color"} + + {/* RGB Color Picker Panel */} {showRgbPicker && ( @@ -290,46 +299,43 @@ export default function CustomizeStylePanel({ label, onClose }: Props) {
    {/* Size Selection */} -
    +

    Size:

    - {NODE_SIZE_OPTIONS.map((size) => { - const displaySize = 12 + (size - 1) * 12; // Scale for display - return ( - - - - - - {size}x - - - ); - })} + {NODE_SIZE_OPTIONS.map((size) => ( + + + + + + {(size / 6).toFixed(2)}x + + + ))}
    {/* Caption Selection */} -
    +

    Caption:

    -
    +
    {captionOptions.map((option) => (
    -
    + + + + + + Graph ID + +
    {/* Sticky Save/Cancel Buttons - Only show when there are changes */} - {hasChanges && ( -
    -
    - - + onClick={handleSave} + > + Save Changes + +
    -
    - )} -
    + ) + } + ); } diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx index 6a682bbf..26e17ac1 100644 --- a/app/graph/DataPanel.tsx +++ b/app/graph/DataPanel.tsx @@ -2,9 +2,9 @@ /* eslint-disable no-param-reassign */ /* eslint-disable react/require-default-props */ -'use client' +'use client'; -import { prepareArg, securedFetch } from "@/lib/utils"; +import { prepareArg, securedFetch, GraphRef } from "@/lib/utils"; import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; import { Pencil, X } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; @@ -20,91 +20,110 @@ interface Props { object: Node | Link; onClose: () => void; setLabels: Dispatch>; + canvasRef: GraphRef; } -export default function DataPanel({ object, onClose, setLabels }: Props) { - const { setIndicator } = useContext(IndicatorContext) - const { graph, setGraphInfo } = useContext(GraphContext) +export default function DataPanel({ object, onClose, setLabels, canvasRef }: Props) { + const { setIndicator } = useContext(IndicatorContext); + const { graph, setGraphInfo } = useContext(GraphContext); - const lastObjId = useRef(undefined) - const labelsListRef = useRef(null) + const lastObjId = useRef(undefined); + const labelsListRef = useRef(null); - const { toast } = useToast() - const { data: session } = useSession() + const { toast } = useToast(); + const { data: session } = useSession(); - const [labelsHover, setLabelsHover] = useState(false) + const [labelsHover, setLabelsHover] = useState(false); const [label, setLabel] = useState([]); - const type = !object.source + const type = !("source" in object); const handleClose = useCallback((e: KeyboardEvent) => { if (e.key === "Escape") { - onClose() + onClose(); } - }, [onClose]) + }, [onClose]); useEffect(() => { - window.addEventListener("keydown", handleClose) + window.addEventListener("keydown", handleClose); return () => { - window.removeEventListener("keydown", handleClose) - } - }, [handleClose]) + window.removeEventListener("keydown", handleClose); + }; + }, [handleClose]); useEffect(() => { if (lastObjId.current !== object.id) { - setLabelsHover(false) + setLabelsHover(false); } setLabel(type ? [...(object as Node).labels.filter((c) => c !== "")] : [object.relationship]); - lastObjId.current = object.id + lastObjId.current = object.id; }, [object, type]); const handleAddLabel = async (newLabel: string) => { - const node = object as Node + const node = object as Node; if (newLabel === "") { toast({ title: "Error", description: "Please fill the label", variant: "destructive" - }) - return false + }); + return false; } if (label.includes(newLabel)) { toast({ title: "Error", description: "Label already exists", variant: "destructive" - }) - return false + }); + return false; } const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label`, { method: "POST", body: JSON.stringify({ label: newLabel }) - }, toast, setIndicator) + }, toast, setIndicator); if (result.ok) { - setLabels([...graph.addLabel(newLabel, node)]) - setLabel([...node.labels]) - const newGraphInfo = graph.GraphInfo.clone() - setGraphInfo(newGraphInfo) - graph.GraphInfo = newGraphInfo - return true + setLabels([...graph.addLabel(newLabel, node)]); + setLabel([...node.labels]); + const newGraphInfo = graph.GraphInfo.clone(); + setGraphInfo(newGraphInfo); + graph.GraphInfo = newGraphInfo; + + const canvas = canvasRef.current; + + if (canvas) { + const currentData = canvas.getGraphData(); + + currentData.nodes.forEach(canvasNode => { + if (canvasNode.id === node.id) { + canvasNode.labels = [...node.labels]; + canvasNode.color = node.color; + canvasNode.size = node.size || canvasNode.size; + canvasNode.caption = node.caption; + } + }); + + canvas.setGraphData({ ...currentData }); + } + + return true; } - return false - } + return false; + }; const handleRemoveLabel = async (removeLabel: string) => { - const node = object as Node + const node = object as Node; if (removeLabel === "") { toast({ title: "Error", description: "You cannot remove the default label", variant: "destructive" - }) - return false + }); + return false; } const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label`, { @@ -112,20 +131,39 @@ export default function DataPanel({ object, onClose, setLabels }: Props) { body: JSON.stringify({ label: removeLabel }) - }, toast, setIndicator) + }, toast, setIndicator); if (result.ok) { - graph.removeLabel(removeLabel, node) - setLabels([...graph.Labels]) - setLabel([...node.labels]) - const newGraphInfo = graph.GraphInfo.clone() - setGraphInfo(newGraphInfo) - graph.GraphInfo = newGraphInfo - return true + graph.removeLabel(removeLabel, node); + setLabels([...graph.Labels]); + setLabel([...node.labels]); + const newGraphInfo = graph.GraphInfo.clone(); + setGraphInfo(newGraphInfo); + graph.GraphInfo = newGraphInfo; + + const canvas = canvasRef.current; + if (canvas) { + const currentData = canvas.getGraphData(); + + currentData.nodes.forEach(canvasNode => { + if (canvasNode.id === node.id) { + + // Update canvas node to match the updated graph node + canvasNode.labels = [...node.labels]; + canvasNode.color = node.color; + canvasNode.size = node.size || canvasNode.size; + canvasNode.caption = node.caption; + } + }); + + canvas.setGraphData({ ...currentData }); + } + + return true; } - return false - } + return false; + }; return (
    @@ -201,7 +239,8 @@ export default function DataPanel({ object, onClose, setLabels }: Props) { lastObjId={lastObjId} object={object} type={type} + canvasRef={canvasRef} />
    - ) + ); } \ No newline at end of file diff --git a/app/graph/DataTable.tsx b/app/graph/DataTable.tsx index 7c9a0555..a028691c 100644 --- a/app/graph/DataTable.tsx +++ b/app/graph/DataTable.tsx @@ -1,130 +1,133 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-param-reassign */ -import { Check, CirclePlus, Info, Pencil, Trash2, X } from "lucide-react" -import { cn, prepareArg, securedFetch } from "@/lib/utils" -import { toast } from "@/components/ui/use-toast" -import { Fragment, MutableRefObject, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react" -import { useSession } from "next-auth/react" -import { Switch } from "@/components/ui/switch" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import Input from "../components/ui/Input" -import DialogComponent from "../components/DialogComponent" -import CloseDialog from "../components/CloseDialog" -import { Link, Node, Value } from "../api/graph/model" -import { GraphContext, IndicatorContext, BrowserSettingsContext } from "../components/provider" -import ToastButton from "../components/ToastButton" -import Button from "../components/ui/Button" -import Combobox from "../components/ui/combobox" - -type ValueType = "string" | "number" | "boolean" +'use client'; + +import { Check, CirclePlus, Info, Pencil, Trash2, X } from "lucide-react"; +import { cn, prepareArg, securedFetch, GraphRef } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; +import { Fragment, MutableRefObject, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useSession } from "next-auth/react"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getNodeDisplayKey } from "@falkordb/canvas"; +import Input from "../components/ui/Input"; +import DialogComponent from "../components/DialogComponent"; +import CloseDialog from "../components/CloseDialog"; +import { EMPTY_DISPLAY_NAME, Link, Node, Value } from "../api/graph/model"; +import { GraphContext, IndicatorContext } from "../components/provider"; +import ToastButton from "../components/ToastButton"; +import Button from "../components/ui/Button"; +import Combobox from "../components/ui/combobox"; + +type ValueType = "string" | "number" | "boolean"; interface Props { object: Node | Link type: boolean lastObjId: MutableRefObject + canvasRef: GraphRef className?: string } -export default function DataTable({ object, type, lastObjId, className }: Props) { - - const { graph, graphInfo, setGraphInfo } = useContext(GraphContext) - const { settings: { graphInfo: graphInfoSettings } } = useContext(BrowserSettingsContext) - const { displayTextPriority } = graphInfoSettings - - const setInputRef = useRef(null) - const setTextareaRef = useRef(null) - const addInputRef = useRef(null) - const scrollableContainerRef = useRef(null) - - const [hover, setHover] = useState("") - const [editable, setEditable] = useState("") - const [isAddValue, setIsAddValue] = useState(false) - const [newKey, setNewKey] = useState("") - const [newVal, setNewVal] = useState("") - const [newType, setNewType] = useState("string") - const [isSetLoading, setIsSetLoading] = useState(false) - const [isAddLoading, setIsAddLoading] = useState(false) - const [isRemoveLoading, setIsRemoveLoading] = useState(false) - const { indicator, setIndicator } = useContext(IndicatorContext) - const { data: session } = useSession() - const [attributes, setAttributes] = useState([]) - const [expandedAttributes, setExpandedAttributes] = useState>({}) - const valueParagraphRefs = useRef>({}) - const [valueOverflowMap, setValueOverflowMap] = useState>({}) +export default function DataTable({ object, type, lastObjId, canvasRef, className }: Props) { + + const { graph, graphInfo, setGraphInfo } = useContext(GraphContext); + const { toast } = useToast(); + + const setInputRef = useRef(null); + const setTextareaRef = useRef(null); + const addInputRef = useRef(null); + const scrollableContainerRef = useRef(null); + + const [hover, setHover] = useState(""); + const [editable, setEditable] = useState(""); + const [isAddValue, setIsAddValue] = useState(false); + const [newKey, setNewKey] = useState(""); + const [newVal, setNewVal] = useState(""); + const [newType, setNewType] = useState("string"); + const [isSetLoading, setIsSetLoading] = useState(false); + const [isAddLoading, setIsAddLoading] = useState(false); + const [isRemoveLoading, setIsRemoveLoading] = useState(false); + const { indicator, setIndicator } = useContext(IndicatorContext); + const { data: session } = useSession(); + const [attributes, setAttributes] = useState([]); + const [expandedAttributes, setExpandedAttributes] = useState>({}); + const valueParagraphRefs = useRef>({}); + const [valueOverflowMap, setValueOverflowMap] = useState>({}); const setValueParagraphRef = useCallback((key: string) => (el: HTMLParagraphElement | null) => { if (!el) { - delete valueParagraphRefs.current[key] - return + delete valueParagraphRefs.current[key]; + return; } - valueParagraphRefs.current[key] = el - }, []) + valueParagraphRefs.current[key] = el; + }, []); const measureValueOverflow = useCallback(() => { - if (typeof window === "undefined") return + if (typeof window === "undefined") return; - const nextMap: Record = {} + const nextMap: Record = {}; attributes.forEach((key) => { - const element = valueParagraphRefs.current[key] - if (!element) return + const element = valueParagraphRefs.current[key]; + if (!element) return; - const computedStyle = window.getComputedStyle(element) - let lineHeight = parseFloat(computedStyle.lineHeight) + const computedStyle = window.getComputedStyle(element); + let lineHeight = parseFloat(computedStyle.lineHeight); if (Number.isNaN(lineHeight)) { - const fontSize = parseFloat(computedStyle.fontSize) - lineHeight = Number.isNaN(fontSize) ? 16 : fontSize * 1.2 + const fontSize = parseFloat(computedStyle.fontSize); + lineHeight = Number.isNaN(fontSize) ? 16 : fontSize * 1.2; } - const collapsedHeight = lineHeight * 3 - nextMap[key] = element.scrollHeight - collapsedHeight > 1 - }) + const collapsedHeight = lineHeight * 3; + nextMap[key] = element.scrollHeight - collapsedHeight > 1; + }); setValueOverflowMap((prev) => { - const prevKeys = Object.keys(prev) - const nextKeys = Object.keys(nextMap) + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(nextMap); if (prevKeys.length === nextKeys.length && prevKeys.every((key) => prev[key] === nextMap[key])) { - return prev + return prev; } - return nextMap - }) - }, [attributes]) + return nextMap; + }); + }, [attributes]); useLayoutEffect(() => { - measureValueOverflow() - if (typeof window === "undefined") return undefined + measureValueOverflow(); + if (typeof window === "undefined") return undefined; - window.addEventListener("resize", measureValueOverflow) + window.addEventListener("resize", measureValueOverflow); return () => { - window.removeEventListener("resize", measureValueOverflow) - } - }, [measureValueOverflow]) + window.removeEventListener("resize", measureValueOverflow); + }; + }, [measureValueOverflow]); useLayoutEffect(() => { - if (typeof ResizeObserver === "undefined") return undefined - if (!scrollableContainerRef.current) return undefined + if (typeof ResizeObserver === "undefined") return undefined; + if (!scrollableContainerRef.current) return undefined; - const observer = new ResizeObserver(() => measureValueOverflow()) - observer.observe(scrollableContainerRef.current) + const observer = new ResizeObserver(() => measureValueOverflow()); + observer.observe(scrollableContainerRef.current); return () => { - observer.disconnect() - } - }, [measureValueOverflow]) + observer.disconnect(); + }; + }, [measureValueOverflow]); useEffect(() => { if (editable) { if (setInputRef.current) { - setInputRef.current.focus() + setInputRef.current.focus(); } else if (setTextareaRef.current) { - setTextareaRef.current.focus() + setTextareaRef.current.focus(); } } - }, [editable]) + }, [editable]); useEffect(() => { if (isAddValue) { @@ -133,139 +136,128 @@ export default function DataTable({ object, type, lastObjId, className }: Props) scrollableContainerRef.current?.scrollTo({ top: scrollableContainerRef.current.scrollHeight, behavior: "smooth" - }) - }, 0) + }); + }, 0); } if (addInputRef.current) { - addInputRef.current.focus() + addInputRef.current.focus(); } } - }, [isAddValue]) + }, [isAddValue]); useEffect(() => { if (lastObjId.current !== object.id) { - setEditable("") - setNewVal("") - setNewKey("") - setIsAddValue(false) - } - setAttributes(Object.keys(object.data)) - setExpandedAttributes({}) - }, [lastObjId, object, setAttributes, type]) - - const getNodeDisplayKey = (node: Node) => { - const { data: nodeData } = node; - - const displayText = displayTextPriority.find(({ name, ignore }) => { - const key = ignore - ? Object.keys(nodeData).find( - (k) => k.toLowerCase() === name.toLowerCase() - ) - : name; - - return ( - key && - nodeData[key] && - typeof nodeData[key] === "string" && - nodeData[key].trim().length > 0 - ); - }); - - if (displayText) { - const key = displayText.ignore - ? Object.keys(nodeData).find( - (k) => k.toLowerCase() === displayText.name.toLowerCase() - ) - : displayText.name; - - if (key) { - return key; - } + setEditable(""); + setNewVal(""); + setNewKey(""); + setIsAddValue(false); } - - return "id"; - } + setAttributes(Object.keys(object.data)); + setExpandedAttributes({}); + }, [lastObjId, object, setAttributes, type]); const getDefaultVal = (t: ValueType) => { switch (t) { case "boolean": - return false + return false; case "number": - return 0 + return 0; default: - return "" + return ""; } - } + }; const isComplexType = (value: Value) => { - const valueType = typeof value - return valueType !== "string" && valueType !== "number" && valueType !== "boolean" - } + const valueType = typeof value; + return valueType !== "string" && valueType !== "number" && valueType !== "boolean"; + }; const handleSetEditable = (key: string, value?: Value) => { if (key !== "") { - setIsAddValue(false) + setIsAddValue(false); } // Don't allow editing complex types if (value !== undefined && isComplexType(value)) { - return + return; } - setEditable(key) - setNewVal(value ?? "") - setNewType(typeof value === "undefined" ? "string" : typeof value as ValueType) + setEditable(key); + setNewVal(value ?? ""); + setNewType(typeof value === "undefined" ? "string" : typeof value as ValueType); - if (typeof value !== "undefined" && typeof value !== "string") return + if (typeof value !== "undefined" && typeof value !== "string") return; setTimeout(() => { if (setTextareaRef.current) { - setTextareaRef.current.style.height = 'auto' - setTextareaRef.current.style.height = `${setTextareaRef.current.scrollHeight}px` + setTextareaRef.current.style.height = 'auto'; + setTextareaRef.current.style.height = `${setTextareaRef.current.scrollHeight}px`; } - }, 0) - } + }, 0); + }; const setProperty = async (key: string, val: Value, isUndo: boolean, actionType: ("added" | "set") = "set") => { - const { id } = object + const { id } = object; if (val === "") { toast({ title: "Error", description: "Please fill in the value field", variant: "destructive" - }) - return false + }); + return false; } try { - if (actionType === "set") setIsSetLoading(true) + if (actionType === "set") setIsSetLoading(true); const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { method: "POST", body: JSON.stringify({ value: val, type }) - }, toast, setIndicator) + }, toast, setIndicator); if (result.ok) { - const value = object.data[key] + const value = object.data[key]; - graph.setProperty(key, val, id, type) + graph.setProperty(key, val, id, type); graphInfo.PropertyKeys = [...(graphInfo.PropertyKeys || []).filter((k) => k !== key), key]; const graphI = graphInfo.clone(); - graph.GraphInfo = graphI - setGraphInfo(graphI) + graph.GraphInfo = graphI; + setGraphInfo(graphI); - object.data[key] = val + object.data[key] = val; - if (object.labels && getNodeDisplayKey(object as Node) === key) { - object.displayName = ['', ''] - } + setAttributes(Object.keys(object.data)); + + const canvas = canvasRef.current; + + if (canvas) { + const currentData = canvas.getGraphData(); + + if (type) { + const canvasNode = currentData.nodes.find(n => n.id === object.id); + + if (canvasNode) { + canvasNode.data[key] = val; + + if (getNodeDisplayKey(object as Node) === key) { + canvasNode.displayName = EMPTY_DISPLAY_NAME; + } + } + } else { + const canvasLink = currentData.links.find(l => l.id === object.id); - setAttributes(Object.keys(object.data)) + if (canvasLink) { + canvasLink.data[key] = val; + } + } - handleSetEditable("") + canvas.setGraphData({ ...currentData }); + } + + handleSetEditable(""); toast({ title: "Success", description: `Attribute ${actionType}`, @@ -276,14 +268,14 @@ export default function DataTable({ object, type, lastObjId, className }: Props) onClick={() => setProperty(key, value, false)} /> : undefined - }) + }); } - return result.ok + return result.ok; } finally { - if (actionType === "set") setIsSetLoading(false) + if (actionType === "set") setIsSetLoading(false); } - } + }; const handleAddValue = async (key: string, value: Value) => { if (!key || key === "" || value === "") { @@ -291,42 +283,64 @@ export default function DataTable({ object, type, lastObjId, className }: Props) title: "Error", description: "Please fill in both fields", variant: "destructive" - }) - return + }); + return; } try { - setIsAddLoading(true) - const success = await setProperty(key, value, false, "added") - if (!success) return - setIsAddValue(false) - setNewKey("") - setNewVal("") + setIsAddLoading(true); + const success = await setProperty(key, value, false, "added"); + if (!success) return; + setIsAddValue(false); + setNewKey(""); + setNewVal(""); } finally { - setIsAddLoading(false) + setIsAddLoading(false); } - } + }; const removeProperty = async (key: string) => { try { - setIsRemoveLoading(true) - const { id } = object + setIsRemoveLoading(true); + const { id } = object; const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { method: "DELETE", body: JSON.stringify({ type }), - }, toast, setIndicator)).ok + }, toast, setIndicator)).ok; if (success) { - const value = object.data[key] + const value = object.data[key]; - graph.removeProperty(key, id, type) + graph.removeProperty(key, id, type); - if (object.labels && getNodeDisplayKey(object as Node) === key) { - object.displayName = ['', '']; - } + delete object.data[key]; + + setAttributes(Object.keys(object.data)); + + const canvas = canvasRef.current; + + if (canvas) { + const currentData = canvas.getGraphData(); - delete object.data[key] + if (type) { + const canvasNode = currentData.nodes.find(n => n.id === object.id); - setAttributes(Object.keys(object.data)) + if (canvasNode) { + delete canvasNode.data[key]; + + if (getNodeDisplayKey(object as Node) === key) { + canvasNode.displayName = EMPTY_DISPLAY_NAME; + } + } + } else { + const canvasLink = currentData.links.find(l => l.id === object.id); + + if (canvasLink) { + delete canvasLink.data[key]; + } + } + + canvas.setGraphData({ ...currentData }); + } toast({ title: "Success", @@ -337,46 +351,46 @@ export default function DataTable({ object, type, lastObjId, className }: Props) onClick={() => setProperty(key, value, false)} />, variant: "default" - }) + }); } - return success + return success; } finally { - setIsRemoveLoading(false) + setIsRemoveLoading(false); } - } + }; const handleAddKeyDown = async (e: React.KeyboardEvent) => { if (e.key === "Escape") { - setIsAddValue(false) - setNewKey("") - setNewVal("") - e.stopPropagation() + setIsAddValue(false); + setNewKey(""); + setNewVal(""); + e.stopPropagation(); } if (e.key === "Enter" && !e.shiftKey) { - if (isAddLoading || indicator === "offline") return - e.preventDefault() - handleAddValue(newKey, newVal) + if (isAddLoading || indicator === "offline") return; + e.preventDefault(); + handleAddValue(newKey, newVal); } - } + }; const handleSetKeyDown = async (e: React.KeyboardEvent) => { if (e.key === "Escape") { - handleSetEditable("", "") - setNewKey("") - e.stopPropagation() + handleSetEditable("", ""); + setNewKey(""); + e.stopPropagation(); } if (e.key === "Enter" && !e.shiftKey) { - if (isSetLoading || indicator === "offline") return - e.preventDefault() - setProperty(editable, newVal, true) + if (isSetLoading || indicator === "offline") return; + e.preventDefault(); + setProperty(editable, newVal, true); } - } + }; const getCellEditableContent = (t: ValueType, actionType: "set" | "add" = "set") => { - const dataTestId = `DataPanel${actionType === "set" ? "Set" : "Add"}AttributeValue` + const dataTestId = `DataPanel${actionType === "set" ? "Set" : "Add"}AttributeValue`; switch (t) { case "boolean": @@ -385,7 +399,7 @@ export default function DataTable({ object, type, lastObjId, className }: Props) checked={newVal as boolean} data-testid={dataTestId} onCheckedChange={(checked) => setNewVal(checked)} - /> + />; case "number": return { - const num = Number(e.target.value) - if (!Number.isNaN(num)) setNewVal(num) + const num = Number(e.target.value); + if (!Number.isNaN(num)) setNewVal(num); }} onKeyDown={actionType === "set" ? handleSetKeyDown : handleAddKeyDown} - /> + />; default: return