diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index e86401d4..0bffc7d2 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: "18" + node-version: "22" cache: ${{ steps.detect-package-manager.outputs.manager }} - name: Restore cache diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 83757c75..e88f6b09 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,9 +34,20 @@ jobs: npm install npm run build NEXTAUTH_SECRET=SECRET npm start & npx playwright test --reporter=dot,list + - name: Ensure required directories exist + run: | + mkdir -p playwright-report + mkdir -p playwright-report/artifacts - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 + - name: Upload failed test screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: failed-test-screenshots + path: playwright-report/artifacts/ + retention-days: 30 diff --git a/Dockerfile b/Dockerfile index db11d29c..dfd37408 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine AS base +FROM node:22-alpine AS base # Install dependencies only when needed FROM base AS deps @@ -68,4 +68,4 @@ ENV HOSTNAME "0.0.0.0" # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 91df1a2e..eb741342 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -1,9 +1,33 @@ +/* eslint-disable one-var */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EdgeDataDefinition, NodeDataDefinition } from 'cytoscape'; import { LinkObject, NodeObject } from 'react-force-graph-2d'; +const getSchemaValue = (value: string): string[] => { + let unique, required, type, description + if (value.includes("!")) { + value = value.replace("!", "") + unique = "true" + } else { + unique = "false" + } + if (value.includes("*")) { + value = value.replace("*", "") + required = "true" + } else { + required = "false" + } + if (value.includes("-")) { + [type, description] = value.split("-") + } else { + type = "string" + description = "" + } + return [type, description, unique, required] +} + export type Node = NodeObject<{ id: number, category: string[], @@ -63,10 +87,10 @@ export type DataRow = { export type Data = DataRow[] export const DEFAULT_COLORS = [ - "#7466FF", - "#FF66B3", - "#FF804D", - "#80E6E6" + "hsl(246, 100%, 70%)", + "hsl(330, 100%, 70%)", + "hsl(20, 100%, 65%)", + "hsl(180, 66%, 70%)" ] export interface Query { @@ -103,14 +127,12 @@ export class Graph { private elements: GraphData; - private categoriesMap: Map; + private colorIndex: number = 0; - private categoriesColorIndex: number = 0; + private categoriesMap: Map; private labelsMap: Map; - private labelsColorIndex: number = 0; - private nodesMap: Map; private linksMap: Map; @@ -130,7 +152,7 @@ export class Graph { this.labelsMap = labelsMap; this.nodesMap = nodesMap; this.linksMap = edgesMap; - this.COLORS_ORDER_VALUE = colors || DEFAULT_COLORS + this.COLORS_ORDER_VALUE = [...(colors || DEFAULT_COLORS)] } get Id(): string { @@ -209,14 +231,14 @@ export class Graph { return new Graph(graphName || "", [], [], { nodes: [], links: [] }, new Map(), new Map(), new Map(), new Map(), colors) } - public static create(id: string, results: any, colors?: string[]): Graph { + public static create(id: string, results: { data: Data, metadata: any[] }, isCollapsed: boolean, isSchema: boolean, colors?: string[],): Graph { const graph = Graph.empty(undefined, colors) - graph.extend(results) + graph.extend(results, isCollapsed, isSchema) graph.id = id return graph } - public extendNode(cell: NodeCell, collapsed = false) { + public extendNode(cell: NodeCell, collapsed: boolean, isSchema: boolean) { // check if category already exists in categories const categories = this.createCategory(cell.labels.length === 0 ? [""] : cell.labels) // check if node already exists in nodes or fake node was created @@ -233,7 +255,7 @@ export class Graph { data: {} } Object.entries(cell.properties).forEach(([key, value]) => { - node.data[key] = value as string; + node.data[key] = isSchema ? getSchemaValue(value) : value; }); this.nodesMap.set(cell.id, node) this.elements.nodes.push(node) @@ -248,33 +270,15 @@ export class Graph { currentNode.expand = false currentNode.collapsed = collapsed Object.entries(cell.properties).forEach(([key, value]) => { - currentNode.data[key] = value as string; + currentNode.data[key] = isSchema ? getSchemaValue(value) : value; }); - - // remove empty category if there are no more empty nodes category - if (Array.from(this.nodesMap.values()).every(n => n.category.some(c => c !== ""))) { - this.categories = this.categories.filter(l => l.name !== "").map(c => { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - if (this.categoriesMap.get("")?.index! < c.index) { - c.index -= 1 - this.categoriesMap.get(c.name)!.index = c.index - } - return c - }) - this.categoriesMap.delete("") - this.categoriesColorIndex -= 1 - this.elements.nodes.forEach(n => { - n.color = this.getCategoryColorValue(this.categoriesMap.get(n.category[0])?.index) - }) - } } return currentNode } - public extendEdge(cell: LinkCell, collapsed = false) { + public extendEdge(cell: LinkCell, collapsed: boolean, isSchema: boolean) { const label = this.createLabel(cell.relationshipType) - const currentEdge = this.linksMap.get(cell.id) if (!currentEdge) { @@ -369,7 +373,7 @@ export class Graph { } Object.entries(cell.properties).forEach(([key, value]) => { - link.data[key] = value as string; + link.data[key] = isSchema ? getSchemaValue(value) : value; }); this.linksMap.set(cell.id, link) @@ -381,7 +385,7 @@ export class Graph { return currentEdge } - public extend(results: { data: Data, metadata: any[] }, collapsed = false): (Node | Link)[] { + public extend(results: { data: Data, metadata: any[] }, collapsed = false, isSchema = false): (Node | Link)[] { const newElements: (Node | Link)[] = [] const data = results?.data @@ -399,15 +403,15 @@ export class Graph { if (cell instanceof Object) { if (cell.nodes) { cell.nodes.forEach((node: any) => { - newElements.push(this.extendNode(node, collapsed)) + newElements.push(this.extendNode(node, collapsed, isSchema)) }) cell.edges.forEach((edge: any) => { - newElements.push(this.extendEdge(edge, collapsed)) + newElements.push(this.extendEdge(edge, collapsed, isSchema)) }) } else if (cell.relationshipType) { - newElements.push(this.extendEdge(cell, collapsed)) + newElements.push(this.extendEdge(cell, collapsed, isSchema)) } else if (cell.labels) { - newElements.push(this.extendNode(cell, collapsed)) + newElements.push(this.extendNode(cell, collapsed, isSchema)) } } }) @@ -435,6 +439,13 @@ export class Graph { link.curve = curve * 0.1 }) + + // remove empty category if there are no more empty nodes category + if (Array.from(this.nodesMap.values()).every(n => n.category.some(c => c !== ""))) { + this.categories = this.categories.filter(c => c.name !== "") + this.categoriesMap.delete("") + } + return newElements } @@ -461,8 +472,8 @@ export class Graph { let c = this.categoriesMap.get(category) if (!c) { - c = { name: category, index: this.categoriesColorIndex, show: true } - this.categoriesColorIndex += 1 + c = { name: category, index: this.colorIndex, show: true } + this.colorIndex += 1 this.categoriesMap.set(c.name, c) this.categories.push(c) } @@ -475,8 +486,8 @@ export class Graph { let l = this.labelsMap.get(category) if (!l) { - l = { name: category, index: this.labelsColorIndex, show: true } - this.labelsColorIndex += 1 + l = { name: category, index: this.colorIndex, show: true } + this.colorIndex += 1 this.labelsMap.set(l.name, l) this.labels.push(l) } @@ -520,7 +531,14 @@ export class Graph { } } - public getCategoryColorValue(index = 0): string { - return this.COLORS_ORDER_VALUE[index % this.COLORS_ORDER_VALUE.length] + public getCategoryColorValue(index = 0) { + if (index < this.COLORS_ORDER_VALUE.length) { + return this.COLORS_ORDER_VALUE[index]; + } + + const newColor = `hsl(${(index - 4) * 20}, 100%, 70%)` + this.COLORS_ORDER_VALUE.push(newColor) + DEFAULT_COLORS.push(newColor) + return newColor } -} \ No newline at end of file +} diff --git a/app/api/user/[user]/route.ts b/app/api/user/[user]/route.ts index 4ac20f50..0e09058c 100644 --- a/app/api/user/[user]/route.ts +++ b/app/api/user/[user]/route.ts @@ -11,7 +11,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ us } const { user: username } = await params - const role = ROLE.get(req.nextUrl.searchParams.get("role") || "") + const role = ROLE.get(req.nextUrl.searchParams.get("userRole") || "") try { if (!role) throw new Error("Role is missing") diff --git a/app/api/user/route.ts b/app/api/user/route.ts index 5463c89c..6ac747c5 100644 --- a/app/api/user/route.ts +++ b/app/api/user/route.ts @@ -58,7 +58,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: `User ${username} already exists` }, { status: 409 }) } } catch (err: unknown) { - console.error(err) // Just a workaround for https://github.com/redis/node-redis/issues/2745 } diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx index 8cb14894..bb54f3d9 100644 --- a/app/components/CreateGraph.tsx +++ b/app/components/CreateGraph.tsx @@ -3,9 +3,11 @@ "use client" import { useState } from "react" -import { AlertCircle, PlusCircle } from "lucide-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 { useSession } from "next-auth/react" import DialogComponent from "./DialogComponent" import Button from "./ui/Button" import CloseDialog from "./CloseDialog" @@ -23,7 +25,7 @@ export default function CreateGraph({ trigger = ( @@ -33,10 +35,12 @@ export default function CreateGraph({ const [graphName, setGraphName] = useState("") const [open, setOpen] = useState(false) const { toast } = useToast() - + const { data: session } = useSession() + const handleCreateGraph = async (e: React.FormEvent) => { e.preventDefault() - if (!graphName) { + const name = graphName.trim() + if (!name) { toast({ title: "Error", description: "Graph name cannot be empty", @@ -45,15 +49,19 @@ export default function CreateGraph({ return } const q = 'RETURN 1' - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(q)}`, { + const result = await securedFetch(`api/graph/${prepareArg(name)}/?query=${prepareArg(q)}`, { method: "GET", - }, toast) + }, session?.user?.role, toast) if (!result.ok) return - onSetGraphName(graphName) + onSetGraphName(name) setGraphName("") setOpen(false) + toast({ + title: "Graph created successfully", + description: "The graph has been created successfully", + }) } return ( @@ -68,14 +76,15 @@ export default function CreateGraph({ handleCreateGraph(e) }}>
- -

Name your graph:

+ + + + + + {`${type} names can be edited later`} + + +

Name your {type}:

