diff --git a/app/api/graph/[graph]/[node]/[key]/route.ts b/app/api/graph/[graph]/[node]/[key]/route.ts index 230ca7e8..300ee4ee 100644 --- a/app/api/graph/[graph]/[node]/[key]/route.ts +++ b/app/api/graph/[graph]/[node]/[key]/route.ts @@ -8,8 +8,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { graph: graphId, node, key } = await params const nodeId = Number(node) const { value, type } = await request.json() @@ -25,7 +24,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ? `MATCH (n) WHERE ID(n) = $nodeId SET n.${key} = $value` : `MATCH (n)-[e]-(m) WHERE ID(e) = $nodeId SET e.${key} = $value`; - const result = await graph.query(query, { params: { nodeId, value } }); + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId, value } }) + : await graph.query(query, { params: { nodeId, value } }); if (!result) throw new Error("Something went wrong") @@ -42,7 +43,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session + const { client, user } = session const { graph: graphId, node, key } = await params const nodeId = Number(node) @@ -57,7 +58,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise ? `MATCH (n) WHERE ID(n) = $nodeId SET n.${key} = NULL` : `MATCH (n)-[e]-(m) WHERE ID(e) = $nodeId SET e.${key} = NULL`; - const result = await graph.query(query, { params: { nodeId } }); + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }); if (!result) throw new Error("Something went wrong") diff --git a/app/api/graph/[graph]/[node]/label/route.ts b/app/api/graph/[graph]/[node]/label/route.ts index f8a1006d..c941357e 100644 --- a/app/api/graph/[graph]/[node]/label/route.ts +++ b/app/api/graph/[graph]/[node]/label/route.ts @@ -9,8 +9,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session - + const { client, user } = session const { graph: graphId, node } = await params const nodeId = Number(node) const { label } = await request.json() @@ -20,7 +19,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const query = `MATCH (n) WHERE ID(n) = $nodeId REMOVE n:${label}` const graph = client.selectGraph(graphId); - const result = await graph.query(query, { params: { nodeId } }) + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }) if (!result) throw new Error("Something went wrong") @@ -39,8 +40,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { graph: graphId, node } = await params const nodeId = Number(node) const { label } = await request.json() @@ -50,7 +50,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const query = `MATCH (n) WHERE ID(n) = $nodeId SET n:${label}` const graph = client.selectGraph(graphId); - const result = await graph.query(query, { params: { nodeId } }) + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }) if (!result) throw new Error("Something went wrong") diff --git a/app/api/graph/[graph]/[node]/route.ts b/app/api/graph/[graph]/[node]/route.ts index 6fd6436e..035a18e0 100644 --- a/app/api/graph/[graph]/[node]/route.ts +++ b/app/api/graph/[graph]/[node]/route.ts @@ -9,20 +9,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { graph: graphId, node } = await params const nodeId = Number(node) - const graph = client.selectGraph(graphId); + try { + const graph = client.selectGraph(graphId); + + // Get node's neighbors + const query = `MATCH (src)-[e]-(n) + WHERE ID(src) = $nodeId + RETURN e, n`; - // Get node's neighbors - const query = `MATCH (src)-[e]-(n) - WHERE ID(src) = $nodeId - RETURN e, n`; + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }) - try { - const result = await graph.query(query, { params: { nodeId } }); return NextResponse.json({ result }, { status: 200 }) } catch (err: unknown) { console.error(err) @@ -36,8 +38,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session - + const { client, user } = session const { graph: graphId, node } = await params const nodeId = Number(node) const { type } = await request.json() @@ -49,7 +50,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const query = type ? `MATCH (n) WHERE ID(n) = $nodeId DELETE n` : `MATCH ()-[e]-() WHERE ID(e) = $nodeId DELETE e`; - const result = await graph.query(query, { params: { nodeId } }); + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }) if (!result) throw new Error("Something went wrong") diff --git a/app/api/graph/[graph]/count/route.ts b/app/api/graph/[graph]/count/route.ts index b260e8a9..431c44ac 100644 --- a/app/api/graph/[graph]/count/route.ts +++ b/app/api/graph/[graph]/count/route.ts @@ -9,20 +9,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - - const { graph: graphId } = await params + const { graph } = await params try { - const graph = client.selectGraph(graphId) const query = "MATCH (n) OPTIONAL MATCH (n)-[e]->() WITH count(n) as nodes, count(e) as edges RETURN nodes, edges" - const { data } = await graph.query(query) - if (!data) throw new Error("Something went wrong") - - const result = data.length === 0 ? { nodes: 0, edges: 0 } : data[0] + const result = await fetch(`${request.nextUrl.origin}/api/graph/${graph}/?query=${encodeURIComponent(query)}`, { + method: "GET", + headers: { + cookie: request.headers.get('cookie') || '', + } + }) + + if (!result.ok) throw new Error("Something went wrong") + + const json = await result.json() + + const data = typeof json.result === "number" ? json.result : { data: [json.result.data[0] || { edges: 0, nodes: 0 }] } - return NextResponse.json({ result }, { status: 200 }) + return NextResponse.json({ result: data }, { status: 200 }) } catch (error) { console.log(error) return NextResponse.json({ error: (error as Error).message }, { status: 400 }) diff --git a/app/api/graph/[graph]/duplicate/route.tsx b/app/api/graph/[graph]/duplicate/route.tsx new file mode 100644 index 00000000..6d306436 --- /dev/null +++ b/app/api/graph/[graph]/duplicate/route.tsx @@ -0,0 +1,26 @@ +import { getClient } from "@/app/api/auth/[...nextauth]/options" +import { NextResponse, NextRequest } from "next/server" + +// eslint-disable-next-line import/prefer-default-export +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { + const session = await getClient() + + if (session instanceof NextResponse) { + return session + } + + const { client } = session + const { graph: graphId } = await params + const sourceName = request.nextUrl.searchParams.get("sourceName") + + try { + if (!sourceName) throw new Error("Missing parameter sourceName") + + const result = await client.selectGraph(sourceName).copy(graphId) + + return NextResponse.json({ result }) + } catch (error) { + console.error(error) + return NextResponse.json({ error: (error as Error).message }, { status: 400 }) + } +} \ No newline at end of file diff --git a/app/api/graph/[graph]/query/route.ts b/app/api/graph/[graph]/query/route.ts index d717ff4b..a571f6be 100644 --- a/app/api/graph/[graph]/query/route.ts +++ b/app/api/graph/[graph]/query/route.ts @@ -6,6 +6,7 @@ const INITIAL = Number(process.env.INITIAL) || 0 // eslint-disable-next-line import/prefer-default-export export async function GET(request: NextRequest) { const session = await getClient() + if (session instanceof NextResponse) { return session } diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index d1bd1203..7fbf78aa 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -49,13 +49,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session + const { client, user } = session const { graph: graphId } = await params try { const graph = client.selectGraph(graphId) - const result = await graph.query("RETURN 1") + const result = user.role === "Read-Only" + ? await graph.roQuery("RETURN 1") + : await graph.query("RETURN 1") if (!result) throw new Error("Something went wrong") diff --git a/app/api/graph/[graph]/suggestions/route.ts b/app/api/graph/[graph]/suggestions/route.ts index 9d336d91..79f1d7bc 100644 --- a/app/api/graph/[graph]/suggestions/route.ts +++ b/app/api/graph/[graph]/suggestions/route.ts @@ -4,17 +4,15 @@ import { NextRequest, NextResponse } from "next/server" // eslint-disable-next-line import/prefer-default-export export async function GET(request: NextRequest, { params }: { params: Promise<{ graph: string }> }) { const session = await getClient() + if (session instanceof NextResponse) { return session } - const { client } = session - + const { client, user } = session const { graph: graphId } = await params - const type = request.nextUrl.searchParams.get("type") as "(function)" | "(property key)" | "(label)" | "(relationship type)" | undefined - try { const getQuery = () => { @@ -34,7 +32,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const graph = client.selectGraph(graphId) - const result = await graph.query(getQuery()) + const result = user.role === "Read-Only" + ? await graph.roQuery(getQuery()) + : await graph.query(getQuery()) return NextResponse.json({ result }, { status: 200 }) } catch (error) { diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index ed3198c9..bd6a9064 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -4,6 +4,20 @@ import { EdgeDataDefinition, NodeDataDefinition } from 'cytoscape'; import { LinkObject, NodeObject } from 'react-force-graph-2d'; +import { Dispatch, SetStateAction } from 'react'; + +export type HistoryQuery = { + queries: Query[] + query: string + currentQuery: string + counter: number +} + +export type Query = { + text: string + metadata: string[] + explain: string[] + } const getSchemaValue = (value: string): string[] => { let unique, required, type, description @@ -35,7 +49,6 @@ export type Node = NodeObject<{ visible: boolean, expand: boolean, collapsed: boolean, - displayName: string, data: { [key: string]: any } @@ -94,19 +107,20 @@ export const DEFAULT_COLORS = [ "hsl(180, 66%, 70%)" ] -export interface Category { +export interface Category { index: number, name: string, show: boolean, textWidth?: number, - elements: (Node | Link)[] + elements: T[], textHeight?: number, } export interface ExtractedData { data: any[][], columns: string[], - categories: Map, + categories: Map>, + labels: Map>, nodes: Map, edges: Map, } @@ -120,17 +134,19 @@ export class Graph { private metadata: any[]; - private categories: Category[]; + private currentQuery: Query; - private labels: Category[]; + private categories: Category[]; + + private labels: Category[]; private elements: GraphData; private colorIndex: number = 0; - private categoriesMap: Map; + private categoriesMap: Map>; - private labelsMap: Map; + private labelsMap: Map>; private nodesMap: Map; @@ -138,12 +154,13 @@ export class Graph { private COLORS_ORDER_VALUE: string[] = [] - private constructor(id: string, categories: Category[], labels: Category[], elements: GraphData, - categoriesMap: Map, labelsMap: Map, nodesMap: Map, edgesMap: Map, colors?: string[]) { + private constructor(id: string, categories: Category[], labels: Category[], elements: GraphData, + categoriesMap: Map>, labelsMap: Map>, nodesMap: Map, edgesMap: Map, currentQuery?: Query, colors?: string[]) { this.id = id; this.columns = []; this.data = []; this.metadata = []; + this.currentQuery = currentQuery || { text: "", metadata: [], explain: [] }; this.categories = categories; this.labels = labels; this.elements = elements; @@ -158,27 +175,31 @@ export class Graph { return this.id; } - get Categories(): Category[] { + get CurrentQuery(): Query { + return this.currentQuery; + } + + get Categories(): Category[] { return this.categories; } - set Categories(categories: Category[]) { + set Categories(categories: Category[]) { this.categories = categories; } - get CategoriesMap(): Map { + get CategoriesMap(): Map> { return this.categoriesMap; } - get Labels(): Category[] { + get Labels(): Category[] { return this.labels; } - set Labels(labels: Category[]) { + set Labels(labels: Category[]) { this.labels = labels; } - get LabelsMap(): Map { + get LabelsMap(): Map> { return this.labelsMap; } @@ -226,12 +247,12 @@ export class Graph { return [...this.elements.nodes, ...this.elements.links] } - public static empty(graphName?: string, colors?: string[]): Graph { - return new Graph(graphName || "", [], [], { nodes: [], links: [] }, new Map(), new Map(), new Map(), new Map(), colors) + public static empty(graphName?: string, colors?: string[], currentQuery?: Query): Graph { + return new Graph(graphName || "", [], [], { nodes: [], links: [] }, new Map>(), new Map>(), new Map(), new Map(), currentQuery, colors) } - public static create(id: string, results: { data: Data, metadata: any[] }, isCollapsed: boolean, isSchema: boolean, colors?: string[],): Graph { - const graph = Graph.empty(undefined, colors) + public static create(id: string, results: { data: Data, metadata: any[] }, isCollapsed: boolean, isSchema: boolean, colors?: string[], currentQuery?: Query): Graph { + const graph = Graph.empty(undefined, colors, currentQuery) graph.extend(results, isCollapsed, isSchema) graph.id = id return graph @@ -251,7 +272,6 @@ export class Graph { visible: true, expand: false, collapsed, - displayName: "", data: {} } Object.entries(cell.properties).forEach(([key, value]) => { @@ -307,7 +327,6 @@ export class Graph { expand: false, collapsed, visible: true, - displayName: "", data: {}, } @@ -333,7 +352,7 @@ export class Graph { let target = this.nodesMap.get(cell.destinationId) if (!source || !target) { - [category] = this.createCategory([""]) + [category] = this.createCategory([""],) } if (!source) { @@ -344,7 +363,6 @@ export class Graph { expand: false, collapsed, visible: true, - displayName: "", data: {}, } @@ -362,13 +380,15 @@ export class Graph { expand: false, collapsed, visible: true, - displayName: "", data: {}, } category?.elements.push(target) this.nodesMap.set(cell.destinationId, target) this.elements.nodes.push(target) + target.category.forEach(c => { + this.categoriesMap.get(c)!.elements.push(target!) + }) } link = { @@ -467,7 +487,7 @@ export class Graph { return newElements } - public createCategory(categories: string[], node?: Node): Category[] { + public createCategory(categories: string[], node?: Node): Category[] { return categories.map(category => { let c = this.categoriesMap.get(category) @@ -486,7 +506,7 @@ export class Graph { }) } - public createLabel(category: string): Category { + public createLabel(category: string): Category { let l = this.labelsMap.get(category) if (!l) { @@ -501,7 +521,8 @@ export class Graph { public visibleLinks(visible: boolean) { this.elements.links.forEach(link => { - if (visible && (this.elements.nodes.map(n => n.id).includes(link.source.id) && link.source.visible) && (this.elements.nodes.map(n => n.id).includes(link.target.id) && link.target.visible)) { + + if (this.LabelsMap.get(link.label)!.show && visible && (this.elements.nodes.map(n => n.id).includes(link.source.id) && link.source.visible) && (this.elements.nodes.map(n => n.id).includes(link.target.id) && link.target.visible)) { // eslint-disable-next-line no-param-reassign link.visible = true } @@ -513,19 +534,13 @@ export class Graph { }) } - public removeLinks(ids: number[] = []) { + public removeLinks(setter: Dispatch[]>>, ids: number[] = []) { const elements = this.elements.links.filter(link => ids.includes(link.source.id) || ids.includes(link.target.id)) this.elements = { nodes: this.elements.nodes, links: this.elements.links.map(link => { - if (ids.length !== 0 && elements.includes(link)) { - this.linksMap.delete(link.id) - - return undefined - } - - if (this.elements.nodes.map(n => n.id).includes(link.source.id) && this.elements.nodes.map(n => n.id).includes(link.target.id)) { + if (ids.length !== 0 && !elements.includes(link) || this.elements.nodes.map(n => n.id).includes(link.source.id) && this.elements.nodes.map(n => n.id).includes(link.target.id)) { return link } @@ -533,10 +548,11 @@ export class Graph { if (category) { category.elements = category.elements.filter(e => e.id !== link.id) - + if (category.elements.length === 0) { this.labels.splice(this.labels.findIndex(c => c.name === category.name), 1) this.labelsMap.delete(category.name) + setter(this.labels) } } @@ -569,21 +585,34 @@ export class Graph { if (type) { this.elements.nodes.splice(this.elements.nodes.findIndex(n => n.id === id), 1) + const category = this.categoriesMap.get(element.category[0]) + + if (category) { + category.elements = category.elements.filter((e) => e.id !== id) + if (category.elements.length === 0) { + this.categories.splice(this.categories.findIndex(c => c.name === category.name), 1) + this.categoriesMap.delete(category.name) + } + } } else { this.elements.links.splice(this.elements.links.findIndex(l => l.id === id), 1) - } - - const category = type ? this.categoriesMap.get(element.category[0]) : this.labelsMap.get(element.label) + const category = this.labelsMap.get(element.label) - if (category) { - category.elements = category.elements.filter((e) => e.id !== id) - if (category.elements.length === 0) { - this.categories.splice(this.categories.findIndex(c => c.name === category.name), 1) - this.categoriesMap.delete(category.name) + if (category) { + category.elements = category.elements.filter((e) => e.id !== id) + if (category.elements.length === 0) { + this.labels.splice(this.labels.findIndex(c => c.name === category.name), 1) + this.labelsMap.delete(category.name) + } } } }) + this.elements = { + nodes: this.elements.nodes.filter(node => !elements.filter(e => !e.source).some(element => element.id === node.id)), + links: this.elements.links.filter(link => !elements.filter(e => e.source).some(element => element.id === link.id)) + } + this.data = this.data.map(row => { const newRow = Object.entries(row).map(([key, cell]) => { if (cell && typeof cell === "object" && elements.some(element => element.id === cell.id)) { @@ -600,32 +629,74 @@ export class Graph { }).filter((row) => row !== undefined) } - public removeLabel(label: string, selectedElement: Node) { - this.Data = this.Data.map(row => Object.fromEntries(Object.entries(row).map(([key, cell]) => { - if (cell && typeof cell === "object" && cell.id === selectedElement.id && "labels" in cell) { - const newCell = { ...cell } - newCell.labels = newCell.labels.filter((l) => l !== label) - return [key, newCell] + public removeCategory(label: string, selectedElement: Node, updateData = true) { + if (updateData) { + this.Data = this.Data.map(row => Object.fromEntries(Object.entries(row).map(([key, cell]) => { + if (cell && typeof cell === "object" && cell.id === selectedElement.id && "labels" in cell) { + const newCell = { ...cell } + newCell.labels = newCell.labels.filter((l) => l !== label) + return [key, newCell] + } + return [key, cell] + }))) + } + + const category = this.CategoriesMap.get(label) + + if (category) { + category.elements = category.elements.filter((element) => element.id !== selectedElement.id) + if (category.elements.length === 0) { + this.Categories.splice(this.Categories.findIndex(c => c.name === category.name), 1) + this.CategoriesMap.delete(category.name) } - return [key, cell] - }))) + } + + selectedElement.category.splice(selectedElement.category.findIndex(l => l === label), 1) + + if (selectedElement.category.length === 0) { + const [emptyCategory] = this.createCategory([""], selectedElement) + selectedElement.category.push(emptyCategory.name) + selectedElement.color = this.getCategoryColorValue(emptyCategory.index) + } } - public addLabel(label: string, selectedElement: Node) { - this.Data = this.Data.map(row => Object.fromEntries(Object.entries(row).map(([key, cell]) => { - if (cell && typeof cell === "object" && cell.id === selectedElement.id && "labels" in cell) { - const newCell = { ...cell } - newCell.labels.push(label) - return [key, newCell] + public addCategory(label: string, selectedElement: Node, updateData = true) { + const [category] = this.createCategory([label], selectedElement) + + if (updateData) { + this.Data = this.Data.map(row => Object.fromEntries(Object.entries(row).map(([key, cell]) => { + if (cell && typeof cell === "object" && cell.id === selectedElement.id && "labels" in cell) { + const newCell = { ...cell } + newCell.labels.push(label) + return [key, newCell] + } + return [key, cell] + }))) + } + + const emptyCategoryIndex = selectedElement.category.findIndex(c => c === "") + + if (emptyCategoryIndex !== -1) { + this.removeCategory(selectedElement.category[emptyCategoryIndex], selectedElement) + selectedElement.category.splice(emptyCategoryIndex, 1) + selectedElement.color = this.getCategoryColorValue(category.index) + + const emptyCategory = this.categoriesMap.get("") + if (emptyCategory) { + emptyCategory.elements = emptyCategory.elements.filter(e => e.id !== selectedElement.id) + if (emptyCategory.elements.length === 0) { + this.categories.splice(this.categories.findIndex(c => c.name === emptyCategory.name), 1) + this.categoriesMap.delete(emptyCategory.name) + } } - return [key, cell] - }))) + } + selectedElement.category.push(label) } - public removeProperty(key: string, id: number) { + public removeProperty(key: string, id: number, type: boolean) { this.Data = this.Data.map(row => { const newRow = Object.entries(row).map(([k, cell]) => { - if (cell && typeof cell === "object" && cell.id === id) { + if (cell && typeof cell === "object" && cell.id === id && (type ? !("sourceId" in cell) : "sourceId" in cell)) { delete cell.properties[key] return [k, cell] } @@ -635,9 +706,9 @@ export class Graph { }) } - public setProperty(key: string, val: string, id: number) { + public setProperty(key: string, val: string, id: number, type: boolean) { this.Data = this.Data.map(row => Object.fromEntries(Object.entries(row).map(([k, cell]) => { - if (cell && typeof cell === "object" && cell.id === id) { + if (cell && typeof cell === "object" && cell.id === id && (type ? !("sourceId" in cell) : "sourceId" in cell)) { return [k, { ...cell, properties: { ...cell.properties, [key]: val } }] } return [k, cell] diff --git a/app/api/schema/[schema]/[node]/[key]/route.ts b/app/api/schema/[schema]/[node]/[key]/route.ts index f6cd6b57..6f438fbc 100644 --- a/app/api/schema/[schema]/[node]/[key]/route.ts +++ b/app/api/schema/[schema]/[node]/[key]/route.ts @@ -9,8 +9,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return session } - const { client } = session - + const { client, user } = session const { schema, node, key } = await params const schemaName = `${schema}_schema` const { type, attribute } = await request.json() @@ -24,7 +23,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const q = type ? `MATCH (n) WHERE ID(n) = ${node} SET n.${formattedKey} = "${formattedValue}"` : `MATCH (n)-[e]-(m) WHERE ID(e) = ${node} SET e.${formattedKey} = "${formattedValue}"` - const result = await graph.query(q) + const result = user.role === "Read-Only" + ? await graph.roQuery(q) + : await graph.query(q) if (!result) throw new Error("Something went wrong") @@ -42,8 +43,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session - + const { client, user } = session const { schema, node, key } = await params const schemaName = `${schema}_schema` const { type } = await request.json() @@ -55,7 +55,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const q = type ? `MATCH (n) WHERE ID(n) = ${node} SET n.${key} = NULL` : `MATCH (n)-[e]-(m) WHERE ID(e) = ${node} SET e.${key} = NULL` - const result = await graph.query(q) + const result = user.role === "Read-Only" + ? await graph.roQuery(q) + : await graph.query(q) if (!result) throw new Error("Something went wrong") diff --git a/app/api/schema/[schema]/[node]/label/route.ts b/app/api/schema/[schema]/[node]/label/route.ts index a78ae2b5..bc37c3fd 100644 --- a/app/api/schema/[schema]/[node]/label/route.ts +++ b/app/api/schema/[schema]/[node]/label/route.ts @@ -8,12 +8,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { schema, node } = await params - const schemaName = `${schema}_schema` - const { label } = await request.json() try { @@ -21,7 +18,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const graph = client.selectGraph(schemaName) const q = `MATCH (n) WHERE ID(n) = ${node} SET n:${label}` - const result = await graph.query(q) + const result = user.role === "Read-Only" + ? await graph.roQuery(q) + : await graph.query(q) if (!result) throw new Error("Something went wrong") @@ -39,12 +38,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session - + const { client, user } = session const { schema, node } = await params - const { label } = await request.json() - const schemaName = `${schema}_schema` try { @@ -53,7 +49,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const graph = client.selectGraph(schemaName) const q = `MATCH (n) WHERE ID(n) = ${node} REMOVE n:${label}` - const result = await graph.query(q) + const result = user.role === "Read-Only" + ? await graph.roQuery(q) + : await graph.query(q) if (!result) throw new Error("Something went wrong") diff --git a/app/api/schema/[schema]/[node]/route.ts b/app/api/schema/[schema]/[node]/route.ts index f76abfca..4a826245 100644 --- a/app/api/schema/[schema]/[node]/route.ts +++ b/app/api/schema/[schema]/[node]/route.ts @@ -10,30 +10,32 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { schema } = await params const schemaName = `${schema}_schema` const { type, label, attributes, selectedNodes } = await request.json() try { - if (!label) throw new Error("Label is required") + if (type ? !label : !label[0]) throw new Error("Label is required") + if (!type && (!selectedNodes || selectedNodes.length !== 2)) throw new Error("Selected nodes are required") if (!attributes) throw new Error("Attributes are required") if (type === undefined) { throw new Error("Type is required") } else if (!type && !selectedNodes) { throw new Error("Selected nodes are required") } + const formateAttributes = formatAttributes(attributes) const graph = client.selectGraph(schemaName) const query = type - ? `CREATE (n${label ? `:${label.join(":")}` : ""}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}) RETURN n` - : `MATCH (a), (b) WHERE ID(a) = ${selectedNodes[0].id} AND ID(b) = ${selectedNodes[1].id} CREATE (a)-[e${label[0] ? `:${label[0]}` : ""}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}]->(b) RETURN e` - const result = await graph.query(query) + ? `CREATE (n${label.length > 0 ? `:${label.join(":")}` : ""}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}) RETURN n` + : `MATCH (a), (b) WHERE ID(a) = ${selectedNodes[0].id} AND ID(b) = ${selectedNodes[1].id} CREATE (a)-[e:${label[0]}${formateAttributes?.length > 0 ? ` {${formateAttributes.map(([k, v]) => `${k}: "${v}"`).join(",")}}` : ""}]->(b) RETURN e` + const result = user.role === "Read-Only" + ? await graph.roQuery(query) + : await graph.query(query) if (!result) throw new Error("Something went wrong") - return NextResponse.json({ result }, { status: 200 }) } catch (error) { console.error(error) @@ -48,8 +50,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return session } - const { client } = session - + const { client, user } = session const { schema, node } = await params const schemaName = `${schema}_schema` const nodeId = Number(node) @@ -62,7 +63,9 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const query = type ? `MATCH (n) WHERE ID(n) = $nodeId DELETE n` : `MATCH ()-[e]-() WHERE ID(e) = $nodeId DELETE e` - const result = await graph.query(query, { params: { nodeId } }) + const result = user.role === "Read-Only" + ? await graph.roQuery(query, { params: { nodeId } }) + : await graph.query(query, { params: { nodeId } }) if (!result) throw new Error("Something went wrong") diff --git a/app/api/schema/[schema]/count/route.ts b/app/api/schema/[schema]/count/route.ts index d8901d9d..62917621 100644 --- a/app/api/schema/[schema]/count/route.ts +++ b/app/api/schema/[schema]/count/route.ts @@ -9,21 +9,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - const { schema } = await params const schemaName = `${schema}_schema` try { - const graph = client.selectGraph(schemaName) const query = "MATCH (n) OPTIONAL MATCH (n)-[e]->() WITH count(n) as nodes, count(e) as edges RETURN nodes, edges" - const { data } = await graph.query(query) - if (!data) throw new Error("Something went wrong") + const result = await fetch(`${request.nextUrl.origin}/api/graph/${schemaName}/?query=${encodeURIComponent(query)}`, { + method: "GET", + headers: { + cookie: request.headers.get('cookie') || '', + } + }) + + if (!result.ok) throw new Error("Something went wrong") + + const json = await result.json() - const result = data.length === 0 ? { nodes: 0, edges: 0 } : data[0] + const data = typeof json.result === "number" ? json.result : { data: [json.result.data[0] || { nodes: 0, edges: 0 }] } - return NextResponse.json({ result }, { status: 200 }) + return NextResponse.json({ result: data }, { status: 200 }) } catch (error) { console.log(error) return NextResponse.json({ error: (error as Error).message }, { status: 400 }) diff --git a/app/api/schema/[schema]/duplicate/route.tsx b/app/api/schema/[schema]/duplicate/route.tsx new file mode 100644 index 00000000..38f9ec86 --- /dev/null +++ b/app/api/schema/[schema]/duplicate/route.tsx @@ -0,0 +1,28 @@ +import { getClient } from "@/app/api/auth/[...nextauth]/options" +import { NextResponse, NextRequest } from "next/server" + +// eslint-disable-next-line import/prefer-default-export +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ schema: string }> }) { + const session = await getClient() + + if (session instanceof NextResponse) { + return session + } + + const { client } = session + const { schema } = await params + const schemaName = `${schema}_schema` + const source = request.nextUrl.searchParams.get("sourceName") + + try { + if (!source) throw new Error("Missing parameter sourceName") + + const sourceName = `${source}_schema` + const result = await client.selectGraph(sourceName).copy(schemaName) + + return NextResponse.json({ result }) + } catch (error) { + console.error(error) + return NextResponse.json({ error: (error as Error).message }, { status: 400 }) + } +} \ No newline at end of file diff --git a/app/api/schema/[schema]/route.ts b/app/api/schema/[schema]/route.ts index 16cd18b3..c3dc6a23 100644 --- a/app/api/schema/[schema]/route.ts +++ b/app/api/schema/[schema]/route.ts @@ -8,8 +8,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { schema } = await params const schemaName = `${schema}_schema` const create = request.nextUrl.searchParams.get("create") @@ -20,7 +19,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (create === "false" && !schemas.includes(schemaName)) return NextResponse.json({ message: "Schema not found" }, { status: 200 }) const graph = client.selectGraph(schemaName) - const result = await graph.query("MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN * LIMIT 100") + const result = user.role === "Read-Only" + ? await graph.roQuery("MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN * LIMIT 100") + : await graph.query("MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN * LIMIT 100") return NextResponse.json({ result }, { status: 200 }) } catch (error) { @@ -36,14 +37,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return session } - const { client } = session - + const { client, user } = session const { schema } = await params const schemaName = `${schema}_schema` try { const graph = client.selectGraph(schemaName) - const result = await graph.query("RETURN 1") + const result = user.role === "Read-Only" + ? await graph.roQuery("RETURN 1") + : await graph.query("RETURN 1") if (!result) throw new Error("Something went wrong") @@ -76,4 +78,32 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise console.error(error) return NextResponse.json({ error: (error as Error).message }, { status: 400 }) } +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ schema: string }> }) { + + const session = await getClient() + if (session instanceof NextResponse) { + return session + } + + const { client } = session + + const { schema } = await params + const schemaName = `${schema}_schema` + const source = request.nextUrl.searchParams.get("sourceName") + + try { + if (!source) throw new Error("Missing parameter sourceName") + + const sourceName = `${source}_schema` + const data = await (await client.connection).renameNX(sourceName, schemaName); + + if (!data) throw new Error(`${schema} already exists`) + + return NextResponse.json({ data }) + } catch (err: unknown) { + console.error(err) + return NextResponse.json({ message: (err as Error).message }, { status: 400 }) + } } \ No newline at end of file diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx index 2aab2f31..2ced8f6e 100644 --- a/app/components/CreateGraph.tsx +++ b/app/components/CreateGraph.tsx @@ -15,8 +15,9 @@ import { IndicatorContext } from "./provider" interface Props { onSetGraphName: (name: string) => void - type: string + type: "Graph" | "Schema" graphNames: string[] + label?: string trigger?: React.ReactNode } @@ -24,22 +25,25 @@ export default function CreateGraph({ onSetGraphName, type, graphNames, + label = "", trigger = ( ), }: Props) { const { indicator, setIndicator } = useContext(IndicatorContext) + + const { toast } = useToast() + + const [isLoading, setIsLoading] = useState(false) const [graphName, setGraphName] = useState("") const [open, setOpen] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() const handleCreateGraph = async (e: React.FormEvent) => { e.preventDefault() @@ -99,6 +103,7 @@ export default function CreateGraph({

Name your {type}:

ref?.focus()} value={graphName} @@ -107,6 +112,7 @@ export default function CreateGraph({
+ } +
+ + - - - -
- {PLACEHOLDER} -
- - +
+ + } + + + e.preventDefault()} className="flex gap-2"> + +

