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..adfffbb3 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -16,7 +16,7 @@ jobs: services: falkordb: - image: falkordb/falkordb:latest + image: falkordb/falkordb:v4.4.1 ports: - 6379:6379 @@ -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..ec94948b 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[], @@ -209,14 +233,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 +257,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,7 +272,7 @@ 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 @@ -272,9 +296,8 @@ export class Graph { 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 +392,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 +404,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 +422,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)) } } }) diff --git a/app/components/EditorComponent.tsx b/app/components/EditorComponent.tsx index c8b28db7..d12292ac 100644 --- a/app/components/EditorComponent.tsx +++ b/app/components/EditorComponent.tsx @@ -3,33 +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 { prepareArg, securedFetch } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; +import { Session } from "next-auth"; +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 } @@ -78,7 +71,7 @@ const KEYWORDS = [ "ORDER BY", "SKIP", "LIMIT", - "MARGE", + "MERGE", "DELETE", "SET", "WITH", @@ -184,52 +177,77 @@ 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, data }: 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() + + 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 +274,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)" - }) - }) - }) + monacoI.editor.setTheme('custom-theme') } - 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)" - }) - }) - }) - } - - 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)}&role=${data?.user.role}`, { + method: 'GET', + }, 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() + 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() + ) - sug.functions.forEach(({ label }) => { - const names = (label as string).split(".") - names.forEach((name, i) => { - if (i === names.length - 1) return - namespaces.set(name, name) - }) - }) - - 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 +355,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 +378,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 +408,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 +434,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 +461,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 +499,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 +513,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 +545,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 +555,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 +582,10 @@ export default function EditorComponent({ currentQuery, historyQueries, setCurre /> + + + + > - isCollapsed: boolean } const NODE_SIZE = 6 @@ -43,27 +42,35 @@ 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 lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) const toast = useToast() - 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]) + useEffect(() => { + const handleResize = () => { + if (!parentRef.current) return + setParentWidth(parentRef.current.clientWidth) + setParentHeight(parentRef.current.clientHeight) + } + + window.addEventListener('resize', handleResize) + + const observer = new ResizeObserver(handleResize) - useEffect(() => { - if (!parentRef.current) return - setParentWidth(parentRef.current.clientWidth) - setParentHeight(parentRef.current.clientHeight) - }, [parentRef.current?.clientWidth, parentRef.current?.clientHeight, isCollapsed]) + 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}`, { @@ -105,19 +112,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 +230,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 +243,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 +254,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 +293,14 @@ 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.id === selectedElement?.id || link.id === hoverElement?.id + ? link.color + : lightenColor(link.color)} + linkColor={(link) => link.id === selectedElement?.id || link.id === hoverElement?.id + ? link.color + : lightenColor(link.color)} />
) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 66c05915..16849a84 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 (
@@ -49,55 +52,82 @@ export default function Header({ onSetGraphName }: Props) {
- - - + + + + + + + + + +

Help

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

Help

-
- - - -
- { - !inCreate && - - } - -
-
+ + + + + +
+ Loading... +

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/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/globals.css b/app/globals.css index 830ec076..28ca906e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -60,11 +60,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%); } diff --git a/app/graph/GraphDataPanel.tsx b/app/graph/GraphDataPanel.tsx index abe0c406..30f106fd 100644 --- a/app/graph/GraphDataPanel.tsx +++ b/app/graph/GraphDataPanel.tsx @@ -294,7 +294,6 @@ export default function GraphDataPanel({ obj, setObj, onExpand, onDeleteElement, value={newVal} onChange={(e) => setNewVal(e.target.value)} onKeyDown={handleSetKeyDown} - onBlur={() => handleSetEditable("", "")} /> :
{cooldownTicks === undefined ? : } @@ -292,7 +290,6 @@ function GraphView({ graph, selectedElement, setSelectedElement, runQuery, histo />
("") const handlePreferencesChange = (colors?: string[]) => { - setGraph(Graph.create(graph.Id, { data: graph.Data, metadata: graph.Metadata }, colors || colorsArr)) - if (colors) return + setGraph(Graph.create(graph.Id, { data: graph.Data, metadata: graph.Metadata }, false, true, colors || colorsArr)) + if (colors) { + localStorage.removeItem(graph.Id) + } localStorage.setItem(graph.Id, JSON.stringify(colorsArr)); } @@ -40,9 +42,9 @@ export default function View({ graph, setGraph, selectedValue }: { className="w-[30%] h-[50%]" title="Preferences" > -
+

Legends

-
    +
      { colorsArr.map((c, i) => (
    • setHover(c)} onMouseLeave={(() => setHover(""))} key={c} className={cn(`flex gap-8 items-center`)}> @@ -154,7 +156,7 @@ export default function View({ graph, setGraph, selectedValue }: {
    • )) diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 760ece24..ad47ac94 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -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/layout.tsx b/app/layout.tsx index 141d0f53..185eaa86 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -24,7 +24,7 @@ export default function RootLayout({ // caused by mismatched client/server content caused by next-themes return ( - + {children} diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index be315cb7..3c8458bc 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -118,12 +118,10 @@ export default function LoginForm() { } return ( -
      +
      -
      - Loading... -
      + Loading...
      -
      +
      ); } diff --git a/app/page.tsx b/app/page.tsx index 2fd5e7c6..4c6b09f9 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 ( -
      -
      - +
      +
      + FalkorDB Logo +
      +
      +

      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..13d9f354 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..4daa9d4f 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..0bdb6aa7 100644 --- a/app/schema/SchemaView.tsx +++ b/app/schema/SchemaView.tsx @@ -28,10 +28,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) { @@ -292,10 +301,10 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { 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) } @@ -337,7 +346,7 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { }} onDeleteElement={handleDeleteElement} chartRef={chartRef} - addDisabled={session?.user.role === "Read-Only"} + addDisabled={session?.user.role === "Read-Only" || !schema.Id} /> { isCollapsed && @@ -374,7 +383,6 @@ export default function SchemaView({ schema, fetchCount, session }: Props) { /> c) - setSchema(Graph.create(schemaName, json.result, colors)) + setSchema(Graph.create(schemaName, json.result, false, true, colors)) fetchCount() diff --git a/app/settings/Configurations.tsx b/app/settings/Configurations.tsx index 689937c2..a3404ed4 100644 --- a/app/settings/Configurations.tsx +++ b/app/settings/Configurations.tsx @@ -138,6 +138,17 @@ 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' }, @@ -145,7 +156,7 @@ export default function Configurations() { ); if (!result.ok) return false; - + const configToUpdate = configs.find(row => row.cells[0].value === name); const oldValue = configToUpdate?.cells[2].value; 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) => ( -
      +
      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/api/apiCalls.ts b/e2e/logic/api/apiCalls.ts index 26881060..1697766c 100644 --- a/e2e/logic/api/apiCalls.ts +++ b/e2e/logic/api/apiCalls.ts @@ -35,6 +35,7 @@ export default class ApiCalls { async getSettingsRoleValue(roleName: string, data?: any): Promise { const result = await getRequest(urls.api.settingsConfig + roleName, data) const jsonData = await result.json(); + console.log("api calls res:", jsonData, " role: ", roleName); return jsonData } 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..8f753fdc 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,120 @@ 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'; + console.log(value); + 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: 24`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxQueuedQueries, "24") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.maxQueuedQueries)).config[1]); + expect(value === "24").toBe(true); + await apiCall.modifySettingsRole(roles.maxQueuedQueries, "25") + }); + + test(`@admin Modify TimeOut via UI validation via API: Input value: 1001`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.TimeOut, "1001") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.TimeOut)).config[1]); + expect(value === "1001").toBe(true); + await apiCall.modifySettingsRole(roles.TimeOut, "1000") + }); + + test(`@admin Modify maxTimeOut via UI validation via API: Input value: 1`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxTimeOut, "1") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.maxTimeOut)).config[1]); + expect(value === "1").toBe(true); + await apiCall.modifySettingsRole(roles.maxTimeOut, "0") + }); + + test(`@admin Modify defaultTimeOut via UI validation via API: Input value: 1`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.defaultTimeOut, "1") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.defaultTimeOut)).config[1]); + expect(value === "1").toBe(true); + await apiCall.modifySettingsRole(roles.defaultTimeOut, "0") + }); + + test(`@admin Modify resultSetSize via UI validation via API: Input value: 10001`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.resultSetSize, "10001") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.resultSetSize)).config[1]); + expect(value === "10001").toBe(true); + await apiCall.modifySettingsRole(roles.resultSetSize, "10000") + }); + + test(`@admin Modify queryMemCapacity via UI validation via API: Input value: 1`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.queryMemCapacity, "1") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.queryMemCapacity)).config[1]); + expect(value === "1").toBe(true); + await apiCall.modifySettingsRole(roles.queryMemCapacity, "0") + }); + + test(`@admin Modify vKeyMaxEntityCount via UI validation via API: Input value: 100001`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.vKeyMaxEntityCount, "100001") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.vKeyMaxEntityCount)).config[1]); + expect(value === "100001").toBe(true); + await apiCall.modifySettingsRole(roles.vKeyMaxEntityCount, "100000") + }); + + test(`@admin Modify cmdInfo via UI validation via API: Input value: no`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.cmdInfo, "no") + const apiCall = new ApiCalls() + let value = String((await apiCall.getSettingsRoleValue(roles.cmdInfo)).config[1]); + value = value === '1' ? 'yes' : value === '0' ? 'no' : value; + expect(value === "no").toBe(true); + await apiCall.modifySettingsRole(roles.cmdInfo, "yes") + }); + + test(`@admin Modify maxInfoQueries via UI validation via API: Input value: 999`, async () => { + const settingsConfigPage = await browser.createNewPage(SettingsConfigPage, urls.settingsUrl) + await settingsConfigPage.modifyRoleValue(roles.maxInfoQueries, "999") + await settingsConfigPage.refreshPage(); + await settingsConfigPage.scrollToBottomInTable(); + const res = await settingsConfigPage.getRoleContentValue(roles.maxInfoQueries); + console.log("ui value: ", res); + + await new Promise(resolve => { setTimeout(resolve, 3000) }); + const apiCall = new ApiCalls() + let value; + for (let i = 0; i < 5; i++) { + value = String((await apiCall.getSettingsRoleValue(roles.maxInfoQueries)).config[1]); + if (value === "999") break; + await new Promise(resolve => setTimeout(resolve, 1500)); + } + + console.log("api value:", value); + expect(value).toBe("999"); + await apiCall.modifySettingsRole(roles.maxInfoQueries, "1000"); + }); }) \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 059aa9d6..a5256ea8 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -41,4 +41,19 @@ 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 const lightenColor = (hex: string): string => { + // Remove the # if present + const color = hex.replace('#', ''); + // Convert to RGB + const r = parseInt(color.slice(0, 2), 16); + const g = parseInt(color.slice(2, 4), 16); + const b = parseInt(color.slice(4, 6), 16); + // Mix with white (add 20% of the remaining distance to white) + const lightR = Math.min(255, r + Math.floor((255 - r) * 0.2)); + const lightG = Math.min(255, g + Math.floor((255 - g) * 0.2)); + const lightB = Math.min(255, b + Math.floor((255 - b) * 0.2)); + // Convert back to hex + return `#${lightR.toString(16).padStart(2, '0')}${lightG.toString(16).padStart(2, '0')}${lightB.toString(16).padStart(2, '0')}`; +} \ 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 */