ref?.focus()} diff --git a/app/components/DialogComponent.tsx b/app/components/DialogComponent.tsx index f6ae932f..3ac83f6e 100644 --- a/app/components/DialogComponent.tsx +++ b/app/components/DialogComponent.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { ReactNode } from "react"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import CloseDialog from "./CloseDialog"; /* eslint-disable react/require-default-props */ @@ -36,10 +37,13 @@ export default function DialogComponent({ { - description && - - {description} - + description ? + + {description} + + : + + } {children} diff --git a/app/components/EditorComponent.tsx b/app/components/EditorComponent.tsx index c8b28db7..3007ebaa 100644 --- a/app/components/EditorComponent.tsx +++ b/app/components/EditorComponent.tsx @@ -3,34 +3,26 @@ "use client"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Editor, Monaco } from "@monaco-editor/react" import { useEffect, useRef, useState } from "react" import * as monaco from "monaco-editor"; -import { prepareArg, securedFetch } from "@/lib/utils"; import { Maximize2 } from "lucide-react"; -import { Session } from "next-auth"; import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "next-auth/react"; +import { prepareArg, securedFetch } from "@/lib/utils"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { Graph } from "../api/graph/model"; import Button from "./ui/Button"; -type Suggestions = { - keywords: monaco.languages.CompletionItem[], - labels: monaco.languages.CompletionItem[], - relationshipTypes: monaco.languages.CompletionItem[], - propertyKeys: monaco.languages.CompletionItem[], - functions: monaco.languages.CompletionItem[] -} - interface Props { currentQuery: string historyQueries: string[] + setHistoryQueries: (queries: string[]) => void setCurrentQuery: (query: string) => void maximize: boolean runQuery: (query: string) => void graph: Graph - isCollapsed: boolean - data: Session | null } const monacoOptions: monaco.editor.IStandaloneEditorConstructionOptions = { @@ -78,7 +70,7 @@ const KEYWORDS = [ "ORDER BY", "SKIP", "LIMIT", - "MARGE", + "MERGE", "DELETE", "SET", "WITH", @@ -184,52 +176,79 @@ const FUNCTIONS = [ "vec.cosineDistance", ] -const MAX_HEIGHT = 20 -const LINE_HEIGHT = 38 - -const getEmptySuggestions = (): Suggestions => ({ - keywords: KEYWORDS.map(key => ({ +const SUGGESTIONS: monaco.languages.CompletionItem[] = [ + ...KEYWORDS.map(key => ({ insertText: key, label: key, kind: monaco.languages.CompletionItemKind.Keyword, range: new monaco.Range(1, 1, 1, 1), detail: "(keyword)" })), - labels: [], - relationshipTypes: [], - propertyKeys: [], - functions: [] -}) + ...FUNCTIONS.map(f => ({ + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + insertText: `${f}(\${0})`, + label: `${f}()`, + kind: monaco.languages.CompletionItemKind.Function, + range: new monaco.Range(1, 1, 1, 1), + detail: "(function)" + })) +] + +const MAX_HEIGHT = 20 +const LINE_HEIGHT = 38 const PLACEHOLDER = "Type your query here to start" -export default function EditorComponent({ currentQuery, historyQueries, setCurrentQuery, maximize, runQuery, graph, isCollapsed, data }: Props) { +export default function EditorComponent({ currentQuery, historyQueries, setHistoryQueries, setCurrentQuery, maximize, runQuery, graph }: Props) { const [query, setQuery] = useState(currentQuery) const placeholderRef = useRef(null) - const [monacoInstance, setMonacoInstance] = useState() - const [prevGraphName, setPrevGraphName] = useState("") - const [sugProvider, setSugProvider] = useState() - const [suggestions, setSuggestions] = useState(getEmptySuggestions()) - const [lineNumber, setLineNumber] = useState(0) + const [lineNumber, setLineNumber] = useState(1) + const graphIdRef = useRef(graph.Id) + const [blur, setBlur] = useState(false) + const [sugDisposed, setSugDisposed] = useState() + const { toast } = useToast() const submitQuery = useRef(null) const editorRef = useRef(null) - const [blur, setBlur] = useState(false) + const containerRef = useRef(null) const historyRef = useRef({ historyQueries, currentQuery, - historyCounter: historyQueries.length + historyCounter: 0 }) - const { toast } = useToast() + const { data: session } = useSession() + + + useEffect(() => { + graphIdRef.current = graph.Id + }, [graph.Id]) + + useEffect(() => () => { + sugDisposed?.dispose() + }, [sugDisposed]) useEffect(() => { historyRef.current.historyQueries = historyQueries }, [historyQueries, currentQuery]) useEffect(() => { - if (!editorRef.current) return - editorRef.current.layout(); - }, [isCollapsed]) + if (!containerRef.current) return + + const handleResize = () => { + editorRef.current?.layout() + } + + window.addEventListener("resize", handleResize) + + const observer = new ResizeObserver(handleResize) + + observer.observe(containerRef.current) + + return () => { + window.removeEventListener("resize", handleResize) + observer.disconnect() + } + }, [containerRef.current]) useEffect(() => { setQuery(currentQuery) @@ -256,134 +275,77 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre 'editorSuggestWidget.hoverBackground': '#28283F', }, }); - } - - const addSuggestions = (MonacoI: Monaco, sug?: monaco.languages.CompletionItem[], procedures?: monaco.languages.CompletionItem[]) => { - sugProvider?.dispose() - const provider = MonacoI.languages.registerCompletionItemProvider('custom-language', { - triggerCharacters: ["."], - provideCompletionItems: (model, position) => { - const word = model.getWordUntilPosition(position) - const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) - - return { - suggestions: [ - ...(sug || []).map(s => ({ ...s, range })), - ...suggestions.keywords.map(s => ({ ...s, range })), - ...(procedures || []).map(s => ({ ...s, range, })) - ] - } - }, - }) - - setSugProvider(provider) - } - - const addLabelsSuggestions = async (sug: monaco.languages.CompletionItem[]) => { - const labelsQuery = `CALL db.labels()` - - await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(labelsQuery)}&role=${data?.user.role}`, { - method: "GET" - }, toast).then((res) => res.json()).then((json) => { - json.result.data.forEach(({ label }: { label: string }) => { - sug.push({ - label, - kind: monaco.languages.CompletionItemKind.TypeParameter, - insertText: label, - range: new monaco.Range(1, 1, 1, 1), - detail: "(label)" - }) - }) - }) - } - - const addRelationshipTypesSuggestions = async (sug: monaco.languages.CompletionItem[]) => { - const relationshipTypeQuery = `CALL db.relationshipTypes()` - - await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(relationshipTypeQuery)}&role=${data?.user.role}`, { - method: "GET" - }, toast).then((res) => res.json()).then((json) => { - json.result.data.forEach(({ relationshipType }: { relationshipType: string }) => { - sug.push({ - label: relationshipType, - kind: monaco.languages.CompletionItemKind.TypeParameter, - insertText: relationshipType, - range: new monaco.Range(1, 1, 1, 1), - detail: "(relationship type)" - }) - }) - }) - } - - const addPropertyKeysSuggestions = async (sug: monaco.languages.CompletionItem[]) => { - const propertyKeysQuery = `CALL db.propertyKeys()` - - await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(propertyKeysQuery)}&role=${data?.user.role}`, { - method: "GET" - }, toast).then((res) => res.json()).then((json) => { - json.result.data.forEach(({ propertyKey }: { propertyKey: string }) => { - sug.push({ - label: propertyKey, - kind: monaco.languages.CompletionItemKind.Property, - insertText: propertyKey, - range: new monaco.Range(1, 1, 1, 1), - detail: "(property)" - }) - }) - }) + monacoI.editor.setTheme('custom-theme') } - const addFunctionsSuggestions = async (functions: monaco.languages.CompletionItem[]) => { - const proceduresQuery = `CALL dbms.procedures() YIELD name` - await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(proceduresQuery)}&role=${data?.user.role}`, { - method: "GET" - }, toast).then((res) => res.json()).then((json) => { - [...json.result.data.map(({ name }: { name: string }) => name), ...FUNCTIONS].forEach((name: string) => { - functions.push({ - label: name, - kind: monaco.languages.CompletionItemKind.Function, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - insertText: `${name}($0)`, - range: new monaco.Range(1, 1, 1, 1), - detail: "(function)" - }) - }) - }) + const fetchSuggestions = async (q: string, detail: string): Promise => { + const result = await securedFetch(`api/graph/${graphIdRef.current}/?query=${prepareArg(q)}`, { + method: 'GET', + }, session?.user.role, toast) + + if (!result) return [] + + const json = await result.json() + + if (json.result.data.length === 0) return [] + + return json.result.data.map(({ sug }: { sug: string }) => ({ + insertTextRules: detail === '(function)' ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined, + insertText: detail === '(function)' ? `${sug}(\${0})` : sug, + label: detail === '(function)' ? `${sug}()` : sug, + kind: (() => { + switch (detail) { + case '(function)': + return monaco.languages.CompletionItemKind.Function; + case '(property key)': + return monaco.languages.CompletionItemKind.Property; + default: + return monaco.languages.CompletionItemKind.Variable; + } + })(), + range: new monaco.Range(1, 1, 1, 1), + detail + })) } - const getSuggestions = async (monacoI?: Monaco) => { - - if (!graph.Id || (!monacoInstance && !monacoI)) return - const m = monacoI || monacoInstance - const sug: Suggestions = getEmptySuggestions() - - await Promise.all([ - addLabelsSuggestions(sug.labels), - addRelationshipTypesSuggestions(sug.relationshipTypes), - addPropertyKeysSuggestions(sug.propertyKeys), - addFunctionsSuggestions(sug.functions) - ]) - - const namespaces = new Map() - - sug.functions.forEach(({ label }) => { - const names = (label as string).split(".") - names.forEach((name, i) => { - if (i === names.length - 1) return - namespaces.set(name, name) - }) - }) + const getSuggestions = async () => (await Promise.all([ + fetchSuggestions('CALL dbms.procedures() YIELD name as sug', '(function)'), + fetchSuggestions('CALL db.propertyKeys() YIELD propertyKey as sug', '(property key)'), + fetchSuggestions('CALL db.labels() YIELD label as sug', '(label)'), + fetchSuggestions('CALL db.relationshipTypes() YIELD relationshipType as sug', '(relationship type)') + ])).flat() + + const addSuggestions = async (monacoI: Monaco) => { + const sug = [ + ...SUGGESTIONS, + ...(graphIdRef.current ? await getSuggestions() : []) + ]; + + 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 + }).flat() + ) - m!.languages.setMonarchTokensProvider('custom-language', { + monacoI.languages.setMonarchTokensProvider('custom-language', { tokenizer: { - root: [ + root: graphIdRef.current ? [ [new RegExp(`\\b(${Array.from(namespaces.keys()).join('|')})\\b`), "keyword"], [new RegExp(`\\b(${KEYWORDS.join('|')})\\b`), "keyword"], [ - new RegExp(`\\b(${sug.functions.map(({ label }) => { - const labels = (label as string).split(".") - return labels[labels.length - 1] + new RegExp(`\\b(${functions.map(({ label }) => { + if ((label as string).includes(".")) { + const labels = (label as string).split(".") + return labels[labels.length - 1] + } + return label }).join('|')})\\b`), "function" ], @@ -394,6 +356,15 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre [/\{/, { token: 'delimiter.curly', next: '@bracketCounting' }], [/\[/, { token: 'delimiter.square', next: '@bracketCounting' }], [/\(/, { token: 'delimiter.parenthesis', next: '@bracketCounting' }], + ] : [ + [new RegExp(`\\b(${KEYWORDS.join('|')})\\b`), "keyword"], + [/"([^"\\]|\\.)*"/, 'string'], + [/'([^'\\]|\\.)*'/, 'string'], + [/\d+/, 'number'], + [/:(\w+)/, 'type'], + [/\{/, { token: 'delimiter.curly', next: '@bracketCounting' }], + [/\[/, { token: 'delimiter.square', next: '@bracketCounting' }], + [/\(/, { token: 'delimiter.parenthesis', next: '@bracketCounting' }], ], bracketCounting: [ [/\{/, 'delimiter.curly', '@bracketCounting'], @@ -408,77 +379,13 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre ignoreCase: true, }) - m!.languages.setLanguageConfiguration('custom-language', { - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'] - ], - autoClosingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"', notIn: ['string'] }, - { open: "'", close: "'", notIn: ['string', 'comment'] } - ], - surroundingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ] - }); - - m!.editor.setTheme('custom-theme'); - - addSuggestions(m!, [...sug.labels, ...sug.propertyKeys, ...sug.relationshipTypes], sug.functions) - - setSuggestions(sug) + return sug } - useEffect(() => { - if (!graph || !monacoInstance || graph.Id !== prevGraphName) return setPrevGraphName(graph.Id) - - const run = async () => { - const sug: Suggestions = getEmptySuggestions() - if (graph.Metadata.length > 0) { - await Promise.all(graph.Metadata.map(async (meta: string) => { - if (meta.includes("Labels")) await addLabelsSuggestions(sug.labels) - if (meta.includes("RelationshipTypes")) await addRelationshipTypesSuggestions(sug.relationshipTypes) - if (meta.includes("PropertyKeys")) await addPropertyKeysSuggestions(sug.propertyKeys) - })) - } - Object.entries(sug).forEach(([key, value]) => { - if (value.length === 0) { - sug[key as keyof Suggestions] = suggestions[key as keyof Suggestions] - } - }) - - addSuggestions(monacoInstance, [...sug.labels, ...sug.propertyKeys, ...sug.relationshipTypes], sug.functions) - - setSuggestions(sug) - } - - run() - }, [graph]) - - - useEffect(() => { - getSuggestions() - }, [graph.Id]) - - useEffect(() => { - const interval = setInterval(() => { - getSuggestions() - }, 5000) + const handleEditorWillMount = async (monacoI: Monaco) => { - return () => { - clearInterval(interval) - } - }, []) + monacoI.languages.register({ id: "custom-language" }) - const handleEditorWillMount = (monacoI: Monaco) => { monacoI.languages.setMonarchTokensProvider('custom-language', { tokenizer: { root: [ @@ -502,9 +409,9 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre ], }, ignoreCase: true, - }); + }) - monacoI.languages.register({ id: 'custom-language' }); + setTheme(monacoI) monacoI.languages.setLanguageConfiguration('custom-language', { brackets: [ @@ -528,18 +435,22 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre ] }); - setTheme(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) + return { + suggestions: (await addSuggestions(monacoI)).map(s => ({ ...s, range })) + } + }, + }) - monacoI.editor.setTheme('custom-theme'); + setSugDisposed(provider) + } - if (graph.Id) { - getSuggestions(monacoI) - } else { - addSuggestions(monacoI) - } - }; + const handleEditorDidMount = (e: monaco.editor.IStandaloneCodeEditor) => { + editorRef.current = e - const handleEditorDidMount = (e: monaco.editor.IStandaloneCodeEditor, monacoI: Monaco) => { const updatePlaceholderVisibility = () => { const hasContent = !!e.getValue(); if (placeholderRef.current) { @@ -551,28 +462,17 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre if (placeholderRef.current) { placeholderRef.current.style.display = 'none'; } + setBlur(false) }); e.onDidBlurEditorText(() => { updatePlaceholderVisibility(); + setBlur(true) }); - // Initial check updatePlaceholderVisibility(); - setMonacoInstance(monacoI) - - editorRef.current = e - - e.onDidBlurEditorText(() => { - setBlur(true) - }) - - e.onDidFocusEditorText(() => { - setBlur(false) - }) - - const isFirstLine = e.createContextKey('isFirstLine', false as boolean); + const isFirstLine = e.createContextKey('isFirstLine', true); // Update the context key value based on the cursor position e.onDidChangeCursorPosition(() => { @@ -600,9 +500,9 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre contextMenuOrder: 1.5, run: async () => { if (historyRef.current.historyQueries.length === 0) return - const counter = historyRef.current.historyCounter ? historyRef.current.historyCounter - 1 : historyRef.current.historyQueries.length; - historyRef.current.historyCounter = counter; - setQuery(counter ? historyRef.current.historyQueries[counter - 1] : historyRef.current.currentQuery); + const counter = (historyRef.current.historyCounter + 1) % (historyRef.current.historyQueries.length + 1) + historyRef.current.historyCounter = counter + setQuery(counter ? historyRef.current.historyQueries[counter - 1] : historyRef.current.currentQuery) }, precondition: 'isFirstLine && !suggestWidgetVisible', }); @@ -614,14 +514,20 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre contextMenuOrder: 1.5, run: async () => { if (historyRef.current.historyQueries.length === 0) return - const counter = (historyRef.current.historyCounter + 1) % (historyRef.current.historyQueries.length + 1) - historyRef.current.historyCounter = counter - setQuery(counter ? historyRef.current.historyQueries[counter - 1] : historyRef.current.currentQuery) + const counter = historyRef.current.historyCounter ? historyRef.current.historyCounter - 1 : historyRef.current.historyQueries.length; + historyRef.current.historyCounter = counter; + setQuery(counter ? historyRef.current.historyQueries[counter - 1] : historyRef.current.currentQuery); }, precondition: 'isFirstLine && !suggestWidgetVisible', }); } + const handleSetHistoryQuery = (val: string) => { + setQuery(val) + historyRef.current.historyQueries[historyRef.current.historyCounter - 1] = val + setHistoryQueries(historyRef.current.historyQueries) + } + useEffect(() => { setLineNumber(query.split("\n").length) }, [query]) @@ -640,7 +546,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre runQuery(query) }} > -
+
document.body.clientHeight / 100 * MAX_HEIGHT ? document.body.clientHeight / 100 * MAX_HEIGHT : lineNumber * LINE_HEIGHT} @@ -650,7 +556,7 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre lineNumbers: lineNumber > 1 ? "on" : "off", }} value={(blur ? query.replace(/\s+/g, ' ').trim() : query)} - onChange={(val) => historyRef.current.historyCounter ? setQuery(val || "") : setCurrentQuery(val || "")} + onChange={(val) => historyRef.current.historyCounter ? handleSetHistoryQuery(val || "") : setCurrentQuery(val || "")} theme="custom-theme" beforeMount={handleEditorWillMount} onMount={handleEditorDidMount} @@ -677,6 +583,10 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre /> + + + + { selectedValues.map(async value => { const name = `${value}${!type ? "_schema" : ""}` const result = await securedFetch(`api/graph/${prepareArg(name)}/export`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return @@ -49,12 +52,7 @@ export default function ExportGraph({ selectedValues, type }: Props) { value !== "").length === 0} - /> - } + trigger={trigger} title="Export your graph" description="Export a .dump file of your data" > diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx index 0380c22c..3a902e23 100644 --- a/app/components/ForceGraph.tsx +++ b/app/components/ForceGraph.tsx @@ -8,6 +8,7 @@ import { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from import ForceGraph2D from "react-force-graph-2d" import { securedFetch } from "@/lib/utils" import { useToast } from "@/components/ui/use-toast" +import { useSession } from "next-auth/react" import { Graph, GraphData, Link, Node } from "../api/graph/model" interface Props { @@ -24,7 +25,6 @@ interface Props { type?: "schema" | "graph" isAddElement?: boolean setSelectedNodes?: Dispatch> - isCollapsed: boolean } const NODE_SIZE = 6 @@ -43,27 +43,36 @@ export default function ForceGraph({ type = "graph", isAddElement = false, setSelectedNodes, - isCollapsed }: Props) { const [parentWidth, setParentWidth] = useState(0) const [parentHeight, setParentHeight] = useState(0) const [hoverElement, setHoverElement] = useState() const parentRef = useRef(null) - const toast = useToast() - + const lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) + const { toast } = useToast() + const { data: session } = useSession() + useEffect(() => { - if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return - chartRef.current.d3Force('link').id((link: any) => link.id).distance(50) - chartRef.current.d3Force('charge').strength(-300) - chartRef.current.d3Force('center').strength(0.05) - }, [chartRef, data.links.length, data.nodes.length]) + const handleResize = () => { + if (!parentRef.current) return + setParentWidth(parentRef.current.clientWidth) + setParentHeight(parentRef.current.clientHeight) + } - useEffect(() => { - if (!parentRef.current) return - setParentWidth(parentRef.current.clientWidth) - setParentHeight(parentRef.current.clientHeight) - }, [parentRef.current?.clientWidth, parentRef.current?.clientHeight, isCollapsed]) + window.addEventListener('resize', handleResize) + + const observer = new ResizeObserver(handleResize) + + if (parentRef.current) { + observer.observe(parentRef.current) + } + + return () => { + window.removeEventListener('resize', handleResize) + observer.disconnect() + } + }, [parentRef]) const onFetchNode = async (node: Node) => { const result = await securedFetch(`/api/graph/${graph.Id}/${node.id}`, { @@ -71,11 +80,18 @@ export default function ForceGraph({ headers: { 'Content-Type': 'application/json' } - }, toast); + }, session?.user?.role, toast); if (result.ok) { const json = await result.json() - return graph.extend(json.result, true) + const elements = graph.extend(json.result, true) + if (elements.length === 0) { + toast({ + title: `No neighbors found`, + description: `No neighbors found`, + }) + } + return elements } return [] @@ -105,19 +121,29 @@ export default function ForceGraph({ graph.removeLinks() } - const handleNodeRightClick = async (node: Node) => { + const handleNodeClick = async (node: Node) => { + + const now = new Date() + const { date, name } = lastClick.current + + if (now.getTime() - date.getTime() < 1000 && name === (node.data.name || node.id.toString())) { + return + } + if (!node.expand) { await onFetchNode(node) } else { deleteNeighbors([node]) } + + lastClick.current = { date: new Date(), name: node.data.name || node.id.toString() } } const handleHover = (element: Node | Link | null) => { setHoverElement(element === null ? undefined : element) } - const handleClick = (element: Node | Link, evt: MouseEvent) => { + const handleRightClick = (element: Node | Link, evt: MouseEvent) => { if (!("source" in element) && isAddElement) { if (setSelectedNodes) { setSelectedNodes(prev => { @@ -213,9 +239,6 @@ export default function ForceGraph({ if (!start.x || !start.y || !end.x || !end.y) return - ctx.strokeStyle = link.color; - ctx.globalAlpha = 0.5; - if (start.id === end.id) { const radius = NODE_SIZE * link.curve * 6.2; const angleOffset = -Math.PI / 4; // 45 degrees offset for text alignment @@ -229,7 +252,7 @@ export default function ForceGraph({ const midX = (start.x + end.x) / 2 + (end.y - start.y) * (link.curve / 2); const midY = (start.y + end.y) / 2 + (start.x - end.x) * (link.curve / 2); - let textAngle = Math.atan2(end.y - start.y, end.x - start.x) + let textAngle = Math.atan2(end.y - start.y, end.x - start.x); // maintain label vertical orientation for legibility if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle); @@ -240,20 +263,35 @@ export default function ForceGraph({ ctx.rotate(textAngle); } + // Set text properties first to measure + ctx.font = "2px Arial"; + const textMetrics = ctx.measureText(link.label); + const boxWidth = textMetrics.width; + const boxHeight = 2; // Height of text + + // Draw background block + ctx.fillStyle = '#191919'; + + // Draw block aligned with text + ctx.fillRect( + -textMetrics.width / 2, + -1, + boxWidth, + boxHeight + ); + // add label - ctx.globalAlpha = 1; - ctx.fillStyle = 'black'; + ctx.fillStyle = 'white'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.font = "2px Arial" ctx.fillText(link.label, 0, 0); - ctx.restore() + ctx.restore(); }} - onNodeClick={handleClick} - onLinkClick={handleClick} + onNodeClick={handleNodeClick} onNodeHover={handleHover} onLinkHover={handleHover} - onNodeRightClick={handleNodeRightClick} + onNodeRightClick={handleRightClick} + onLinkRightClick={handleRightClick} onBackgroundClick={handleUnselected} onBackgroundRightClick={handleUnselected} onEngineStop={() => { @@ -264,6 +302,10 @@ export default function ForceGraph({ linkVisibility="visible" cooldownTicks={cooldownTicks} cooldownTime={2000} + linkDirectionalArrowRelPos={1} + linkDirectionalArrowLength={(link) => link.source.id === link.target.id ? 0 : 2} + linkDirectionalArrowColor={(link) => link.color} + linkColor={(link) => link.color} />
) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index fd7af685..3ad3702d 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -4,8 +4,9 @@ "use client" import { useState } from "react" -import { AlertCircle, EyeIcon, EyeOffIcon } from "lucide-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" @@ -74,9 +75,14 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, { field.info && - + + + + + + {field.info} + + }
diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 66c05915..fc70b91f 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -9,6 +9,9 @@ import { cn } from "@/lib/utils"; import { useRouter, usePathname } from "next/navigation"; import { signOut, useSession } from "next-auth/react"; import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@/components/ui/navigation-menu"; +import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import pkg from '@/package.json'; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import Button from "./ui/Button"; import CreateGraph from "./CreateGraph"; @@ -22,7 +25,7 @@ export default function Header({ onSetGraphName }: Props) { const type = pathname.includes("/schema") ? "Schema" : "Graph" const inCreate = pathname.includes("/create") const { data: session } = useSession() - + return (
@@ -32,72 +35,101 @@ export default function Header({ onSetGraphName }: Props) { href="https://www.falkordb.com" target="_blank" rel="noreferrer" > - +

|

- - - + + + + + + + + e.preventDefault()} className="gap-2 bg-foreground"> + +

Help

+
+ + + +
+ { + !inCreate && + + } -
- - - -

Help

-
- - - -
- { - !inCreate && - - } - -
-
+ + + + + + + +
+ +

We Make AI Reliable

+

+ Delivering a scalable, + low-latency graph database designed for development teams managing + structured and unstructured interconnected data in real-time or interactive environments. +

+
+
+

Version: {`{${pkg.version}}`}

+

All Rights Reserved © 2024 - {new Date().getFullYear()} falkordb.com

+
+
+
-
+
) } diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 75162630..c07bc04c 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable react/no-array-index-key */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-nested-ternary */ @@ -11,13 +12,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { cn } from "@/lib/utils"; import { useState } from "react"; import { CheckCircle, Pencil, XCircle } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import Button from "./ui/Button"; import Input from "./ui/Input"; import { DataCell } from "../api/graph/model"; +import Combobox from "./ui/combobox"; type Cell = { value: DataCell, - onChange?: (value: string) => Promise + onChange?: (value: string) => Promise, + type?: string + comboboxType?: string } export interface Row { @@ -29,10 +34,11 @@ interface Props { headers: string[], rows: Row[], children?: React.ReactNode, - setRows?: (rows: Row[]) => void + setRows?: (rows: Row[]) => void, + options?: string[] } -export default function TableComponent({ headers, rows, children, setRows }: Props) { +export default function TableComponent({ headers, rows, children, setRows, options }: Props) { const [search, setSearch] = useState("") const [isSearchable, setIsSearchable] = useState(false) @@ -152,50 +158,66 @@ export default function TableComponent({ headers, rows, children, setRows }: Pro /> : cell.value && editable === `${i}-${j}` ? -
- ref?.focus()} - variant="primary" - className="grow" - value={newValue} - onChange={(e) => setNewValue(e.target.value)} - onKeyDown={async (e) => { - if (e.key === "Escape") { - e.preventDefault() - handleSetEditable("", "") - } - - if (e.key !== "Enter") return - - e.preventDefault() - const result = await cell.onChange!(newValue) - if (result) { - handleSetEditable("", "") - } + cell.type === "combobox" ? + { + cell.onChange!(value) + handleSetEditable("", "") }} + type={cell.comboboxType} /> -
- - + /> +
+ + +
-
:
-

{cell.value}

+ + +

{cell.value}

+
+ + {cell.value} + +
{ cell.onChange && hover === `${i}` && diff --git a/app/components/graph/DeleteGraph.tsx b/app/components/graph/DeleteGraph.tsx index 7f0de3ff..880f412a 100644 --- a/app/components/graph/DeleteGraph.tsx +++ b/app/components/graph/DeleteGraph.tsx @@ -1,6 +1,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { useToast } from "@/components/ui/use-toast"; import { prepareArg, securedFetch } from "@/lib/utils"; +import { useSession } from "next-auth/react"; export default function DeleteGraph({ graphName, isOpen, onOpen, onDeleteGraph, isSchema }: { graphName: string @@ -12,12 +13,13 @@ export default function DeleteGraph({ graphName, isOpen, onOpen, onDeleteGraph, const type = isSchema ? "Schema" : "Graph" const { toast } = useToast() + const { data: session } = useSession() const deleteGraph = async () => { const name = `${graphName}${isSchema ? "_schema" : ""}` const result = await securedFetch(`/api/graph/${prepareArg(name)}`, { method: "DELETE", - }, toast); + }, session?.user?.role, toast); if (result.ok) { toast({ diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index e5e8ff90..bf181f79 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -1,5 +1,6 @@ /* 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 { forwardRef } from "react" @@ -42,19 +43,38 @@ const getClassName = (variant: Variant, disable: boolean | undefined, open: bool return className } -const Button = forwardRef(({ label, variant = "button", open, className, title, type = "button", disabled, children, ...props }, ref) => ( - -)) +const Button = forwardRef(({ label, variant = "button", open, className, title, type = "button", disabled, children, ...props }, ref) => + (title || label) ? ( + + + + + + {title || label} + + + ) : ( + + )) Button.displayName = "Button" diff --git a/app/components/ui/Dropzone.tsx b/app/components/ui/Dropzone.tsx index 93426311..1ab97cbc 100644 --- a/app/components/ui/Dropzone.tsx +++ b/app/components/ui/Dropzone.tsx @@ -56,7 +56,7 @@ function Dropzone({ filesCount = false, className = "", withTable = false, disab Or Browse
- :

Upload Certificate

+ :

Upload Certificate

}
{ diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index 8ccf2e20..ed51f23c 100644 --- a/app/components/ui/combobox.tsx +++ b/app/components/ui/combobox.tsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable react/require-default-props */ @@ -8,6 +9,8 @@ import { cn, prepareArg, securedFetch } from "@/lib/utils" import { useEffect, useState } from "react" import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select" import { useToast } from "@/components/ui/use-toast" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useSession } from "next-auth/react" import Button from "./Button" import TableComponent, { Row } from "../TableComponent" import CloseDialog from "../CloseDialog" @@ -35,7 +38,7 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa const [rows, setRows] = useState([]) const [openDelete, setOpenDelete] = useState(false) const { toast } = useToast() - + const { data: session } = useSession() const handleSetOption = async (option: string, optionName: string) => { const result = await securedFetch(`api/graph/${prepareArg(option)}/?sourceName=${prepareArg(optionName)}`, { @@ -44,7 +47,7 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa "Content-Type": "application/json" }, body: JSON.stringify({ name: optionName }) - }, toast) + }, session?.user?.role, toast) if (result.ok) { @@ -69,7 +72,7 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa if (options.length !== 1 || !setSelectedValue) return setSelectedValue(options[0]) - }, [options]) + }, [options, setSelectedValue]) const handleDelete = async (opts: string[]) => { const names = opts.map(opt => isSchema ? `${opt}_schema` : opt) @@ -77,7 +80,7 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa const newNames = await Promise.all(names.map(async (name) => { const result = await securedFetch(`api/graph/${prepareArg(name)}`, { method: "DELETE" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return name @@ -92,6 +95,10 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa setOpenDelete(false) setOpenMenage(false) handleSetRows(options.filter(opt => !opts.includes(opt))) + toast({ + title: "Graph(s) deleted successfully", + description: "The graph(s) have been deleted successfully", + }) } return ( @@ -100,19 +107,26 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa setOpen(o) if (onOpenChange) onOpenChange(o) }}> - - - + + + + + + + + {options.length === 0 ? "There is no graphs" : selectedValue || `Select ${type || "Graph"}`} + +
    { options.map((option) => ( - {option} + {!option ? '""' : option} )) } @@ -148,10 +162,11 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa onOpenChange={setOpenDelete} title="Delete Graph" trigger={
- opt.checked).map(opt => opt.cells[0].value as string)} type={type!} /> + opt.checked).length === 0} + /> + } + selectedValues={rows.filter(opt => opt.checked).map(opt => opt.cells[0].value as string)} + type={type!} + /> diff --git a/app/create/page.tsx b/app/create/page.tsx deleted file mode 100644 index 7f24880a..00000000 --- a/app/create/page.tsx +++ /dev/null @@ -1,347 +0,0 @@ -'use client' - -import { AlertCircle, ChevronLeft, ChevronRight, PlusCircle } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Progress } from "@/components/ui/progress"; -import useSWR from "swr"; -import { prepareArg, securedFetch } from "@/lib/utils"; -import { useToast } from "@/components/ui/use-toast"; -import Header from "../components/Header"; -import Input from "../components/ui/Input"; -import Button from "../components/ui/Button"; -import Dropzone from "../components/ui/Dropzone"; - -type CurrentTab = "loadSchema" | "schema" | "graph" - -export default function Create() { - - const [currentTab, setCurrentTab] = useState() - // const [schema, setSchema] = useState(Graph.empty()) - const [ID, setID] = useState() - const [files, setFiles] = useState([]) - const [filesPath, setFilesPath] = useState() - const [nodesCount, setNodesCount] = useState(0) - const [edgesCount, setEdgesCount] = useState([]) - const [graphName, setGraphName] = useState("") - const [progress, setProgress] = useState(0) - const [openaiKey, setOpenaiKey] = useState("") - const router = useRouter() - const { toast } = useToast() - - useEffect(() => { - if (progress !== 100) return - const run = async () => { - const q = "MATCH (n)-[e]-(m) RETURN n,e,m" - - const res = await securedFetch(`api/graph/${prepareArg(graphName)}_schema/?query=${prepareArg(q)}`, { - method: "GET" - }, toast) - - if (!res.ok) { - toast({ - title: "Error", - description: "Error while loading schema", - variant: "destructive" - }) - setProgress(0) - setCurrentTab(null) - setFilesPath([]) - return - } - - const j = await res.json() - - if (!j.result.data.length) { - toast({ - title: "Error", - description: "Error while loading schema", - variant: "destructive" - }) - setProgress(0) - setCurrentTab(null) - setFilesPath([]) - return - } - - setProgress(0) - // setSchema(Graph.create(`${graphName}_schema`, j.result)) - setCurrentTab("schema") - } - run() - }, [progress]) - - const fetcher = async (url: string) => { - - const result = await securedFetch(url, { - method: "GET", - }, toast) - - if (!result.ok) { - toast({ - title: "Error", - description: "Error while loading schema", - variant: "destructive" - }) - return - } - - const json = await result.json() - - setProgress(prev => json.progress + prev) - } - - useSWR((currentTab === "loadSchema" && progress < 100) && ID && `api/graph/${prepareArg(graphName)}/?ID=${prepareArg(ID)}`, fetcher, { refreshInterval: 2500 }) - - const handleCreateSchema = async (e: FormEvent) => { - - e.preventDefault() - - setCurrentTab("loadSchema") - - if (!files) return - - const newFilesPath = await Promise.all(files.map(async (file) => { - const formData = new FormData(); - - formData.append("file", file); - - const result = await securedFetch(`api/upload`, { - method: "POST", - body: formData - }, toast); - - if (!result.ok) { - toast({ - title: "Error", - description: "Error while uploading file", - variant: "destructive" - }) - return "" - } - - const json = await result.json() - - return json.path - })) - - setFilesPath(newFilesPath) - - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?type=detect_schema&key=${prepareArg(openaiKey)}`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(newFilesPath) - }, toast) - - - if (!result.ok) { - toast({ - title: "Error", - description: "Error while creating schema", - variant: "destructive" - }) - setFilesPath([]) - setProgress(0) - setCurrentTab(null) - return - } - - const json = await result.json() - - setID(json.ID) - } - - const handleCreateGraph = async () => { - - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?type=populate_kg/?key=${prepareArg(openaiKey)}`, { - method: "POST", - body: JSON.stringify(filesPath) - }, toast) - - if (!result.ok) { - toast({ - title: "Error", - description: "Error while creating graph", - variant: "destructive" - }) - return - } - setCurrentTab("graph") - } - - const handleGoToMain = () => { - router.push("/graph") - } - - // const setLabel = async (selectedElement: ElementDataDefinition, label: string) => { - - // const { id } = selectedElement - // const q = `MATCH (n) WHERE ID(n) = ${id} SET n:${label} WITH n REMOVE n:${selectedElement.category || selectedElement.label}` - // const { ok } = await securedFetch(`api/graph/${prepareArg(graphName)}_schema/?query=${prepareArg(q)}`, { - // method: "GET" - // }) - - // if (!ok) { - // return ok - // } - - // schema.Elements = schema.Elements.map(e => { - // if (e.data.id === id) { - // const updatedElement = { ...e } - // if (updatedElement.data.label) { - // updatedElement.data.label = label - // } else { - // updatedElement.data.category = label - // } - // return updatedElement - // } - // return e - // }) - - // return ok - // } - - const getCurrentTab = () => { - switch (currentTab) { - case "loadSchema": - return ( -
-
-

Processing ...

- -
-
- ) - case "schema": - return ( -
-
-
-
- ) - case "graph": { - - const q = "MATCH (n) WITH Count(n) as nodes MATCH ()-[e]->() return nodes, Count(e) as edges" - - securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(q)}`, { - method: "GET", - }, toast).then((response) => response.json()).then((json) => { - const data = json.result.data[0] - setNodesCount(data.nodes) - setEdgesCount(data.edges) - }).catch(() => { - toast({ - title: "Error", - description: "Failed to get graph metadata", - variant: "destructive" - }) - }) - - return ( -
-
- Created on 2/2 24 - {nodesCount} Nodes -

|

- {edgesCount} Edges -
- {/* */} -
- -
-
- ) - } - default: - return ( -
{ await handleCreateSchema(e) }} className="grow flex flex-col gap-8"> -
-
-
-

Graph Name

- setGraphName(e.target.value)} - required - /> -
-
-

OpenAI Key

- setOpenaiKey(e.target.value)} - required - /> -
-
-
-

Files

-

URLs

-

Amazon S3/GCP

-
- -
-
- -
-
- ) - } - } - - return ( -
-
-
-

Create New Graph

-
-
-
-

Add Data

- -

Schema

- -

Knowledge Graph

-
-
- {getCurrentTab()} -
-
-
- ) -} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 830ec076..829b728c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -31,24 +31,13 @@ @layer components { @font-face { - font-family: 'Fira Code'; + font-family: 'FiraCode'; src: url('/fonts/fira_code.ttf') format('truetype'); font-display: swap; } - - @font-face { - font-family: 'Oswald'; - src: url('/fonts/oswald.ttf') format('truetype'); - font-display: swap; - } .tabs-trigger { - @apply !p-2 rounded-lg data-[state=active]:bg-foreground data-[state=active]:!text-white !text-white; - } - - .hide-scrollbar { - scrollbar-width: none; - -ms-overflow-style: none; + @apply !p-2 rounded-lg data-[state=active]:bg-foreground data-[state=active]:!text-white text-gray-500; } .hide-scrollbar { @@ -60,11 +49,7 @@ @apply h-full w-full flex flex-col bg-background; } - .LandingPage { - background: linear-gradient(180deg, #EC806C 0%, #B66EBD 43.41%, #7568F2 100%); - } - - .Top { + .Gradient { background: linear-gradient(90deg, #EC806C 0%, #B66EBD 43.41%, #7568F2 100%); } @@ -80,40 +65,12 @@ align-items: center; justify-content: center; } - - .Secondary { - border-image: linear-gradient(90deg, #EC806C 0%, #B66EBD 43.41%, #7568F2 100%) 1; - } - - .monaco-placeholder { - @apply text-[#666] absolute select-none top-2 left-2; - } -} - -input[type="number"]::-webkit-outer-spin-button, -input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -input[type="number"] { - -moz-appearance: textfield; - -} - -.monaco-editor .suggest-widget { - margin-top: 0.5rem; } * { - font-family: font-FiraCode, 'monospace'; + font-family: 'FiraCode', 'Arial', monospace !important; } -.Title { - font-family: font-Oswald, 'monospace'; -} - - ::-webkit-scrollbar { width: 20px; height: 20px; diff --git a/app/graph/Duplicate.tsx b/app/graph/Duplicate.tsx index f39ae8dd..9ae27049 100644 --- a/app/graph/Duplicate.tsx +++ b/app/graph/Duplicate.tsx @@ -1,6 +1,7 @@ import { FormEvent, useState } from "react"; import { prepareArg, securedFetch } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "next-auth/react"; import DialogComponent from "../components/DialogComponent"; import Button from "../components/ui/Button"; import Input from "../components/ui/Input"; @@ -15,17 +16,24 @@ export default function Duplicate({ open, onOpenChange, selectedValue, onDuplica const [duplicateName, setDuplicateName] = useState(""); const { toast } = useToast() + const { data: session } = useSession() const handleDuplicate = async (e: FormEvent) => { e.preventDefault() - await securedFetch(`api/graph/${prepareArg(duplicateName)}/?sourceName=${prepareArg(selectedValue)}`, { + const result = await securedFetch(`api/graph/${prepareArg(duplicateName)}/?sourceName=${prepareArg(selectedValue)}`, { method: "POST" - }, toast) + }, session?.user?.role, toast) + + if (!result.ok) return - onOpenChange(false) onDuplicate(duplicateName) + onOpenChange(false) + toast({ + title: "Graph duplicated successfully", + description: "The graph has been duplicated successfully", + }) } return ( diff --git a/app/graph/GraphDataPanel.tsx b/app/graph/GraphDataPanel.tsx index abe0c406..b3c6c271 100644 --- a/app/graph/GraphDataPanel.tsx +++ b/app/graph/GraphDataPanel.tsx @@ -7,9 +7,9 @@ import { prepareArg, securedFetch } from "@/lib/utils"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { Check, ChevronRight, Pencil, Plus, Trash2, X } from "lucide-react"; -import { Session } from "next-auth"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "next-auth/react"; import Button from "../components/ui/Button"; import { Graph, Link, Node } from "../api/graph/model"; import Input from "../components/ui/Input"; @@ -24,10 +24,9 @@ interface Props { onExpand: () => void; graph: Graph; onDeleteElement: () => Promise; - data: Session | null; } -export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, graph, data }: Props) { +export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, graph }: Props) { const [attributes, setAttributes] = useState([]); const [editable, setEditable] = useState(""); @@ -39,7 +38,8 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, const [label, setLabel] = useState([""]); const type = !("source" in obj) const { toast } = useToast() - + const { data: session } = useSession() + const handleSetEditable = (key: string, val: string) => { if (key !== "") { setIsAddValue(false) @@ -67,7 +67,7 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, const q = `MATCH ${type ? "(e)" : "()-[e]-()"} WHERE id(e) = ${id} SET e.${key} = '${val}'` const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(q)}`, { method: "GET" - }, toast)).ok + }, session?.user?.role, toast)).ok if (success) { graph.getElements().forEach(e => { @@ -138,7 +138,7 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, const q = `MATCH ${type ? "(e)" : "()-[e]-()"} WHERE id(e) = ${id} SET e.${key} = NULL` const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/?query=${prepareArg(q)}`, { method: "GET" - }, toast)).ok + }, session?.user?.role, toast)).ok if (success) { const value = obj.data[key] @@ -212,7 +212,7 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement,

{label}

-

{attributes.length} Attributes

+

{attributes.length} Attributes

@@ -238,7 +238,7 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement,
{ - editable === key && data?.user.role !== "Read-Only" ? + editable === key && session?.user?.role !== "Read-Only" ? <> @@ -288,13 +289,12 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, {key}: { - editable === key && data?.user.role !== "Read-Only" ? + editable === key && session?.user?.role !== "Read-Only" ? setNewVal(e.target.value)} onKeyDown={handleSetKeyDown} - onBlur={() => handleSetEditable("", "")} /> : setTabsValue("Table")} - title="Table" > -
+
+ @@ -259,7 +266,7 @@ function GraphView({ graph, selectedElement, setSelectedElement, runQuery, histo deleteDisabled={(Object.values(selectedElements).length === 0 && !selectedElement) || session?.user.role === "Read-Only"} onDeleteElement={handleDeleteElement} chartRef={chartRef} - addDisabled + displayAdd={false} /> { isCollapsed && graph.Id && @@ -278,21 +285,31 @@ function GraphView({ graph, selectedElement, setSelectedElement, runQuery, histo title={!maximize ? "Maximize" : "Minimize"} onClick={() => setMaximize(prev => !prev)} > - {maximize ? : } + {!maximize ? : } -
- {cooldownTicks === undefined ? : } - { - handleCooldown(cooldownTicks === undefined ? 0 : undefined) - }} - /> -
+ { + graph.getElements().length > 0 && +
+ + +
+ {cooldownTicks === undefined ? : } + { + handleCooldown(cooldownTicks === undefined ? 0 : undefined) + }} + /> +
+
+ +

Animation Control

+
+
+
+ } } diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 96ad75f3..f8fb06d6 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { SetStateAction, Dispatch, useEffect, useRef, useState } from "react"; +import { SetStateAction, Dispatch, useEffect, useRef, useState, useCallback } from "react"; import { DialogTitle } from "@/components/ui/dialog"; import { Editor } from "@monaco-editor/react"; import { editor } from "monaco-editor"; @@ -46,7 +46,7 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r const type = pathname.includes("/schema") ? "Schema" : "Graph" const [isRotating, setIsRotating] = useState(false); const { toast } = useToast() - + useEffect(() => { if (!graphName) return setOptions(prev => { @@ -56,38 +56,39 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r }) }, [graphName]) - const getOptions = async () => { + const getOptions = useCallback(async () => { const result = await securedFetch("api/graph", { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return const res = (await result.json()).result as string[] setOptions(!runQuery ? res.filter(name => name.includes("_schema")).map(name => name.split("_")[0]) : res.filter(name => !name.includes("_schema"))) - } + }, [runQuery, session?.user?.role, toast]) useEffect(() => { getOptions() - }, []) + }, [getOptions]) const handleEditorDidMount = (e: editor.IStandaloneCodeEditor) => { editorRef.current = e } const handleOnChange = async (name: string) => { + const formattedName = name === '""' ? "" : name if (runQuery) { const q = 'MATCH (n)-[e]-(m) return n,e,m' - const result = await securedFetch(`api/graph/${prepareArg(name)}_schema/?query=${prepareArg(q)}&create=false&role=${session?.user.role}`, { + const result = await securedFetch(`api/graph/${prepareArg(name)}_schema/?query=${prepareArg(q)}&create=false`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return const json = await result.json() if (json.result) { - setSchema(Graph.create(name, json.result)) + setSchema(Graph.create(name, json.result, false, true)) } } - onChange(name) + onChange(formattedName) setSelectedValue(name) } @@ -120,6 +121,7 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r isRotating && "animate-spin duration-1000" )} onClick={handleReloadClick} + title="Reload Graphs List" > @@ -135,6 +137,12 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r
+ } type={type} selectedValues={[selectedValue]} /> @@ -145,6 +153,7 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r onDuplicate={(name) => { setOptions(prev => [...prev, name]) setSelectedValue(name) + setGraphName(name) }} selectedValue={selectedValue} /> @@ -155,10 +164,9 @@ export default function Selector({ onChange, graphName, setGraphName, queries, r { selectedValue &&
-

Created on 2/2 24

- {nodesCount} Nodes + {nodesCount} Nodes

|

- {edgesCount} Edges + {edgesCount} Edges
} { diff --git a/app/graph/Tutorial.tsx b/app/graph/Tutorial.tsx index f907fd9c..0fb0bc4d 100644 --- a/app/graph/Tutorial.tsx +++ b/app/graph/Tutorial.tsx @@ -33,7 +33,7 @@ export default function Tutorial({ onSetGraphName }: Props) { - +
@@ -42,8 +42,10 @@ export default function Tutorial({ onSetGraphName }: Props) {

Our Browser allows you to visualize, manipulate and explore your data.

- -

Easily interact with your data in our adaptive force canvas

+ +

Interact with your data with a force-directed layout, + with features including zoom, pan, + node-drag and interactive node/link hover and click events

Configure or export your graph with ease from the control center

diff --git a/app/graph/View.tsx b/app/graph/View.tsx index 201fa0af..39fe7a66 100644 --- a/app/graph/View.tsx +++ b/app/graph/View.tsx @@ -1,12 +1,11 @@ /* eslint-disable no-param-reassign */ -import { ChevronDown, ChevronUp, FileCheck2, PlusCircle, RotateCcw, Trash2 } from "lucide-react" +import { Check, ChevronDown, ChevronUp, FileCheck2, Pencil, PlusCircle, RotateCcw, Trash2, X } from "lucide-react" import { useEffect, useState } from "react" -import { cn } from "@/lib/utils" +import { cn, rgbToHSL } from "@/lib/utils" import { DEFAULT_COLORS, Graph } from "../api/graph/model" import Button from "../components/ui/Button" import DialogComponent from "../components/DialogComponent" -import Input from "../components/ui/Input" export default function View({ graph, setGraph, selectedValue }: { graph: Graph, @@ -19,9 +18,12 @@ export default function View({ graph, setGraph, selectedValue }: { const [editable, setEditable] = useState("") const handlePreferencesChange = (colors?: string[]) => { - setGraph(Graph.create(graph.Id, { data: graph.Data, metadata: graph.Metadata }, colors || colorsArr)) - if (colors) return - localStorage.setItem(graph.Id, JSON.stringify(colorsArr)); + setGraph(Graph.create(graph.Id, { data: graph.Data, metadata: graph.Metadata }, false, true, colors || colorsArr)) + if (colors) { + localStorage.removeItem(graph.Id) + } else { + localStorage.setItem(graph.Id, JSON.stringify(colorsArr)); + } } useEffect(() => { @@ -38,11 +40,11 @@ export default function View({ graph, setGraph, selectedValue }: { /> } className="w-[30%] h-[50%]" - title="Preferences" + title="Labels Legend" + description="Pick a color for each label" > -
-

Legends

-
    +
    +
      { colorsArr.map((c, i) => (
    • setHover(c)} onMouseLeave={(() => setHover(""))} key={c} className={cn(`flex gap-8 items-center`)}> @@ -84,49 +86,74 @@ export default function View({ graph, setGraph, selectedValue }: { c === newColor || c === editable ? <>
      - ref?.focus()} - className="w-24" value={editable === c ? editable : newColor} onChange={(e) => { + const newHslColor = rgbToHSL(e.target.value); setColorsArr(prev => { const newArr = [...prev]; - newArr[i] = e.target.value; + newArr[i] = newHslColor; return newArr; }); if (editable === c) { - setEditable(e.target.value) - } else setNewColor(e.target.value); - }} - onBlur={() => { - setNewColor(""); - colorsArr.splice(i, 1); + setEditable(newHslColor) + } else { + setNewColor(newHslColor); + } }} onKeyDown={(e) => { if (e.key !== "Enter") return setNewColor(""); setEditable(""); }} + type="color" /> : <>
      - + (c === newColor || c === editable) ? +
      + + +
      + : hover === c && +
      + + +
      }
    • )) @@ -134,7 +161,7 @@ export default function View({ graph, setGraph, selectedValue }: {
    )) diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 760ece24..305d11de 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -32,19 +32,19 @@ export default function Page() { const q1 = "MATCH (n) RETURN COUNT(n) as nodes" const q2 = "MATCH ()-[e]->() RETURN COUNT(e) as edges" - const nodes = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q1}&role=${session?.user.role}`, { + const nodes = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q1}`, { method: "GET" - }, toast)).json() + }, session?.user?.role, toast)).json() - const edges = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q2}&role=${session?.user.role}`, { + const edges = await (await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${q2}`, { method: "GET" - }, toast)).json() + }, session?.user?.role, toast)).json() if (!edges || !nodes) return setEdgesCount(edges.result?.data[0].edges) setNodesCount(nodes.result?.data[0].nodes) - }, [graphName]) + }, [graphName, session?.user?.role, toast]) useEffect(() => { fetchCount() @@ -68,9 +68,9 @@ export default function Page() { return null } - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(query)}&role=${session?.user.role}`, { + const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?query=${prepareArg(query)}`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return null @@ -87,7 +87,7 @@ export default function Page() { const queryArr = queries.some(q => q.text === query) ? queries : [...queries, { text: query, metadata: result.metadata }] setQueries(queryArr) localStorage.setItem("query history", JSON.stringify(queryArr)) - const g = Graph.create(graphName, result, graph.Colors) + const g = Graph.create(graphName, result, false, false, graph.Colors) setGraph(g) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -100,7 +100,7 @@ export default function Page() { const queryArr = queries.some(q => q.text === query) ? queries : [...queries, { text: query, metadata: result.metadata }] setQueries(queryArr) localStorage.setItem("query history", JSON.stringify(queryArr)) - setGraph(Graph.create(graphName, result)) + setGraph(Graph.create(graphName, result, false, false, graph.Colors)) setHistoryQuery(query) setQueriesOpen(false) } @@ -129,6 +129,7 @@ export default function Page() { runQuery={runQuery} historyQuery={historyQuery} historyQueries={queries.map(({ text }) => text)} + setHistoryQueries={(queriesArr) => setQueries(queries.map((query, i) => ({ text: queriesArr[i], metadata: query.metadata } as Query)))} fetchCount={fetchCount} session={session} /> diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index ba2762ec..fc3d8ffb 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -8,7 +8,6 @@ import Button from "../components/ui/Button"; import DeleteElement from "./DeleteElement"; interface Props { - addDisabled?: boolean, disabled?: boolean, // eslint-disable-next-line @typescript-eslint/no-explicit-any chartRef: React.RefObject, @@ -16,18 +15,19 @@ interface Props { onAddEntity?: () => void, onAddRelation?: () => void, deleteDisabled?: boolean, - selectedElementsLength: number + selectedElementsLength: number, + displayAdd: boolean } export default function Toolbar({ disabled, - addDisabled, chartRef, onDeleteElement, onAddEntity, onAddRelation, deleteDisabled, - selectedElementsLength + selectedElementsLength, + displayAdd }: Props) { const [deleteOpen, setDeleteOpen] = useState(false) @@ -55,26 +55,31 @@ export default function Toolbar({ return (
    - - + { + displayAdd && + <> + + + + } 1 ? "elements" : "element"}?`} open={deleteOpen} @@ -85,7 +90,7 @@ export default function Toolbar({ className="text-nowrap" variant="Primary" label="Delete" - disabled={deleteDisabled} + disabled={deleteDisabled || disabled} > diff --git a/app/layout.tsx b/app/layout.tsx index 141d0f53..325e6711 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Inter } from "next/font/google"; import { Metadata } from "next"; import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; import NextAuthProvider from "./providers"; import GTM from "./GTM"; @@ -24,9 +25,13 @@ export default function RootLayout({ // caused by mismatched client/server content caused by next-themes return ( - + - {children} + + + {children} + + diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index be315cb7..4bffe92f 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -118,12 +118,10 @@ export default function LoginForm() { } return ( -
    +
    -
    - -
    +
    -
    +
    ); } diff --git a/app/monitor/page.tsx b/app/monitor/page.tsx index a29f9463..a84aa34e 100644 --- a/app/monitor/page.tsx +++ b/app/monitor/page.tsx @@ -4,19 +4,21 @@ import useSWR from 'swr' import React, { useState } from 'react' import { securedFetch } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' +import { useSession } from 'next-auth/react' import MonitorView from './MonitorView' export default function Page() { const [time, setTime] = useState(null) const { toast } = useToast() - + const { data: session } = useSession() + const fetcher = (url: string) => securedFetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } - }, toast).then((result) => { + }, session?.user?.role, toast).then((result) => { if (result.ok) { setTime(new Date()) return result.json() diff --git a/app/page.tsx b/app/page.tsx index 2fd5e7c6..d16a27af 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,13 +1,21 @@ "use client"; import Spinning from "@/app/components/ui/spinning"; +import Image from "next/image"; +import pkg from '@/package.json'; export default function Home() { return ( -
    -
    - +
    +
    + +
    +
    +

    Version: {`{${pkg.version}}`}

    +

    All Rights Reserved © 2024 - {new Date().getFullYear()} falkordb.com

    +
    +
    ) } diff --git a/app/schema/SchemaCreateElement.tsx b/app/schema/SchemaCreateElement.tsx index 45e700aa..2012f06a 100644 --- a/app/schema/SchemaCreateElement.tsx +++ b/app/schema/SchemaCreateElement.tsx @@ -63,7 +63,7 @@ export default function SchemaCreateElement({ onCreate, onExpand, selectedNodes, const handleSetAttribute = (isUndo: boolean, att?: [string, string[]]) => { const newAtt = att || attribute - + if (!newAtt[0] || newAtt[1].some((v) => !v)) { toast({ title: "Error", @@ -194,7 +194,7 @@ export default function SchemaCreateElement({ onCreate, onExpand, selectedNodes, /> }
    -

    {attributes.length} Attributes

    +

    {attributes.length} Attributes

diff --git a/app/schema/SchemaDataPanel.tsx b/app/schema/SchemaDataPanel.tsx index bc36331d..0ca7f8fa 100644 --- a/app/schema/SchemaDataPanel.tsx +++ b/app/schema/SchemaDataPanel.tsx @@ -143,7 +143,7 @@ export default function SchemaDataPanel({ obj, onExpand, onSetAttributes, onRemo

{label}

-

{attributes.length} Attributes

+

{attributes.length} Attributes

diff --git a/app/schema/SchemaView.tsx b/app/schema/SchemaView.tsx index ce23fc07..80eefba7 100644 --- a/app/schema/SchemaView.tsx +++ b/app/schema/SchemaView.tsx @@ -11,6 +11,7 @@ import { Session } from "next-auth" import dynamic from "next/dynamic" import { useToast } from "@/components/ui/use-toast" import { Switch } from "@/components/ui/switch" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import Toolbar from "../graph/toolbar" import SchemaDataPanel from "./SchemaDataPanel" import Labels from "../graph/labels" @@ -28,10 +29,19 @@ interface Props { } const getCreateQuery = (type: boolean, selectedNodes: [Node, Node], attributes: [string, string[]][], label?: string) => { + const formateAttributes: [string, string][] = attributes.map((att) => { + 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] + }) + if (type) { - return `CREATE (n${label ? `:${label}` : ""}${attributes?.length > 0 ? ` {${attributes.map(([k, [t, d, u, r]]) => `${k}: ["${t}", "${d}", "${u}", "${r}"]`).join(",")}}` : ""}) RETURN n` + return `CREATE (n${label ? `:${label}` : ""}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}) RETURN n` } - return `MATCH (a), (b) WHERE ID(a) = ${selectedNodes[0].id} AND ID(b) = ${selectedNodes[1].id} CREATE (a)-[e${label ? `:${label}` : ""}${attributes?.length > 0 ? ` {${attributes.map(([k, [t, d, u, un]]) => `${k}: ["${t}", "${d}", "${u}", "${un}"]`).join(",")}}` : ""}]->(b) RETURN e` + return `MATCH (a), (b) WHERE ID(a) = ${selectedNodes[0].id} AND ID(b) = ${selectedNodes[1].id} CREATE (a)-[e${label ? `:${label}` : ""}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}]->(b) RETURN e` } export default function SchemaView({ schema, fetchCount, session }: Props) { @@ -50,7 +60,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { useEffect(() => { setData({ ...schema.Elements }) - }, [schema.Id]) + }, [schema.Elements, schema.Id]) useEffect(() => { dataPanel.current?.collapse() @@ -136,7 +146,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { const q = `${conditionsNodes.length > 0 ? `MATCH (n) WHERE ${conditionsNodes.join(" OR ")} DELETE n` : ""}${conditionsEdges.length > 0 && conditionsNodes.length > 0 ? " WITH * " : ""}${conditionsEdges.length > 0 ? `MATCH ()-[e]-() WHERE ${conditionsEdges.join(" OR ")} DELETE e` : ""}` const result = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)} `, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return @@ -173,7 +183,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { const q = `MATCH ${type ? "(e)" : "()-[e]-()"} WHERE ID(e) = ${id} SET e.${key} = "${value.join(",")}"` const { ok } = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)}`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (ok) { if (type) { @@ -247,7 +257,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { const q = `MATCH ${type ? "(e)" : "()-[e]-()"} WHERE ID(e) = ${id} SET e.${key} = NULL` const { ok } = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${prepareArg(q)}`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (ok) { if (type) { @@ -286,16 +296,16 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { const result = await securedFetch(`api/graph/${prepareArg(schema.Id)}_schema/?query=${getCreateQuery(isAddEntity, selectedNodes as [Node, Node], attributes, label)}`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (result.ok) { const json = await result.json() if (isAddEntity) { - schema.extendNode(json.result.data[0].n) + schema.extendNode(json.result.data[0].n, false, true) setIsAddEntity(false) } else { - schema.extendEdge(json.result.data[0].e, true) + schema.extendEdge(json.result.data[0].e, false, true) setIsAddRelation(false) } @@ -319,7 +329,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) {
{ setIsAddEntity(true) @@ -337,7 +347,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { }} onDeleteElement={handleDeleteElement} chartRef={chartRef} - addDisabled={session?.user.role === "Read-Only"} + displayAdd /> { isCollapsed && @@ -362,19 +372,26 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { : } -
- {cooldownTicks === undefined ? : } - { - handleCooldown(cooldownTicks === undefined ? 0 : undefined) - }} - /> +
+ + +
+ {cooldownTicks === undefined ? : } + { + handleCooldown(cooldownTicks === undefined ? 0 : undefined) + }} + /> +
+
+ +

Animation Control

+
+
{ if (!schemaName) return const run = async () => { - const result = await securedFetch(`/api/graph/${prepareArg(schemaName)}_schema/?query=${defaultQuery()}&role=${session?.user.role}`, { + const result = await securedFetch(`/api/graph/${prepareArg(schemaName)}_schema/?query=${defaultQuery()}`, { method: "GET" - }, toast) + }, session?.user?.role, toast) if (!result.ok) return const json = await result.json() const colors = localStorage.getItem(schemaName)?.split(/[[\]",]/).filter(c => c) - setSchema(Graph.create(schemaName, json.result, colors)) + setSchema(Graph.create(schemaName, json.result, false, true, colors)) fetchCount() @@ -56,7 +56,7 @@ export default function Page() { return (
-
+
bolt protocol`, + description: "Defines the port number on which FalkorDB processes Bolt protocol connections.", value: "" }, { name: "MAX_QUEUED_QUERIES", - description: `Setting the maximum number of queued queries allows the server to reject incoming queries with the error message Max pending queries exceeded. - This reduces the memory overhead of pending queries on an overloaded server and avoids congestion when the server processes its backlog of queries.`, + description: "Sets the maximum number of queries that can be queued. When this limit is reached, new queries are rejected with the error message \"Max pending queries exceeded.\" This reduces memory usage and prevents congestion during query backlogs.", value: "" }, { name: "TIMEOUT", - description: `(Deprecated in FalkorDB v2.10 It is recommended to use TIMEOUT_MAX and TIMEOUT_DEFAULT instead) - The TIMEOUT configuration parameter specifies the default maximal execution time for read queries, in milliseconds. - Write queries do not timeout. - When a read query execution time exceeds the maximal execution time, the query is aborted and the query reply is (error) Query timed out. - The TIMEOUT query parameter of the GRAPH.QUERY, GRAPH.RO_QUERY, and GRAPH.PROFILE commands can be used to override this value.`, + description: "(Deprecated in FalkorDB v2.10; use TIMEOUT_MAX and TIMEOUT_DEFAULT instead) Specifies the maximum execution time for read queries, in milliseconds. If a read query exceeds this limit, it is aborted with the error message \"Query timed out.\" Write queries are not subject to this timeout.", value: "" }, { name: "TIMEOUT_MAX", - description: `(Since RedisGraph v2.10) The TIMEOUT_MAX configuration parameter specifies the maximum execution time for both read and write queries, in milliseconds. - The TIMEOUT query parameter value of the GRAPH.QUERY, GRAPH.RO_QUERY, and GRAPH.PROFILE commands cannot exceed the TIMEOUT_MAX value (the command would abort with a (error) The query TIMEOUT parameter value cannot exceed the TIMEOUT_MAX configuration parameter value reply). - Similarly, the TIMEOUT_DEFAULT configuration parameter cannot exceed the TIMEOUT_MAX value. - When a query execution time exceeds the maximal execution time, the query is aborted and the query reply is (error) Query timed out. - For a write query - any change to the graph is undone (which may take additional time).`, + description: "(Since RedisGraph v2.10) Specifies the maximum execution time for both read and write queries, in milliseconds. The TIMEOUT parameter in GRAPH.QUERY, GRAPH.RO_QUERY, and GRAPH.PROFILE commands cannot exceed this value. Queries exceeding this time limit are aborted with the error \"Query timed out.\" For write queries, any graph changes are undone, which may take additional time.", value: "" }, { name: "TIMEOUT_DEFAULT", - description: `(Since RedisGraph v2.10) The TIMEOUT_DEFAULT configuration parameter specifies the default maximal execution time for both read and write queries, in milliseconds. - For a given query, this default maximal execution time can be overridden by the TIMEOUT query parameter of the GRAPH.QUERY, GRAPH.RO_QUERY, and GRAPH.PROFILE commands. - However, a query execution time cannot exceed TIMEOUT_MAX.`, + description: "(Since RedisGraph v2.10) Sets the default maximum execution time for both read and write queries, in milliseconds. This default can be overridden by the TIMEOUT parameter in GRAPH.QUERY, GRAPH.RO_QUERY, and GRAPH.PROFILE commands but cannot exceed TIMEOUT_MAX.", value: "" }, { name: "RESULTSET_SIZE", - description: `Result set size is a limit on the number of records that should be returned by any query. - This can be a valuable safeguard against incurring a heavy IO load while running queries with unknown results.`, + description: "Limits the number of records returned by any query. This helps prevent heavy I/O loads when running queries with unpredictable results.", value: "" }, { name: "QUERY_MEM_CAPACITY", - description: `Setting the memory capacity of a query allows the server to kill queries that are consuming too much memory and return with the error message Query's mem consumption exceeded capacity. - This helps to avoid scenarios when the server becomes unresponsive due to an unbounded query exhausting system resources. - The configuration argument is the maximum number of bytes that can be allocated by any single query.`, + description: "Specifies the maximum memory, in bytes, that a single query can use. Queries exceeding this limit are terminated with the error \"Query's memory consumption exceeded capacity,\" helping prevent server unresponsiveness.", value: "" }, { name: "VKEY_MAX_ENTITY_COUNT", - description: `To lower the time Redis is blocked when replicating large graphs, FalkorDB serializes the graph in a number of virtual keys. - One virtual key is created for every N graph entities, where N is the value defined by this configuration.`, + description: "Reduces the time Redis is blocked during large graph replication by serializing the graph into multiple virtual keys. One virtual key is created for every N graph entities, where N is defined by this configuration.", value: "" }, { name: "CMD_INFO", - description: `An on/off toggle for the GRAPH.INFO command. - Disabling this command may increase performance and lower the memory usage and these are the main reasons for it to be disabled - It’s valid values are ‘yes’ and ‘no’ (i.e., on and off).`, + description: "Enables or disables the GRAPH.INFO command. Disabling this command can improve performance and reduce memory usage. Acceptable values are \"yes\" (enabled) and \"no\" (disabled).", value: "" }, { name: "MAX_INFO_QUERIES", - description: `A limit for the number of previously executed queries stored in the telemetry stream.`, + description: "Sets a limit on the number of previously executed queries stored in the telemetry stream.", value: "" }, ] @@ -126,7 +102,8 @@ const Configs: Config[] = [ export default function Configurations() { const [configs, setConfigs] = useState([]); const { toast } = useToast(); - + const { data: session } = useSession() + // Memoize the config update handler const handleSetConfig = useCallback(async (name: string, value: string, isUndo: boolean) => { if (!value) { @@ -138,14 +115,25 @@ export default function Configurations() { return false; } + if (name === "MAX_INFO_QUERIES") { + if (Number(value) > 1000) { + toast({ + title: "Error", + description: "Value must be less than 1000", + variant: "destructive" + }); + return false; + } + } + const result = await securedFetch( `api/graph/?config=${prepareArg(name)}&value=${prepareArg(value)}`, { method: 'POST' }, - toast + session?.user?.role, toast ); if (!result.ok) return false; - + const configToUpdate = configs.find(row => row.cells[0].value === name); const oldValue = configToUpdate?.cells[2].value; @@ -180,7 +168,7 @@ export default function Configurations() { const result = await securedFetch( `api/graph/?config=${prepareArg(config.name)}`, { method: 'GET' }, - toast + session?.user?.role, toast ); if (!result.ok) { diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 7cd2c7f9..622b3100 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -35,12 +35,12 @@ export default function Settings() {

Settings

diff --git a/components/ui/table.tsx b/components/ui/table.tsx index dbe1675d..6e98a5b1 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -11,7 +11,7 @@ const Table = React.forwardRef< HTMLTableElement, TableProps >(({ className, parentClassName, ...props }, ref) => ( -
+
{ - const value = await this.roleContentValue(role).getAttribute('title') + const value = await this.roleContentValue(role).textContent(); return value } @@ -24,6 +24,14 @@ export default class SettingsConfigPage extends BasePage { return (role: string) => this.page.locator(`//tbody//tr[@data-id='${role}']/td[3]/div/div/button[1]`) } + private get toastCloseBtn(): Locator { + return this.page.locator("//li[@role='status']/button"); + } + + private get tableContent(): Locator { + return this.page.locator("//div[@id='tableContent']"); + } + async modifyRoleValue(role: string, input: string): Promise { await this.roleContentValue(role).hover(); await this.EditRoleButton(role).click(); @@ -33,4 +41,11 @@ export default class SettingsConfigPage extends BasePage { return value } + async clickOnToastCloseBtn(): Promise{ + await this.toastCloseBtn.click(); + } + + async scrollToBottomInTable(): Promise { + await this.tableContent.evaluate((el) => el.scrollTo(0, el.scrollHeight)); + } } \ No newline at end of file diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index 6e282435..fa4af779 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -20,8 +20,16 @@ export default class SettingsUsersPage extends BasePage { return this.page.locator("//div[@role='dialog']//button[span[text()='Select Role']]") } - private get selectRoleBtn(): Locator { - return this.page.locator("//button[span[text()='Select Role']]") + private get userSelectRoleEditBtn(): (selectedUser: string) => Locator { + return (selectedUser: string) => this.page.locator(`//tbody/tr[@data-id='${selectedUser}']/td[3]/div/div/button`) + } + + private get userRow(): (selectedUser: string) => Locator { + return (selectedUser: string) => this.page.locator(`//tbody/tr[@data-id='${selectedUser}']`) + } + + private get userSelectRoleBtn(): (selectedUser: string) => Locator { + return (selectedUser: string) => this.page.locator(`//tbody/tr[@data-id='${selectedUser}']/td[3]/button`) } private get selectUserRole(): (role: string) => Locator { @@ -93,20 +101,13 @@ export default class SettingsUsersPage extends BasePage { async modifyUserRole(selectedUser: string, role: string): Promise { await this.page.waitForLoadState('networkidle'); - await this.userCheckboxBtn(selectedUser).click(); - await this.selectRoleBtn.click(); + await this.userRow(selectedUser).hover(); + await this.userSelectRoleEditBtn(selectedUser).click(); + await this.userSelectRoleBtn(selectedUser).click(); await this.selectUserRole(role).click(); await waitForTimeOut(this.page, 1500) } - async modifyTwoUsersRoles(selectedUser1: string, selectedUser2: string, role: string): Promise { - await this.page.waitForLoadState('networkidle'); - await this.userCheckboxBtn(selectedUser1).click(); - await this.userCheckboxBtn(selectedUser2).click(); - await this.selectRoleBtn.click(); - await this.selectUserRole(role).click() - } - async deleteTwoUsers(selectedUser1: string, selectedUser2: string): Promise { await this.page.waitForLoadState('networkidle'); await this.userCheckboxBtn(selectedUser1).click() diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index 97c85499..f516bc55 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -13,6 +13,7 @@ setup("admin authentication", async () => { try { const browserWrapper = new BrowserWrapper(); const loginPage = await browserWrapper.createNewPage(LoginPage, urls.loginUrl); + await browserWrapper.setPageToFullScreen(); await loginPage.clickOnConnect(); await loginPage.dismissDialogAtStart(); const context = browserWrapper.getContext(); @@ -37,6 +38,7 @@ userRoles.forEach(({ name, file, userName }) => { try { const browserWrapper = new BrowserWrapper(); const loginPage = await browserWrapper.createNewPage(LoginPage, urls.loginUrl); + await browserWrapper.setPageToFullScreen(); await loginPage.connectWithCredentials(userName, user.password); await loginPage.dismissDialogAtStart(); const context = browserWrapper.getContext(); diff --git a/e2e/tests/settingsConfig.spec.ts b/e2e/tests/settingsConfig.spec.ts index 9b36bb4a..6eea15bb 100644 --- a/e2e/tests/settingsConfig.spec.ts +++ b/e2e/tests/settingsConfig.spec.ts @@ -17,7 +17,7 @@ test.describe('Settings Tests', () => { await browser.closeBrowser(); }) - Data.inputDataRejectsZero.forEach(({ input, description, expected }) => { + Data.inputDataRejectsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.maxQueuedQueries} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() @@ -25,56 +25,85 @@ test.describe('Settings Tests', () => { await apiCall.modifySettingsRole(roles.maxQueuedQueries, input) await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.maxQueuedQueries) - expect(value === input).toBe(expected) + expect(value === input).toBe(expected); + if (index === Data.inputDataRejectsZero.length - 1) { + await apiCall.modifySettingsRole(roles.maxQueuedQueries, "25") + } + }); + }) + + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { + test(`@admin Modify ${roles.TimeOut} via API validation via UI: Input value: ${input} description: ${description}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + const apiCall = new ApiCalls() + await new Promise(resolve => { setTimeout(resolve, 1000) }); + await apiCall.modifySettingsRole(roles.TimeOut, input) + await settingsConfigPage.refreshPage() + const value = await settingsConfigPage.getRoleContentValue(roles.TimeOut) + expect(value === input).toBe(expected); + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.TimeOut, "1000") + } }); }) - Data.maxTimeOut.forEach(({ input, description, expected }) => { + Data.maxTimeOut.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.maxTimeOut} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() await apiCall.modifySettingsRole(roles.maxTimeOut, input) await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.maxTimeOut) - expect(value === input).toBe(expected) + expect(value === input).toBe(expected); + if (index === Data.maxTimeOut.length - 1) { + await apiCall.modifySettingsRole(roles.maxTimeOut, "0") + } }); }) - Data.inputDataAcceptsZero.forEach(({ input, description, expected }) => { + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.defaultTimeOut} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() await apiCall.modifySettingsRole(roles.defaultTimeOut, input) await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.defaultTimeOut) - expect(value === input).toBe(expected) + expect(value === input).toBe(expected); + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.defaultTimeOut, "0") + } }); }) - Data.inputDataAcceptsZero.forEach(({ input, description, expected }) => { + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.resultSetSize} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() await apiCall.modifySettingsRole(roles.resultSetSize, input) await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.resultSetSize) - expect(value === input).toBe(expected) + expect(value === input).toBe(expected); + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.resultSetSize, "10000") + } }); }) - Data.inputDataAcceptsZero.forEach(({ input, description, expected }) => { + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.queryMemCapacity} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() await apiCall.modifySettingsRole(roles.queryMemCapacity, input) await settingsConfigPage.refreshPage() - const value = await settingsConfigPage.getRoleContentValue(roles.queryMemCapacity) - await apiCall.modifySettingsRole(roles.queryMemCapacity, "0") // update to default values - expect(value === input).toBe(expected) + const value = await settingsConfigPage.getRoleContentValue(roles.queryMemCapacity) + expect(value === input).toBe(expected); + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.queryMemCapacity, "0") + } }); }) - Data.inputDataAcceptsZero.forEach(({ input, description, expected }) => { + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.vKeyMaxEntityCount} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() @@ -82,10 +111,13 @@ test.describe('Settings Tests', () => { await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.vKeyMaxEntityCount) expect(value === input).toBe(expected) + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.vKeyMaxEntityCount, "100000") + } }); }) - Data.CMDData.forEach(({ input, description, expected }) => { + Data.CMDData.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.cmdInfo} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() @@ -93,36 +125,109 @@ test.describe('Settings Tests', () => { await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.cmdInfo) expect(value === input).toBe(expected) + if (index === Data.CMDData.length - 1) { + await apiCall.modifySettingsRole(roles.cmdInfo, "yes") + } }); }) - Data.inputDataAcceptsZero.forEach(({ input, description, expected }) => { + Data.inputDataAcceptsZero.forEach(({ input, description, expected }, index) => { test(`@admin Modify ${roles.maxInfoQueries} via API validation via UI: Input value: ${input} description: ${description}`, async () => { const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) const apiCall = new ApiCalls() await apiCall.modifySettingsRole(roles.maxInfoQueries, input) await settingsConfigPage.refreshPage() const value = await settingsConfigPage.getRoleContentValue(roles.maxInfoQueries) - expect(value === input).toBe(expected) - }); - }) - - Data.roleModificationData.forEach(({ role, input, description, expected }) => { - test(`@admin Modify ${role} via UI validation via API: Input value: ${input} description: ${description}`, async () => { - const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) - await settingsConfigPage.modifyRoleValue(role, input) - const apiCall = new ApiCalls() - let value = String((await apiCall.getSettingsRoleValue(role)).config[1]); - // Convert numeric values to yes/no for boolean settings - if (value === '1') { - value = 'yes'; - } else if (value === '0') { - value = 'no'; + expect(value === input).toBe(expected); + if (index === Data.inputDataAcceptsZero.length - 1) { + await apiCall.modifySettingsRole(roles.maxInfoQueries, "1000"); } - await apiCall.modifySettingsRole(roles.queryMemCapacity, "0") // update to default values - expect(value === input).toBe(expected) }); }) + test(`@admin Modify maxQueuedQueries via UI validation via API: Input value: ${Data.roleModificationData[0].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxQueuedQueries, Data.roleModificationData[0].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.maxQueuedQueries)).config[1]); + expect(value === Data.roleModificationData[0].input).toBe(true); + await apiCall.modifySettingsRole(roles.maxQueuedQueries, "25") + }); + + test(`@admin Modify TimeOut via UI validation via API: Input value: ${Data.roleModificationData[1].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.TimeOut, Data.roleModificationData[1].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.TimeOut)).config[1]); + expect(value === Data.roleModificationData[1].input).toBe(true); + await apiCall.modifySettingsRole(roles.TimeOut, "1000") + }); + + test(`@admin Modify maxTimeOut via UI validation via API: Input value: ${Data.roleModificationData[2].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxTimeOut, Data.roleModificationData[2].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.maxTimeOut)).config[1]); + expect(value === Data.roleModificationData[2].input).toBe(true); + await apiCall.modifySettingsRole(roles.maxTimeOut, "0") + }); + + test(`@admin Modify defaultTimeOut via UI validation via API: Input value: ${Data.roleModificationData[3].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.defaultTimeOut, Data.roleModificationData[3].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.defaultTimeOut)).config[1]); + expect(value === Data.roleModificationData[3].input).toBe(true); + await apiCall.modifySettingsRole(roles.defaultTimeOut, "0") + }); + + test(`@admin Modify resultSetSize via UI validation via API: Input value: ${Data.roleModificationData[4].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.resultSetSize, Data.roleModificationData[4].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.resultSetSize)).config[1]); + expect(value === Data.roleModificationData[4].input).toBe(true); + await apiCall.modifySettingsRole(roles.resultSetSize, "10000") + }); + + test(`@admin Modify queryMemCapacity via UI validation via API: Input value: ${Data.roleModificationData[5].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.queryMemCapacity, Data.roleModificationData[5].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.queryMemCapacity)).config[1]); + expect(value === Data.roleModificationData[5].input).toBe(true); + await apiCall.modifySettingsRole(roles.queryMemCapacity, "0") + }); + + test(`@admin Modify vKeyMaxEntityCount via UI validation via API: Input value: ${Data.roleModificationData[6].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.vKeyMaxEntityCount, Data.roleModificationData[6].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.vKeyMaxEntityCount)).config[1]); + expect(value === Data.roleModificationData[6].input).toBe(true); + await apiCall.modifySettingsRole(roles.vKeyMaxEntityCount, "100000") + }); + + test(`@admin Modify cmdInfo via UI validation via API: Input value: ${Data.roleModificationData[7].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.cmdInfo, Data.roleModificationData[7].input) + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.cmdInfo)).config[1]); + value = value === '1' ? 'yes' : value === '0' ? 'no' : value; + expect(value === Data.roleModificationData[7].input).toBe(true); + await apiCall.modifySettingsRole(roles.cmdInfo, "yes") + }); + + test(`@admin Modify maxInfoQueries via UI validation via API: Input value: ${Data.roleModificationData[8].input}`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxInfoQueries, Data.roleModificationData[8].input) + await settingsConfigPage.refreshPage(); + await settingsConfigPage.scrollToBottomInTable(); + await settingsConfigPage.getRoleContentValue(roles.maxInfoQueries); + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.maxInfoQueries)).config[1]); + expect(value).toBe(Data.roleModificationData[8].input); + await apiCall.modifySettingsRole(roles.maxInfoQueries, "1000"); + }); }) \ No newline at end of file diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index 71953bb8..8d737f04 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -56,7 +56,7 @@ test.describe('Settings Tests', () => { }) - test("@admin Add two users -> change their roles via checkbox -> Validate that the users roles have been changed", async () => { + test("@admin Add two users -> change their roles -> Validate that the users roles have been changed", async () => { // Adding two user const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl) await settingsUsersPage.navigateToUserTab(); @@ -67,7 +67,8 @@ test.describe('Settings Tests', () => { // modify users roles const userRole = user.ReadOnly; - await settingsUsersPage.modifyTwoUsersRoles(username1, username2, userRole) + await settingsUsersPage.modifyUserRole(username1, userRole) + await settingsUsersPage.modifyUserRole(username2, userRole) await settingsUsersPage.refreshPage() await settingsUsersPage.navigateToUserTab() const userName1Role = await settingsUsersPage.getUserRole(username1) @@ -146,7 +147,7 @@ test.describe('Settings Tests', () => { const User = users.result.find(u => u.username === username); expect(User?.username === username).toBe(true) }) - // fail tests + test(`@admin API Test: without passing a username, Attempt to add a user and validate the user was not added`, async () => { const apiCall = new ApiCalls() const username = '' diff --git a/lib/utils.ts b/lib/utils.ts index 059aa9d6..1a6ecfe3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -12,27 +12,29 @@ export function cn(...inputs: ClassValue[]) { } export async function securedFetch( - input: string | URL | globalThis.Request, + input: string, init: RequestInit, + role?: string, toast?: any, ): Promise { - const response = await fetch(input, init) + let url = input + if (role) { + url += input.includes("?") ? `&role=${role}` : `?role=${role}` + } + const response = await fetch(url, init) const { status } = response - if (status >= 300) { - response.json().then((err) => { - if (toast) { - toast({ - title: "Error", - description: err.message, - variant: "destructive" - }) - } - }).then(() => { - if (status === 401 || status >= 500) { - signOut({ callbackUrl: '/login' }) - } - }) + const err = await response.text() + if (toast) { + toast({ + title: "Error", + description: err, + variant: "destructive" + }) + } + if (status === 401 || status >= 500) { + signOut({ callbackUrl: '/login' }) + } } return response } @@ -41,4 +43,48 @@ export function prepareArg(arg: string) { return encodeURIComponent(arg.trim()) } -export const defaultQuery = (q?: string) => q || "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) return n,e,m LIMIT 100" \ No newline at end of file +export const defaultQuery = (q?: string) => q || "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) return n,e,m LIMIT 100" + +export function rgbToHSL(hex: string): string { + // Remove the # if present + const formattedHex = hex.replace(/^#/, ''); + + // Convert hex to RGB + const r = parseInt(formattedHex.slice(0, 2), 16) / 255; + const g = parseInt(formattedHex.slice(2, 4), 16) / 255; + const b = parseInt(formattedHex.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + default: + h = 0; + break; + } + h /= 6; + } + + // Convert to degrees and percentages + const hDeg = Math.round(h * 360); + const sPct = Math.round(s * 100); + const lPct = Math.round(l * 100); + + return `hsl(${hDeg}, ${sPct}%, ${lPct}%)`; +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index b705a6f5..4bc5ebd2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,8 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: [['html', { outputFolder: 'playwright-report' }]], + outputDir: 'playwright-report/artifacts', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -29,6 +30,7 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + screenshot: 'only-on-failure', }, /* Configure projects for major browsers */ diff --git a/public/fonts/obviously_narrow.woff b/public/fonts/obviously_narrow.woff deleted file mode 100644 index 7498e5b6..00000000 Binary files a/public/fonts/obviously_narrow.woff and /dev/null differ diff --git a/public/fonts/obviously_narrow.woff2 b/public/fonts/obviously_narrow.woff2 deleted file mode 100644 index f2dac670..00000000 Binary files a/public/fonts/obviously_narrow.woff2 and /dev/null differ diff --git a/public/fonts/oswald.ttf b/public/fonts/oswald.ttf deleted file mode 100644 index 938e912a..00000000 Binary files a/public/fonts/oswald.ttf and /dev/null differ