Help

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

Help

-
- - - -
- { - !inCreate && session?.user?.role !== "Read-Only" && - - } - { - indicator === "offline" && -
- - -

Offline

-
- -

The FalkorDB server is offline

-
-
-
- } - - - - - - - - -
-
- -

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

-
+ label="About" + title="Learn more about the application" + /> + + + + + + + + + + +
+
+ +

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

+
+
+
+
+ { + indicator === "offline" && + <> +
+
+ + +

Offline

+
+ +

The FalkorDB server is offline

+
+
+
+ + } +
+
-
) } diff --git a/app/components/PaginationList.tsx b/app/components/PaginationList.tsx new file mode 100644 index 00000000..a8db8177 --- /dev/null +++ b/app/components/PaginationList.tsx @@ -0,0 +1,139 @@ +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { Loader2 } from "lucide-react" +import Button from "./ui/Button" +import { Query } from "../api/graph/model" +import Input from "./ui/Input" + +type Item = string | Query + +interface Props { + list: T[] + step: number + onClick: (label: string) => void + dataTestId: string + label: string + afterSearchCallback: (newFilteredList: T[]) => void + isSelected: (item: T) => boolean + isLoading?: boolean + className?: string + children?: React.ReactNode +} + +export default function PaginationList({ list, step, onClick, dataTestId, afterSearchCallback, isSelected, label, isLoading, className, children }: Props) { + const [filteredList, setFilteredList] = useState([...list]) + const [stepCounter, setStepCounter] = useState(0) + const [pageCount, setPageCount] = useState(0) + const [search, setSearch] = useState("") + const startIndex = stepCounter * step + const endIndex = Math.min(startIndex + step, filteredList.length) + + useEffect(() => { + setStepCounter(0) + }, [list]) + + useEffect(() => { + setPageCount(Math.ceil(list.length / step)) + }, [list, step]) + + useEffect(() => { + const timeout = setTimeout(() => { + const newFilteredList = list.filter((item) => !search || (typeof item === "string" ? item.toLowerCase().includes(search.toLowerCase()) : item.text.toLowerCase().includes(search.toLowerCase()))) || [] + setFilteredList([...newFilteredList]) + setStepCounter(0) + afterSearchCallback([...newFilteredList]) + }, 500) + + return () => { + clearTimeout(timeout) + } + }, [list, search]) + + return ( +
+
+ setSearch(e.target.value)} + /> + {isLoading && } +
+ { + children && +
+ {children} +
+ } +
    + { + filteredList.slice(startIndex, endIndex).map((item, index) => { + const selected = isSelected ? isSelected(item) : false + return ( +
  • + { + onClick ? +
  • + ) + }) + } +
+
    +
  • +
  • + { + Array(pageCount).fill(0).map((_, index) => index) + .slice( + Math.max(0, Math.min( + stepCounter - 1, + pageCount - 3 + )), + Math.min( + Math.max(3, stepCounter + 2), + pageCount + ) + ) + .map((index) => ( +
  • +
  • + )) + } +
  • +
  • +
+
+ ) +} + +PaginationList.defaultProps = { + className: undefined, + children: undefined, + isLoading: undefined +} \ No newline at end of file diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 2268f0b7..3f2e5113 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -21,20 +21,21 @@ import { IndicatorContext } from "./provider"; interface Props { headers: string[], rows: Row[], + label: "Graphs" | "Schemas" | "Configs" | "Users" | "TableView", + entityName?: "Graph" | "Schema" | "Config" | "User", children?: React.ReactNode, setRows?: (rows: Row[]) => void, - options?: string[] className?: string } -export default function TableComponent({ headers, rows, children, setRows, options, className }: Props) { +export default function TableComponent({ headers, rows, label, entityName, children, setRows, className }: Props) { const [search, setSearch] = useState("") - const [isSearchable, setIsSearchable] = useState(false) const [editable, setEditable] = useState("") const [hover, setHover] = useState("") const [newValue, setNewValue] = useState("") const [filteredRows, setFilteredRows] = useState(rows) + const [isLoading, setIsLoading] = useState(false) const { indicator } = useContext(IndicatorContext) const handleSearchFilter = useCallback((cell: Cell): boolean => { @@ -42,7 +43,7 @@ export default function TableComponent({ headers, rows, children, setRows, optio const searchLower = search.toLowerCase(); - if (typeof cell.value === "object") { + if (cell.type === "object") { return Object.values(cell.value).some(value => { if (typeof value === "object") { return Object.values(value).some(val => @@ -75,38 +76,27 @@ export default function TableComponent({ headers, rows, children, setRows, optio return (
-
+
{children} - { - isSearchable ? - ref?.focus()} - variant="primary" - className="grow" - value={search} - type="text" - placeholder="Search for" - onBlur={() => setIsSearchable(false)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault() - setIsSearchable(false) - setSearch("") - } + ref?.focus()} + variant="primary" + className="grow" + value={search} + type="text" + placeholder={`Search for${entityName ? ` a ${entityName}` : ""}`} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + setSearch("") + } - if (e.key !== "Enter") return - e.preventDefault() - setIsSearchable(false) - }} - onChange={(e) => setSearch(e.target.value)} - /> - :
@@ -115,6 +105,7 @@ export default function TableComponent({ headers, rows, children, setRows, optio setRows ? 0 && rows.every(row => row.checked)} onCheckedChange={() => { const checked = !rows.every(row => row.checked) @@ -137,6 +128,7 @@ export default function TableComponent({ headers, rows, children, setRows, optio { filteredRows.map((row, i) => ( setHover(`${i}`)} onMouseLeave={() => setHover("")} data-id={typeof row.cells[0].value === "string" ? row.cells[0].value : undefined} @@ -146,6 +138,7 @@ export default function TableComponent({ headers, rows, children, setRows, optio setRows ? { setRows(rows.map((r, k) => k === i ? ({ ...r, checked: !r.checked }) : r)) @@ -156,9 +149,9 @@ export default function TableComponent({ headers, rows, children, setRows, optio } { row.cells.map((cell, j) => ( - + { - typeof cell.value === "object" ? + cell.type === "object" ? @@ -188,17 +181,22 @@ export default function TableComponent({ headers, rows, children, setRows, optio : editable === `${i}-${j}` ?
{ - cell.type === "combobox" ? + cell.type === "select" ? { - cell.onChange!(value) - handleSetEditable("", "") + inTable + options={cell.options} + setSelectedValue={async (value) => { + const result = await cell.onChange(value) + if (result) { + handleSetEditable("", "") + } }} - label={cell.comboboxType} + label={cell.selectType} selectedValue={cell.value.toString()} /> - : ref?.focus()} variant="primary" className="grow" @@ -213,7 +211,7 @@ export default function TableComponent({ headers, rows, children, setRows, optio if (e.key !== "Enter") return e.preventDefault() - const result = await cell.onChange!(newValue) + const result = await cell.onChange(newValue) if (result) { handleSetEditable("", "") } @@ -222,25 +220,38 @@ export default function TableComponent({ headers, rows, children, setRows, optio }
{ - cell.type !== "combobox" && + cell.type !== "select" && cell.type !== "readonly" && + } + { + !isLoading && + } -
:
@@ -254,10 +265,10 @@ export default function TableComponent({ headers, rows, children, setRows, optio
{ - cell.onChange && hover === `${i}` && + cell.type !== "readonly" && hover === `${i}` &&
); diff --git a/app/graph/Duplicate.tsx b/app/components/graph/DuplicateGraph.tsx similarity index 71% rename from app/graph/Duplicate.tsx rename to app/components/graph/DuplicateGraph.tsx index 07b65355..7bb31288 100644 --- a/app/graph/Duplicate.tsx +++ b/app/components/graph/DuplicateGraph.tsx @@ -1,12 +1,12 @@ import { FormEvent, useContext, useState } from "react"; import { prepareArg, securedFetch } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; -import DialogComponent from "../components/DialogComponent"; -import Button from "../components/ui/Button"; -import Input from "../components/ui/Input"; -import { IndicatorContext } from "../components/provider"; +import DialogComponent from "../DialogComponent"; +import Button from "../ui/Button"; +import Input from "../ui/Input"; +import { IndicatorContext } from "../provider"; -export default function Duplicate({ open, onOpenChange, selectedValue, onDuplicate, disabled, type }: { +export default function DuplicateGraph({ open, onOpenChange, selectedValue, onDuplicate, disabled, type }: { selectedValue: string, open: boolean, onOpenChange: (open: boolean) => void @@ -22,14 +22,19 @@ export default function Duplicate({ open, onOpenChange, selectedValue, onDuplica const handleDuplicate = async (e: FormEvent) => { e.preventDefault() - try { - setIsLoading(true) - const graphName = type === "Schema" ? `${duplicateName}_schema` : duplicateName - const sourceName = type === "Schema" ? `${selectedValue}_schema` : selectedValue + if (duplicateName === "") { + toast({ + title: "Error", + description: "Graph name cannot be empty", + }) + return + } - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/?sourceName=${prepareArg(sourceName)}`, { - method: "POST" + try { + setIsLoading(true) + const result = await securedFetch(`api/${type === "Graph" ? "graph" : "schema"}/${prepareArg(duplicateName)}/duplicate/?sourceName=${prepareArg(selectedValue)}`, { + method: "PATCH" }, toast, setIndicator) if (!result.ok) return @@ -50,6 +55,7 @@ export default function Duplicate({ open, onOpenChange, selectedValue, onDuplica open={open} onOpenChange={onOpenChange} trigger={ - + { indicator === "offline" && "The FalkorDB server is offline" } diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index b34c14f3..7fea20f8 100644 --- a/app/components/ui/combobox.tsx +++ b/app/components/ui/combobox.tsx @@ -1,22 +1,12 @@ -/* eslint-disable import/no-cycle */ -/* eslint-disable @typescript-eslint/no-use-before-define */ -/* eslint-disable react/require-default-props */ "use client" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" -import { cn, prepareArg, Row, securedFetch } from "@/lib/utils" +import { Dialog } from "@/components/ui/dialog" +import { cn } from "@/lib/utils" import { useContext, useEffect, useState } from "react" -import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select" -import { useToast } from "@/components/ui/use-toast" +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { useSession } from "next-auth/react" -import { VisuallyHidden } from "@radix-ui/react-visually-hidden" import Button from "./Button" -import TableComponent from "../TableComponent" -import CloseDialog from "../CloseDialog" -import ExportGraph from "../ExportGraph" -import DeleteGraph from "../graph/DeleteGraph" import Input from "./Input" import { IndicatorContext } from "../provider" @@ -24,29 +14,23 @@ interface ComboboxProps { options: string[], selectedValue: string, setSelectedValue: (value: string) => void, - type?: "Graph" | "Schema", - isSelectGraph?: boolean, + label: "Role" | "Type", disabled?: boolean, inTable?: boolean, - label?: string, - setOptions?: (value: string[]) => void, defaultOpen?: boolean, - onOpenChange?: (open: boolean) => void } const STEP = 4 -export default function Combobox({ isSelectGraph = false, disabled = false, inTable, type = "Graph", label = type, options, setOptions, selectedValue, setSelectedValue, defaultOpen = false, onOpenChange }: ComboboxProps) { +export default function Combobox({ disabled = false, inTable = false, label, options, selectedValue, setSelectedValue, defaultOpen = false }: ComboboxProps) { + const { indicator } = useContext(IndicatorContext) + + const [filteredOptions, setFilteredOptions] = useState([]) const [openMenage, setOpenMenage] = useState(false) + const [maxOptions, setMaxOptions] = useState(STEP) const [open, setOpen] = useState(defaultOpen) - const [rows, setRows] = useState([]) const [search, setSearch] = useState("") - const [filteredOptions, setFilteredOptions] = useState([]) - const [maxOptions, setMaxOptions] = useState(STEP) - const { toast } = useToast() - const { data: session } = useSession() - const { indicator, setIndicator } = useContext(IndicatorContext) useEffect(() => { const timeout = setTimeout(() => { @@ -56,62 +40,46 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa return () => clearTimeout(timeout) }, [options, search]) - const handleSetOption = async (option: string, optionName: string) => { - const result = await securedFetch(`api/graph/${prepareArg(option)}/?sourceName=${prepareArg(optionName)}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ name: optionName }) - }, toast, setIndicator) - - if (result.ok) { - - const newOptions = options.map((opt) => opt === optionName ? option : opt) - setOptions!(newOptions) - - if (setSelectedValue && optionName === selectedValue) setSelectedValue(option) - - handleSetRows(newOptions) - } - - return result.ok - } - - const handleSetRows = (opts: string[]) => { - setRows(opts.map(opt => ({ checked: false, name: opt, cells: [{ value: opt, onChange: (value: string) => handleSetOption(value, opt) }] }))) - } - - useEffect(() => { - handleSetRows(options) - }, [options]) - return ( - - + {indicator === "offline" && "The FalkorDB server is offline"} - {indicator !== "offline" && (options.length === 0 ? "There is no graphs" : selectedValue || `Select ${label}`)} + {indicator !== "offline" && (options.length === 0 ? `There are no ${label}s` : selectedValue || `Select ${label}`)} - -
- ref?.focus()} className="w-full" placeholder={`Search for a ${label}`} onChange={(e) => { - setSearch(e.target.value) - setMaxOptions(5) - }} value={search} /> + +
+ ref?.focus()} + className="w-1 grow" + placeholder={`Search for a ${label}`} + onChange={(e) => { + setSearch(e.target.value) + setMaxOptions(5) + }} + value={search} + />
-
    +
      {selectedValue && ( {selectedValue} @@ -120,6 +88,7 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa { filteredOptions.slice(0, maxOptions).filter((option) => selectedValue !== option).map((option) => ( @@ -146,69 +115,14 @@ export default function Combobox({ isSelectGraph = false, disabled = false, inTa

      ({maxOptions > filteredOptions.length ? filteredOptions.length : maxOptions}/{filteredOptions.length} results)

    - { - isSelectGraph && - <> - - -
) +} + +Combobox.defaultProps = { + disabled: false, + inTable: false, + defaultOpen: false, } \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index bd3344fb..1a7f88b5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -10,7 +10,7 @@ --secondary: 39 33% 96%; --destructive: 0 84% 60%; --accent: var(--background); - --popover: var(--primary); + --popover: var(--foreground); --popover-foreground: 0 0% 100%; --radius: 0.5rem; --input: var(--popover-foreground); @@ -37,7 +37,7 @@ } .tabs-trigger { - @apply !p-2 rounded-lg data-[state=active]:bg-foreground data-[state=active]:!text-white text-gray-500; + @apply !p-2 rounded-lg data-[state=active]:!text-primary text-white !bg-transparent; } .hide-scrollbar { @@ -46,7 +46,7 @@ } .Page { - @apply h-full w-full flex flex-col bg-background; + @apply w-1 grow bg-foreground relative overflow-hidden flex flex-col; } .Gradient { @@ -54,7 +54,7 @@ } .DataPanel { - @apply h-full w-full flex flex-col shadow-lg bg-background; + @apply z-10 absolute right-4 top-12 min-w-[30%] min-h-[60%] max-w-[50%] max-h-[85%] flex flex-col gap-4 bg-foreground border rounded-lg; } .Dropzone { diff --git a/app/graph/DeleteElement.tsx b/app/graph/DeleteElement.tsx index 0d142e95..13a4096a 100644 --- a/app/graph/DeleteElement.tsx +++ b/app/graph/DeleteElement.tsx @@ -3,6 +3,8 @@ "use client" import React, { useState, useContext } from "react"; +import { Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import CloseDialog from "../components/CloseDialog"; import DialogComponent from "../components/DialogComponent"; import Button from "../components/ui/Button"; @@ -10,18 +12,29 @@ import { IndicatorContext } from "../components/provider"; interface Props { onDeleteElement: () => Promise - trigger: React.ReactNode open: boolean setOpen: (open: boolean) => void description: string + label?: "Graph" | "Schema" + trigger?: React.ReactNode + backgroundColor?: string } export default function DeleteElement({ onDeleteElement, - trigger, open, setOpen, description, + backgroundColor, + label = "Graph", + trigger = , }: Props) { const { indicator } = useContext(IndicatorContext) @@ -46,15 +59,17 @@ export default function DeleteElement({ >
+ +
+ selectedLabel === l ? setSelectedLabel("") : setSelectedLabel(l)} + isSelected={(item) => item === selectedLabel} + afterSearchCallback={(filteredList) => { + if (!filteredList.includes(selectedLabel)) { + setSelectedLabel("") + } + }} + > + + + +
+ +
+
+ + + ) : ( +
+
+
+
+

ID: {object.id}

+

Attributes: {Object.keys(object.data).length}

+
- { - type ? -
    setLabelsHover(true)} onMouseLeave={() => setLabelsHover(false)}> - {label.map((l) => ( -
  • -

    {l}

    - { - session?.user?.role !== "Read-Only" && - - } -
  • - ))} -
  • - { - labelsHover && !labelsEditable && session?.user?.role !== "Read-Only" && +
+
    setLabelsHover(true)} + onMouseLeave={() => setLabelsHover(false)} + > + {label.map((l) => ( +
  • +

    {l || "No Label"}

    + { + l && session?.user?.role !== "Read-Only" && + setLabelsEditable(true)} + data-testid={`DataPanelRemoveLabel${l}`} + title="Remove Label" + tooltipVariant="Delete" > - + } - { - labelsEditable && - <> - ref?.focus()} - className="max-w-[50%] h-full bg-foreground border-none text-white" - value={newLabel} - onChange={(e) => setNewLabel(e.target.value)} - onKeyDown={(e) => { - - if (e.key === "Escape") { - e.preventDefault() - setLabelsEditable(false) - setNewLabel("") - } - - if (e.key !== "Enter" || isLabelLoading) return - - e.preventDefault() - handleAddLabel() - }} - /> - - { - !isLabelLoading && - - } - - } -
  • -
- : -

{label[0]}

- } -
-

{attributes.length} Attributes

-
-
- - - - Key - Value - - - - - - id: - {obj.id} - - { - attributes.map((key) => ( - setHover(key)} - onMouseLeave={() => setHover("")} - key={key} - > - -
- { - session?.user?.role !== "Read-Only" && ( - editable === key ? - <> - - { - !isSetLoading && - - } - - : hover === key && - <> - - - - - } - title="Delete Attribute" - description="Are you sure you want to delete this attribute?" - > -
-
-
- - ) - } -
-
- {key}: - - { - editable === key ? - setNewVal(e.target.value)} - onKeyDown={handleSetKeyDown} - /> - : - { - !isAddLoading && + /> + } + + ))} +
  • + { + type && (labelsHover || label.length === 0) && session?.user?.role !== "Read-Only" && + setIsAddValue(false)} - title="Cancel" + data-testid="DataPanelAddLabel" + className="p-2 text-nowrap text-xs justify-center border border-background rounded-full" + label="Add Label" + title="" > - + } - - - !newKey ? ref?.focus() : undefined} - className="w-full" - value={newKey} - onChange={(e) => setNewKey(e.target.value)} - onKeyDown={handleAddKeyDown} - /> - - - setNewVal(e.target.value)} - onKeyDown={handleAddKeyDown} - /> - - - } - - - { - session?.user?.role !== "Read-Only" && - - } - -
  • -
    - { - session?.user?.role !== "Read-Only" && - - - + /> } - /> - } + + +
    +
    +
    ) diff --git a/app/graph/GraphDataTable.tsx b/app/graph/GraphDataTable.tsx new file mode 100644 index 00000000..ae1e7294 --- /dev/null +++ b/app/graph/GraphDataTable.tsx @@ -0,0 +1,379 @@ +/* eslint-disable no-param-reassign */ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Check, Pencil, Plus, Trash2, X } from "lucide-react" +import { prepareArg, securedFetch } from "@/lib/utils" +import { toast } from "@/components/ui/use-toast" +import { MutableRefObject, useContext, useEffect, useState } from "react" +import { useSession } from "next-auth/react" +import DeleteElement from "./DeleteElement" +import Input from "../components/ui/Input" +import DialogComponent from "../components/DialogComponent" +import CloseDialog from "../components/CloseDialog" +import { Graph, Link, Node } from "../api/graph/model" +import { IndicatorContext } from "../components/provider" +import ToastButton from "../components/ToastButton" +import Button from "../components/ui/Button" + +interface Props { + graph: Graph + object: Node | Link + type: boolean + onDeleteElement: () => Promise + lastObjId: MutableRefObject +} + +export default function GraphDataTable({ graph, object, type, onDeleteElement, lastObjId }: Props) { + const [hover, setHover] = useState("") + const [editable, setEditable] = useState("") + const [isAddValue, setIsAddValue] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [newKey, setNewKey] = useState("") + const [newVal, setNewVal] = useState("") + const [isSetLoading, setIsSetLoading] = useState(false) + const [isAddLoading, setIsAddLoading] = useState(false) + const [isRemoveLoading, setIsRemoveLoading] = useState(false) + const { indicator, setIndicator } = useContext(IndicatorContext) + const { data: session } = useSession() + const [attributes, setAttributes] = useState(Object.keys(object.data)) + + useEffect(() => { + if (lastObjId.current !== object.id) { + setEditable("") + setNewVal("") + setNewKey("") + setIsAddValue(false) + } + setAttributes(Object.keys(object.data)) + }, [lastObjId, object, setAttributes, type]) + + const handleSetEditable = (key: string, val: string) => { + if (key !== "") { + setIsAddValue(false) + } + + setEditable(key) + setNewVal(val) + } + + const setProperty = async (key: string, val: string, isUndo: boolean, actionType: ("added" | "set") = "set") => { + const { id } = object + if (!val || val === "") { + toast({ + title: "Error", + description: "Please fill in the value field", + variant: "destructive" + }) + return false + } + try { + if (actionType === "set") setIsSetLoading(true) + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { + method: "POST", + body: JSON.stringify({ + value: val, + type + }) + }, toast, setIndicator) + + if (result.ok) { + const value = object.data[key] + + graph.setProperty(key, val, id, type) + object.data[key] = val + setAttributes(Object.keys(object.data)) + + handleSetEditable("", "") + toast({ + title: "Success", + description: `Attribute ${actionType}`, + variant: "default", + action: isUndo ? setProperty(key, value, false)} /> : undefined + }) + } + + return result.ok + } finally { + if (actionType === "set") setIsSetLoading(false) + } + } + + const handleAddValue = async (key: string, value: string) => { + if (!key || key === "" || !value || value === "") { + toast({ + title: "Error", + description: "Please fill in both fields", + variant: "destructive" + }) + return + } + try { + setIsAddLoading(true) + const success = await setProperty(key, value, false, "added") + if (!success) return + setIsAddValue(false) + setNewKey("") + setNewVal("") + } finally { + setIsAddLoading(false) + } + } + + const removeProperty = async (key: string) => { + try { + setIsRemoveLoading(true) + const { id } = object + const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { + method: "DELETE", + body: JSON.stringify({ type }), + }, toast, setIndicator)).ok + + if (success) { + const value = object.data[key] + + graph.removeProperty(key, id, type) + delete object.data[key] + setAttributes(Object.keys(object.data)) + + toast({ + title: "Success", + description: "Attribute removed", + action: handleAddValue(key, value)} />, + variant: "default" + }) + } + + return success + } finally { + setIsRemoveLoading(false) + } + } + + const handleAddKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setIsAddValue(false) + setNewKey("") + setNewVal("") + return + } + + if (e.key !== "Enter" || isAddLoading || indicator === "offline") return + + handleAddValue(newKey, newVal) + } + + const handleSetKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + handleSetEditable("", "") + setNewKey("") + } + + if (e.key !== "Enter" || isSetLoading || indicator === "offline") return + + setProperty(editable, newVal, true) + } + + const handleDeleteElement = async () => { + await onDeleteElement() + setDeleteOpen(false) + } + + return ( + <> + + + +
    + Key + Value + + + + { + attributes.map((key) => ( + setHover(key)} + onMouseLeave={() => setHover("")} + key={key} + > + +
    + { + session?.user?.role !== "Read-Only" && ( + editable === key ? + <> + + { + !isSetLoading && + + } + + : hover === key && + <> + + + + + } + title="Delete Attribute" + description="Are you sure you want to delete this attribute?" + > +
    +
    +
    + + ) + } +
    +
    + {key}: + + { + editable === key ? + setNewVal(e.target.value)} + onKeyDown={handleSetKeyDown} + /> + : + { + !isAddLoading && + + } + + + !newKey ? ref?.focus() : undefined} + className="w-full" + value={newKey} + onChange={(e) => setNewKey(e.target.value)} + onKeyDown={handleAddKeyDown} + /> + + + setNewVal(e.target.value)} + onKeyDown={handleAddKeyDown} + /> + +
    + } +
    +
    +
    + { + session?.user?.role !== "Read-Only" && + + } + { + session?.user?.role !== "Read-Only" && + + + } + /> + } +
    + + ) +} \ No newline at end of file diff --git a/app/graph/GraphDetails.tsx b/app/graph/GraphDetails.tsx new file mode 100644 index 00000000..0a07e9ca --- /dev/null +++ b/app/graph/GraphDetails.tsx @@ -0,0 +1,47 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Graph } from "../api/graph/model" + +export default function GraphDetails({ + graph, + tabsValue = "Graph", + nodesCount, + edgesCount +}: { + graph: Graph, + nodesCount: number, + edgesCount: number, + tabsValue?: string, +}) { + return ( +
    + { + graph.Id && tabsValue === "Graph" && + [["Nodes", nodesCount, "nodesCount"], ["Edges", edgesCount, "edgesCount"], ["GraphName", graph.Id, "graphName"]].map(([label, value, testId]) => ( +
    +

    + {label}: +

    + + +

    + {value} +

    +
    + + {value} + +
    +
    + )) + } +
    + ) +} + +GraphDetails.defaultProps = { + tabsValue: "Graph", +} \ No newline at end of file diff --git a/app/graph/GraphView.tsx b/app/graph/GraphView.tsx index d879a725..6023e9ec 100644 --- a/app/graph/GraphView.tsx +++ b/app/graph/GraphView.tsx @@ -3,66 +3,71 @@ 'use client' -import { useRef, useState, useEffect, Dispatch, SetStateAction, useContext } from "react"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { ImperativePanelHandle } from "react-resizable-panels"; -import { ChevronLeft, GitGraph, Info, Maximize2, Minimize2, Pause, Play, Search, Table } from "lucide-react" -import { cn, handleZoomToFit, HistoryQuery, prepareArg, Query, securedFetch } from "@/lib/utils"; +import { useState, useEffect, Dispatch, SetStateAction, useContext } from "react"; +import { GitGraph, Info, Table } from "lucide-react" +import { GraphRef } from "@/lib/utils"; import dynamic from "next/dynamic"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useToast } from "@/components/ui/use-toast"; -import { Switch } from "@/components/ui/switch"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { ForceGraphMethods } from "react-force-graph-2d"; -import { IndicatorContext } from "@/app/components/provider"; -import { Category, Graph, GraphData, Link, Node } from "../api/graph/model"; -import DataPanel from "./GraphDataPanel"; -import Labels from "./labels"; -import Toolbar from "./toolbar"; +import { GraphContext } from "@/app/components/provider"; +import { Category, GraphData, Link, Node } from "../api/graph/model"; import Button from "../components/ui/Button"; import TableView from "./TableView"; +import Toolbar from "./toolbar"; +import Controls from "./controls"; +import GraphDataPanel from "./GraphDataPanel"; +import GraphDetails from "./GraphDetails"; +import Labels from "./labels"; import MetadataView from "./MetadataView"; -import Input from "../components/ui/Input"; const ForceGraph = dynamic(() => import("../components/ForceGraph"), { ssr: false }); -const EditorComponent = dynamic(() => import("../components/EditorComponent"), { ssr: false }) -function GraphView({ graph, selectedElement, setSelectedElement, runQuery, historyQuery, fetchCount, setHistoryQuery }: { - graph: Graph +interface Props { + data: GraphData + setData: Dispatch> selectedElement: Node | Link | undefined setSelectedElement: Dispatch> - runQuery: (query: string) => Promise - historyQuery: HistoryQuery - setHistoryQuery: Dispatch> + selectedElements: (Node | Link)[] + setSelectedElements: Dispatch> + nodesCount: number + edgesCount: number fetchCount: () => void -}) { + handleCooldown: (ticks?: number) => void + cooldownTicks: number | undefined + chartRef: GraphRef + handleDeleteElement: () => Promise + setLabels: Dispatch[]>> + setCategories: Dispatch[]>> + labels: Category[] + categories: Category[] +} + +function GraphView({ + data, + setData, + selectedElement, + setSelectedElement, + selectedElements, + setSelectedElements, + nodesCount, + edgesCount, + fetchCount, + handleCooldown, + cooldownTicks, + chartRef, + handleDeleteElement, + setLabels, + setCategories, + labels, + categories +}: Props) { - const [data, setData] = useState(graph.Elements) - const [selectedElements, setSelectedElements] = useState<(Node | Link)[]>([]); - const [isCollapsed, setIsCollapsed] = useState(true); - const chartRef = useRef>() - const dataPanel = useRef(null) - const [maximize, setMaximize] = useState(false) const [tabsValue, setTabsValue] = useState("") - const [cooldownTicks, setCooldownTicks] = useState(0) - const [currentQuery, setCurrentQuery] = useState() - const [searchElement, setSearchElement] = useState("") - const { toast } = useToast() - const { setIndicator } = useContext(IndicatorContext); + const { graph } = useContext(GraphContext) useEffect(() => { - let timeout: NodeJS.Timeout - if (tabsValue === "Graph" && selectedElement) { - timeout = setTimeout(() => { - dataPanel.current?.expand() - }, 0) - } - dataPanel.current?.collapse() - - return () => { - clearInterval(timeout) - } - }, [tabsValue]) + setCategories([...graph.Categories]) + setLabels([...graph.Labels]) + }, [graph, graph.Categories, graph.Labels]) useEffect(() => { let defaultChecked = "Graph" @@ -70,7 +75,7 @@ function GraphView({ graph, selectedElement, setSelectedElement, runQuery, histo defaultChecked = "Graph" } else if (graph.Data.length !== 0) { defaultChecked = "Table"; - } else if (currentQuery && currentQuery.metadata.length > 0 && graph.Metadata.length > 0 && currentQuery.explain.length > 0) { + } else if (graph.CurrentQuery && graph.CurrentQuery.metadata.length > 0 && graph.Metadata.length > 0 && graph.CurrentQuery.explain.length > 0) { defaultChecked = "Metadata"; } @@ -78,351 +83,165 @@ function GraphView({ graph, selectedElement, setSelectedElement, runQuery, histo setData({ ...graph.Elements }) }, [graph, graph.Id, graph.getElements().length, graph.Data.length]) - const handleCooldown = (ticks?: number) => { - setCooldownTicks(ticks) - - const canvas = document.querySelector('.force-graph-container canvas'); - if (!canvas) return - if (ticks === 0) { - canvas.setAttribute('data-engine-status', 'stop') - } else { - canvas.setAttribute('data-engine-status', 'running') + useEffect(() => { + if (tabsValue === "Graph") { + handleCooldown() } - } + }, [tabsValue]) useEffect(() => { setSelectedElement(undefined) setSelectedElements([]) }, [graph.Id]) - const onExpand = (expand?: boolean) => { - if (!dataPanel.current) return - const panel = dataPanel.current - if (expand !== undefined) { - if (expand && panel?.isCollapsed()) { - panel?.expand() - } else if (!expand && panel?.isExpanded()) { - panel?.collapse() - } - return - } - if (panel.isCollapsed()) { - panel.expand() - } else { - panel.collapse() - } - } - - useEffect(() => { - dataPanel.current?.collapse() - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setMaximize(false) - } - } - - window.addEventListener("keydown", handleKeyDown) - - return () => { - window.removeEventListener("keydown", handleKeyDown) - } - }, []) - - const onCategoryClick = (category: Category) => { + const onCategoryClick = (category: Category) => { category.show = !category.show - category.elements.forEach((element) => { - if (element.category[0] !== category.name) return + category.elements.forEach((node) => { + if (node.category[0] !== category.name) return if (category.show) { - element.visible = true + node.visible = true } else { - element.visible = false + node.visible = false } }) graph.visibleLinks(category.show) + graph.CategoriesMap.set(category.name, category) setData({ ...graph.Elements }) } - const onLabelClick = (label: Category) => { + const onLabelClick = (label: Category) => { label.show = !label.show - label.elements.forEach((element) => { + + label.elements.filter((link) => link.source.visible && link.target.visible).forEach((link) => { if (label.show) { - element.visible = true + link.visible = true } else { - element.visible = false + link.visible = false } }) - setData({ ...graph.Elements }) - } - const handleDeleteElement = async () => { - if (selectedElements.length === 0 && selectedElement) { - selectedElements.push(selectedElement) - setSelectedElement(undefined) - } - - await Promise.all(selectedElements.map(async (element) => { - const type = !element.source - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${prepareArg(element.id.toString())}`, { - method: "DELETE", - body: JSON.stringify({ type }) - }, toast, setIndicator) - - if (!result.ok) return - - if (type) { - (element as Node).category.forEach((category) => { - const cat = graph.CategoriesMap.get(category) - if (cat) { - cat.elements = cat.elements.filter((e) => e.id !== element.id) - if (cat.elements.length === 0) { - const index = graph.Categories.findIndex(c => c.name === cat.name) - if (index !== -1) { - graph.Categories.splice(index, 1) - graph.CategoriesMap.delete(cat.name) - } - } - } - }) - } else { - const category = graph.LabelsMap.get((element as Link).label) - if (category) { - category.elements = category.elements.filter((e) => e.id !== element.id) - if (category.elements.length === 0) { - const index = graph.Labels.findIndex(l => l.name === category.name) - if (index !== -1) { - graph.Labels.splice(index, 1) - graph.LabelsMap.delete(category.name) - } - } - } - } - })) - - graph.removeElements(selectedElements) - - fetchCount() - setSelectedElements([]) - setSelectedElement(undefined) - - graph.removeLinks(selectedElements.map((element) => element.id)) + graph.LabelsMap.set(label.name, label) setData({ ...graph.Elements }) - toast({ - title: "Success", - description: `${selectedElements.length > 1 ? "Elements" : "Element"} deleted`, - }) - handleCooldown() - onExpand(false) - } - - const handleRunQuery = async (q: string) => { - const newQuery = await runQuery(q) - if (newQuery) { - setCurrentQuery(newQuery) - handleCooldown() - } - return !!newQuery - } - - const handleSearchElement = () => { - if (searchElement) { - const element = graph.Elements.nodes.find(node => node.data.name ? node.data.name.toLowerCase().startsWith(searchElement.toLowerCase()) : node.id.toString().toLowerCase().includes(searchElement.toLowerCase())) - if (element) { - handleZoomToFit(chartRef, (node: Node) => node.id === element.id) - setSelectedElement(element) - } - } } return ( - - - +
    + - - - + + - - + + + + - { - "source" in obj ? -

    {label[0]}

    - : -
      setLabelsHover(true)} onMouseLeave={() => setLabelsHover(false)}> - {label.map((l) => ( -
    • -

      {l}

      - { - session?.user?.role !== "Read-Only" && - +
      + +
        setLabelsHover(true)} onMouseLeave={() => setLabelsHover(false)}> + {label.map((l) => ( +
      • +

        {l}

        + { + session?.user?.role !== "Read-Only" && + + } +
      • + ))} +
      • + { + type && labelsHover && !labelsEditable && session?.user?.role !== "Read-Only" && + + } + { + labelsEditable && + <> + ref?.focus()} + className="max-w-[20dvw] h-full bg-background border-none text-white" + value={newLabel} + onChange={(e) => setNewLabel(e.target.value)} + onKeyDown={(e) => { + + if (e.key === "Escape") { + e.preventDefault() + setLabelsEditable(false) + setNewLabel("") } -
      • - ))} -
      • - { - labelsHover && !labelsEditable && session?.user?.role !== "Read-Only" && - - } - { - labelsEditable && - <> - ref?.focus()} - className="max-w-[20dvw] h-full bg-foreground border-none text-white" - value={newLabel} - onChange={(e) => setNewLabel(e.target.value)} - onKeyDown={(e) => { - - if (e.key === "Escape") { - e.preventDefault() - setLabelsEditable(false) - setNewLabel("") - } - - if (e.key !== "Enter" || isLabelLoading || indicator === "offline") return - - e.preventDefault() - handleAddLabel() - }} - /> - - { - !isLabelLoading && - - } - - } -
      • -
      - } -
      + + if (e.key !== "Enter" || isLabelLoading || indicator === "offline") return + + e.preventDefault() + handleAddLabel() + }} + /> + + { + !isLabelLoading && + + } + + } +
    • +

    {attributes.length} Attributes

    @@ -726,11 +683,11 @@ export default function SchemaDataPanel({ obj, onExpand, onDeleteElement, schema { session?.user.role !== "Read-Only" && } /> } diff --git a/app/schema/SchemaView.tsx b/app/schema/SchemaView.tsx index 3c55eb88..23eec9ef 100644 --- a/app/schema/SchemaView.tsx +++ b/app/schema/SchemaView.tsx @@ -2,54 +2,83 @@ 'use client' -import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from "@/components/ui/resizable" -import { ChevronLeft, Maximize2, Minimize2, Pause, Play } from "lucide-react" -import { ImperativePanelHandle } from "react-resizable-panels" -import { useEffect, useRef, useState, useContext } from "react" -import { cn, prepareArg, securedFetch } from "@/lib/utils" +import { useEffect, useState, useContext, Dispatch, SetStateAction } from "react" +import { GraphRef, prepareArg, securedFetch } from "@/lib/utils" 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 { ForceGraphMethods } from "react-force-graph-2d" -import Toolbar from "../graph/toolbar" import SchemaDataPanel from "./SchemaDataPanel" import Labels from "../graph/labels" -import { Category, Graph, Link, Node, GraphData } from "../api/graph/model" -import Button from "../components/ui/Button" +import { Category, Link, Node, GraphData } from "../api/graph/model" import CreateElement from "./SchemaCreateElement" -import { IndicatorContext } from "../components/provider" +import { IndicatorContext, SchemaContext } from "../components/provider" +import Controls from "../graph/controls" +import GraphDetails from "../graph/GraphDetails" const ForceGraph = dynamic(() => import("../components/ForceGraph"), { ssr: false }) /* eslint-disable react/require-default-props */ interface Props { - schema: Graph fetchCount?: () => void + edgesCount: number + nodesCount: number + selectedElement: Node | Link | undefined + setSelectedElement: Dispatch> + selectedElements: (Node | Link)[] + setSelectedElements: Dispatch> + isAddRelation: boolean + setIsAddRelation: Dispatch> + isAddEntity: boolean + setIsAddEntity: Dispatch> + chartRef: GraphRef + cooldownTicks: number | undefined + handleCooldown: () => void + data: GraphData + setData: Dispatch> + handleDeleteElement: () => Promise + setLabels: Dispatch[]>> + setCategories: Dispatch[]>> + labels: Category[] + categories: Category[] } -export default function SchemaView({ schema, fetchCount }: Props) { - const [selectedElement, setSelectedElement] = useState(); - const [selectedElements, setSelectedElements] = useState<(Node | Link)[]>([]); - const [selectedNodes, setSelectedNodes] = useState<[Node | undefined, Node | undefined]>([undefined, undefined]); - const [isCollapsed, setIsCollapsed] = useState(false); - const chartRef = useRef>(); - const dataPanel = useRef(null); - const [isAddRelation, setIsAddRelation] = useState(false) - const [isAddEntity, setIsAddEntity] = useState(false) - const [maximize, setMaximize] = useState(false) - const [cooldownTicks, setCooldownTicks] = useState(0) - const [data, setData] = useState(schema.Elements) - const { toast } = useToast() +export default function SchemaView({ + fetchCount, + edgesCount, + nodesCount, + selectedElement, + setSelectedElement, + selectedElements, + setSelectedElements, + isAddRelation, + setIsAddRelation, + isAddEntity, + setIsAddEntity, + chartRef, + cooldownTicks, + handleCooldown, + data, + setData, + handleDeleteElement, + setLabels, + setCategories, + labels, + categories +}: Props) { const { setIndicator } = useContext(IndicatorContext) + const { schema } = useContext(SchemaContext) + + const { toast } = useToast() + + const [selectedNodes, setSelectedNodes] = useState<[Node | undefined, Node | undefined]>([undefined, undefined]); useEffect(() => { setData({ ...schema.Elements }) }, [schema.Elements, schema.Id]) useEffect(() => { - dataPanel.current?.collapse() - }, []) + setCategories([...schema.Categories]) + setLabels([...schema.Labels]) + }, [schema.Id, schema.Categories.length, schema.Labels.length]) useEffect(() => { setSelectedElement(undefined) @@ -60,275 +89,125 @@ export default function SchemaView({ schema, fetchCount }: Props) { setSelectedNodes([undefined, undefined]) }, [isAddRelation]) - const handleCooldown = (ticks?: number) => { - setCooldownTicks(ticks) - } - - const onCategoryClick = (category: Category) => { + const onCategoryClick = (category: Category) => { category.show = !category.show + schema.Elements.nodes.forEach((node) => { if (node.category[0] !== category.name) return node.visible = category.show }) schema.visibleLinks(category.show) - + schema.CategoriesMap.set(category.name, category) setData({ ...schema.Elements }) } - const onLabelClick = (label: Category) => { + const onLabelClick = (label: Category) => { label.show = !label.show + schema.Elements.links.forEach((link) => { if (link.label !== label.name) return link.visible = label.show }) + schema.LabelsMap.set(label.name, label) setData({ ...schema.Elements }) } - const handleSetSelectedElement = (element?: Node | Link | undefined) => { - setSelectedElement(element) - if (isAddRelation || isAddEntity) return - if (element) { - dataPanel.current?.expand() - } else dataPanel.current?.collapse() - } - - const onExpand = (expand?: boolean) => { - if (!dataPanel.current) return - const panel = dataPanel.current - if (expand !== undefined) { - if (expand && panel?.isCollapsed()) { - panel?.expand() - } else if (!expand && panel?.isExpanded()) { - panel?.collapse() - } - return - } - if (panel.isCollapsed()) { - panel.expand() - } else { - panel.collapse() - } - } - - const handleDeleteElement = async () => { - const stateSelectedElements = Object.values(selectedElements) - - if (stateSelectedElements.length === 0 && selectedElement) { - stateSelectedElements.push(selectedElement) - setSelectedElement(undefined) - } - - await Promise.all(stateSelectedElements.map(async (element) => { - const { id } = element - const type = !("source" in element) - const result = await securedFetch(`api/schema/${prepareArg(schema.Id)}/${prepareArg(id.toString())}`, { - method: "DELETE", - body: JSON.stringify({ type }), - }, toast, setIndicator) - - if (!result.ok) return - - if (type) { - schema.Elements.nodes.splice(schema.Elements.nodes.findIndex(node => node.id === element.id), 1) - schema.NodesMap.delete(id) - } else { - schema.Elements.links.splice(schema.Elements.links.findIndex(link => link.id === element.id), 1) - schema.EdgesMap.delete(id) - } - - if (type) { - element.category.forEach((category) => { - const cat = schema.CategoriesMap.get(category) - - if (cat) { - cat.elements = cat.elements.filter(n => n.id !== id) - - if (cat.elements.length === 0) { - schema.Categories.splice(schema.Categories.findIndex(c => c.name === cat.name), 1) - schema.CategoriesMap.delete(cat.name) - } - } - }) - } else { - const cat = schema.LabelsMap.get(element.label) - - if (cat) { - cat.elements = cat.elements.filter(n => n.id !== id) - - if (cat.elements.length === 0) { - schema.Labels.splice(schema.Labels.findIndex(c => c.name === cat.name), 1) - schema.LabelsMap.delete(cat.name) - } - } - } - })) - - schema.removeLinks() - - if (fetchCount) fetchCount() - - setSelectedElement(undefined) - setSelectedElements([]) - setData({ ...schema.Elements }) - onExpand(false) - } - - const onCreateElement = async (attributes: [string, string[]][], label?: string[]) => { + const onCreateElement = async (attributes: [string, string[]][], elementLabel?: string[]) => { const fakeId = "-1" const result = await securedFetch(`api/schema/${prepareArg(schema.Id)}/${prepareArg(fakeId)}`, { method: "POST", - body: JSON.stringify({ type: isAddEntity, label, attributes, selectedNodes }) + body: JSON.stringify({ type: isAddEntity, label: elementLabel, attributes, selectedNodes }) }, toast, setIndicator) if (result.ok) { const json = await result.json() if (isAddEntity) { - schema.extendNode(json.result.data[0].n, false, true) + const { category } = schema.extendNode(json.result.data[0].n, false, true)! + setCategories(prev => [...prev, ...category.map(c => schema.CategoriesMap.get(c)!)]) setIsAddEntity(false) } else { - schema.extendEdge(json.result.data[0].e, false, true) + const { label } = schema.extendEdge(json.result.data[0].e, false, true)! + setLabels(prev => [...prev, schema.LabelsMap.get(label)!]) setIsAddRelation(false) } if (fetchCount) fetchCount() - onExpand() - + setSelectedElement(undefined) } setData({ ...schema.Elements }) + handleCooldown() + return result.ok } return ( - - -
    - +
    + + { + schema.getElements().length > 0 && + { - setIsAddEntity(true) - setIsAddRelation(false) - setSelectedElement(undefined) - if (dataPanel.current?.isExpanded()) return - onExpand() - }} - onAddRelation={() => { - setIsAddRelation(true) - setIsAddEntity(false) - setSelectedElement(undefined) - if (dataPanel.current?.isExpanded()) return - onExpand() - }} - onDeleteElement={handleDeleteElement} - chartRef={chartRef} - displayAdd - type="Schema" - /> - { - isCollapsed && - - } -
    -
    - -
    - - -
    - {cooldownTicks === undefined ? : } - { - handleCooldown(cooldownTicks === undefined ? 0 : undefined) - }} - /> -
    -
    - -

    Animation Control

    -
    -
    -
    - - { - (schema.Categories.length > 0 || schema.Labels.length > 0) && - <> - - - - } -
    - - - setIsCollapsed(true)} - onExpand={() => setIsCollapsed(false)} - > + } +
    +
    + + { + (categories.length > 0 || labels.length > 0) && + <> + + + + } { selectedElement ? - : (isAddEntity || isAddRelation) && + : (isAddRelation || isAddEntity) && } - - +
    + ) } \ No newline at end of file diff --git a/app/schema/page.tsx b/app/schema/page.tsx index 2c7f66fd..f6dff13f 100644 --- a/app/schema/page.tsx +++ b/app/schema/page.tsx @@ -1,41 +1,68 @@ 'use client' -import { useCallback, useEffect, useContext, useState } from "react"; +import { useCallback, useEffect, useContext, useState, useRef } from "react"; import { prepareArg, securedFetch } from "@/lib/utils"; -import { useSession } from "next-auth/react"; import { useToast } from "@/components/ui/use-toast"; import dynamic from "next/dynamic"; -import Header from "../components/Header"; +import { ForceGraphMethods } from "react-force-graph-2d"; import SchemaView from "./SchemaView"; -import { Graph } from "../api/graph/model"; -import { IndicatorContext } from "../components/provider"; +import { Category, Graph, GraphData, Link, Node } from "../api/graph/model"; +import { IndicatorContext, SchemaContext, SchemaNameContext, SchemaNamesContext } from "../components/provider"; const Selector = dynamic(() => import("../graph/Selector"), { ssr: false }) export default function Page() { - const [schemaName, setSchemaName] = useState("") - const [schema, setSchema] = useState(Graph.empty()) + const { schemaNames, setSchemaNames } = useContext(SchemaNamesContext) + const { schemaName, setSchemaName } = useContext(SchemaNameContext) + const { indicator, setIndicator } = useContext(IndicatorContext) + const { schema, setSchema } = useContext(SchemaContext) + + const { toast } = useToast() + + const [selectedElement, setSelectedElement] = useState() + const [selectedElements, setSelectedElements] = useState<(Node | Link)[]>([]) + const [cooldownTicks, setCooldownTicks] = useState(0) + const [categories, setCategories] = useState[]>([]) + const [data, setData] = useState(schema.Elements) + const [labels, setLabels] = useState[]>([]) + const [isAddRelation, setIsAddRelation] = useState(false) + const chartRef = useRef>() const [edgesCount, setEdgesCount] = useState(0) const [nodesCount, setNodesCount] = useState(0) - const [schemaNames, setSchemaNames] = useState([]) - const { data: session } = useSession() - const { toast } = useToast() - const { indicator, setIndicator } = useContext(IndicatorContext); - + const [isAddEntity, setIsAddEntity] = useState(false) + const fetchCount = useCallback(async () => { const result = await securedFetch(`api/schema/${prepareArg(schemaName)}/count`, { method: "GET" }, toast, setIndicator) - if (!result) return + if (!result.ok) return + + let json = await result.json() + + while (typeof json.result === "number") { + // eslint-disable-next-line no-await-in-loop + const res = await securedFetch(`api/graph/${prepareArg(schemaName)}/query/?id=${prepareArg(json.result.toString())}`, { + method: "GET" + }, toast, setIndicator) + + if (!res.ok) return + + // eslint-disable-next-line no-await-in-loop + json = await res.json() + } - const json = await result.json() + [json] = json.result.data - setEdgesCount(json.result.edges) - setNodesCount(json.result.nodes) + setEdgesCount(json.edges) + setNodesCount(json.nodes) }, [schemaName, toast, setIndicator]) + const handleCooldown = (ticks?: number) => { + setCooldownTicks(ticks) + } + useEffect(() => { if (!schemaName || indicator === "offline") return const run = async () => { @@ -53,30 +80,117 @@ export default function Page() { fetchCount() + handleCooldown() } run() }, [fetchCount, schemaName, toast, setIndicator, indicator]) + + const handleDeleteElement = async () => { + const stateSelectedElements = Object.values(selectedElements) + + if (stateSelectedElements.length === 0 && selectedElement) { + stateSelectedElements.push(selectedElement) + setSelectedElement(undefined) + } + + await Promise.all(stateSelectedElements.map(async (element) => { + const { id } = element + const type = !("source" in element) + const result = await securedFetch(`api/schema/${prepareArg(schema.Id)}/${prepareArg(id.toString())}`, { + method: "DELETE", + body: JSON.stringify({ type }), + }, toast, setIndicator) + + if (!result.ok) return + + if (type) { + schema.Elements.nodes.splice(schema.Elements.nodes.findIndex(node => node.id === element.id), 1) + schema.NodesMap.delete(id) + } else { + schema.Elements.links.splice(schema.Elements.links.findIndex(link => link.id === element.id), 1) + schema.EdgesMap.delete(id) + } + + if (type) { + element.category.forEach((category) => { + const cat = schema.CategoriesMap.get(category) + + if (cat) { + cat.elements = cat.elements.filter(n => n.id !== id) + + if (cat.elements.length === 0) { + schema.Categories.splice(schema.Categories.findIndex(c => c.name === cat.name), 1) + schema.CategoriesMap.delete(cat.name) + } + } + }) + } else { + const cat = schema.LabelsMap.get(element.label) + + if (cat) { + cat.elements = cat.elements.filter(n => n.id !== id) + + if (cat.elements.length === 0) { + schema.Labels.splice(schema.Labels.findIndex(c => c.name === cat.name), 1) + schema.LabelsMap.delete(cat.name) + } + } + } + })) + + schema.removeLinks(setLabels, selectedElements.map((element) => element.id)) + + if (fetchCount) fetchCount() + + handleCooldown() + setSelectedElement(undefined) + setSelectedElements([]) + setData({ ...schema.Elements }) + } + return (
    -
    { - setSchemaName(newSchemaName) - setSchemaNames(prev => [...prev, newSchemaName]) - }} graphNames={schemaNames} /> -
    - +
    + -
    +
    ) } \ No newline at end of file diff --git a/app/settings/Configurations.tsx b/app/settings/Configurations.tsx index 94d00109..cc51543e 100644 --- a/app/settings/Configurations.tsx +++ b/app/settings/Configurations.tsx @@ -198,24 +198,28 @@ export default function Configurations() { const { configs: configurations } = await result.json(); - const newConfigs = configurations.map((config: [string, string | number]) => { + const newConfigs: Row[] = configurations.map((config: [string, string | number]) => { const [name, value] = config; const formattedValue = name === "CMD_INFO" ? (value === 0 ? "no" : "yes") - : value; + : value as string; const description = Configs.get(name)?.description ?? ""; return { cells: [ - { value: name }, - { value: description }, - { - value: formattedValue, - onChange: !disableRunTimeConfigs.has(name) - ? (val: string) => handleSetConfig(name, val, true) - : undefined - } + { value: name, type: "readonly" }, + { value: description, type: "readonly" }, + !disableRunTimeConfigs.has(name) + ? { + value: formattedValue, + onChange: (val: string) => handleSetConfig(name, val, true), + type: "text", + } + : { + value: formattedValue, + type: "readonly" + } ] } }); @@ -229,6 +233,8 @@ export default function Configurations() { return ( diff --git a/app/settings/QuerySettings.tsx b/app/settings/QuerySettings.tsx index c50c87df..259302a7 100644 --- a/app/settings/QuerySettings.tsx +++ b/app/settings/QuerySettings.tsx @@ -1,7 +1,8 @@ import { useContext } from "react" -import { Minus, Plus } from "lucide-react" +import { Info, Minus, Plus } from "lucide-react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import Link from "next/link" +import { cn } from "@/lib/utils" import Input from "../components/ui/Input" import { LimitContext, TimeoutContext } from "../components/provider" import Button from "../components/ui/Button" @@ -14,82 +15,112 @@ export default function QuerySettings() {
    - - -

    Timeout

    -
    - -

    Shows a `Timed Out` error if the query takes longer than the timeout in seconds.

    - - Learn more - -
    -
    +
    +

    Timeout

    + + + + + +

    Shows a `Timed Out` error if the query takes longer than the timeout in seconds.

    + + Learn more + +
    +
    +
    { - const value = parseInt(e.target.value, 10) + const value = parseInt(e.target.value.replace('∞', ''), 10) + + if (!value) { + setTimeout(0) + return + } if (value < 0 || Number.isNaN(value)) return setTimeout(value) + localStorage.setItem("timeout", value.toString()) }} />
    - - -

    Limit

    -
    - -

    Limits the number of rows returned by the query.

    - - Learn more - -
    -
    +
    +

    Limit

    + + + + + +

    Limits the number of rows returned by the query.

    + + Learn more + +
    +
    +
    { - const value = parseInt(e.target.value, 10) + const value = parseInt(e.target.value.replace('∞', ''), 10) + + if (!value) { + setLimit(0) + return + } if (value < 0 || Number.isNaN(value)) return setLimit(value) + localStorage.setItem("limit", value.toString()) }} /> diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 9ee11855..90a6be1c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react" import { useSession } from "next-auth/react" import { useRouter } from "next/navigation" import { cn } from "@/lib/utils" -import Header from "../components/Header" import Users from "./users/Users" import Configurations from "./Configurations" import Button from "../components/ui/Button" @@ -16,6 +15,22 @@ export default function Settings() { const router = useRouter() const { data: session } = useSession() + useEffect(() => { + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + router.back() + } + }) + + return () => { + window.removeEventListener("keydown", (e) => { + if (e.key === "Escape") { + router.back() + } + }) + } + }, [router]) + useEffect(() => { if (session && session.user.role !== "Admin") router.back() }, [router, session]) @@ -33,24 +48,23 @@ export default function Settings() { return (
    -
    -
    +

    Settings

    -
    +
    +
    diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx index e2233852..d8be192d 100644 --- a/components/ui/toaster.tsx +++ b/components/ui/toaster.tsx @@ -17,7 +17,7 @@ export function Toaster() { {toasts.map(function ({ id, title, description, action, ...props }) { return ( - +
    {title && {title}} {description && ( diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 30fc44d9..d1540a49 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -4,6 +4,7 @@ import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils" +import { Info } from "lucide-react" const TooltipProvider = TooltipPrimitive.Provider @@ -19,12 +20,15 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "flex gap-2 items-center text-wrap max-w-[90dvw] z-50 overflow-hidden rounded-md border bg-popover-foreground px-3 py-1.5 text-sm text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} - /> + > + + {props.children} + )) -TooltipContent.displayName = TooltipPrimitive.Content.displayName + TooltipContent.displayName = TooltipPrimitive.Content.displayName export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/e2e/infra/utils.ts b/e2e/infra/utils.ts index 71c24852..06b0f5a3 100644 --- a/e2e/infra/utils.ts +++ b/e2e/infra/utils.ts @@ -6,6 +6,12 @@ import crypto from "crypto"; const adminAuthFile = 'playwright/.auth/admin.json' +export const DEFAULT_CREATE_QUERY = "UNWIND range(1, 10) as x CREATE (n:n)-[e:e]->(m:m) RETURN *" +export const CREATE_TWO_NODES_QUERY = 'CREATE (a:person1 {name: "a"}), (b:person2 {name: "b"}) RETURN *' +export const CREATE_NODE_QUERY = 'CREATE (a:person1 {name: "a"}) RETURN *' +export const CREATE_QUERY = 'CREATE (a:person1 {name: "a"})-[c:KNOWS {name: "knows"}]->(b:person2) RETURN *' + + export function delay(ms: number) { return new Promise(resolve => { setTimeout(resolve, ms) }); } @@ -24,6 +30,20 @@ export const waitForElementToBeVisible = async (locator: Locator, time = 500, re return false; }; +export const waitForElementToNotBeVisible = async (locator: Locator, time = 500, retry = 10): Promise => { + for (let i = 0; i < retry; i += 1) { + try { + if (await locator.count() > 0 && !await locator.isVisible()) { + return true; + } + } catch (error) { + console.error(`Error checking element visibility: ${error}`); + } + await delay(time); + } + return false; +}; + export const waitForElementToBeEnabled = async (locator: Locator, time = 500, retry = 10): Promise => { for (let i = 0; i < retry; i += 1) { try { @@ -120,3 +140,8 @@ export async function interactWhenVisible(element: Locator, action: (el: Loca if (!isVisible) throw new Error(`${name} is not visible!`); return action(element); } + +export function inferLabelFromGraph(graph: string): string { + if (graph.toLowerCase().includes('schema')) return 'Schema'; + return 'Graph'; +} \ No newline at end of file diff --git a/e2e/logic/POM/dataPanelComponent.ts b/e2e/logic/POM/dataPanelComponent.ts index e825bd39..787f2f1b 100644 --- a/e2e/logic/POM/dataPanelComponent.ts +++ b/e2e/logic/POM/dataPanelComponent.ts @@ -5,198 +5,258 @@ /* eslint-disable arrow-body-style */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Locator } from "@playwright/test"; -import { interactWhenVisible } from "@/e2e/infra/utils"; +import { interactWhenVisible, waitForElementToBeVisible, waitForElementToNotBeVisible } from "@/e2e/infra/utils"; import GraphPage from "./graphPage"; export default class DataPanel extends GraphPage { - private get addAttributeButtonInGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel').locator('button:has-text("Add Attribute")'); + private get dataPanel(): Locator { + return this.page.getByTestId("DataPanel"); } - private get deleteNodeButtonInGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel').locator('button:has-text("Delete Node")'); + // CLOSE + private get dataPanelClose(): Locator { + return this.page.getByTestId("DataPanelClose"); } - private get attributeInputInGraphDataPanel(): (index: number) => Locator { - return (index: number) => this.page.locator('#graphDataPanel tbody tr input').nth(index); + // COUNT + private get dataPanelAttributesCount(): Locator { + return this.page.getByTestId("DataPanelAttributesCount"); } - private get saveAttributeButtonInGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel tbody tr').nth(2).locator('button').first(); + // LABEL + private get dataPanelLabel(): Locator { + return this.page.getByTestId("DataPanelLabel"); } - private get modifyAttributeButtonInLastRowOfGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel tbody tr').last().locator('td').last().locator('button'); + private get dataPanelLabelByName(): (label: string) => Locator { + return (label: string) => this.page.getByTestId(`DataPanelLabel${label}`); } - private get lastAttributeRowInGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel tr').last(); + // REMOVE LABEL + private get dataPanelRemoveLabelByLabel(): (label: string) => Locator { + return (label: string) => this.page.getByTestId(`DataPanelRemoveLabel${label}`); } - private get editAttributeButtonForFirstRowInGraphDataPanel(): Locator { - return this.page.locator("//div[@id='graphDataPanel']//td[1]//button[1]"); + // ADD LABEL + private get dataPanelAddLabel(): Locator { + return this.page.getByTestId("DataPanelAddLabel"); } - private get deleteAttributeButtonForFirstRowInGraphDataPanel(): Locator { - return this.page.locator("//div[@id='graphDataPanel']//td[1]//button[2]"); + private get dataPanelAddLabelInput(): Locator { + return this.page.getByTestId("DataPanelAddLabelInput"); } - private get deleteButtonInDialog(): Locator { - return this.page.locator('#dialog').locator('button:has-text("Delete")'); + private get dataPanelAddLabelConfirm(): Locator { + return this.page.getByTestId("DataPanelAddLabelConfirm"); } - private get lastAttributeNameCellInGraphDataPanel(): (attribute: string) => Locator { - return (attribute: string) => this.page.locator(`//div[@id='graphDataPanel']//td[2][normalize-space(text()) = '${attribute}']`); + private get dataPanelAddLabelCancel(): Locator { + return this.page.getByTestId("DataPanelAddLabelCancel"); } - private get attributeValueInputInGraphDataPanel(): Locator { - return this.page.locator('#graphDataPanel tr input'); + private get dataPanelAttribute(): (key: string) => Locator { + return (key: string) => this.page.getByTestId(`DataPanelAttribute${key}`); } - private get dataPanelHeaderAttr(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]/div/ul"); + // SET ATTRIBUTE + private get dataPanelValueSetAttribute(): Locator { + return this.page.getByTestId("DataPanelValueSetAttribute"); } - private get addButtonInDataPanelHeader(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]//button[contains(text(), 'Add')]"); + private get dataPanelSetAttribute(): Locator { + return this.page.getByTestId("DataPanelSetAttribute"); } - private get inputInDataPanelHeader(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]//input"); + private get dataPanelSetAttributeInput(): Locator { + return this.page.getByTestId("DataPanelSetAttributeInput"); } - private get saveButtonInDataPanelHeader(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]//button[contains(text(), 'Save')]"); + private get dataPanelSetAttributeConfirm(): Locator { + return this.page.getByTestId("DataPanelSetAttributeConfirm"); } - private get removeAttributeButtonInDataPanelHeader(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]//li//button").first(); + private get dataPanelSetAttributeCancel(): Locator { + return this.page.getByTestId("DataPanelSetAttributeCancel"); } - private get attributeHeaderLabelInDataPanelHeader(): Locator { - return this.page.locator("//div[contains(@id, 'dataPanelHeader')]//li/p"); + // ADD ATTRIBUTE + private get dataPanelAddAttribute(): Locator { + return this.page.getByTestId("DataPanelAddAttribute"); } - async clickAddAttributeButtonInGraphDataPanel(): Promise { - await interactWhenVisible(this.addAttributeButtonInGraphDataPanel, el => el.click(), "add attribute button in graph data panel"); + private get dataPanelAddAttributeConfirm(): Locator { + return this.page.getByTestId("DataPanelAddAttributeConfirm"); } - - async clickDeleteNodeButtonInGraphDataPanel(): Promise { - await interactWhenVisible(this.deleteNodeButtonInGraphDataPanel, el => el.click(), "delete node button in graph data panel"); + + private get dataPanelAddAttributeCancel(): Locator { + return this.page.getByTestId("DataPanelAddAttributeCancel"); } - - async fillAttributeInputInGraphDataPanel(index: number, input: string): Promise { - await interactWhenVisible(this.attributeInputInGraphDataPanel(index), el => el.fill(input), `attribute input in graph data panel [index ${index}]`); + + private get dataPanelAddAttributeKey(): Locator { + return this.page.getByTestId("DataPanelAddAttributeKey"); } - - async clickSaveAttributeButtonInGraphDataPanel(): Promise { - await interactWhenVisible(this.saveAttributeButtonInGraphDataPanel, el => el.click(), "save attribute button in graph data panel"); + + private get dataPanelAddAttributeValue(): Locator { + return this.page.getByTestId("DataPanelAddAttributeValue"); } - - async getAttributeValueInGraphDataPanel(): Promise { - await interactWhenVisible(this.saveAttributeButtonInGraphDataPanel, async () => {}, "save attribute button in graph data panel"); - return await this.saveAttributeButtonInGraphDataPanel.textContent(); - } - async clickModifyAttributeButtonInLastRowOfGraphDataPanel(): Promise { - await interactWhenVisible(this.modifyAttributeButtonInLastRowOfGraphDataPanel, el => el.click(), "modify attribute button in last row of graph data panel"); + // DELETE ATTRIBUTE + private get dataPanelDeleteAttribute(): Locator { + return this.page.getByTestId("DataPanelDeleteAttribute"); } - - async hoverLastAttributeRowInGraphDataPanel(): Promise { - await interactWhenVisible(this.lastAttributeRowInGraphDataPanel, el => el.hover(), "last attribute row in graph data panel"); + + private get dataPanelDeleteAttributeConfirm(): Locator { + return this.page.getByTestId("DataPanelDeleteAttributeConfirm"); } - - async clickEditAttributeButtonForFirstRowInGraphDataPanel(): Promise { - await interactWhenVisible(this.editAttributeButtonForFirstRowInGraphDataPanel, el => el.click(), "edit attribute button in first row of graph data panel"); + + private get dataPanelDeleteAttributeCancel(): Locator { + return this.page.getByTestId("DataPanelDeleteAttributeCancel"); } - - async clickDeleteAttributeButtonForFirstRowInGraphDataPanel(): Promise { - await interactWhenVisible(this.deleteAttributeButtonForFirstRowInGraphDataPanel, el => el.click(), "delete attribute button in first row of graph data panel"); + + async getContentDataPanelAttributesCount(): Promise { + const content = await interactWhenVisible(this.dataPanelAttributesCount, (el) => el.textContent(), "Data Panel Attributes Count"); + return Number(content ?? "0") } - - async clickDeleteButtonInDialog(): Promise { - await interactWhenVisible(this.deleteButtonInDialog, el => el.click(), "delete button in dialog"); - } - async isLastAttributeNameCellInGraphDataPanel(attribute: string): Promise{ - return await this.lastAttributeNameCellInGraphDataPanel(attribute).isVisible(); + async isVisibleDataPanel(): Promise { + const isVisible = await waitForElementToBeVisible(this.dataPanel); + return isVisible; } - async getLastAttributeNameCellInGraphDataPanel(attribute: string): Promise { - await interactWhenVisible(this.lastAttributeNameCellInGraphDataPanel(attribute), async () => {}, `last attribute name cell for "${attribute}"`); - return await this.lastAttributeNameCellInGraphDataPanel(attribute).textContent(); + async isVisibleLabel(label: string): Promise { + const isVisible = await waitForElementToBeVisible(this.dataPanelLabelByName(label)); + return isVisible; } - - async fillAttributeValueInputInGraphDataPanel(input: string): Promise { - await interactWhenVisible(this.attributeValueInputInGraphDataPanel, el => el.fill(input), "attribute value input in graph data panel"); + + async isVisibleAttribute(key: string): Promise { + const isVisible = await waitForElementToBeVisible(this.dataPanelAttribute(key)); + return isVisible; } - - async hoverOnDataPanelHeaderAttr(): Promise { - await interactWhenVisible(this.dataPanelHeaderAttr, el => el.hover(), "data panel header attribute hover"); + + async hoverDataPanelLabel(): Promise { + await interactWhenVisible(this.dataPanelLabel, (el) => el.hover(), "Data Panel Label"); } - - async clickOnAddButtonInDataPanelHeader(): Promise { - await interactWhenVisible(this.addButtonInDataPanelHeader, el => el.click(), "add button in data panel header"); + + async hoverDataPanelAttribute(key: string): Promise { + await interactWhenVisible(this.dataPanelAttribute(key), (el) => el.hover(), "Data Panel Attribute"); } - - async fillInputInDataPanelHeader(attribute: string): Promise { - await interactWhenVisible(this.inputInDataPanelHeader, el => el.fill(attribute), "input in data panel header"); + + async fillDataPanelAddLabelInput(label: string): Promise { + await interactWhenVisible(this.dataPanelAddLabelInput, (el) => el.fill(label), "Data Panel Add Label Input"); } - - async clickOnSaveButtonInDataPanelHeader(): Promise { - await interactWhenVisible(this.saveButtonInDataPanelHeader, el => el.click(), "save button in data panel header"); + + async fillDataPanelAddAttributeKey(key: string): Promise { + await interactWhenVisible(this.dataPanelAddAttributeKey, (el) => el.fill(key), "Data Panel Add Attribute Key"); } - - async clickOnRemoveAttributeButtonInDataPanelHeader(): Promise { - await interactWhenVisible(this.removeAttributeButtonInDataPanelHeader, el => el.click(), "remove attribute button in data panel header"); + + async fillDataPanelAddAttributeValue(value: string): Promise { + await interactWhenVisible(this.dataPanelAddAttributeValue, (el) => el.fill(value), "Data Panel Add Attribute Value"); } - - async getAttributeHeaderLabelInDataPanelHeader(): Promise { - await interactWhenVisible(this.attributeHeaderLabelInDataPanelHeader, async () => {}, "attribute header label in data panel header"); - return await this.attributeHeaderLabelInDataPanelHeader.textContent(); + + async fillDataPanelSetAttributeInput(value: string): Promise { + await interactWhenVisible(this.dataPanelSetAttributeInput, (el) => el.fill(value), "Data Panel Set Attribute Input"); + } + + async clickDataPanelClose(): Promise { + await interactWhenVisible(this.dataPanelClose, (el) => el.click(), "Data Panel Close"); + } + + async clickDataPanelRemoveLabelByLabel(label: string): Promise { + await interactWhenVisible(this.dataPanelRemoveLabelByLabel(label), (el) => el.click(), "Data Panel Remove Label"); + } + + async clickDataPanelAddLabel(): Promise { + await interactWhenVisible(this.dataPanelAddLabel, (el) => el.click(), "Data Panel Add Label"); + } + + async clickDataPanelAddLabelConfirm(): Promise { + await interactWhenVisible(this.dataPanelAddLabelConfirm, (el) => el.click(), "Data Panel Add Label Confirm"); + } + + async clickDataPanelAddLabelCancel(): Promise { + await interactWhenVisible(this.dataPanelAddLabelCancel, (el) => el.click(), "Data Panel Add Label Cancel"); } - async modifyNodeHeaderAttribute(attribute: string): Promise { - await this.hoverOnDataPanelHeaderAttr(); - await this.clickOnAddButtonInDataPanelHeader(); - await this.fillInputInDataPanelHeader(attribute); - await this.clickOnSaveButtonInDataPanelHeader(); - await this.clickOnRemoveAttributeButtonInDataPanelHeader(); + async clickDataPanelAddAttribute(): Promise { + await interactWhenVisible(this.dataPanelAddAttribute, (el) => el.click(), "Data Panel Add Attribute"); } - async addAttribute(attribute: string, attributeValue: string): Promise{ - await this.clickAddAttributeButtonInGraphDataPanel(); - await this.fillAttributeInputInGraphDataPanel(0, attribute); - await this.fillAttributeInputInGraphDataPanel(1, attributeValue); - await this.clickSaveAttributeButtonInGraphDataPanel(); + async clickDataPanelAddAttributeConfirm(): Promise { + await interactWhenVisible(this.dataPanelAddAttributeConfirm, (el) => el.click(), "Data Panel Add Attribute Confirm"); } - async removeAttribute(): Promise{ - await this.hoverLastAttributeRowInGraphDataPanel(); - await this.clickDeleteAttributeButtonForFirstRowInGraphDataPanel(); - await Promise.all([ - this.page.waitForResponse(res => res.status() === 200), - this.clickDeleteButtonInDialog() - ]); + async clickDataPanelAddAttributeCancel(): Promise { + await interactWhenVisible(this.dataPanelAddAttributeCancel, (el) => el.click(), "Data Panel Add Attribute Cancel"); } - async modifyAttribute(input: string): Promise{ - await this.hoverLastAttributeRowInGraphDataPanel(); - await this.clickEditAttributeButtonForFirstRowInGraphDataPanel(); - await this.fillAttributeValueInputInGraphDataPanel(input); - await Promise.all([ - this.page.waitForResponse(res => res.status() === 200), - this.clickSaveAttributeButtonInGraphDataPanel() - ]); + async clickDataPanelValueSetAttribute(): Promise { + await interactWhenVisible(this.dataPanelValueSetAttribute, (el) => el.click(), "Data Panel Value Set Attribute"); } - async deleteNodeViaDataPanel(): Promise{ - await this.clickDeleteNodeButtonInGraphDataPanel(); - await Promise.all([ - this.page.waitForResponse(res => res.status() === 200), - this.clickDeleteButtonInDialog() - ]); + async clickDataPanelSetAttribute(): Promise { + await interactWhenVisible(this.dataPanelSetAttribute, (el) => el.click(), "Data Panel Set Attribute"); } + async clickDataPanelSetAttributeConfirm(): Promise { + await interactWhenVisible(this.dataPanelSetAttributeConfirm, (el) => el.click(), "Data Panel Set Attribute Confirm"); + } + + async clickDataPanelSetAttributeCancel(): Promise { + await interactWhenVisible(this.dataPanelSetAttributeCancel, (el) => el.click(), "Data Panel Set Attribute Cancel"); + } + + async clickDataPanelDeleteAttribute(): Promise { + await interactWhenVisible(this.dataPanelDeleteAttribute, (el) => el.click(), "Data Panel Delete Attribute"); + } + + async clickDataPanelDeleteAttributeConfirm(): Promise { + await interactWhenVisible(this.dataPanelDeleteAttributeConfirm, (el) => el.click(), "Data Panel Delete Attribute Confirm"); + } + + async clickDataPanelDeleteAttributeCancel(): Promise { + await interactWhenVisible(this.dataPanelDeleteAttributeCancel, (el) => el.click(), "Data Panel Delete Attribute Cancel"); + } + + async closeDataPanel(): Promise { + await this.clickDataPanelClose(); + } + + async removeLabel(label: string): Promise { + await this.clickDataPanelRemoveLabelByLabel(label); + await waitForElementToNotBeVisible(this.dataPanelRemoveLabelByLabel(label)); + } + + async addLabel(label: string, hasLabel = true): Promise { + if (hasLabel) { + await this.hoverDataPanelLabel(); + } + + await this.clickDataPanelAddLabel(); + await this.fillDataPanelAddLabelInput(label); + await this.clickDataPanelAddLabelConfirm(); + } + + async setAttribute(key: string, value: string): Promise { + await this.hoverDataPanelAttribute(key); + await this.clickDataPanelSetAttribute(); + await this.fillDataPanelSetAttributeInput(value); + await this.clickDataPanelSetAttributeConfirm(); + await waitForElementToNotBeVisible(this.dataPanelSetAttributeConfirm); + } + + async addAttribute(key: string, value: string): Promise { + await this.clickDataPanelAddAttribute(); + await this.fillDataPanelAddAttributeKey(key); + await this.fillDataPanelAddAttributeValue(value); + await this.clickDataPanelAddAttributeConfirm(); + await waitForElementToNotBeVisible(this.dataPanelAddAttributeConfirm); + } + + async removeAttribute(key: string): Promise { + await this.hoverDataPanelAttribute(key); + await this.clickDataPanelDeleteAttribute(); + await this.clickDataPanelDeleteAttributeConfirm(); + await waitForElementToNotBeVisible(this.dataPanelDeleteAttributeConfirm); + } } \ No newline at end of file diff --git a/e2e/logic/POM/graphPage.ts b/e2e/logic/POM/graphPage.ts index d6687c36..c6c207d3 100644 --- a/e2e/logic/POM/graphPage.ts +++ b/e2e/logic/POM/graphPage.ts @@ -1,687 +1,421 @@ -/* eslint-disable prefer-destructuring */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable no-plusplus */ -/* eslint-disable no-await-in-loop */ -/* eslint-disable arrow-body-style */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Locator, Download } from "@playwright/test"; -import BasePage from "@/e2e/infra/ui/basePage"; -import { interactWhenVisible, waitForElementToBeVisible, waitForTimeOut } from "@/e2e/infra/utils"; - -export default class GraphPage extends BasePage { - - private get graphsMenu(): Locator { - return this.page.getByRole("combobox"); - } - - private get manageGraphBtn(): Locator { - return this.page.locator("//button[contains(text(), 'Manage Graphs')]") - } - - private get deleteGraphBtn(): Locator { - return this.page.locator("//div[contains(@id, 'tableComponent')]//button[contains(text(), 'Delete')]") - } - - private get addGraphBtnInNavBar(): Locator { - return this.page.locator("//*[contains(@class, 'Header')]//button[contains(text(), 'Create New Graph')]"); - } - - private get addGraphBtnInGraphManager(): Locator { - return this.page.locator("//div[contains(@id, 'graphManager')]//button[contains(@aria-label, 'Create New Graph')]"); - } - - private get graphNameInput(): Locator { - return this.page.getByRole("textbox"); - } - - private get createGraphBtn(): Locator { - return this.page.locator("//div[@id='dialog']//button[contains(text(), 'Create your Graph')]"); - } - - private get exportDataBtn(): Locator { - return this.page.locator("//button[contains(text(), 'Export Data')]"); - } - - private get exportDataConfirmBtn(): Locator { - return this.page.getByRole("button", { name: "Download" }); - } - - private get findGraphInMenu(): (graph: string) => Locator { - return (graph: string) => this.page.getByRole('row', { name: graph, exact: true }); - } +/* eslint-disable no-await-in-loop */ +import { Download, Locator } from "@playwright/test"; +import { waitForElementToBeVisible, waitForElementToBeEnabled, waitForElementToNotBeVisible, interactWhenVisible } from "@/e2e/infra/utils"; +import Page from "./page"; - private get checkGraphInMenu(): (graph: string) => Locator { - return (graph: string) => this.findGraphInMenu(graph).getByRole('checkbox'); - } +export default class GraphPage extends Page { - private get deleteAllGraphInMenu(): Locator { - return this.page.getByRole('row', { name: 'Name', exact: true }).getByRole('checkbox'); + // TABS + private get graphTab(): Locator { + return this.page.getByTestId("graphTab"); } - private get graphMenuElements(): Locator { - return this.page.getByRole('row'); + private get tableTab(): Locator { + return this.page.getByTestId("tableTab"); } - private get deleteGraphConfirmBtn(): Locator { - return this.page.locator("//button[contains(text(), 'Delete Graph')]") + private get metadataTab(): Locator { + return this.page.getByTestId("metadataTab"); } - private get errorNotification(): Locator { - return this.page.locator("//ol//li//div[text()='Error']") + // EDITOR + public get editorInput(): Locator { + return this.page.getByTestId(`editorInput`); } - private get queryInput(): Locator { - return this.page.locator("#editor-container") + public get editorRun(): Locator { + return this.page.getByTestId(`editorRun`); } - private get queryRunBtn(): Locator { - return this.page.locator("//button[text()='Run']"); + public get editorMaximize(): Locator { + return this.page.getByTestId(`editorMaximize`); } - protected get canvasElement(): Locator { - return this.page.locator("//div[contains(@class, 'force-graph-container')]//canvas"); + // QUERY HISTORY + public get queryHistory(): Locator { + return this.page.getByTestId(`queryHistory`); } - private get selectBtnFromGraphManager(): (buttonNumber: string) => Locator { - return (buttonNumber: string) => this.page.locator(`//div[@id='graphManager']//button[${buttonNumber}]`); + public get queryHistorySearch(): Locator { + return this.page.getByTestId(`queryHistorySearch`); } - private get selectGraphList(): (graph: string, label: string) => Locator { - return (graph: string, label: string) => this.page.locator(`//ul[@id='${label}List']/div[descendant::text()[contains(., '${graph}')]]`); + public get queryHistoryButtonByIndex(): Locator { + return this.page.getByTestId(`queryHistoryButtonByIndex`); } - private get graphSelectSearchInput(): (label: string) => Locator { - return (label: string) => this.page.locator(`//div[@id='${label}Search']//input`); + public get queryHistoryEditorInput(): Locator { + return this.page.getByTestId(`queryHistoryEditorInput`); } - private get canvasElementSearchInput(): Locator { - return this.page.locator("//div[@id='elementCanvasSearch']//input"); + public get queryHistoryEditorRun(): Locator { + return this.page.getByTestId(`queryHistoryEditorRun`); } - private get canvasElementSearchBtn(): Locator { - return this.page.locator("//div[@id='elementCanvasSearch']//button"); + async getBoundingBoxCanvasElement(): Promise { + const boundingBox = await interactWhenVisible(this.canvasElement, (el) => el.boundingBox(), "Canvas Element"); + return boundingBox; } - private get nodeCanvasToolTip(): Locator { - return this.page.locator("//div[contains(@class, 'float-tooltip-kap')]"); + async getAttributeCanvasElement(attribute: string): Promise { + const attributeValue = await interactWhenVisible(this.canvasElement, (el) => el.getAttribute(attribute), "Canvas Element"); + return attributeValue ?? ""; } - private get reloadGraphListBtn(): (index: string) => Locator { - return (index: string) => this.page.locator(`//div[@id='graphManager']//button[${index}]`); + async fillCreateInput(text: string): Promise { + await interactWhenVisible(this.createInput("Graph"), (el) => el.fill(text), "Create Graph Input"); } - private get zoomInBtn(): Locator { - return this.page.locator("//button[contains(., 'Zoom In')]"); + async fillSearch(text: string): Promise { + await interactWhenVisible(this.search("Graph"), (el) => el.fill(text), "Search Graph"); } - private get zoomOutBtn(): Locator { - return this.page.locator("//button[contains(., 'Zoom Out')]"); + async fillInput(text: string): Promise { + await interactWhenVisible(this.input("Graph"), (el) => el.fill(text), "Input Graphs"); } - private get fitToSizeBtn(): Locator { - return this.page.locator("//button[contains(., 'Fit To Size')]"); + async fillQueryHistorySearch(text: string): Promise { + await interactWhenVisible(this.queryHistorySearch, (el) => el.fill(text), "Query History Search"); } - private get editBtnInGraphListMenu(): (graph: string) => Locator { - return (graph: string) => this.page.locator(`//table//tbody/tr[@data-id='${graph}']//td[2]//button`); + async fillElementCanvasSearch(text: string): Promise { + await interactWhenVisible(this.elementCanvasSearch("Graph"), (el) => el.fill(text), "Element Canvas Search"); } - private get editInputInGraphListMenu(): (graph: string) => Locator { - return (graph: string) => this.page.locator(`//table//tbody/tr[@data-id='${graph}']//td[2]//input`); + async clickCanvasElement(x: number, y: number): Promise { + await interactWhenVisible(this.canvasElement, (el) => el.click({ position: { x, y }, button: "right" }), "Canvas Element"); } - private get editSaveBtnInGraphListMenu(): (graph: string) => Locator { - return (graph: string) => this.page.locator(`//table//tbody/tr[@data-id='${graph}']//td[2]//button[1]`); + async clickEditorInput(): Promise { + await interactWhenVisible(this.editorInput, (el) => el.click(), "Editor Input"); } - private get valueCellByAttributeInDataPanel(): (attribute: string) => Locator { - return (attribute: string) => this.page.locator(`//div[contains(@id, 'graphDataPanel')]//tbody//tr[td[contains(text(), '${attribute}')]]/td[last()]/button`); + async clickCreate(): Promise { + await interactWhenVisible(this.create("Graph"), (el) => el.click(), "Create Graph"); } - private get nodesGraphStats(): Locator { - return this.page.locator("//div[@id='graphStats']//span[1]"); + async clickCreateConfirm(): Promise { + await interactWhenVisible(this.createConfirm("Graph"), (el) => el.click(), "Create Graph Confirm"); } - private get edgesGraphStats(): Locator { - return this.page.locator("//div[@id='graphStats']//span[2]"); + async clickCreateCancel(): Promise { + await interactWhenVisible(this.createCancel("Graph"), (el) => el.click(), "Create Graph Cancel"); } - private get deleteNodeInGraphDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[contains(text(), "Delete Node")]'); + async clickDelete(): Promise { + await interactWhenVisible(this.delete("Graph"), (el) => el.click(), "Delete Graph"); } - private get confirmDeleteNodeInDataPanel(): Locator { - return this.page.locator('//div[@role="dialog"]//button[contains(text(), "Delete")]'); + async clickDeleteConfirm(): Promise { + await interactWhenVisible(this.deleteConfirm("Graph"), (el) => el.click(), "Confirm Delete Graph"); } - private get deleteRelationInGraphDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[contains(text(), "Delete Relation")]'); + async clickDeleteCancel(): Promise { + await interactWhenVisible(this.deleteCancel("Graph"), (el) => el.click(), "Cancel Delete Graph"); } - private get deleteNodeInCanvasPanel(): Locator { - return this.page.locator('//button[normalize-space(text()) = "Delete"]'); + async clickExport(): Promise { + await interactWhenVisible(this.export("Graph"), (el) => el.click(), "Export Graph"); } - private get addBtnInHeaderGraphDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[normalize-space(text()) = "Add"]'); + async clickExportConfirm(): Promise { + await interactWhenVisible(this.exportConfirm("Graph"), (el) => el.click(), "Confirm Export Graph"); } - private get graphDataPanelHeaderInput(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//input'); + async clickExportCancel(): Promise { + await interactWhenVisible(this.exportCancel("Graph"), (el) => el.click(), "Cancel Export Graph"); } - private get saveBtnInGraphHeaderDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[contains(text(), "Save")]'); + async clickSelect(): Promise { + await interactWhenVisible(this.select("Graph"), (el) => el.click(), "Select Graph"); } - private get headerDataPanelList(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//ul'); + async clickSelectItem(graphName: string): Promise { + await interactWhenVisible(this.selectItem("Graph", graphName), (el) => el.click(), `Select Graph Item ${graphName}`); } - private get labelsInCanvas(): Locator { - return this.page.locator('//div[contains(@id, "LabelsPanel")]//ul/li'); + async clickSearch(): Promise { + await interactWhenVisible(this.search("Graph"), (el) => el.click(), "Search Graph"); } - private get labelsInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//ul//li'); + async clickElementCanvasAdd(): Promise { + await interactWhenVisible(this.elementCanvasAdd("Graph"), (el) => el.click(), "Add Element"); } - private get deleteFirstLabelDataPanelBtn(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//ul//li[1]//button'); + async clickElementCanvasAddNode(): Promise { + await interactWhenVisible(this.elementCanvasAddNode("Graph"), (el) => el.click(), "Add Node"); } - private get keyInputInDataPanel(): Locator { - return this.page.locator('(//div[contains(@id, "graphDataPanel")]//tr//input)[1]'); + async clickElementCanvasAddEdge(): Promise { + await interactWhenVisible(this.elementCanvasAddEdge("Graph"), (el) => el.click(), "Add Edge"); } - private get valueInputInDataPanel(): Locator { - return this.page.locator('(//div[contains(@id, "graphDataPanel")]//tr//input)[2]'); + async clickDeleteElement(): Promise { + await interactWhenVisible(this.deleteElement("Graph"), (el) => el.click(), "Delete Element"); } - private get addAttributeBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[contains(text(), "Add Attribute")]'); + async clickDeleteElementConfirm(): Promise { + await interactWhenVisible(this.deleteElementConfirm("Graph"), (el) => el.click(), "Confirm Delete Element"); } - private get saveAttributeBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//button[1]'); + async clickDeleteElementCancel(): Promise { + await interactWhenVisible(this.deleteElementCancel("Graph"), (el) => el.click(), "Cancel Delete Element"); } - private get deleteLastAttributeInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//td[1]//button[2]'); + async clickAnimationControl(): Promise { + await interactWhenVisible(this.animationControl("Graph"), (el) => el.click(), "Animation Control"); } - private get modifyLastAttributeInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//td[1]//button[1]'); + async clickZoomInControl(): Promise { + await interactWhenVisible(this.zoomInControl("Graph"), (el) => el.click(), "Zoom In Control"); } - private get valueInputLastAttributeInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//td//input'); + async clickZoomOutControl(): Promise { + await interactWhenVisible(this.zoomOutControl("Graph"), (el) => el.click(), "Zoom Out Control"); } - private get undoBtnInNotification(): Locator { - return this.page.locator('//ol//li//button[contains(text(), "Undo")]'); + async clickCenterControl(): Promise { + await interactWhenVisible(this.centerControl("Graph"), (el) => el.click(), "Center Control"); } - private get lastAttributeValueBtn(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//td[3]//button'); + async clickGraphTab(): Promise { + await interactWhenVisible(this.graphTab, (el) => el.click(), "Graph Tab"); } - private get lastAttributeValue(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//tr[last()]//td[1]'); + async clickTableTab(): Promise { + await interactWhenVisible(this.tableTab, (el) => el.click(), "Table Tab"); } - private get attributesStatsInDataPanel(): Locator { - return this.page.locator('(//div[contains(@id, "graphDataPanel")]//p)[2]'); + async clickMetadataTab(): Promise { + await interactWhenVisible(this.metadataTab, (el) => el.click(), "Metadata Tab"); } - private get deleteRelationBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "graphDataPanel")]//button[contains(text(), "Delete Relation")]'); + async clickElementCanvasSuggestionByName(name: string): Promise { + await interactWhenVisible(this.elementCanvasSuggestionByName("Graph", name), (el) => el.click(), `Element Canvas Suggestion ${name}`); } - private get relationshipTypesPanelBtn(): Locator { - return this.page.locator('//div[contains(@id, "RelationshipTypesPanel")]//button'); + async clickLabelsButtonByLabel(label: "RelationshipTypes" | "Labels", name: string): Promise { + await interactWhenVisible(this.labelsButtonByName("Graph", label, name), (el) => el.click(), `Labels Panel Button ${label} ${name}`); } - private get animationControlBtn(): Locator { - return this.page.locator('//div[contains(@id, "canvasPanel")]//button[@role="switch"]'); + async clickEditorRun(): Promise { + await interactWhenVisible(this.editorRun, (el) => el.click(), "Editor Run"); } - private get labelsPanelBtn(): Locator { - return this.page.locator('//div[contains(@id, "LabelsPanel")]//button'); + async clickManage(): Promise { + await interactWhenVisible(this.manage("Graph"), (el) => el.click(), "Manage Graphs Button"); } - private get querySearchList(): Locator { - return this.page.locator("//div[contains(@class, 'tree')]"); + async clickTableCheckboxByName(name: string): Promise { + await interactWhenVisible(this.tableCheckboxByName("Graph", name), (el) => el.click(), `Table Graphs Checkbox ${name}`); } - private get querySearchListItems(): Locator { - return this.page.locator("//div[contains(@class, 'tree')]//div[contains(@class, 'contents')]"); + async clickReloadList(): Promise { + await interactWhenVisible(this.reloadList("Graph"), (el) => el.click(), "Reload Graphs List"); } - async insertGraphInSearchInput(graph: string, label: string): Promise { - await interactWhenVisible(this.graphSelectSearchInput(label), el => el.fill(graph), "graph search input"); + async clickEditButton(): Promise { + await interactWhenVisible(this.editButton("Graph"), (el) => el.click(), "Edit Button Graphs"); } - async insertElementInCanvasSearch(node: string): Promise { - await interactWhenVisible(this.canvasElementSearchInput, el => el.fill(node), "canvas element search input"); + async clickSaveButton(): Promise { + await interactWhenVisible(this.saveButton("Graph"), (el) => el.click(), "Save Button Graphs"); } - async clickOnElementSearchInCanvas(): Promise { - await interactWhenVisible(this.canvasElementSearchBtn, el => el.click(), "canvas element search button"); + async hoverCanvasElement(x: number, y: number): Promise { + await interactWhenVisible(this.canvasElement, (el) => el.hover({ position: { x, y } }), "Canvas Element"); } - async reloadGraphList(role: string = "admin"): Promise { - const index = role === 'readonly' ? "1" : "2"; - await interactWhenVisible(this.reloadGraphListBtn(index), el => el.click(), "reload graph button"); + async hoverTableRowByName(name: string): Promise { + await interactWhenVisible(this.tableRowByName("Graph", name), (el) => el.hover(), `Table Graphs Row ${name}`); } - async clickOnZoomIn(): Promise { - await interactWhenVisible(this.zoomInBtn, el => el.click(), "zoom in button"); + async isVisibleSelectItem(name: string): Promise { + const isVisible = await waitForElementToBeVisible(this.selectItem("Graph", name)); + return isVisible; } - async clickOnZoomOut(): Promise { - await interactWhenVisible(this.zoomOutBtn, el => el.click(), "zoom out button"); + async isVisibleLabelsButtonByName(label: "RelationshipTypes" | "Labels", name: string): Promise { + const isVisible = await waitForElementToBeVisible(this.labelsButtonByName("Graph", label, name)); + return isVisible; } - async clickOnFitToSize(): Promise { - await interactWhenVisible(this.fitToSizeBtn, el => el.click(), "fit to size button"); - await this.waitForCanvasAnimationToEnd(); + async isVisibleEditButton(): Promise { + const isVisible = await waitForElementToBeVisible(this.editButton("Graph")); + return isVisible; } - async clickAddGraphBtnInNavBar(): Promise { - await interactWhenVisible(this.addGraphBtnInNavBar, el => el.click(), "add graph in nav bar button"); + async isVisibleToast(): Promise { + const isVisible = await waitForElementToBeVisible(this.toast); + return isVisible; } - async openGraphsMenu(): Promise { - await interactWhenVisible(this.graphsMenu, el => el.click(), "graphs menu combobox"); + async isVisibleNodeCanvasToolTip(): Promise { + const isVisible = await waitForElementToBeVisible(this.nodeCanvasToolTip); + return isVisible; } - async clickManageGraphButton(): Promise { - await interactWhenVisible(this.manageGraphBtn, el => el.click(), "manage graphs button"); + async getNodeCanvasToolTipContent(): Promise { + const content = await interactWhenVisible(this.nodeCanvasToolTip, (el) => el.textContent(), "Node Canvas Tooltip"); + return content; } - async clickDeleteGraphButton(): Promise { - await interactWhenVisible(this.deleteGraphBtn, el => el.click(), "delete graph button"); + async getNodeCountContent(): Promise { + const count = await interactWhenVisible(this.nodesCount("Graph"), (el) => el.textContent(), "Nodes Count"); + return count?.split(" ")[1] ?? "0"; } - async insertGraphName(name: string): Promise { - await interactWhenVisible(this.graphNameInput, el => el.fill(name), "graph name input"); + async getEdgesCountContent(): Promise { + const count = await interactWhenVisible(this.edgesCount("Graph"), (el) => el.textContent(), "Edges Count"); + return count?.split(" ")[1] ?? "0"; } - async clickCreateGraphButton(): Promise { - await interactWhenVisible(this.createGraphBtn, el => el.click(), "create graph button"); + async searchElementInCanvas(name: string): Promise { + await this.fillElementCanvasSearch(name); + await this.clickElementCanvasSuggestionByName(name); } - async clickExportDataButton(): Promise { - await interactWhenVisible(this.exportDataBtn, el => el.click(), "export data button"); + async verifyGraphExists(graphName: string): Promise { + await this.clickSelect(); + await this.fillSearch(graphName); + const isVisible = await this.isVisibleSelectItem(graphName); + return isVisible; } - async clickExportDataConfirmButton(): Promise { - await interactWhenVisible(this.exportDataConfirmBtn, el => el.click(), "confirm export data button"); + async addGraph(graphName: string): Promise { + await this.clickCreate(); + await this.fillCreateInput(graphName); + await this.clickCreateConfirm(); + await waitForElementToNotBeVisible(this.create("Graph")); } - async confirmGraphDeletion(): Promise { - await interactWhenVisible(this.deleteGraphConfirmBtn, el => el.click(), "confirm delete graph button"); + async insertQuery(query: string): Promise { + await this.clickEditorInput(); + await this.page.keyboard.type(query); } - async clickRunQuery(waitForAnimation = true): Promise { - await interactWhenVisible(this.queryRunBtn, el => el.click(), "run query button"); + async clickRunQuery(waitForAnimation = false): Promise { + await this.clickEditorRun(); + await waitForElementToBeEnabled(this.editorRun); if (waitForAnimation) { await this.waitForCanvasAnimationToEnd(); } } - async clickOnQueryInput(): Promise { - await interactWhenVisible(this.queryInput, el => el.click(), "query input"); - } - - async clickGraphCheckbox(graph: string): Promise { - const checkbox = this.checkGraphInMenu(graph); - await interactWhenVisible(checkbox, el => el.click(), `checkbox for graph "${graph}"`); - } - - async clickSelectBtnFromGraphManager(role: string = "admin"): Promise { - const index = role === 'readonly' ? "2" : "3"; - const button = this.selectBtnFromGraphManager(index); - await interactWhenVisible(button, el => el.click(), `graph manager select button #${index}`); - } - - async clickDeleteAllGraphsCheckbox(): Promise { - await interactWhenVisible(this.deleteAllGraphInMenu, el => el.click(), "delete all graphs checkbox"); - } - - async hoverOverGraphInMenu(graph: string): Promise { - const row = this.findGraphInMenu(graph); - await interactWhenVisible(row, el => el.hover(), `graph row for "${graph}"`); - } - - async clickEditBtnInGraphListMenu(graph: string): Promise { - const editBtn = this.editBtnInGraphListMenu(graph); - await interactWhenVisible(editBtn, el => el.click(), `edit button for graph "${graph}"`); - } - - async fillEditInputInGraphListMenu(graph: string, newName: string): Promise { - const input = this.editInputInGraphListMenu(graph); - await interactWhenVisible(input, el => el.fill(newName), `edit input for graph "${graph}"`); - } - - async clickEditSaveBtnInGraphListMenu(graph: string): Promise { - const saveBtn = this.editSaveBtnInGraphListMenu(graph); - await interactWhenVisible(saveBtn, el => el.click(), `save button for graph "${graph}"`); - } - - async clickGraphFromList(graph: string, label: string): Promise { - const graphItem = this.selectGraphList(graph, label); - - await interactWhenVisible(graphItem, el => el.click(), `graph item "${graph}" in graph list`); - } - - async clickAddGraphBtnInGraphManager(): Promise { - await interactWhenVisible(this.addGraphBtnInGraphManager, el => el.click(), "add graph button in graph manager"); - } - - async clickDeleteNodeInGraphDataPanel(): Promise { - await interactWhenVisible(this.deleteNodeInGraphDataPanel, el => el.click(), "delete node in data panel"); - } - - async clickConfirmDeleteNodeInDataPanel(): Promise { - await interactWhenVisible(this.confirmDeleteNodeInDataPanel, el => el.click(), "confirm delete in data panel"); - } - - async clickDeleteRelationInGraphDataPanel(): Promise { - await interactWhenVisible(this.deleteRelationInGraphDataPanel, el => el.click(), "delete relation in data panel"); - } - - async clickDeleteNodeInCanvasPanel(): Promise { - await interactWhenVisible(this.deleteNodeInCanvasPanel, el => el.click(), "delete node in canvas panel"); - } - - async clickAddBtnInHeaderGraphDataPanel(): Promise { - await interactWhenVisible(this.addBtnInHeaderGraphDataPanel, el => el.click(), "add button node in graph data panel"); - } - - async fillGraphDataPanelHeaderInput(label: string): Promise { - await interactWhenVisible(this.graphDataPanelHeaderInput, el => el.fill(label), "graph data panel header input"); - } - - async clickSaveBtnInGraphHeaderDataPanel(): Promise { - await interactWhenVisible(this.saveBtnInGraphHeaderDataPanel, el => el.click(), "save label button in graph data panel"); - } - - async hoverOnHeaderDataPanelList(): Promise { - await interactWhenVisible(this.headerDataPanelList, async (el) => { - await el.hover(); - }, `Header data panel list`); + async exportGraphByName(graphName: string): Promise { + await this.clickSelect(); + await this.clickManage(); + await this.clickTableCheckboxByName(graphName); + await this.clickExport(); + const [download] = await Promise.all([ + this.page.waitForEvent("download"), + this.clickExportConfirm(), + ]); + return download; } - async getLastLabelInCanvas(): Promise { - const text = await interactWhenVisible(this.labelsInCanvas.last(), el => el.textContent(), "last label in canvas"); - return text; + async reloadGraphList(): Promise { + await this.clickReloadList(); + await waitForElementToBeEnabled(this.reloadList("Graph")); } - async getFirstLabelInCanvas(): Promise { - const text = await interactWhenVisible(this.labelsInCanvas.first(), el => el.textContent(), "first label in canvas"); - return text; + async isModifyGraphNameButtonVisible(graphName: string): Promise { + await this.clickSelect(); + await this.clickManage(); + await this.hoverTableRowByName(graphName); + const isVisible = await this.isVisibleEditButton(); + return isVisible; } - async fillkeyInputInDataPanel(key: string): Promise { - await interactWhenVisible(this.keyInputInDataPanel.first(), el => el.fill(key), "key input in data panel"); + async modifyGraphName(oldName: string, newName: string): Promise { + await this.clickSelect(); + await this.clickManage(); + await this.hoverTableRowByName(oldName); + await this.clickEditButton(); + await this.fillInput(newName); + await this.clickSaveButton(); + await waitForElementToNotBeVisible(this.saveButton("Graph")); } - async fillValueInputInDataPanel(key: string): Promise { - await interactWhenVisible(this.valueInputInDataPanel.first(), el => el.fill(key), "value input in data panel"); + async selectGraphByName(graphName: string): Promise { + await this.clickSelect(); + await this.fillSearch(graphName); + await this.clickSelectItem(graphName); } - async getLabesCountlInDataPanel(): Promise { - await this.page.waitForTimeout(500); - const count = await this.labelsInDataPanel.count(); + async getNodesCount(): Promise { + const count = await this.getNodeCountContent(); return count; } - async getLabesCountlInCanvas(): Promise { - await this.page.waitForTimeout(500); - const count = await this.labelsInCanvas.count(); + async getEdgesCount(): Promise { + const count = await this.getEdgesCountContent(); return count; } - async clickDeleteBtnInFirstLabelDataPanel(): Promise { - await interactWhenVisible(this.deleteFirstLabelDataPanelBtn, el => el.click(), "delete button for first label in data panel"); - } - - async clickAddAttributeBtnInDataPanel(): Promise { - await interactWhenVisible(this.addAttributeBtnInDataPanel, el => el.click(), "add attribute button in data panel"); - } - - async clickSaveAttributeBtnInDataPanel(): Promise { - await interactWhenVisible(this.saveAttributeBtnInDataPanel, el => el.click(), "save attribute button in data panel"); - } - - async clickDeleteLastAttributeInDataPanel(): Promise { - await interactWhenVisible(this.deleteLastAttributeInDataPanel, el => el.click(), "delete last attribute button in data panel"); - } - - async clickModifyLastAttributeInDataPanel(): Promise { - await interactWhenVisible(this.modifyLastAttributeInDataPanel, el => el.click(), "modify last attribute button in data panel"); - } - - async fillValueInputLastAttributeInDataPanel(value: string): Promise { - await interactWhenVisible(this.valueInputLastAttributeInDataPanel, el => el.fill(value), "value input last attribute in data panel"); - } - - async clickUndoBtnInNotification(): Promise { - await interactWhenVisible(this.undoBtnInNotification, el => el.click(), "undo button in notification"); - } - - async getLastAttributeValue(): Promise { - const text = await interactWhenVisible(this.lastAttributeValueBtn, el => el.innerText(), "last attribute value in data panel"); - return text; - } - - async hoverOnLastAttributeInDataPanel(): Promise { - await interactWhenVisible(this.lastAttributeValue, el => el.hover(), "hover on last attribute in data panel"); - } - - async getAttributesStatsInDataPanel(): Promise { - return await interactWhenVisible(this.attributesStatsInDataPanel, el => el.innerText(), "attributes stats in data panel"); - } - - async clickDeleteRelationBtnInDataPanel(): Promise { - await interactWhenVisible(this.deleteRelationBtnInDataPanel, el => el.click(), "delete relation button in data panel"); - } - - async clickRelationshipTypesPanelBtn(): Promise { - await interactWhenVisible(this.relationshipTypesPanelBtn, el => el.click(), "relationship types panel button"); - } - - async getRelationshipTypesPanelBtn(): Promise { - const text = await interactWhenVisible(this.relationshipTypesPanelBtn, el => el.textContent(), "relationship types panel button"); - return text; - } - - async isRelationshipTypesPanelBtnHidden(): Promise { - const isHidden = await this.relationshipTypesPanelBtn.isHidden(); - return isHidden; - } - - async clickAnimationControlPanelbtn(): Promise { - await interactWhenVisible(this.animationControlBtn, el => el.click(), "animation control button"); - } - - async getAnimationControlPanelState(): Promise { - return await this.animationControlBtn.getAttribute('data-state'); - } - - async clickLabelsPanelBtn(): Promise { - await interactWhenVisible(this.labelsPanelBtn, el => el.click(), "Labels panel button"); - } - - async getLabelsPanelBtn(): Promise { - return await interactWhenVisible(this.labelsPanelBtn, el => el.textContent(), "Labels panel button"); - } - - async getDataCellByAttrInDataPanel(attribute: string): Promise { - return await interactWhenVisible(this.valueCellByAttributeInDataPanel(attribute), el => el.textContent(), "value cell by attribute button"); - } - - async getNodesGraphStats(): Promise { - await this.page.waitForTimeout(500); - return await interactWhenVisible(this.nodesGraphStats, el => el.textContent(), "node graph stats button"); - } - - async getEdgesGraphStats(): Promise { - await this.page.waitForTimeout(500); - return await interactWhenVisible(this.edgesGraphStats, el => el.textContent(), "edges graph stats button"); - } - - async getQuerySearchListText(): Promise { - await waitForElementToBeVisible(this.querySearchList); - const elements = this.querySearchListItems; - const count = await elements.count(); - const texts: string[] = []; - - for (let i = 0; i < count; i++) { - const item = elements.nth(i); - const text = await interactWhenVisible(item, el => el.textContent(), `Query search list item #${i}`); - if (text) texts.push(text.trim()); - } - - return texts; - } - - async countGraphsInMenu(): Promise { - await waitForTimeOut(this.page, 1000); - - if (await this.graphsMenu.isEnabled()) { - await this.openGraphsMenu(); - await this.clickManageGraphButton(); - const count = await this.graphMenuElements.count(); - await this.refreshPage(); - return count; - } - return 0; - } - - async removeAllGraphs(): Promise { - await waitForTimeOut(this.page, 1000); - if (await this.graphsMenu.isEnabled()) { - await this.openGraphsMenu(); - await this.clickManageGraphButton(); - await this.clickDeleteAllGraphsCheckbox() - await this.clickDeleteGraphButton() - await this.confirmGraphDeletion(); - } - } - - async addGraph(graphName: string): Promise { - await this.clickAddGraphBtnInNavBar(); - await this.insertGraphName(graphName); - await this.clickCreateGraphButton(); - } - - async exportGraph(): Promise { - await this.waitForPageIdle(); - const [download] = await Promise.all([ - this.page.waitForEvent('download'), - this.clickExportDataButton(), - this.clickExportDataConfirmButton() - ]); - - return download; - } - - async verifyGraphExists(graph: string): Promise { - await this.page.waitForTimeout(500); - if (await this.graphsMenu.isDisabled()) return false; - - await this.openGraphsMenu(); - await this.clickManageGraphButton(); - const isVisible = await this.findGraphInMenu(graph).isVisible(); - return isVisible; - } - - async modifyGraphName(graph: string, newGraphName: string): Promise { - await this.openGraphsMenu(); - await this.clickManageGraphButton(); - await this.hoverOverGraphInMenu(graph); - await this.clickEditBtnInGraphListMenu(graph); - await this.fillEditInputInGraphListMenu(graph, newGraphName); - await this.clickEditSaveBtnInGraphListMenu(graph); + async deleteElementsByPosition(positions: { x: number, y: number }[]): Promise { + positions.forEach(async (position) => { + await this.elementClick(position.x, position.y); + }); + await this.clickDeleteElement(); + await this.clickDeleteElementConfirm(); + await waitForElementToNotBeVisible(this.deleteElement("Graph")); } - async deleteGraph(graph: string): Promise { - await this.openGraphsMenu(); - await this.clickManageGraphButton(); - await this.clickGraphCheckbox(graph); - await this.clickDeleteGraphButton(); - await this.confirmGraphDeletion(); + async deleteElementByName(name: string): Promise { + await this.searchElementInCanvas(name); + await this.clickDeleteElement(); + await this.clickDeleteElementConfirm(); + await waitForElementToNotBeVisible(this.deleteElement("Graph")); } async getErrorNotification(): Promise { await this.page.waitForTimeout(1000); - const isVisible = await this.errorNotification.isVisible(); + const isVisible = await this.isVisibleToast(); return isVisible; } - async insertQuery(query: string): Promise { - await this.clickOnQueryInput(); - await this.page.keyboard.type(query); - } - - async waitForRunQueryToBeEnabled(): Promise { - const maxRetries = 5; - let retries = 0; - while (await this.queryRunBtn.isDisabled()) { - if (retries++ >= maxRetries) { - throw new Error("Timed out waiting for Run Query button to be enabled."); - } - await this.page.waitForTimeout(1000); - } - } - - async nodeClick(x: number, y: number): Promise { - await this.canvasElement.hover({ position: { x, y } }); - await this.page.waitForTimeout(500); - await this.canvasElement.click({ position: { x, y }, button: 'right' }); - } - - async selectGraphFromList(graph: string, label: string): Promise { - await this.insertGraphInSearchInput(graph, label); - await this.clickGraphFromList(graph, label); - } - - private inferLabelFromGraph(graph: string): string { - if (graph.toLowerCase().includes('schema')) return 'Schema'; - return 'Graph'; - } - - async selectExistingGraph(graph: string, role?: string): Promise{ - const resolvedLabel = this.inferLabelFromGraph(graph); - await this.clickSelectBtnFromGraphManager(role); - await this.selectGraphFromList(graph, resolvedLabel); + async getAnimationControl(): Promise { + const status = await this.getAttributeCanvasElement("data-engine-status"); + return status === "running" } - async searchForElementInCanvas(node: string): Promise { - await this.insertElementInCanvasSearch(node); - await this.clickOnElementSearchInCanvas(); - await this.waitForCanvasAnimationToEnd(); + // 6000 is the timeout for the animation to end + // 1500 is the timeout for the fit to size animation + // 2000 is extra timeout to ensure the animation is over + async waitForCanvasAnimationToEnd(timeout = 9500): Promise { + await this.page.waitForFunction( + (selector: string) => { + const canvas = document.querySelector(selector) as HTMLCanvasElement; + return canvas.getAttribute("data-engine-status") === "stop"; + }, + '.force-graph-container canvas', + { timeout } + ); } - async isNodeCanvasToolTip(): Promise { + async isNodeCanvasToolTipVisible(): Promise { await this.page.waitForTimeout(500); - const isVisible = await this.nodeCanvasToolTip.isVisible(); + const isVisible = await this.isVisibleNodeCanvasToolTip(); return isVisible; } async getNodeCanvasToolTip(): Promise { await this.page.waitForTimeout(1000); - const toolTipText = await this.nodeCanvasToolTip.textContent(); + const toolTipText = await this.getNodeCanvasToolTipContent(); return toolTipText; } // eslint-disable-next-line class-methods-use-this async getCanvasTransform(canvasElement: Locator): Promise { let transformData = null; - for (let attempt = 0; attempt < 3; attempt++) { + for (let attempt = 0; attempt < 3; attempt += 1) { transformData = await canvasElement.evaluate((canvas: HTMLCanvasElement) => { const rect = canvas.getBoundingClientRect(); const ctx = canvas.getContext('2d'); @@ -699,48 +433,78 @@ export default class GraphPage extends BasePage { throw new Error("Canvas transform data not available!"); } - async getNodeScreenPositions(windowKey: 'graph' | 'schema'): Promise { - await this.page.waitForTimeout(2000); + async getNodesScreenPositions(windowKey: 'graph' | 'schema'): Promise { + // Get canvas element and its properties + const canvas = await this.page.evaluate((selector) => { + const canvasElement = document.querySelector(selector); + if (!canvasElement) return null; + const rect = canvasElement.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + left: rect.left, + top: rect.top, + scale: window.devicePixelRatio || 1 + }; + }, ".force-graph-container canvas"); + + if (!canvas) return []; - const graphData = await this.page.evaluate((key) => { - return (window as any)[key]; - }, windowKey); + // Get graph data + const graphData = await this.page.evaluate((key) => (window as any)[key], windowKey); + // Get canvas transform const transformData = await this.getCanvasTransform(this.canvasElement); const { a, e, d, f } = transformData.transform; - const { left, top } = transformData; - const offsets = { - graph: { x: -105, y: -380 }, - schema: { x: -40, y: -370 } - }; + return graphData.elements.nodes.map((node: any) => { + // Calculate node position relative to canvas + const screenX = canvas.left + (node.x * a + e) * canvas.scale; + const screenY = canvas.top + (node.y * d + f) * canvas.scale; - const { x: offsetX, y: offsetY } = offsets[windowKey]; + // Check if node is visible in viewport + const isVisible = ( + screenX >= canvas.left && + screenX <= canvas.left + canvas.width && + screenY >= canvas.top && + screenY <= canvas.top + canvas.height + ); - return graphData.elements.nodes.map((node: any) => ({ - ...node, - screenX: left + node.x * a + e + offsetX, - screenY: top + node.y * d + f + offsetY, - })); + return { + id: node.id, + screenX, + screenY, + isVisible, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + ...node + }; + }); } async getLinksScreenPositions(windowKey: 'graph' | 'schema'): Promise { - await this.page.waitForTimeout(2000); + // Get canvas element and its properties + const canvas = await this.page.evaluate((selector) => { + const canvasElement = document.querySelector(selector); + if (!canvasElement) return null; + const rect = canvasElement.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + left: rect.left, + top: rect.top, + scale: window.devicePixelRatio || 1 + }; + }, ".force-graph-container canvas"); + + if (!canvas) return []; - const graphData = await this.page.evaluate((key) => { - return (window as any)[key]; - }, windowKey); + // Get graph data + const graphData = await this.page.evaluate((key) => (window as any)[key], windowKey); + // Get canvas transform const transformData = await this.getCanvasTransform(this.canvasElement); const { a, e, d, f } = transformData.transform; - const { left, top } = transformData; - - const offsets = { - graph: { x: -105, y: -380 }, - schema: { x: -40, y: -370 } - }; - - const { x: offsetX, y: offsetY } = offsets[windowKey]; return graphData.elements.links.map((link: any) => { const sourceId = typeof link.source === 'object' ? link.source.id : link.source; @@ -749,10 +513,26 @@ export default class GraphPage extends BasePage { const source = graphData.elements.nodes.find((n: any) => n.id === sourceId); const target = graphData.elements.nodes.find((n: any) => n.id === targetId); - const sourceScreenX = left + source.x * a + e + offsetX; - const sourceScreenY = top + source.y * d + f + offsetY; - const targetScreenX = left + target.x * a + e + offsetX; - const targetScreenY = top + target.y * d + f + offsetY; + // Calculate node positions relative to canvas + const sourceScreenX = canvas.left + (source.x * a + e) * canvas.scale; + const sourceScreenY = canvas.top + (source.y * d + f) * canvas.scale; + const targetScreenX = canvas.left + (target.x * a + e) * canvas.scale; + const targetScreenY = canvas.top + (target.y * d + f) * canvas.scale; + + // Calculate midpoint + const midX = (sourceScreenX + targetScreenX) / 2; + const midY = (sourceScreenY + targetScreenY) / 2; + + // Check if link is visible in viewport + const isVisible = ( + (sourceScreenX >= canvas.left && sourceScreenX <= canvas.left + canvas.width) || + (targetScreenX >= canvas.left && targetScreenX <= canvas.left + canvas.width) || + (midX >= canvas.left && midX <= canvas.left + canvas.width) + ) && ( + (sourceScreenY >= canvas.top && sourceScreenY <= canvas.top + canvas.height) || + (targetScreenY >= canvas.top && targetScreenY <= canvas.top + canvas.height) || + (midY >= canvas.top && midY <= canvas.top + canvas.height) + ); return { id: link.id, @@ -762,28 +542,25 @@ export default class GraphPage extends BasePage { sourceScreenY, targetScreenX, targetScreenY, - midX: (sourceScreenX + targetScreenX) / 2, - midY: (sourceScreenY + targetScreenY) / 2, + midX, + midY, + isVisible, + canvasWidth: canvas.width, + canvasHeight: canvas.height, ...link }; }); } async changeNodePosition(fromX: number, fromY: number, toX: number, toY: number): Promise { - const box = (await this.canvasElement.boundingBox())!; - const absStartX = box.x + fromX; - const absStartY = box.y + fromY; - const absEndX = box.x + toX; - const absEndY = box.y + toY; - - await this.page.mouse.move(absStartX, absStartY); + await this.page.mouse.move(fromX, fromY); await this.page.mouse.down(); - await this.page.mouse.move(absEndX, absEndY); + await this.page.mouse.move(toX, toY); await this.page.mouse.up(); - } + } async rightClickAtCanvasCenter(): Promise { - const boundingBox = await this.canvasElement.boundingBox(); + const boundingBox = await this.getBoundingBoxCanvasElement(); if (!boundingBox) throw new Error('Canvas bounding box not found'); const centerX = boundingBox.x + boundingBox.width / 2; const centerY = boundingBox.y + boundingBox.height / 2; @@ -791,100 +568,16 @@ export default class GraphPage extends BasePage { } async hoverAtCanvasCenter(): Promise { - const boundingBox = await this.canvasElement.boundingBox(); + const boundingBox = await this.getBoundingBoxCanvasElement(); if (!boundingBox) throw new Error('Canvas bounding box not found'); const centerX = boundingBox.x + boundingBox.width / 2; const centerY = boundingBox.y + boundingBox.height / 2; await this.page.mouse.move(centerX, centerY); } - async waitForCanvasAnimationToEnd(timeout = 7000): Promise { - await this.page.waitForTimeout(1500); // fit to size animation - - await this.page.waitForFunction( - (selector) => { - const canvas = document.querySelector(selector); - return canvas?.getAttribute("data-engine-status") === "stop"; - }, - '.force-graph-container canvas', - { timeout } - ); - } - - async getCanvasScaling(): Promise<{ scaleX: number; scaleY: number }> { - const { scaleX, scaleY } = await this.canvasElement.evaluate((canvas: HTMLCanvasElement) => { - const ctx = canvas.getContext('2d'); - const transform = ctx?.getTransform(); - return { - scaleX: transform?.a || 1, - scaleY: transform?.d || 1, - }; - }); - return { scaleX, scaleY }; - } - - async deleteNodeViaCanvasPanel(x: number, y: number): Promise { - await this.nodeClick(x, y); - await this.clickDeleteNodeInCanvasPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); - } - - async deleteNode(x: number, y: number): Promise { - await this.nodeClick(x, y); - await this.clickDeleteNodeInGraphDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); - } - - async openDataPanelForElementInCanvas(node: string): Promise { - await this.searchForElementInCanvas(node); - await this.rightClickAtCanvasCenter(); - } - - async modifyLabel(node: string, label: string): Promise { - await this.openDataPanelForElementInCanvas(node); - await this.hoverOnHeaderDataPanelList(); - await this.clickAddBtnInHeaderGraphDataPanel(); - await this.fillGraphDataPanelHeaderInput(label); - await this.clickSaveBtnInGraphHeaderDataPanel(); - await this.waitForPageIdle(); - } - - async deleteRelation(x: number, y: number): Promise { - await this.nodeClick(x, y); - await this.clickDeleteRelationInGraphDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); - } - - async deleteLabel(node: string): Promise { - await this.openDataPanelForElementInCanvas(node); - await this.clickDeleteBtnInFirstLabelDataPanel(); - } - - async addGraphAttribute(node: string, key: string, value: string): Promise { - await this.openDataPanelForElementInCanvas(node); - await this.clickAddAttributeBtnInDataPanel(); - await this.fillkeyInputInDataPanel(key); - await this.fillValueInputInDataPanel(value); - await this.clickSaveAttributeBtnInDataPanel(); - } - - async modifyAttribute(value: string): Promise { - await this.hoverOnLastAttributeInDataPanel(); - await this.clickModifyLastAttributeInDataPanel(); - await this.fillValueInputLastAttributeInDataPanel(value); - await this.clickSaveAttributeBtnInDataPanel(); - await this.waitForPageIdle(); - } - - async deleteGraphAttribute(): Promise { - await this.hoverOnLastAttributeInDataPanel(); - await this.clickDeleteLastAttributeInDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); - } - - async deleteGraphRelation(x: number, y: number): Promise { - await this.nodeClick(x, y); - await this.clickDeleteRelationBtnInDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); + async elementClick(x: number, y: number): Promise { + await this.hoverCanvasElement(x, y); + await this.page.waitForTimeout(500); + await this.clickCanvasElement(x, y); } } \ No newline at end of file diff --git a/e2e/logic/POM/page.ts b/e2e/logic/POM/page.ts new file mode 100644 index 00000000..1836db63 --- /dev/null +++ b/e2e/logic/POM/page.ts @@ -0,0 +1,190 @@ +import BasePage from "@/e2e/infra/ui/basePage"; +import { Locator } from "@playwright/test"; + +type Type = "Graph" | "Schema" + +export default class Page extends BasePage { + // CREATE + public get create(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`create${type}`); + } + + public get createInput(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`create${type}Input`); + } + + public get createConfirm(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`create${type}Confirm`); + } + + public get createCancel(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`create${type}Cancel`); + } + + // DELETE + public get delete(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`delete${type}`); + } + + public get deleteConfirm(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`delete${type}Confirm`); + } + + public get deleteCancel(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`delete${type}Cancel`); + } + + // EXPORT + public get export(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`export${type}`); + } + + public get exportConfirm(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`export${type}Confirm`); + } + + public get exportCancel(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`export${type}Cancel`); + } + + // RELOAD + public get reloadList(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`reload${type}sList`); + } + + // SELECT + public get select(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`select${type}`); + } + + + public get selectItem(): (type: Type, graphName: string) => Locator { + return (type: Type, graphName: string) => this.page.getByTestId(`select${type}Item${graphName}`); + } + + // SEARCH + public get search(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`search${type}`); + } + + // MANAGE + public get manage(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`manage${type}s`); + } + + // TABLE + public get tableCheckbox(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`table${type}sCheckbox`); + } + + public get tableRowByName(): (type: Type, name: string) => Locator { + return (type: Type, name: string) => this.page.getByTestId(`table${type}Row${name}`); + } + + public get tableCheckboxByName(): (type: Type, name: string) => Locator { + return (type: Type, name: string) => this.page.getByTestId(`table${type}Checkbox${name}`); + } + + public get editButton(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`edit${type}Button`); + } + + public get input(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`input${type}`); + } + + public get saveButton(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`save${type}Button`); + } + + public get cancelButton(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`cancel${type}Button`); + } + + // CANVAS TOOLBAR + + // SEARCH + public get elementCanvasSearch(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`elementCanvasSearch${type}`); + } + + public get elementCanvasSuggestionList(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`elementCanvasSuggestionsList${type}`); + } + + public get elementCanvasSuggestionByName(): (type: Type, name: string) => Locator { + return (type: Type, name: string) => this.page.getByTestId(`elementCanvasSuggestion${type}${name}`); + } + + // ADD + public get elementCanvasAdd(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`elementCanvasAdd${type}`); + } + + public get elementCanvasAddNode(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`elementCanvasAddNode${type}`); + } + + public get elementCanvasAddEdge(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`elementCanvasAddEdge${type}`); + } + + // DELETE + public get deleteElement(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`deleteElement${type}`); + } + + public get deleteElementConfirm(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`deleteElementConfirm${type}`); + } + + public get deleteElementCancel(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`deleteElementCancel${type}`); + } + + // LABELS + public get labelsButtonByName(): (type: Type, label: "RelationshipTypes" | "Labels", name: string) => Locator { + return (type: Type, label: "RelationshipTypes" | "Labels", name: string) => this.page.getByTestId(`${label}Button${type}${name}`); + } + + // CANVAS CONTROLS + public get animationControl(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`animationControl${type}`); + } + + public get zoomInControl(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`zoomInControl${type}`); + } + + public get zoomOutControl(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`zoomOutControl${type}`); + } + + public get centerControl(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`centerControl${type}`); + } + + // COUNT + public get nodesCount(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`nodesCount${type}`); + } + + public get edgesCount(): (type: Type) => Locator { + return (type: Type) => this.page.getByTestId(`edgesCount${type}`); + } + + // CANVAS TOOLTIP + public get nodeCanvasToolTip(): Locator { + return this.page.locator("//div[contains(@class, 'float-tooltip-kap')]"); + } + + // CANVAS + public get canvasElement(): Locator { + return this.page.locator("//div[contains(@class, 'force-graph-container')]//canvas"); + } + + // TOAST + public get toast(): Locator { + return this.page.getByTestId(`toast`); + } +} diff --git a/e2e/logic/POM/queryHistoryComponent.ts b/e2e/logic/POM/queryHistoryComponent.ts index 430fc490..608a9876 100644 --- a/e2e/logic/POM/queryHistoryComponent.ts +++ b/e2e/logic/POM/queryHistoryComponent.ts @@ -1,98 +1,78 @@ -import { Locator } from "@playwright/test"; -import GraphPage from "./graphPage"; -import { waitForElementToBeVisible } from "@/e2e/infra/utils"; +// import { Locator } from "@playwright/test"; +// import { waitForElementToBeVisible } from "@/e2e/infra/utils"; +// import GraphPage from "./graphPage"; -export default class QueryHistory extends GraphPage { +// export default class QueryHistory extends GraphPage { - private get queryHistoryDialog(): Locator { - return this.page.locator("//div[contains(@id, 'queryHistory')]"); - } +// private get queryHistoryList(): Locator { +// return this.page.getByTestId("queryHistoryList"); +// } - private get queryHistory(): Locator { - return this.page.locator("//button[contains(text(), 'Query History')]"); - } - - private get queryInHistory(): (query: string) => Locator { - return (query: string) => this.page.locator(`//div[contains(@id, 'queryHistory')]//ul//li[${query}]`); - } - - private get selectQueryInHistoryBtn(): (query: string) => Locator { - return (query: string) => this.page.locator(`//div[contains(@id, 'queryHistory')]//ul//li[${query}]/button`); - } - - private get runBtnInQueryHistory(): Locator { - return this.page.locator("//div[contains(@id, 'queryHistory')]//button[contains(text(), 'Run')]"); - } - - private get queryHistoryTextarea(): Locator { - return this.page.locator("#queryHistoryEditor textarea"); - } - - private get queryHistoryPanel(): Locator { - return this.page.locator("//div[@id='queryHistoryPanel']//ul"); - } - - async clickOnQueryHistory(): Promise { - const isVisible = await waitForElementToBeVisible(this.queryHistory); - if (!isVisible) throw new Error("query history button is not visible!"); - await this.queryHistory.click(); - } - - async selectQueryInHistory(query: string): Promise { - await this.queryInHistory(query).click(); - } - - async getQueryHistory(query: string): Promise { - try { - return await this.queryInHistory(query).isVisible(); - } catch (error) { - return false; - } - } - - async clickOnRunBtnInQueryHistory(): Promise { - await this.runBtnInQueryHistory.click(); - } - - async isQueryHistoryDialog(): Promise { - return await this.queryHistoryDialog.isVisible(); - } - - async ClickOnSelectQueryInHistoryBtn(queryNumber: string): Promise { - await this.selectQueryInHistoryBtn(queryNumber).click(); - } - - async getSelectQueryInHistoryText(queryNumber: string): Promise { - const text = await this.selectQueryInHistoryBtn(queryNumber).textContent(); - return text; - } - - async runAQueryFromHistory(queryNumber: string): Promise { - await this.clickOnQueryHistory(); - await this.ClickOnSelectQueryInHistoryBtn(queryNumber); - await this.clickOnRunBtnInQueryHistory(); - await this.waitForCanvasAnimationToEnd(); - } - - async getQueryHistoryEditor(): Promise { - await this.page.waitForTimeout(500); - return await this.queryHistoryTextarea.inputValue(); - } - - async getQueryHistoryPanel(): Promise { - const rawText = await this.queryHistoryPanel.allTextContents(); - if (!rawText || rawText.length === 0) { - return []; - } - const formattedText = rawText[0] - .replace(/Query internal execution time:.*/, '') - .replace(/([a-z]+: \d+)/gi, '$1\n') - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); +// async clickOnQueryHistory(): Promise { +// const isVisible = await waitForElementToBeVisible(this.queryHistoryList); +// if (!isVisible) throw new Error("query history button is not visible!"); +// await this.queryHistoryList.click(); +// } + +// async selectQueryInHistory(query: string): Promise { +// await this.queryInHistory(query).click(); +// } + +// async getQueryHistory(query: string): Promise { +// try { +// return await this.queryInHistory(query).isVisible(); +// } catch (error) { +// return false; +// } +// } + +// async clickOnRunBtnInQueryHistory(): Promise { +// await this.runBtnInQueryHistory.click(); +// } + +// async isQueryHistoryDialog(): Promise { +// const isVisible = await this.queryHistoryDialog.isVisible(); +// return isVisible; +// } + +// async ClickOnSelectQueryInHistoryBtn(queryNumber: string): Promise { +// await this.selectQueryInHistoryBtn(queryNumber).click(); +// } + +// async getSelectQueryInHistoryText(queryNumber: string): Promise { +// const text = await this.selectQueryInHistoryBtn(queryNumber).textContent(); +// return text; +// } + +// async runAQueryFromHistory(queryNumber: string): Promise { +// await this.clickOnQueryHistory(); +// await this.ClickOnSelectQueryInHistoryBtn(queryNumber); +// await this.clickOnRunBtnInQueryHistory(); +// await this.waitForCanvasAnimationToEnd(); +// } + +// async getQueryHistoryEditor(): Promise { +// await this.page.waitForTimeout(500); +// const text = await this.queryHistoryTextarea.inputValue(); +// return text; +// } + +// async getQueryHistoryPanel(): Promise { +// const rawText = await this.queryHistoryPanel.allTextContents(); + +// if (!rawText || rawText.length === 0) { +// return []; +// } + +// const formattedText = rawText[0] +// .replace(/Query internal execution time:.*/, '') +// .replace(/([a-z]+: \d+)/gi, '$1\n') +// .split('\n') +// .map(line => line.trim()) +// .filter(line => line.length > 0); - return formattedText; - } -} +// return formattedText; +// } +// } diff --git a/e2e/logic/POM/schemaPage.ts b/e2e/logic/POM/schemaPage.ts index 3fcb510b..a483ad44 100644 --- a/e2e/logic/POM/schemaPage.ts +++ b/e2e/logic/POM/schemaPage.ts @@ -1,325 +1,174 @@ -/* eslint-disable prefer-destructuring */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable no-plusplus */ -/* eslint-disable no-await-in-loop */ -/* eslint-disable arrow-body-style */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Locator } from "@playwright/test"; import { interactWhenVisible, waitForElementToBeVisible } from "@/e2e/infra/utils"; -import GraphPage from "./graphPage"; +import Page from "./page"; -export default class SchemaPage extends GraphPage { +export default class SchemaPage extends Page { - private get addSchemaBtnInNavBar(): Locator { - return this.page.getByText("Create New Schema"); + async clickCreate(): Promise { + await interactWhenVisible(this.create("Schema"), (el) => el.click(), "Click Create"); } - private get schemaNameInput(): Locator { - return this.page.getByRole("textbox"); + async clickCreateInput(): Promise { + await interactWhenVisible(this.createInput("Schema"), (el) => el.click(), "Click Create Input"); } - private get createSchemaBtn(): Locator { - return this.page.locator("//div[@id='dialog']//button[contains(text(), 'Create your Schema')]"); + async clickCreateConfirm(): Promise { + await interactWhenVisible(this.createConfirm("Schema"), (el) => el.click(), "Click Create Confirm"); } - private get addNodeBtn(): Locator { - return this.page.locator('button').filter({ hasText: 'Add Node' }); + async clickCreateCancel(): Promise { + await interactWhenVisible(this.createCancel("Schema"), (el) => el.click(), "Click Create Cancel"); } - private get addRelationBtn(): Locator { - return this.page.locator('button').filter({ hasText: 'Add Relation' }); + async clickDelete(): Promise { + await interactWhenVisible(this.delete("Schema"), (el) => el.click(), "Click Delete"); } - private get closeBtnInHeaderDataPanel(): Locator { - return this.page.locator('(//div[contains(@id, "headerDataPanel")]//button)[1]'); + async clickDeleteConfirm(): Promise { + await interactWhenVisible(this.deleteConfirm("Schema"), (el) => el.click(), "Click Delete Confirm"); } - private get addBtnInHeaderDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "headerDataPanel")]//button[contains(text(), "Add")]'); + async clickDeleteCancel(): Promise { + await interactWhenVisible(this.deleteCancel("Schema"), (el) => el.click(), "Click Delete Cancel"); } - private get saveBtnInHeaderDataPanel(): Locator { - return this.page.locator('//div[contains(@id, "headerDataPanel")]//button[contains(text(), "Save")]'); + async clickExport(): Promise { + await interactWhenVisible(this.export("Schema"), (el) => el.click(), "Click Export"); } - private get dataPanelHeaderInput(): Locator { - return this.page.locator('//div[contains(@id, "headerDataPanel")]//input'); + async clickExportConfirm(): Promise { + await interactWhenVisible(this.exportConfirm("Schema"), (el) => el.click(), "Click Export Confirm"); } - private get keyInDataPanel(): (keyIndex: string) => Locator { - return (keyIndex: string) => this.page.locator(`//div[contains(@class, "DataPanel")]//tr[${keyIndex}]//td[1]`); + async clickExportCancel(): Promise { + await interactWhenVisible(this.exportCancel("Schema"), (el) => el.click(), "Click Export Cancel"); } - private get activekeyInputInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[1]//input[not(@disabled)]'); + async clickReloadList(): Promise { + await interactWhenVisible(this.reloadList("Schema"), (el) => el.click(), "Click Reload List"); } - private get activeDescInputInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[3]//input[not(@disabled)]'); + async clickSelect(): Promise { + await interactWhenVisible(this.select("Schema"), (el) => el.click(), "Click Select"); } - private get descInDataPanel(): (descIndex: string) => Locator { - return (descIndex: string) => this.page.locator(`//div[contains(@class, "DataPanel")]//tr[${descIndex}]//td[3]`); + async clickSelectItem(name: string): Promise { + await interactWhenVisible(this.selectItem("Schema", name), (el) => el.click(), "Click Select Item"); } - private get typeActiveBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//td[2]//button[not(@disabled)]'); + async clickSearch(): Promise { + await interactWhenVisible(this.search("Schema"), (el) => el.click(), "Click Search"); } - private get uniqueActiveRadioBtn(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[4]//button[not(@disabled)]'); + async clickManage(): Promise { + await interactWhenVisible(this.manage("Schema"), (el) => el.click(), "Click Manage"); } - private get requiredActiveRadioBtn(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[5]//button[not(@disabled)]'); + async clickTableCheckbox(): Promise { + await interactWhenVisible(this.tableCheckbox("Schema"), (el) => el.click(), "Click Table Checkbox"); } - private get addActiveBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[6]//button[not(@disabled) and contains(text(), "Add")]'); + async clickTableCheckboxByName(name: string): Promise { + await interactWhenVisible(this.tableCheckboxByName("Schema", name), (el) => el.click(), "Click Table Checkbox By Name"); } - private get cancelActiveBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tr//td[6]//button[not(@disabled) and contains(text(), "Cancel")]'); + async clickEditButton(): Promise { + await interactWhenVisible(this.editButton("Schema"), (el) => el.click(), "Click Edit Button"); } - private get createNewNodeBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//button[contains(text(), "Create new node")]'); + async clickInput(): Promise { + await interactWhenVisible(this.input("Schema"), (el) => el.click(), "Click Input"); } - private get createNewEdgeBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//button[contains(text(), "Create new edge")]'); + async clickSaveButton(): Promise { + await interactWhenVisible(this.saveButton("Schema"), (el) => el.click(), "Click Save Button"); } - private get addValueBtnInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//button[contains(text(), "Add Value")]'); + async clickCancelButton(): Promise { + await interactWhenVisible(this.cancelButton("Schema"), (el) => el.click(), "Click Cancel Button"); } - private get deleteValueBtnInDataPanel(): Locator { - return this.page.locator('(//div[contains(@class, "DataPanel")]//tr//td//button)[1]'); + async clickCanvasElement(x: number, y: number): Promise { + await interactWhenVisible(this.canvasElement, (el) => el.click({ position: { x, y }, button: "right" }), "Canvas Element"); } - private get editValueBtnInDataPanel(): Locator { - return this.page.locator('(//div[contains(@class, "DataPanel")]//tr//td//button)[2]'); + async hoverCanvasElement(x: number, y: number): Promise { + await interactWhenVisible(this.canvasElement, (el) => el.hover({ position: { x, y } }), "Canvas Element"); } - private get attributeRows(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//tbody//tr'); + async clickElementCanvasAdd(): Promise { + await interactWhenVisible(this.elementCanvasAdd("Schema"), (el) => el.click(), "Add Element"); } - private get deleteNodeInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//button[contains(text(), "Delete Node")]'); + async clickElementCanvasAddNode(): Promise { + await interactWhenVisible(this.elementCanvasAddNode("Schema"), (el) => el.click(), "Add Node"); } - private get deleteRelationInDataPanel(): Locator { - return this.page.locator('//div[contains(@class, "DataPanel")]//button[contains(text(), "Delete Relation")]'); + async clickElementCanvasAddEdge(): Promise { + await interactWhenVisible(this.elementCanvasAddEdge("Schema"), (el) => el.click(), "Add Edge"); } - private get categoriesPanelBtn(): Locator { - return this.page.locator('//div[contains(@id, "CategoriesPanel")]//button'); + async clickDeleteElement(): Promise { + await interactWhenVisible(this.deleteElement("Schema"), (el) => el.click(), "Delete Element"); } - private get typeSelectSearchInput(): Locator { - return this.page.locator(`//div[@id='TypeSearch']//input`); + async clickDeleteElementConfirm(): Promise { + await interactWhenVisible(this.deleteElementConfirm("Schema"), (el) => el.click(), "Confirm Delete Element"); } - private get selectSearchType(): Locator { - return this.page.locator(`//ul[@id='TypeList']//div[@role="option"]`); + async clickDeleteElementCancel(): Promise { + await interactWhenVisible(this.deleteElementCancel("Schema"), (el) => el.click(), "Cancel Delete Element"); } - async clickAddNewSchemaBtn(): Promise { - await interactWhenVisible(this.addSchemaBtnInNavBar, el => el.click(), "add new schema button"); - } - - async fillSchemaNameInput(schemaName: string): Promise { - await interactWhenVisible(this.schemaNameInput, el => el.fill(schemaName), "schema name input"); - } - - async clickCreateSchemaBtn(): Promise { - await interactWhenVisible(this.createSchemaBtn, el => el.click(), "create schema button"); - } - - async clickAddNode(): Promise { - await interactWhenVisible(this.addNodeBtn, el => el.click(), "add node button"); - } - - async clickAddRelation(): Promise { - await interactWhenVisible(this.addRelationBtn, el => el.click(), "add relation button"); - } - - async clickCloseBtnInHeaderDataPanel(): Promise { - await interactWhenVisible(this.closeBtnInHeaderDataPanel, el => el.click(), "close button in header data panel"); - } - - async clickAddBtnInHeaderDataPanel(): Promise { - await interactWhenVisible(this.addBtnInHeaderDataPanel, el => el.click(), "add button in header data panel"); - } - - async insertDataPanelHeader(title: string): Promise { - await interactWhenVisible(this.dataPanelHeaderInput, el => el.fill(title), "data panel header input"); - } - - async clickSaveBtnInHeaderDataPanel(): Promise { - await interactWhenVisible(this.saveBtnInHeaderDataPanel, el => el.click(), "save button in header data panel"); - } - - async insertActiveKeyInputInDataPanelAttr(key: string): Promise { - await interactWhenVisible(this.activekeyInputInDataPanel, el => el.fill(key), "active key input in data panel"); - } - - async getKeyInDataPanelAttr(keyIndex: string): Promise { - return await interactWhenVisible(this.keyInDataPanel(keyIndex), el => el.textContent(), "key input in data panel"); - } - - async insertActiveDescInputInDataPanelAttr(desc: string): Promise { - await interactWhenVisible(this.activeDescInputInDataPanel, el => el.fill(desc), "desc input in data panel"); - } - - async getDescInDataPanelAttr(descIndex: string): Promise { - return await interactWhenVisible(this.descInDataPanel(descIndex), el => el.textContent(), "desc input in data panel"); - } - - async clickTypeActiveBtnInDataPanel(): Promise { - await interactWhenVisible(this.typeActiveBtnInDataPanel, el => el.click(), "type active button in data panel"); - } - - async clickUniqueActiveRadioBtn(): Promise { - await interactWhenVisible(this.uniqueActiveRadioBtn, el => el.click(), "unique active button in data panel"); - } - - async clickRequiredActiveRadioBtn(): Promise { - await interactWhenVisible(this.requiredActiveRadioBtn, el => el.click(), "required active button in data panel"); - } - - async clickAddActiveBtnInDataPanel(): Promise { - await interactWhenVisible(this.addActiveBtnInDataPanel, el => el.click(), "add active button in data panel"); - } - - async clickCancelActiveBtnInDataPanel(): Promise { - await interactWhenVisible(this.cancelActiveBtnInDataPanel, el => el.click(), "cancel active button in data panel"); - } - - async clickCreateNewNodeBtnInDataPanel(): Promise { - await interactWhenVisible(this.createNewNodeBtnInDataPanel, el => el.click(), "create new node button in data panel"); - } - - async clickCreateNewEdgeBtnInDataPanel(): Promise { - await interactWhenVisible(this.createNewEdgeBtnInDataPanel, el => el.click(), "create new edge button in data panel"); - } - - async clickAddValueBtnInDataPanel(): Promise { - await interactWhenVisible(this.addValueBtnInDataPanel, el => el.click(), "add value button in data panel"); - } - - async clickDeleteValueBtnInDataPanel(): Promise { - await interactWhenVisible(this.deleteValueBtnInDataPanel, el => el.click(), "delete value button in data panel"); - } - - async clickEditValueBtnInDataPanel(): Promise { - await interactWhenVisible(this.editValueBtnInDataPanel, el => el.click(), "edit value button in data panel"); - } - - async hasAttributeRows(): Promise { - const isVisible = await waitForElementToBeVisible(this.attributeRows); - if (!isVisible) return false; - const rows = await this.attributeRows.count(); - return rows > 0; - } - - async getAttributeRowsCount(): Promise { - return await this.attributeRows.count(); - } - - async clickDeleteNodeInDataPanel(): Promise { - await interactWhenVisible(this.deleteNodeInDataPanel, el => el.click(), "delete node in data panel"); + async clickAnimationControl(): Promise { + await interactWhenVisible(this.animationControl("Schema"), (el) => el.click(), "Animation Control"); } - async clickDeleteRelationInDataPanel(): Promise { - await interactWhenVisible(this.deleteRelationInDataPanel, el => el.click(), "delete relation in data panel"); - } - - async clickCategoriesPanelBtn(): Promise { - await interactWhenVisible(this.categoriesPanelBtn, el => el.click(), "categories panel button"); - } - - async getCategoriesPanelBtn(): Promise { - return await interactWhenVisible(this.categoriesPanelBtn, el => el.textContent(), "categories panel button"); + async clickZoomInControl(): Promise { + await interactWhenVisible(this.zoomInControl("Schema"), (el) => el.click(), "Zoom In Control"); } - async fillTypeSelectSearchInput(type: string): Promise { - await interactWhenVisible(this.typeSelectSearchInput, el => el.fill(type), "type search input"); + async clickZoomOutControl(): Promise { + await interactWhenVisible(this.zoomOutControl("Schema"), (el) => el.click(), "Zoom Out Control"); } - async clickSearchedType(): Promise { - await interactWhenVisible(this.selectSearchType, el => el.click(), "type search input"); - } - - async isCategoriesPanelBtnHidden(): Promise { - return await this.categoriesPanelBtn.isHidden(); + async clickCenterControl(): Promise { + await interactWhenVisible(this.centerControl("Schema"), (el) => el.click(), "Center Control"); } - async addSchema(schemaName: string): Promise { - await this.clickAddNewSchemaBtn(); - await this.fillSchemaNameInput(schemaName); - await this.clickCreateSchemaBtn(); + async fillElementCanvasSearch(text: string): Promise { + await interactWhenVisible(this.elementCanvasSearch("Schema"), (el) => el.fill(text), "Element Canvas Search"); } - async addNode(title: string, key: string, type: string, desc: string, unique: boolean, required: boolean): Promise { - await this.clickAddNode(); - await this.clickAddBtnInHeaderDataPanel(); - await this.insertDataPanelHeader(title); - await this.clickSaveBtnInHeaderDataPanel(); - await this.addAttribute(key, type, desc, unique, required); - await this.clickCreateNewNodeBtnInDataPanel(); + async clickElementCanvasSuggestionByName(name: string): Promise { + await interactWhenVisible(this.elementCanvasSuggestionByName("Schema", name), (el) => el.click(), `Element Canvas Suggestion ${name}`); } - async deleteNode(x: number, y: number): Promise{ - await this.nodeClick(x, y); - await this.clickDeleteNodeInDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); + async clickLabelsButtonByLabel(label: "RelationshipTypes" | "Labels", name: string): Promise { + await interactWhenVisible(this.labelsButtonByName("Schema", label, name), (el) => el.click(), `Labels Panel Button ${label} ${name}`); } - async addLabel(title: string): Promise{ - await this.clickAddRelation(); - await this.clickAddBtnInHeaderDataPanel(); - await this.insertDataPanelHeader(title); - await this.clickSaveBtnInHeaderDataPanel(); + async getNodesCount(): Promise { + const count = await interactWhenVisible(this.nodesCount("Schema"), (el) => el.textContent(), "Nodes Count"); + return count?.split(" ")[1] ?? "0"; } - async prepareRelation(title: string, key: string, type: string, desc: string, unique: boolean, required: boolean): Promise { - await this.addLabel(title); - await this.addAttribute(key, type, desc, unique, required); + async getEdgesCount(): Promise { + const count = await interactWhenVisible(this.edgesCount("Schema"), (el) => el.textContent(), "Edges Count"); + return count?.split(" ")[1] ?? "0"; } - - async clickRelationBetweenNodes(): Promise { - const schema = await this.getNodeScreenPositions('schema'); - await this.nodeClick(schema[0].screenX, schema[0].screenY); - await this.nodeClick(schema[1].screenX, schema[1].screenY); - await this.clickCreateNewEdgeBtnInDataPanel(); - } - async deleteRelation(x: number, y: number): Promise { - await this.nodeClick(x, y); - await this.clickDeleteRelationInDataPanel(); - await this.clickConfirmDeleteNodeInDataPanel(); + async isVisibleLabelsButtonByName(label: "RelationshipTypes" | "Labels", name: string): Promise { + const isVisible = await waitForElementToBeVisible(this.labelsButtonByName("Schema", label, name)); + return isVisible; } - async selectTypeFromList(type: string): Promise { - await this.fillTypeSelectSearchInput(type); - await this.clickSearchedType(); + async isVisibleNodeCanvasToolTip(): Promise { + const isVisible = await waitForElementToBeVisible(this.nodeCanvasToolTip); + return isVisible; } - async addAttribute(key: string, type: string, desc: string, unique: boolean, required: boolean): Promise{ - await this.insertActiveKeyInputInDataPanelAttr(key); - await this.clickTypeActiveBtnInDataPanel(); - await this.selectTypeFromList(type); - await this.insertActiveDescInputInDataPanelAttr(desc); - if(unique){ - await this.clickUniqueActiveRadioBtn(); - } - if(required){ - await this.clickRequiredActiveRadioBtn(); - } - await this.clickAddActiveBtnInDataPanel(); + async getNodeCanvasToolTipContent(): Promise { + const content = await interactWhenVisible(this.nodeCanvasToolTip, (el) => el.textContent(), "Node Canvas Tooltip"); + return content; } - } \ No newline at end of file diff --git a/e2e/logic/POM/settingsQueryPage.ts b/e2e/logic/POM/settingsQueryPage.ts index 61a5bbe5..d268e041 100644 --- a/e2e/logic/POM/settingsQueryPage.ts +++ b/e2e/logic/POM/settingsQueryPage.ts @@ -20,12 +20,12 @@ export default class SettingsQueryPage extends GraphPage { return this.page.locator("#increaseTimeoutBtn"); } - private get graphsButton(): Locator { - return this.page.locator("//button[contains(text(), 'Graphs')]"); + private get decreaseLimitBtn(): Locator { + return this.page.locator("#decreaseLimitBtn"); } - async clickOnGraph(): Promise { - await interactWhenVisible(this.graphsButton, el => el.click(), "graph button"); + private get decreaseTimeoutBtn(): Locator { + return this.page.locator("#decreaseTimeoutBtn"); } async clickIncreaseLimit(): Promise { @@ -36,6 +36,14 @@ export default class SettingsQueryPage extends GraphPage { await interactWhenVisible(this.increaseTimeoutBtn, el => el.click(), "increase timeout button"); } + async clickDecreaseLimit(): Promise { + await interactWhenVisible(this.decreaseLimitBtn, el => el.click(), "decrease limit button"); + } + + async clickDecreaseTimeout(): Promise { + await interactWhenVisible(this.decreaseTimeoutBtn, el => el.click(), "decrease timeout button"); + } + async fillTimeoutInput(input: string): Promise { await interactWhenVisible(this.timeoutInput, el => el.fill(input), "time out input"); } @@ -44,19 +52,40 @@ export default class SettingsQueryPage extends GraphPage { await interactWhenVisible(this.limitInput, el => el.fill(input), "limit input"); } - async addLimit(limit?: number): Promise { - if (limit) { + async fillLimit(limit: number): Promise { await this.fillLimitInput(limit.toString()) - } else { - await this.clickIncreaseLimit(); - } } - async addTimeout(timeout?: number): Promise { - if (timeout) { - await this.fillTimeoutInput(timeout.toString()) - } else { - await this.clickIncreaseTimeout(); - } + async fillTimeout(timeout: number): Promise { + await this.fillTimeoutInput(timeout.toString()) + } + + async increaseLimit(): Promise { + await this.clickIncreaseLimit(); + } + + async increaseTimeout(): Promise { + await this.clickIncreaseTimeout(); + } + + async decreaseLimit(): Promise { + await this.clickDecreaseLimit(); } + + async decreaseTimeout(): Promise { + await this.clickDecreaseTimeout(); + } + + async getLimit(): Promise { + const limit = await this.limitInput.inputValue(); + return limit; + } + + async getTimeout(): Promise { + const timeout = await this.timeoutInput.inputValue(); + return timeout; + } + + + } \ No newline at end of file diff --git a/e2e/logic/POM/tableView.ts b/e2e/logic/POM/tableView.ts new file mode 100644 index 00000000..09230d0c --- /dev/null +++ b/e2e/logic/POM/tableView.ts @@ -0,0 +1,40 @@ +import { interactWhenVisible, waitForElementToBeEnabled } from "@/e2e/infra/utils"; +import { Locator } from "@playwright/test"; +import GraphPage from "./graphPage"; + +export default class TableView extends GraphPage { + + private get tableViewTab(): Locator { + return this.page.getByRole('tab', { name: 'Table' }); + } + + private get tableViewTabPanel(): Locator { + return this.page.getByRole('tabpanel', { name: 'Table' }); + } + + private get tableViewTableRows(): Locator { + return this.tableViewTabPanel.locator('tbody tr'); + } + + public async clickTableViewTab(): Promise { + await interactWhenVisible(this.tableViewTab, async (el: Locator) => { + await el.click(); + }, 'Table View Tab'); + } + + public async GetIsTableViewTabEnabled(): Promise { + const isEnabled = await waitForElementToBeEnabled(this.tableViewTab); + return isEnabled; + } + + public async GetIsTableViewTabSelected(): Promise { + await waitForElementToBeEnabled(this.tableViewTab); + const isSelected = await this.tableViewTabPanel.getAttribute('data-state') === 'active'; + return isSelected; + } + + public async getRowsCount(): Promise { + const rows = await this.tableViewTableRows.count(); + return rows; + } +} \ No newline at end of file diff --git a/e2e/logic/POM/tutorialPanelComponent.ts b/e2e/logic/POM/tutorialPanelComponent.ts new file mode 100644 index 00000000..6c086427 --- /dev/null +++ b/e2e/logic/POM/tutorialPanelComponent.ts @@ -0,0 +1,74 @@ +// /* eslint-disable prefer-destructuring */ +// /* eslint-disable @typescript-eslint/no-shadow */ +// /* eslint-disable no-plusplus */ +// /* eslint-disable no-await-in-loop */ +// /* eslint-disable arrow-body-style */ +// /* eslint-disable @typescript-eslint/no-explicit-any */ +// import { Locator } from "@playwright/test"; +// import { interactWhenVisible } from "@/e2e/infra/utils"; +// import GraphPage from "./graphPage"; + +// export default class TutotialPanel extends GraphPage { + +// private get sideButtons(): (side: string) => Locator { +// return (side: string) => this.page.locator(`//div[@id='graphTutorial']//div[@class='overflow-hidden']/following-sibling::button[${side}]`); +// } + +// private get contentInCenter(): Locator { +// return this.page.locator("//div[@id='graphTutorial']//div[@class='overflow-hidden']//p"); +// } + +// private get createNewGraphBtn(): Locator { +// return this.page.locator("//div[@id='graphTutorial']//button[contains(text(), 'Create New Graph')]"); +// } + +// private get dissmissDialogCheckbox(): Locator { +// return this.page.locator("//div[p[text()=\"Don't show this again\"]]//button"); +// } + +// async clickOnsideButtons(side: string): Promise { +// await interactWhenVisible(this.sideButtons(side), el => el.click(), "side button"); +// } + +// async isContentInCenterHidden(): Promise { +// return await this.contentInCenter.isHidden(); +// } + +// async clickOnCreateNewGraph(): Promise { +// await interactWhenVisible(this.createNewGraphBtn, el => el.click(), "content in center"); +// } + +// async disableTutorial(): Promise { +// await interactWhenVisible(this.dissmissDialogCheckbox, el => el.click(), "disable tutorial"); +// } + +// async scrollRightInTutorial(amount: number): Promise { +// for(let i = 0; i < amount; i++){ +// await this.clickOnsideButtons('2'); +// await this.page.waitForTimeout(500); +// } +// } + +// async createNewGraph(graphName: string): Promise { +// await this.scrollRightInTutorial(3); +// await this.clickOnCreateNewGraph(); +// await this.insertGraphName(graphName); +// await this.clickCreateGraphButton(); +// } + +// async changeLocalStorage(value: string): Promise { +// await this.page.evaluate((val) => { +// localStorage.setItem('tutorial', val); +// }, value); +// } + +// async clickAtTopLeftCorner(): Promise { +// await this.page.mouse.click(10, 10); +// } + +// async dismissTutorial(): Promise{ +// await this.disableTutorial(); +// await this.clickAtTopLeftCorner(); +// } + +// } \ No newline at end of file diff --git a/e2e/logic/api/apiCalls.ts b/e2e/logic/api/apiCalls.ts index ed250baf..1d0bb45e 100644 --- a/e2e/logic/api/apiCalls.ts +++ b/e2e/logic/api/apiCalls.ts @@ -1,6 +1,9 @@ +/* eslint-disable no-await-in-loop */ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getAdminToken } from "@/e2e/infra/utils"; +import { APIRequestContext } from "playwright"; import { deleteRequest, getRequest, patchRequest, postRequest } from "../../infra/api/apiRequests"; import urls from '../../config/urls.json' import { AddGraphResponse } from "./responses/addGraphResponse"; @@ -17,8 +20,6 @@ import { AuthCredentialsResponse } from "./responses/LoginResponse"; import { LogoutResponse } from "./responses/logoutResponse"; import { AddSchemaResponse } from "./responses/addSchemaResponse"; import { GetGraphsResponse } from "./responses/getGraphsResponse"; -import { getAdminToken } from "@/e2e/infra/utils"; -import { APIRequestContext } from "playwright"; import { SchemaListResponse } from "./responses/getSchemaResponse"; import { GraphCountResponse } from "./responses/graphCountResponse"; import { GraphNodeResponse } from "./responses/graphNodeResponse"; @@ -26,15 +27,15 @@ import { GraphAttributeResponse } from "./responses/graphAttributeResponse"; export default class ApiCalls { - async login(request : APIRequestContext, username?: string, password?: string): Promise { + async login(request: APIRequestContext, username?: string, password?: string): Promise { try { - const result = await getRequest(`${urls.api.LoginApiUrl}`,undefined, {username, password}, request); + const result = await getRequest(`${urls.api.LoginApiUrl}`, undefined, { username, password }, request); return await result.json(); } catch (error) { throw new Error("Failed to login. Please try again."); } } - + async logout(): Promise { try { const result = await postRequest(`${urls.api.LogoutApiUrl}`); @@ -43,7 +44,7 @@ export default class ApiCalls { throw new Error("Failed to logout. Please try again."); } } - + async addGraph(graphName: string, role?: string): Promise { try { const headers = role === "admin" ? await getAdminToken() : undefined; @@ -55,7 +56,7 @@ export default class ApiCalls { throw new Error("Failed to add graph."); } } - + async getGraphs(): Promise { try { const result = await getRequest(`${urls.api.graphUrl}`); @@ -64,7 +65,7 @@ export default class ApiCalls { throw new Error("Failed to retrieve graphs."); } } - + async removeGraph(graphName: string, role?: string): Promise { try { const headers = role === "admin" ? await getAdminToken() : undefined; @@ -74,7 +75,7 @@ export default class ApiCalls { throw new Error("Failed to remove graph."); } } - + async changeGraphName(sourceGraph: string, destinationGraph: string): Promise { try { const result = await patchRequest(`${urls.api.graphUrl + destinationGraph}?sourceName=${sourceGraph}`); @@ -83,7 +84,7 @@ export default class ApiCalls { throw new Error("Failed to change graph name."); } } - + async exportGraph(graphName: string): Promise { try { const result = await getRequest(`${urls.api.graphUrl + graphName}/export`); @@ -92,7 +93,7 @@ export default class ApiCalls { throw new Error("Failed to export graph."); } } - + async duplicateGraph(sourceGraph: string, destinationGraph: string, data?: any): Promise { try { const result = await postRequest(`${urls.api.graphUrl + destinationGraph}?sourceName=${sourceGraph}`, data); @@ -101,21 +102,22 @@ export default class ApiCalls { throw new Error("Failed to duplicate graph."); } } - + async runQuery(graphName: string, query: string, role?: string): Promise { try { const headers = role === "admin" ? await getAdminToken() : undefined; - + let result = await getRequest(`${urls.api.graphUrl}${graphName}?query=${encodeURIComponent(query)}`, headers); let rawText = await result.text(); let json = JSON.parse(rawText); - + // Poll if response contains a numeric result (job ID) const MAX_POLLS = 10; let polls = 0; while (typeof json.result === "number") { - if (++polls > MAX_POLLS) { + polls += 1; + if (polls > MAX_POLLS) { throw new Error(`Query polling exceeded ${MAX_POLLS} attempts`); } const jobId = json.result; @@ -123,9 +125,9 @@ export default class ApiCalls { rawText = await result.text(); json = JSON.parse(rawText); } - + return json; - + } catch (error) { console.error(error); throw new Error("Failed to run query."); @@ -152,7 +154,7 @@ export default class ApiCalls { async deleteGraphNodeLabel(graph: string, node: string, data: Record): Promise { try { - const result = await deleteRequest(`${urls.api.graphUrl}${graph}/${node}/label`,undefined ,data); + const result = await deleteRequest(`${urls.api.graphUrl}${graph}/${node}/label`, undefined, data); return await result.json(); } catch (error) { throw new Error("Failed to delete graph node label."); @@ -161,7 +163,7 @@ export default class ApiCalls { async deleteGraphNode(graph: string, node: string, data: Record): Promise { try { - const result = await deleteRequest(`${urls.api.graphUrl}${graph}/${node}`,undefined ,data); + const result = await deleteRequest(`${urls.api.graphUrl}${graph}/${node}`, undefined, data); return await result.json(); } catch (error) { throw new Error("Failed to delete graph node."); @@ -185,7 +187,7 @@ export default class ApiCalls { throw new Error("Failed to delete graph node attribute."); } } - + async modifySettingsRole(roleName: string, roleValue: string): Promise { try { const result = await postRequest(`${urls.api.settingsConfig + roleName}?value=${roleValue}`); @@ -194,7 +196,7 @@ export default class ApiCalls { throw new Error("Failed to modify settings role."); } } - + async getSettingsRoleValue(roleName: string): Promise { try { const result = await getRequest(urls.api.settingsConfig + roleName); @@ -203,7 +205,7 @@ export default class ApiCalls { throw new Error("Failed to get settings role value."); } } - + async getUsers(): Promise { try { const result = await getRequest(urls.api.settingsUsers); @@ -212,16 +214,16 @@ export default class ApiCalls { throw new Error("Failed to retrieve users."); } } - + async createUsers(data?: any, request?: APIRequestContext): Promise { try { - const result = await postRequest(urls.api.settingsUsers ,data, request); + const result = await postRequest(urls.api.settingsUsers, data, request); return await result.json(); } catch (error) { throw new Error("Failed to create users."); } } - + async deleteUsers(data?: any): Promise { try { const result = await deleteRequest(urls.api.settingsUsers, undefined, data); @@ -230,7 +232,7 @@ export default class ApiCalls { throw new Error("Failed to delete users."); } } - + async addSchema(schemaName: string): Promise { try { const result = await getRequest(`${urls.api.graphUrl + schemaName}_schema?query=MATCH%20(n)%20RETURN%20n%20LIMIT%201`); @@ -239,7 +241,7 @@ export default class ApiCalls { throw new Error("Failed to add schema."); } } - + async removeSchema(schemaName: string): Promise { try { const result = await deleteRequest(urls.api.graphUrl + schemaName); @@ -253,28 +255,29 @@ export default class ApiCalls { try { let result = await getRequest(`${urls.api.graphUrl + schemaName}_schema?query=${encodeURIComponent(schema)}`); let json = await result.json(); - + const MAX_POLLS = 10; let polls = 0; - + while (typeof json.result === "number") { - if (++polls > MAX_POLLS) { + polls += 1; + if (polls > MAX_POLLS) { throw new Error(`Schema polling exceeded ${MAX_POLLS} attempts`); } const jobId = json.result; - await new Promise(r => setTimeout(r, 500)); // Wait before polling again + await new Promise(r => { setTimeout(r, 500) }); // Wait before polling again result = await getRequest(`${urls.api.graphUrl + schemaName}_schema/query/?id=${jobId}`); json = await result.json(); } - + return json; - + } catch (error) { console.error(error); throw new Error("Failed to add schema."); } } - + async getSchemas(): Promise { try { const result = await getRequest(`${urls.api.schemaUrl}`); @@ -283,5 +286,5 @@ export default class ApiCalls { throw new Error("Failed to get schema."); } } - + } \ No newline at end of file diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index 32de4a0c..8a458fc2 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -11,7 +11,7 @@ const adminAuthFile = 'playwright/.auth/admin.json' const readWriteAuthFile = 'playwright/.auth/readwriteuser.json' const readOnlyAuthFile = 'playwright/.auth/readonlyuser.json' -setup("setup authentication", async () => { +setup.skip("setup authentication", async () => { try { const browserWrapper = new BrowserWrapper(); const loginPage = await browserWrapper.createNewPage(LoginPage, urls.loginUrl); diff --git a/e2e/tests/canvas.spec.ts b/e2e/tests/canvas.spec.ts index ad2e505d..34cfab43 100644 --- a/e2e/tests/canvas.spec.ts +++ b/e2e/tests/canvas.spec.ts @@ -21,9 +21,9 @@ test.describe('Canvas Tests', () => { test.afterEach(async () => { await browser.closeBrowser(); }) - + const testNodes = [1, 5, 10]; - for (const node of testNodes) { + testNodes.forEach(async (node) => { test(`@admin Validate search for Person ${node} in the canvas and ensure focus`, async () => { const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); @@ -37,7 +37,7 @@ test.describe('Canvas Tests', () => { expect(await graph.getNodeCanvasToolTip()).toBe(searchQuery); await apicalls.removeGraph(graphName); }); - } + }); test(`@admin Validate zoom-in functionality upon clicking the zoom in button`, async () => { const graph = await browser.createNewPage(GraphPage, urls.graphUrl); diff --git a/e2e/tests/dataPanel.spec.ts b/e2e/tests/dataPanel.spec.ts index 2398b3ca..284ce7e4 100644 --- a/e2e/tests/dataPanel.spec.ts +++ b/e2e/tests/dataPanel.spec.ts @@ -4,10 +4,9 @@ import { expect, test } from "@playwright/test"; import BrowserWrapper from "../infra/ui/browserWrapper"; import ApiCalls from "../logic/api/apiCalls"; -import urls from '../config/urls.json' -import { FETCH_FIRST_TEN_NODES } from "../config/constants"; import DataPanel from "../logic/POM/dataPanelComponent"; -import { getRandomString } from "../infra/utils"; +import urls from "../config/urls.json"; +import { CREATE_NODE_QUERY, getRandomString } from "../infra/utils"; test.describe('Data panel Tests', () => { let browser: BrowserWrapper; @@ -22,170 +21,143 @@ test.describe('Data panel Tests', () => { await browser.closeBrowser(); }) - test(`@readwrite Validate modifying node attributes header via UI and validate via API`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (:Person {name: "Alice"}), (:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("bob"); - await graph.rightClickAtCanvasCenter(); - await graph.modifyNodeHeaderAttribute("attributetest"); - const response = await apicalls.runQuery(graphName, FETCH_FIRST_TEN_NODES ?? ""); - const labels = response.result.data.map(item => item.n.labels); - expect(labels.flat()).toContain('attributetest'); - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate modifying node attributes header via API and validate via UI`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (:Person {name: "Alice"}), (:Person {name: "Bob"})'); - await apicalls.runQuery(graphName, 'MATCH (n {name: "Alice"}) SET n:TestHeader REMOVE n:Person'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - expect(await graph.getAttributeHeaderLabelInDataPanelHeader()).toBe("TestHeader"); - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate adding new attribute for node via ui and validation via API`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (:Person {name: "Alice"}), (:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - await graph.addAttribute("age", "30"); - const response = await apicalls.runQuery(graphName, FETCH_FIRST_TEN_NODES ?? ""); - const person = response.result.data.find(item => 'age' in item.n.properties); - expect(person?.n.properties.age).toBe("30"); - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate adding new attribute for node via API and validation via UI`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - expect(await graph.getAttributeValueInGraphDataPanel()).toBe("30"); - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate remove attribute for node via ui and validation via API`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - await graph.removeAttribute(); - const response = await apicalls.runQuery(graphName, FETCH_FIRST_TEN_NODES ?? ""); - const person = response.result.data.find(item => 'age' in item.n.properties); - expect(person?.n.properties.age).toBeUndefined(); - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate remove attribute for node via API and validation via UI`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - await apicalls.runQuery(graphName, 'MATCH (a:Person {name: "Alice"}) REMOVE a.age'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - expect(await graph.isLastAttributeNameCellInGraphDataPanel("age")).toBe(false) - await apicalls.removeGraph(graphName); - }); - - test(`@readwrite Validate modify attribute for node via ui and validation via API`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - await graph.modifyAttribute("70"); - const response = await apicalls.runQuery(graphName, FETCH_FIRST_TEN_NODES ?? ""); - const person = response.result.data.find(item => 'age' in item.n.properties); - expect(person?.n.properties.age).toBe("70"); - await apicalls.removeGraph(graphName); - }); + test("@admin validate selecting node opens data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + expect(await dataPanel.isVisibleDataPanel()).toBe(true); + }) - test(`@readwrite Validate modify attribute for node via API and validation via UI`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - await apicalls.runQuery(graphName, 'MATCH (a:Person {name: "Alice"}) SET a.age = 35'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - expect(await graph.getAttributeValueInGraphDataPanel()).toBe("35") - await apicalls.removeGraph(graphName); - }); + test("@admin validate pressing x closes data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.closeDataPanel(); + expect(await dataPanel.isVisibleDataPanel()).toBe(false); + }) + + test("@admin validate adding label to node via the canvas panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addLabel("test", true); + await dataPanel.closeDataPanel(); + expect(await dataPanel.isVisibleLabelsButtonByName("Labels", "test")).toBe(true); + }) - test(`@readwrite Validate delete node via ui and validation via API`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - await graph.searchForElementInCanvas("alice"); - await graph.rightClickAtCanvasCenter(); - await graph.deleteNodeViaDataPanel(); - const response = await apicalls.runQuery(graphName, FETCH_FIRST_TEN_NODES ?? ""); - expect(response.result.data.length).toBe(1); - await apicalls.removeGraph(graphName); - }); + test("@admin validate adding label to node via the data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addLabel("test", true); + expect(await dataPanel.isVisibleLabel("test")).toBe(true); + }) - test(`@readwrite Validate delete node via API and validation via UI`, async () => { - const graphName = getRandomString('datapanel'); - await apicalls.addGraph(graphName); - await apicalls.runQuery(graphName, 'CREATE (a:Person {name: "Alice", age: 30}), (b:Person {name: "Bob"})'); - await apicalls.runQuery(graphName, 'MATCH (b:Person {name: "Alice"}) DELETE b'); - const graph = await browser.createNewPage(DataPanel, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(FETCH_FIRST_TEN_NODES); - await graph.clickRunQuery(); - const nodes = await graph.getNodesGraphStats(); - expect(parseInt(nodes ?? "")).toBe(1); - await apicalls.removeGraph(graphName); - }); + test("@admin validate removing label to node via the canvas panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addLabel("test", true); + await dataPanel.removeLabel("test"); + expect(await dataPanel.isVisibleLabelsButtonByName("Labels", "test")).toBe(false); + }) + + test("@admin validate removing label to node via the data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addLabel("test"); + await dataPanel.removeLabel("test"); + expect(await dataPanel.isVisibleLabel("test")).toBe(false); + }) + + test("@admin validate adding attribute to node via the data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addAttribute("test", "test"); + expect(await dataPanel.isVisibleAttribute("test")).toBe(true); + }) + + test("@admin validate adding attribute to node via attribute count", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + const initialCount = await dataPanel.getContentDataPanelAttributesCount(); + await dataPanel.addAttribute("test", "test"); + const newCount = await dataPanel.getContentDataPanelAttributesCount(); + expect(newCount).toBe(initialCount + 1); + }) + + test("@admin validate removing attribute to node via the data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addAttribute("test", "test"); + await dataPanel.removeAttribute("test"); + expect(await dataPanel.isVisibleAttribute("test")).toBe(false); + }) + + test("@admin validate removing attribute to node via attribute count", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.addAttribute("test", "test"); + const initialCount = await dataPanel.getContentDataPanelAttributesCount(); + await dataPanel.removeAttribute("test"); + const newCount = await dataPanel.getContentDataPanelAttributesCount(); + expect(newCount).toBe(initialCount - 1); + }) + + test("@admin validate deleting node closes data panel", async () => { + const graphName = getRandomString("DataPanel") + await apicalls.addGraph(graphName, "admin"); + const dataPanel = await browser.createNewPage(DataPanel, urls.graphUrl); + await dataPanel.selectGraphByName(graphName); + await dataPanel.insertQuery(CREATE_NODE_QUERY); + await dataPanel.clickRunQuery(); + await dataPanel.searchElementInCanvas("a"); + await dataPanel.deleteElementByName("a"); + expect(await dataPanel.isVisibleDataPanel()).toBe(false); + }) }) \ No newline at end of file diff --git a/e2e/tests/graph.spec.ts b/e2e/tests/graph.spec.ts index 7320b279..3f4e01ab 100644 --- a/e2e/tests/graph.spec.ts +++ b/e2e/tests/graph.spec.ts @@ -3,12 +3,12 @@ /* eslint-disable no-await-in-loop */ import { expect, test } from "@playwright/test"; import fs from 'fs'; +import { getRandomString, DEFAULT_CREATE_QUERY, CREATE_QUERY, CREATE_TWO_NODES_QUERY, CREATE_NODE_QUERY } from "../infra/utils"; import BrowserWrapper from "../infra/ui/browserWrapper"; import ApiCalls from "../logic/api/apiCalls"; import GraphPage from "../logic/POM/graphPage"; import urls from '../config/urls.json' import queryData from '../config/queries.json' -import { getRandomString } from "../infra/utils"; test.describe('Graph Tests', () => { let browser: BrowserWrapper; @@ -24,10 +24,9 @@ test.describe('Graph Tests', () => { }) test(`@admin Add graph via API -> verify display in UI test`, async () => { - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); - await graph.refreshPage(); + const graph = await browser.createNewPage(GraphPage, urls.graphUrl); expect(await graph.verifyGraphExists(graphName)).toBe(true); await apiCall.removeGraph(graphName); }); @@ -46,7 +45,7 @@ test.describe('Graph Tests', () => { const graph = await browser.createNewPage(GraphPage, urls.graphUrl); const graphName = getRandomString('graph'); await graph.addGraph(graphName); - const download = await graph.exportGraph(); + const download = await graph.exportGraphByName(graphName); const downloadPath = await download.path(); expect(fs.existsSync(downloadPath)).toBe(true); await apiCall.removeGraph(graphName); @@ -58,12 +57,12 @@ test.describe('Graph Tests', () => { const graphName = getRandomString('graph'); await graph.addGraph(graphName); await graph.insertQuery(query.query); - await graph.clickRunQuery(false); + await graph.clickRunQuery(); expect(await graph.getErrorNotification()).toBe(true); await apiCall.removeGraph(graphName); }); }) - + test(`@admin Validate that the reload graph list function works by adding a graph via API and testing the reload button`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); @@ -86,28 +85,21 @@ test.describe('Graph Tests', () => { await apiCall.removeGraph(newGraphName); }); - test(`@readwrite Validate that modifying a graph name fails and does not apply the change`, async () => { + test(`@readwrite Validate that the button for modifying a graph name is not visible for RW user`, async () => { const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); const graphName = getRandomString('graph'); await graph.addGraph(graphName); - await graph.refreshPage(); - const newGraphName = getRandomString('graph'); - await graph.modifyGraphName(graphName, newGraphName); - await graph.refreshPage(); - expect(await graph.verifyGraphExists(newGraphName)).toBe(false); + expect(await graph.isModifyGraphNameButtonVisible(graphName)).toBe(false); await apiCall.removeGraph(graphName); }); - test(`@readonly Validate failure & error message when RO user attempts to rename an existing graph via UI`, async () => { + test(`@readonly Validate that the button for modifying a graph name is not visible for RO user`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName, "admin"); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - const newGraphName = getRandomString('graph'); - await graph.modifyGraphName(graphName, newGraphName); - await graph.refreshPage(); - expect(await graph.verifyGraphExists(newGraphName)).toBe(false); + expect(await graph.isModifyGraphNameButtonVisible(graphName)).toBe(false); await apiCall.removeGraph(graphName, "admin"); }); @@ -142,279 +134,273 @@ test.describe('Graph Tests', () => { await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery("UNWIND range(1, 10) as x CREATE (n:n)-[e:e]->(m:m) RETURN *"); + await graph.selectGraphByName(graphName); + await graph.insertQuery(DEFAULT_CREATE_QUERY); await graph.clickRunQuery(); - const nodes = await graph.getNodesGraphStats(); - const edges = await graph.getEdgesGraphStats(); - expect(parseInt(nodes ?? "", 10)).toBe(20); - expect(parseInt(edges ?? "", 10)).toBe(10); + const nodes = await graph.getNodesCount(); + const edges = await graph.getEdgesCount(); + expect(parseInt(nodes, 10)).toBe(20); + expect(parseInt(edges, 10)).toBe(10); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate that attempting to duplicate a graph with the same name displays an error notification`, async () => { - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - const graphName = getRandomString('graph'); - await graph.addGraph(graphName); - await graph.addGraph(graphName); - expect(await graph.getErrorNotification()).toBe(true); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate that deleting a graph relation doesn't decreases node count`, async () => { + test(`@readwrite validate that deleting graph relation doesn't decreases node count`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}), (a)-[c:knows]->(b) RETURN a, b, c'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - const initCount = parseInt(await graph.getNodesGraphStats() ?? "", 10); - const links = await graph.getLinksScreenPositions('graph'); - await graph.deleteRelation(links[0].midX, links[0].midY); - expect(parseInt(await graph.getNodesGraphStats() ?? "", 10)).toBe(initCount); + const initCount = parseInt(await graph.getNodesCount() ?? "", 10); + await graph.deleteElementByName("knows"); + expect(parseInt(await graph.getNodesCount() ?? "", 10)).toBe(initCount); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate that deleting a graph node doesn't decreases relation count`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}), (c:person3 {id: "3"}), (a)-[d:knows]->(b) RETURN a, b, c, d'); - await graph.clickRunQuery(); - const initCount = parseInt(await graph.getEdgesGraphStats() ?? "", 10); - const nodes = await graph.getNodeScreenPositions('graph'); - await graph.deleteNode(nodes[2].screenX, nodes[2].screenY); - expect(parseInt(await graph.getEdgesGraphStats() ?? "", 10)).toBe(initCount); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate that deleting a graph relation decreases relation count`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}), (c:person3 {id: "3"}), (a)-[d:knows]->(b) RETURN a, b, c, d'); - await graph.clickRunQuery(); - const initCount = parseInt(await graph.getEdgesGraphStats() ?? "", 10); - const links = await graph.getLinksScreenPositions('graph'); - await graph.deleteRelation(links[0].midX, links[0].midY); - expect(parseInt(await graph.getEdgesGraphStats() ?? "", 10)).toBe(initCount -1); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate that deleting a graph node decreases node count`, async () => { + test(`@readwrite validate that deleting graph node decreases relation count by one`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - const initCount = parseInt(await graph.getNodesGraphStats() ?? "", 10); - const nodes = await graph.getNodeScreenPositions('graph'); - await graph.deleteNode(nodes[0].screenX, nodes[0].screenY); - expect(parseInt(await graph.getNodesGraphStats() ?? "", 10)).toBe(initCount -1); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite Validate deleting node via the canvas panel`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); - await graph.clickRunQuery(); - const initCount = parseInt(await graph.getNodesGraphStats() ?? "", 10); - const nodes = await graph.getNodeScreenPositions('graph'); - await graph.deleteNodeViaCanvasPanel(nodes[0].screenX, nodes[0].screenY); - expect(parseInt(await graph.getNodesGraphStats() ?? "", 10)).toBe(initCount -1); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate modifying node label updates label in data and canvas panels correctly`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); - await graph.clickRunQuery(); - await graph.modifyLabel("1", "artist"); - expect(await graph.getLabesCountlInDataPanel()).toBe(3) - expect(await graph.getLastLabelInCanvas()).toBe("artist"); + const initCount = parseInt(await graph.getEdgesCount(), 10); + await graph.deleteElementByName("a"); + expect(parseInt(await graph.getEdgesCount(), 10)).toBe(initCount - 1); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate removing node label updates label in data and canvas panels correctly`, async () => { + test(`@readwrite validate that deleting graph relation decreases relation count`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - const labelsCountInCanvas = await graph.getLabesCountlInCanvas(); - await graph.deleteLabel("1"); - expect(await graph.getLabesCountlInDataPanel()).toBe(1); - expect(await graph.getLabesCountlInCanvas()).toBe(labelsCountInCanvas - 1); + const initCount = parseInt(await graph.getEdgesCount(), 10); + await graph.deleteElementByName("knows"); + expect(parseInt(await graph.getEdgesCount(), 10)).toBe(initCount - 1); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate undo functionally after modifying node attributes update correctly`, async () => { + test(`@readwrite validate that deleting graph node decreases node count`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("1"); - const valueAttribute = await graph.getLastAttributeValue(); - await graph.modifyAttribute("10"); - await graph.clickUndoBtnInNotification(); - expect(await graph.getLastAttributeValue()).toBe(valueAttribute); + const initCount = parseInt(await graph.getNodesCount(), 10); + await graph.deleteElementByName("a"); + expect(parseInt(await graph.getNodesCount(), 10)).toBe(initCount - 1); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate adding attribute updates attributes stats in data panel`, async () => { + test(`@readwrite Validate deleting graph node via the canvas panel`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - await graph.addGraphAttribute("1", "name", "Naseem"); - expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(2); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate removing attribute updates attributes stats in data panel`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); - await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("Alice"); - await graph.deleteGraphAttribute(); - expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(1); + const initCount = parseInt(await graph.getNodesCount(), 10); + await graph.deleteElementByName("a"); + expect(parseInt(await graph.getNodesCount(), 10)).toBe(initCount - 1); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate modifying attribute via UI and verify via API`, async () => { + // test(`@readwrite validate modifying node label updates label in data and canvas panels correctly`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + // await graph.clickRunQuery(); + // await graph.modifyLabel("1", "artist"); + // expect(await graph.getLabesCountlInDataPanel()).toBe(3) + // expect(await graph.getLastLabelInCanvas()).toBe("artist"); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate removing node label updates label in data and canvas panels correctly`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + // await graph.clickRunQuery(); + // const labelsCountInCanvas = await graph.getLabesCountlInCanvas(); + // await graph.deleteLabel("1"); + // expect(await graph.getLabesCountlInDataPanel()).toBe(1); + // expect(await graph.getLabesCountlInCanvas()).toBe(labelsCountInCanvas - 1); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate undo functionally after modifying node attributes update correctly`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + // await graph.clickRunQuery(); + // await graph.searchElementInCanvas("1"); + // const valueAttribute = await graph.getLastAttributeValue(); + // await graph.modifyAttribute("10"); + // await graph.clickUndoBtnInNotification(); + // expect(await graph.getLastAttributeValue()).toBe(valueAttribute); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate adding attribute updates attributes stats in data panel`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectExistingGraph(graphName); + // await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + // await graph.clickRunQuery(); + // await graph.addGraphAttribute("1", "name", "Naseem"); + // expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(2); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate removing attribute updates attributes stats in data panel`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectExistingGraph(graphName); + // await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); + // await graph.clickRunQuery(); + // await graph.openDataPanelForElementInCanvas("Alice"); + // await graph.deleteGraphAttribute(); + // expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(1); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate modifying attribute via UI and verify via API`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + // await graph.clickRunQuery(); + // await graph.searchElementInCanvas("1"); + // await graph.modifyAttribute("10"); + // const response = await apiCall.runQuery(graphName, "match (n) return n"); + // expect(response.result.data[1].n.properties.id).toBe('10') + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite validate deleting attribute via UI and verify via API`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); + // await graph.clickRunQuery(); + // await graph.searchElementInCanvas("Alice"); + // await graph.deleteGraphAttribute(); + // const response = await apiCall.runQuery(graphName, "match (n) return n"); + // expect(response.result.data[0].n.properties).not.toHaveProperty('name'); + // await apiCall.removeGraph(graphName); + // }); + + test(`@readwrite validate deleting graph relation via the canvas panels`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("1"); - await graph.modifyAttribute("10"); - const response = await apiCall.runQuery(graphName, "match (n) return n"); - expect(response.result.data[1].n.properties.id).toBe('10') + const initCount = parseInt(await graph.getEdgesCount(), 10); + await graph.deleteElementByName("knows"); + expect(parseInt(await graph.getEdgesCount(), 10)).toBe(initCount - 1); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate deleting attribute via UI and verify via API`, async () => { + test(`@readwrite validate adding graph node via the canvas panels`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("Alice"); - await graph.deleteGraphAttribute(); - const response = await apiCall.runQuery(graphName, "match (n) return n"); - expect(response.result.data[0].n.properties).not.toHaveProperty('name'); + expect(await graph.isVisibleLabelsButtonByName("Labels", "person1")).toBeTruthy(); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate deleting connection between two nodes updates canvas panels`, async () => { + test(`@readwrite validate adding graph relation via the canvas panels`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1"}), (b:Person {id: "2"}), (a)-[c:KNOWS]->(b) RETURN a, b, c'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - const initCount = parseInt(await graph.getEdgesGraphStats() ?? "", 10); - const links = await graph.getLinksScreenPositions('graph'); - await graph.deleteGraphRelation(links[0].midX, links[0].midY); - expect(parseInt(await graph.getEdgesGraphStats() ?? "", 10)).toBe(initCount - 1); - expect(await graph.isRelationshipTypesPanelBtnHidden()).toBeTruthy(); + expect(await graph.isVisibleLabelsButtonByName("RelationshipTypes", "KNOWS")).toBeTruthy(); await apiCall.removeGraph(graphName); }); - test(`@readwrite validate adding connection between two nodes updates canvas panels`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1"}), (b:Person {id: "2"}), (a)-[c:KNOWS]->(b) RETURN a, b, c'); - await graph.clickRunQuery(); - expect(await graph.getRelationshipTypesPanelBtn()).toBe('KNOWS'); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite validate undo functionally after deleting attribute update correctly`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); - await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("Alice"); - await graph.deleteGraphAttribute(); - await graph.clickUndoBtnInNotification(); - expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(2); - await apiCall.removeGraph(graphName); - }); - - test(`@readwrite Attempting to add existing label name for a node display error`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); - await graph.clickRunQuery(); - await graph.modifyLabel("Alice", "Person"); - expect(await graph.getLabesCountlInDataPanel()).toBe(2) - expect(await graph.getErrorNotification()).toBeTruthy(); - await apiCall.removeGraph(graphName); - }); + // test(`@readwrite validate undo functionally after deleting attribute update correctly`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); + // await graph.clickRunQuery(); + // await graph.openDataPanelForElementInCanvas("Alice"); + // await graph.deleteGraphAttribute(); + // await graph.clickUndoBtnInNotification(); + // expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(2); + // await apiCall.removeGraph(graphName); + // }); + + // test(`@readwrite Attempting to add existing label name for a node display error`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectExistingGraph(graphName); + // await graph.insertQuery('CREATE (a:Person {id: "1", name: "Alice"}) RETURN a'); + // await graph.clickRunQuery(); + // await graph.modifyLabel("Alice", "Person"); + // expect(await graph.getLabesCountlInDataPanel()).toBe(2) + // expect(await graph.getErrorNotification()).toBeTruthy(); + // await apiCall.removeGraph(graphName); + // }); test(`@readwrite moving a node to another node's position while animation is off should place them at the same position`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); - await graph.clickRunQuery(); - const initNodes = await graph.getNodeScreenPositions('graph'); - expect(await graph.getAnimationControlPanelState()).toBe("unchecked"); - + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_TWO_NODES_QUERY); + await graph.clickRunQuery(true); + expect(await graph.getAnimationControl()).toBe(false); + await graph.clickCenterControl(); + const initNodes = await graph.getNodesScreenPositions('graph'); const fromX = initNodes[0].screenX; const fromY = initNodes[0].screenY; const toX = initNodes[1].screenX;; const toY = initNodes[1].screenY; await graph.changeNodePosition(fromX, fromY, toX, toY); await graph.waitForCanvasAnimationToEnd(); - const nodes = await graph.getNodeScreenPositions('graph'); - + const nodes = await graph.getNodesScreenPositions('graph'); expect(nodes[1].screenX - nodes[0].screenX).toBeLessThanOrEqual(2); expect(nodes[1].screenY - nodes[0].screenY).toBeLessThanOrEqual(2); await apiCall.removeGraph(graphName); @@ -425,40 +411,37 @@ test.describe('Graph Tests', () => { await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); - await graph.clickRunQuery(); - const initNodes = await graph.getNodeScreenPositions('graph'); - await graph.clickAnimationControlPanelbtn(); - expect(await graph.getAnimationControlPanelState()).toBe("checked"); - + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_TWO_NODES_QUERY); + await graph.clickRunQuery(true); + const initNodes = await graph.getNodesScreenPositions('graph'); + await graph.clickAnimationControl(); + expect(await graph.getAnimationControl()).toBe(true); const fromX = initNodes[0].screenX; const fromY = initNodes[0].screenY; const toX = initNodes[1].screenX;; const toY = initNodes[1].screenY; await graph.changeNodePosition(fromX, fromY, toX, toY); await graph.waitForCanvasAnimationToEnd(); - const nodes = await graph.getNodeScreenPositions('graph'); - + const nodes = await graph.getNodesScreenPositions('graph'); expect(Math.abs(nodes[1].screenX - nodes[0].screenX)).toBeGreaterThan(2); expect(Math.abs(nodes[1].screenY - nodes[0].screenY)).toBeGreaterThan(2); await apiCall.removeGraph(graphName); }); - test(`@admin Validate that toggling a category label updates edge visibility on the canvas`, async () => { + test(`@admin Validate that toggling a category label updates node visibility on the canvas`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (n:person1 {id: "1"}) RETURN n'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_NODE_QUERY); await graph.clickRunQuery(); - await graph.clickLabelsPanelBtn(); - expect(await graph.getLabelsPanelBtn()).toBe("person1") - const nodes1 = await graph.getNodeScreenPositions('graph'); + await graph.clickLabelsButtonByLabel("Labels", "person1"); + const nodes1 = await graph.getNodesScreenPositions('graph'); expect(nodes1[0].visible).toBeFalsy(); - await graph.clickLabelsPanelBtn(); - const nodes2 = await graph.getNodeScreenPositions('graph'); + await graph.clickLabelsButtonByLabel("Labels", "person1"); + const nodes2 = await graph.getNodesScreenPositions('graph'); expect(nodes2[0].visible).toBeTruthy(); await apiCall.removeGraph(graphName); }); @@ -468,15 +451,14 @@ test.describe('Graph Tests', () => { await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}), (a)-[c:knows]->(b) RETURN a, b, c'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); - await graph.clickRelationshipTypesPanelBtn(); - expect(await graph.getRelationshipTypesPanelBtn()).toBe("knows"); + await graph.clickLabelsButtonByLabel("RelationshipTypes", "KNOWS"); const links1 = await graph.getLinksScreenPositions('graph'); expect(links1[0].visible).toBeFalsy(); - await graph.clickRelationshipTypesPanelBtn(); - const links2 = await graph.getLinksScreenPositions('graph'); + await graph.clickLabelsButtonByLabel("RelationshipTypes", "KNOWS"); + const links2 = await graph.getLinksScreenPositions('graph'); expect(links2[0].visible).toBeTruthy(); await apiCall.removeGraph(graphName); }); @@ -490,60 +472,60 @@ test.describe('Graph Tests', () => { { query: "MERGE (n:Person { name: 'Alice' }) RETURN n", description: 'merge query that creates node' }, { query: "UNWIND [1,2,3] AS x CREATE (:Number {value: x})", description: 'unwind with create query' } ]; - + invalidQueriesRO.forEach(({ query, description }) => { test(`@readonly Validate failure when RO user attempts to execute : ${description}`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName, "admin"); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName, "readonly"); + await graph.selectGraphByName(graphName); await graph.insertQuery(query); await graph.clickRunQuery(); expect(await graph.getErrorNotification()).toBeTruthy(); await apiCall.removeGraph(graphName, "admin"); }); }); - + test(`@readonly Validate success when RO user attempts to execute ro query`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName, "admin"); - await apiCall.runQuery(graphName, "CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})", "admin") + await apiCall.runQuery(graphName, CREATE_QUERY, "admin") const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName, "readonly"); + await graph.selectGraphByName(graphName); await graph.insertQuery("MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 10"); await graph.clickRunQuery(); expect(await graph.getErrorNotification()).toBeFalsy(); await apiCall.removeGraph(graphName, "admin"); }); - const queriesInput = [ - { query: "C", keywords: ['call', 'collect', 'count', 'create'] }, - { query: "M", keywords: ['max', 'min', 'match', 'merge'] }, - ]; - queriesInput.forEach(({query, keywords}) => { - test(`@readwrite Validate auto complete in query search for: ${query}`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery(query); - const response = await graph.getQuerySearchListText(); - const hasAny = response.some(s => keywords.some(k => s.includes(k))); - expect(hasAny).toBeTruthy(); - await apiCall.removeGraph(graphName); - }); - }) + // const queriesInput = [ + // { query: "C", keywords: ['call', 'collect', 'count', 'create'] }, + // { query: "M", keywords: ['max', 'min', 'match', 'merge'] }, + // ]; + // queriesInput.forEach(({ query, keywords }) => { + // test(`@readwrite Validate auto complete in query search for: ${query}`, async () => { + // const graphName = getRandomString('graph'); + // await apiCall.addGraph(graphName); + // const graph = await browser.createNewPage(GraphPage, urls.graphUrl); + // await browser.setPageToFullScreen(); + // await graph.selectGraph(graphName); + // await graph.insertQuery(query); + // const response = await graph.getQuerySearchListText(); + // const hasAny = response.some(s => keywords.some(k => s.includes(k))); + // expect(hasAny).toBeTruthy(); + // await apiCall.removeGraph(graphName); + // }); + // }) test(`@readwrite run graph query via UI and validate node and edge count via API`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery("CREATE (a:Person {name: 'Alice'})-[c:KNOWS]->(b:Person {name: 'Bob'}) return *"); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_QUERY); await graph.clickRunQuery(); const count = await apiCall.getGraphCount(graphName); expect(count.result.edges).toBe(1); @@ -554,15 +536,15 @@ test.describe('Graph Tests', () => { test(`@readwrite add node label via API and validate label count via UI`, async () => { const graphName = getRandomString('graph'); await apiCall.addGraph(graphName); - await apiCall.runQuery(graphName, "CREATE (a:Person {name: 'Alice'})-[c:KNOWS]->(b:Person {name: 'Bob'}) return *"); - await apiCall.addGraphNodeLabel(graphName, "0", { "label" : "artist"}); - + await apiCall.runQuery(graphName, CREATE_QUERY, "admin") + await apiCall.addGraphNodeLabel(graphName, "0", { "label": "artist" }); + const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); + await graph.selectGraphByName(graphName); await graph.insertQuery("match(n) return *"); await graph.clickRunQuery(); - expect(await graph.getLabesCountlInCanvas()).toBe(2); + expect(await graph.isVisibleLabelsButtonByName("Labels", "artist")).toBeTruthy(); await apiCall.removeGraph(graphName); }); @@ -571,15 +553,15 @@ test.describe('Graph Tests', () => { await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery("CREATE (a:Person:Employee {name: 'Alice'}) RETURN *"); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_NODE_QUERY); await graph.clickRunQuery(false); - await apiCall.deleteGraphNodeLabel(graphName, "0", { "label" : "Employee"}) + await apiCall.deleteGraphNodeLabel(graphName, "0", { "label": "Employee" }) await graph.refreshPage(); - await graph.selectExistingGraph(graphName); + await graph.selectGraphByName(graphName); await graph.insertQuery("match(n) return *"); await graph.clickRunQuery(false); - expect(await graph.getLabesCountlInCanvas()).toBe(1); + expect(await graph.isVisibleLabelsButtonByName("Labels", "Employee")).toBeFalsy(); await apiCall.removeGraph(graphName); }); @@ -588,45 +570,25 @@ test.describe('Graph Tests', () => { await apiCall.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('CREATE (a:person1 {id: "1"}), (b:person2 {id: "2"}) RETURN a, b'); + await graph.selectGraphByName(graphName); + await graph.insertQuery(CREATE_TWO_NODES_QUERY); await graph.clickRunQuery(false); - await apiCall.deleteGraphNode(graphName, "0", { "type" : "true"}) + await apiCall.deleteGraphNode(graphName, "0", { "type": "true" }) await graph.refreshPage(); - await graph.selectExistingGraph(graphName); + await graph.selectGraphByName(graphName); await graph.insertQuery("match(n) return *"); await graph.clickRunQuery(false); - expect(parseInt(await graph.getNodesGraphStats() ?? "", 10)).toBe(1); + expect(parseInt(await graph.getNodesCount(), 10)).toBe(1); await apiCall.removeGraph(graphName); }); - test(`@readwrite add node attribute via API and validate attribute count via UI`, async () => { + test(`@readonly Validate that RO user can select graph`, async () => { const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - await apiCall.runQuery(graphName, 'CREATE (a:person1 {id: "1"}) RETURN a'); - await apiCall.addGraphNodeAttribute(graphName, "0", "age", { "value": "31", "type": true }) + await apiCall.addGraph(graphName, "admin"); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('match(n) return n'); - await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("0"); - expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(2); - await apiCall.removeGraph(graphName); + await graph.selectGraphByName(graphName); + expect(await graph.getErrorNotification()).toBeFalsy(); + await apiCall.removeGraph(graphName, "admin"); }); - test(`@readwrite delete node attribute via API and validate attribute count via UI`, async () => { - const graphName = getRandomString('graph'); - await apiCall.addGraph(graphName); - await apiCall.runQuery(graphName, 'CREATE (a:person1 {id: "1", age: "30"}) RETURN a'); - await apiCall.deleteGraphNodeAttribute(graphName, "0", "age", { "type": true }) - const graph = await browser.createNewPage(GraphPage, urls.graphUrl); - await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); - await graph.insertQuery('match(n) return n'); - await graph.clickRunQuery(); - await graph.openDataPanelForElementInCanvas("0"); - expect(parseInt(await graph.getAttributesStatsInDataPanel() ?? "", 10)).toBe(1); - await apiCall.removeGraph(graphName); - }); }) \ No newline at end of file diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index 25fdc426..6cab819e 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -28,7 +28,7 @@ test.describe(`Login tests`, () => { await login.Logout(); await browser.setPageToFullScreen(); await login.connectWithCredentials("readonlyuser", user.password); - await new Promise((res) => setTimeout(res, 500)); + await new Promise((res) => { setTimeout(res, 500) }); expect(login.getCurrentURL()).toBe(urls.graphUrl); }) @@ -39,13 +39,13 @@ test.describe(`Login tests`, () => { { description: 'invalid password', host: 'localhost', port: '6379', username: userRoles[1].name, password: "password1!" }, ]; - invalidInputs.forEach(({ description, host, port, username, password }, index) => { + invalidInputs.forEach(({ description, host, port, username, password }) => { test(`@admin validate user login with wrong credentials: ${description}`, async () => { const login = await browser.createNewPage(LoginPage, urls.loginUrl); if (login.getCurrentURL() === urls.graphUrl) await login.Logout(); await browser.setPageToFullScreen(); await login.connectWithCredentials(username, password, host, port); - await new Promise((res) => setTimeout(res, 500)); + await new Promise((res) => { setTimeout(res, 500) }); expect(login.getCurrentURL()).not.toBe(urls.graphUrl) }) }); diff --git a/e2e/tests/queryHistory.spec.ts b/e2e/tests/queryHistory.spec.ts index 9c2ca120..6e59d9b2 100644 --- a/e2e/tests/queryHistory.spec.ts +++ b/e2e/tests/queryHistory.spec.ts @@ -1,10 +1,10 @@ import { expect, test } from "@playwright/test"; +import { BATCH_CREATE_PERSONS } from "@/e2e/config/constants"; import BrowserWrapper from "../infra/ui/browserWrapper"; import ApiCalls from "../logic/api/apiCalls"; import urls from '../config/urls.json' import QueryHistory from "../logic/POM/queryHistoryComponent"; -import { BATCH_CREATE_PERSONS } from "@/e2e/config/constants"; import { getRandomString } from "../infra/utils"; test.describe('Query history Tests', () => { @@ -25,7 +25,7 @@ test.describe('Query history Tests', () => { await apicalls.addGraph(graphName); const graph = await browser.createNewPage(QueryHistory, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); + await graph.selectGraph(graphName); await graph.insertQuery("CREATE (n:Person { name: 'Alice' }) RETURN n"); await graph.clickRunQuery(); await graph.clickOnQueryHistory(); @@ -38,14 +38,14 @@ test.describe('Query history Tests', () => { await apicalls.addGraph(graphName); const graph = await browser.createNewPage(QueryHistory, urls.graphUrl); await browser.setPageToFullScreen(); - await graph.selectExistingGraph(graphName); + await graph.selectGraph(graphName); await graph.insertQuery("CREATE (n:Person { name: 'Alice' }) RETURN n"); await graph.clickRunQuery(); await graph.refreshPage(); - await graph.selectExistingGraph(graphName) + await graph.selectGraph(graphName) await graph.runAQueryFromHistory("1") const searchQuery = `Alice`; - await graph.searchForElementInCanvas(searchQuery); + await graph.searchElementInCanvas(searchQuery); await graph.hoverAtCanvasCenter(); expect(await graph.getNodeCanvasToolTip()).toBe(searchQuery); await apicalls.removeGraph(graphName); diff --git a/e2e/tests/schema.spec.ts b/e2e/tests/schema.spec.ts index e20c43c7..9ea68597 100644 --- a/e2e/tests/schema.spec.ts +++ b/e2e/tests/schema.spec.ts @@ -38,7 +38,7 @@ test.describe('Schema Tests', () => { await schema.addSchema(schemaName); await schema.addNode("person", 'id', "Integer", "100", true, true); const graph = await schema.getNodeScreenPositions('schema'); - await schema.nodeClick(graph[0].screenX, graph[0].screenY); + await schema.elementClick(graph[0].screenX, graph[0].screenY); expect(await schema.hasAttributeRows()).toBe(true); expect(await schema.getCategoriesPanelBtn()).toBe("person") await apicalls.removeSchema(schemaName); @@ -53,7 +53,7 @@ test.describe('Schema Tests', () => { await schema.prepareRelation("knows", 'id', "Integer", "100", true, true); await schema.clickRelationBetweenNodes(); const links = await schema.getLinksScreenPositions('schema'); - await schema.nodeClick(links[0].midX, links[0].midY); + await schema.elementClick(links[0].midX, links[0].midY); expect(await schema.hasAttributeRows()).toBe(true); expect(await schema.isRelationshipTypesPanelBtnHidden()).toBeFalsy(); await apicalls.removeSchema(schemaName); diff --git a/e2e/tests/settingsQuery.spec.ts b/e2e/tests/settingsQuery.spec.ts index 2a5d3743..e9982108 100644 --- a/e2e/tests/settingsQuery.spec.ts +++ b/e2e/tests/settingsQuery.spec.ts @@ -24,7 +24,7 @@ test.describe("Query Settings", () => { await apiCall.addGraph(graphName) const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); const timeout = 1; - await querySettings.addTimeout(timeout); + await querySettings.fillTimeout(timeout); await querySettings.clickOnGraph(); await querySettings.selectExistingGraph(graphName) const query = `UNWIND range(1, 100000000) AS x RETURN count(x)`; @@ -40,7 +40,7 @@ test.describe("Query Settings", () => { await apiCall.addGraph(graphName) const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); const limit = 5; - await querySettings.addLimit(limit); + await querySettings.fillLimit(limit); await querySettings.clickOnGraph(); await querySettings.selectExistingGraph(graphName); const query = `UNWIND range(1, 10) AS i CREATE (p:Person {id: i, name: 'Person ' + toString(i)}) RETURN p`; @@ -48,6 +48,51 @@ test.describe("Query Settings", () => { await querySettings.clickRunQuery() const res = await querySettings.getNodeScreenPositions('graph'); expect(res.length).toBe(5); + await querySettings.clickOnSettings(); await apiCall.removeGraph(graphName); }); + + test(`@admin Validate that limit can't be negative with the input`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + const limit = -1; + await querySettings.fillLimit(limit); + const limitValue = await querySettings.getLimit(); + expect(limitValue).toBe("∞"); + }); + + test(`@admin Validate that timeout can't be negative with the input`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + const timeout = -1; + await querySettings.fillTimeout(timeout); + const timeoutValue = await querySettings.getTimeout(); + expect(timeoutValue).toBe("∞"); + }); + + test(`@admin Validate that limit can't be negative with the decrease button`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + await querySettings.clickDecreaseLimit(); + const limitValue = await querySettings.getLimit(); + expect(limitValue).toBe("∞"); + }); + + test(`@admin Validate that timeout can't be negative with the decrease button`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + await querySettings.clickDecreaseTimeout(); + const timeoutValue = await querySettings.getTimeout(); + expect(timeoutValue).toBe("∞"); + }); + + test(`@admin Validate that limit changed with the increase button`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + await querySettings.clickIncreaseLimit(); + const limitValue = await querySettings.getLimit(); + expect(limitValue).toBe("1"); + }); + + test(`@admin Validate that timeout changed with the increase button`, async () => { + const querySettings = await browser.createNewPage(QuerySettingsPage, urls.settingsUrl); + await querySettings.clickIncreaseTimeout(); + const timeoutValue = await querySettings.getTimeout(); + expect(timeoutValue).toBe("1"); + }); }); \ No newline at end of file diff --git a/e2e/tests/table.spec.ts b/e2e/tests/table.spec.ts new file mode 100644 index 00000000..e4707533 --- /dev/null +++ b/e2e/tests/table.spec.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-await-in-loop */ + +import test, { expect } from "@playwright/test"; +import BrowserWrapper from "../infra/ui/browserWrapper"; +import urls from '../config/urls.json' +import ApiCalls from "../logic/api/apiCalls"; +import { getRandomString } from "../infra/utils"; +import TableView from "../logic/POM/tableView"; + +test.describe('Table View Tests', () => { + + let browser: BrowserWrapper; + let apiCalls: ApiCalls; + + test.beforeEach(async () => { + browser = new BrowserWrapper(); + apiCalls = new ApiCalls(); + }) + + test.afterEach(async () => { + await browser.closeBrowser(); + }) + + test('@admin Validate that table view is disabled when there is no data', async () => { + const tableView = await browser.createNewPage(TableView, urls.graphUrl); + const isTableViewEnabled = await tableView.GetIsTableViewTabEnabled(); + expect(isTableViewEnabled).toBe(false); + }) + + test('@admin Validate that table view is enabled when there is data', async () => { + const graphName = getRandomString('table'); + await apiCalls.addGraph(graphName); + const tableView = await browser.createNewPage(TableView, urls.graphUrl); + await tableView.selectExistingGraph(graphName); + await tableView.insertQuery("CREATE (n) RETURN n"); + await tableView.clickRunQuery(false); + const isTableViewEnabled = await tableView.GetIsTableViewTabEnabled(); + expect(isTableViewEnabled).toBe(true); + }) + + test('@admin Validate that table view is selected when there is data', async () => { + const graphName = getRandomString('table'); + await apiCalls.addGraph(graphName); + const tableView = await browser.createNewPage(TableView, urls.graphUrl); + await tableView.selectExistingGraph(graphName); + await tableView.insertQuery("UNWIND range(1, 10) AS x RETURN x"); + await tableView.clickRunQuery(false); + const isTableViewSelected = await tableView.GetIsTableViewTabSelected(); + expect(isTableViewSelected).toBe(true); + }) + + test('@admin Validate that the data displayed in the table view is the same as the data returned by the query', async () => { + const graphName = getRandomString('table'); + await apiCalls.addGraph(graphName); + const tableView = await browser.createNewPage(TableView, urls.graphUrl); + await tableView.selectExistingGraph(graphName); + const query = "UNWIND range(1, 10) AS x RETURN x"; + await tableView.insertQuery(query); + await tableView.clickRunQuery(false); + await tableView.waitForRunQueryToBeEnabled(); + const data = await tableView.getRowsCount(); + expect(data).toBe(10); + }) +}) \ No newline at end of file diff --git a/e2e/tests/tutorial.spec.ts b/e2e/tests/tutorial.spec.ts new file mode 100644 index 00000000..9d36bb82 --- /dev/null +++ b/e2e/tests/tutorial.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; +import urls from '../config/urls.json' +import BrowserWrapper from "../infra/ui/browserWrapper"; +import TutotialPanel from "../logic/POM/tutorialPanelComponent"; +import { getRandomString } from "../infra/utils"; +import ApiCalls from "../logic/api/apiCalls"; + +test.describe(`Tutorial Test`, () => { + let browser: BrowserWrapper; + let apiCall: ApiCalls; + + test.beforeEach(async () => { + browser = new BrowserWrapper(); + apiCall = new ApiCalls(); + }) + + test.afterEach(async () => { + await browser.closeBrowser(); + }) + + test("@admin Validate adding a graph via tutorial and validate via API", async () => { + const tutorial = await browser.createNewPage(TutotialPanel, urls.graphUrl); + await tutorial.changeLocalStorage('true'); + await tutorial.refreshPage(); + const graphName = getRandomString('graph'); + await tutorial.createNewGraph(graphName); + const graphs = await apiCall.getGraphs(); + expect(graphs.opts.includes(graphName)).toBeTruthy(); + await tutorial.changeLocalStorage('false'); + await apiCall.removeGraph(graphName); + }) + + test("@admin validate checking don't show this again will not display tutorial on refresh", async () => { + const tutorial = await browser.createNewPage(TutotialPanel, urls.graphUrl); + await tutorial.changeLocalStorage('true'); + await tutorial.refreshPage(); + await tutorial.dismissTutorial(); + await tutorial.refreshPage(); + expect(await tutorial.isContentInCenterHidden()).toBeTruthy(); + await tutorial.changeLocalStorage('false'); + }) + + test("@admin validate that clicking away dismisses the tutorial panel", async () => { + const tutorial = await browser.createNewPage(TutotialPanel, urls.graphUrl); + await tutorial.changeLocalStorage('true'); + await tutorial.refreshPage(); + await tutorial.clickAtTopLeftCorner(); + await new Promise(resolve => setTimeout(resolve, 1000)); + expect(await tutorial.isContentInCenterHidden()).toBeTruthy(); + await tutorial.changeLocalStorage('false'); + }) +}) \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 68e9b0e1..d0c16451 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -9,27 +9,41 @@ import { MutableRefObject } from "react" import { ForceGraphMethods } from "react-force-graph-2d" import { Node, Link, DataCell } from "@/app/api/graph/model" +export const screenSize = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + '2xl': 1536 +} + export type GraphRef = MutableRefObject | undefined> -export interface HistoryQuery { - queries: Query[] - query: string - currentQuery: string - counter: number +export type SelectCell = { + value: string, + type: "select", + options: string[], + selectType: "Role" + onChange: (value: string) => Promise, } -export interface Query { - text: string - metadata: string[] - explain: string[] +export type ObjectCell = { + value: DataCell, + type: "object", } -export type Cell = { - value: DataCell, - onChange?: (value: string) => Promise, - type?: string - comboboxType?: string +export type TextCell = { + value: string, + type: "text", + onChange: (value: string) => Promise, } + +export type ReadOnlyCell = { + value: string, + type: "readonly", +} + +export type Cell = SelectCell | TextCell | ObjectCell | ReadOnlyCell export interface Row { cells: Cell[] checked?: boolean @@ -115,7 +129,7 @@ export function rgbToHSL(hex: string): string { return `hsl(${hDeg}, ${sPct}%, ${lPct}%)`; } -export function handleZoomToFit(chartRef?: GraphRef, filter?: (node: Node) => boolean) { +export function handleZoomToFit(chartRef?: GraphRef, filter?: (node: Node) => boolean, paddingMultiplier = 1) { const chart = chartRef?.current if (chart) { // Get canvas dimensions @@ -125,7 +139,7 @@ export function handleZoomToFit(chartRef?: GraphRef, filter?: (node: Node) => bo // Calculate padding as 10% of the smallest canvas dimension, with minimum of 40px const minDimension = Math.min(canvas.width, canvas.height); const padding = minDimension * 0.1 - chart.zoomToFit(1000, padding, filter) + chart.zoomToFit(1000, padding * paddingMultiplier, filter) } } diff --git a/next.config.js b/next.config.js index e216f179..4654b83d 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,39 @@ const nextConfig = { images: { unoptimized: true }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'none';" + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload' + }, + { + key: 'X-Frame-Options', + value: 'DENY' + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff' + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin' + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()' + } + ] + } + ]; + }, webpack(config) { // Grab the existing rule that handles SVG imports const fileLoaderRule = config.module.rules.find((rule) => diff --git a/package-lock.json b/package-lock.json index c48f0e9e..e7679c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "falkordb-browser", - "version": "1.3.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "falkordb-browser", - "version": "1.3.0", + "version": "2.0.0", "dependencies": { "-": "^0.0.1", "@hookform/resolvers": "^3.10.0", @@ -75,7 +75,7 @@ "zod": "^3.24.2" }, "devDependencies": { - "@playwright/test": "^1.50.1", + "@playwright/test": "^1.52.0", "@types/cytoscape-fcose": "^2.2.4", "@types/lodash": "^4.17.15", "@types/node": "^20.17.17", @@ -1122,12 +1122,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", - "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "devOptional": true, "dependencies": { - "playwright": "1.50.1" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -7389,11 +7389,11 @@ } }, "node_modules/playwright": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", - "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "dependencies": { - "playwright-core": "1.50.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -7406,9 +7406,9 @@ } }, "node_modules/playwright-core": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", - "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "bin": { "playwright-core": "cli.js" }, diff --git a/package.json b/package.json index 8fd93b11..4f7e9a97 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "falkordb-browser", "author": "FalkorDB", "description": "FalkorDB Browser", - "version": "1.3.0", + "version": "2.0.0", "private": true, "scripts": { "dev": "next dev", @@ -79,7 +79,7 @@ "zod": "^3.24.2" }, "devDependencies": { - "@playwright/test": "^1.50.1", + "@playwright/test": "^1.52.0", "@types/cytoscape-fcose": "^2.2.4", "@types/lodash": "^4.17.15", "@types/node": "^20.17.17", diff --git a/public/Logo.svg b/public/Logo.svg index e9e97167..ccfaa15a 100644 --- a/public/Logo.svg +++ b/public/Logo.svg @@ -1,18 +1,485 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/discord.svg b/public/icons/discord.svg new file mode 100644 index 00000000..5068efc9 --- /dev/null +++ b/public/icons/discord.svg @@ -0,0 +1,3 @@ + + +