From c43fcc5554d86825a429be1486a2741288332936 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 11:54:16 -0600 Subject: [PATCH 01/10] Add @types/wicg-file-system-access --- packages/graph-explorer/package.json | 1 + packages/graph-explorer/tsconfig.app.json | 7 ++++++- pnpm-lock.yaml | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 6c5cd950c..093562c6b 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -43,6 +43,7 @@ "@react-stately/list": "^3.11.2", "@tanstack/react-query": "^5.64.2", "@tanstack/react-query-devtools": "^5.64.2", + "@types/wicg-file-system-access": "^2023.10.5", "clsx": "^2.1.1", "color": "^4.2.3", "crypto-js": "^4.2.0", diff --git a/packages/graph-explorer/tsconfig.app.json b/packages/graph-explorer/tsconfig.app.json index e5f589fcc..ab32eb344 100644 --- a/packages/graph-explorer/tsconfig.app.json +++ b/packages/graph-explorer/tsconfig.app.json @@ -5,7 +5,12 @@ "useDefineForClassFields": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + "types": [ + "vite/client", + "vitest/globals", + "@testing-library/jest-dom", + "@types/wicg-file-system-access" + ], /* Path aliasing */ "baseUrl": "./src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7237c4b64..0b4c54c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.64.2 version: 5.64.2(@tanstack/react-query@5.64.2(react@18.3.1))(react@18.3.1) + '@types/wicg-file-system-access': + specifier: ^2023.10.5 + version: 2023.10.5 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -2921,6 +2924,9 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/wicg-file-system-access@2023.10.5': + resolution: {integrity: sha512-e9kZO9kCdLqT2h9Tw38oGv9UNzBBWaR1MzuAavxPcsV/7FJ3tWbU6RI3uB+yKIDPGLkGVbplS52ub0AcRLvrhA==} + '@typescript-eslint/eslint-plugin@8.21.0': resolution: {integrity: sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -9020,6 +9026,8 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/wicg-file-system-access@2023.10.5': {} + '@typescript-eslint/eslint-plugin@8.21.0(@typescript-eslint/parser@8.21.0(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 From 225c2243b53bf45332e7ba4d88dc9c9aa62b1ed4 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 12:08:44 -0600 Subject: [PATCH 02/10] Add file saving helper to use native save dialog --- packages/graph-explorer/src/utils/fileData.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/graph-explorer/src/utils/fileData.ts b/packages/graph-explorer/src/utils/fileData.ts index 68addf89b..fed9444ce 100644 --- a/packages/graph-explorer/src/utils/fileData.ts +++ b/packages/graph-explorer/src/utils/fileData.ts @@ -1,3 +1,5 @@ +import { saveAs } from "file-saver"; + export function toJsonFileData(input: object) { return new Blob([JSON.stringify(input)], { type: "application/json", @@ -14,3 +16,29 @@ export async function fromFileToJson(blob: Blob) { const textContents = await blob.text(); return JSON.parse(textContents) as unknown; } + +/** + * Saves a file using the native file save dialog if possible. + * + * If the browser does not support the native file save dialog, it will fall back + * to using the `file-saver` library. + */ +export async function saveFile(file: Blob, defaultFileName: string) { + if (!("showSaveFilePicker" in window)) { + saveAs(file, defaultFileName); + } + + const fileHandle = await window.showSaveFilePicker({ + suggestedName: defaultFileName, + types: [ + { + description: "JSON", + accept: { "application/json": [".json"] }, + }, + ], + }); + + const writable = await fileHandle.createWritable(); + await writable.write(file); + await writable.close(); +} From 9729e96e742cf030ae26abe5426add14e5261bc3 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 14:59:53 -0600 Subject: [PATCH 03/10] Implement FileButton locally, fixing bug choosing same file --- .../src/components/FileButton.tsx | 62 +++++++++++++++++++ .../graph-explorer/src/components/index.ts | 2 + 2 files changed, 64 insertions(+) create mode 100644 packages/graph-explorer/src/components/FileButton.tsx diff --git a/packages/graph-explorer/src/components/FileButton.tsx b/packages/graph-explorer/src/components/FileButton.tsx new file mode 100644 index 000000000..ff719b808 --- /dev/null +++ b/packages/graph-explorer/src/components/FileButton.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import Button from "./Button"; + +export interface FileButtonProps + extends React.ComponentPropsWithoutRef { + asChild?: boolean; + onChange?: (files: FileList | null) => void; + accept?: string; + multiple?: boolean; +} + +/** + * A wrapper around whatever button you want to open a file dialog. It will + * automatically open the file dialog when clicked. + * + * @example + * console.log(files)} asChild> + * + * + */ +export const FileButton = React.forwardRef( + ( + { asChild, onChange, accept, multiple, isDisabled, children, ...props }, + ref + ) => { + const inputRef = React.useRef(null); + + const handleClick = () => { + !isDisabled && inputRef.current?.click(); + }; + + const handleChange = (event: React.ChangeEvent) => { + const files = event.target.files; + onChange?.(files); + // Reset the input value to allow selecting the same file again + event.target.value = ""; + }; + + const Component = asChild ? (Slot as any) : Button; + + return ( + <> + + {children} + + + + ); + } +); + +FileButton.displayName = "FileButton"; diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 70046cde2..e30e893cd 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -15,6 +15,8 @@ export { default as PanelError } from "./PanelError"; export { default as Divider } from "./Divider"; +export * from "./FileButton"; + export { default as Graph } from "./Graph"; export * from "./Graph"; From 1cf4de75434d789433e456839a4d14022fa529b3 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 14:37:12 -0600 Subject: [PATCH 04/10] Fix layout when clearing and loading the same graph --- .../src/components/Graph/hooks/useRunLayout.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts b/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts index 7cb66f9bb..fa8c77231 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts @@ -57,10 +57,11 @@ function useUpdateLayout({ node.unlock(); }); } - - previousNodesRef.current = new Set(nodesInGraph.map(node => node.id())); previousLayoutRef.current = layout; } + + // Ensure the previousNodesRef is updated on every run + previousNodesRef.current = new Set(nodesInGraph.map(node => node.id())); }, [ cy, layout, From 9ac15d1d8c3055330c617521109798979dc9dbed Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 10:43:50 -0600 Subject: [PATCH 05/10] Import and export graph data --- .../modules/GraphViewer/ExportGraphButton.tsx | 36 ++ .../src/modules/GraphViewer/GraphViewer.tsx | 4 + .../GraphViewer/ImportGraphButton.test.tsx | 229 ++++++++++++ .../modules/GraphViewer/ImportGraphButton.tsx | 337 ++++++++++++++++++ .../modules/GraphViewer/exportedGraph.test.ts | 152 ++++++++ .../src/modules/GraphViewer/exportedGraph.ts | 78 ++++ .../src/utils/testing/randomData.ts | 63 +++- packages/shared/src/types/index.ts | 13 + 8 files changed, 894 insertions(+), 18 deletions(-) create mode 100644 packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx create mode 100644 packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx create mode 100644 packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx create mode 100644 packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts create mode 100644 packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts diff --git a/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx new file mode 100644 index 000000000..fa6440e8c --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useRecoilValue } from "recoil"; +import { SaveIcon } from "lucide-react"; +import { nodesAtom, edgesAtom, useExplorer } from "@/core"; +import { saveFile, toJsonFileData } from "@/utils/fileData"; +import { PanelHeaderActionButton } from "@/components"; +import { createExportedGraph } from "./exportedGraph"; + +export function ExportGraphButton() { + const exportGraph = useExportGraph(); + + return ( + } + label="Save graph to file" + onActionClick={() => exportGraph("graph-export.json")} + /> + ); +} + +export function useExportGraph() { + const vertexIds = useRecoilValue(nodesAtom).keys().toArray(); + const edgeIds = useRecoilValue(edgesAtom).keys().toArray(); + const connection = useExplorer().connection; + + const exportGraph = useCallback( + async (fileName: string) => { + const exportData = createExportedGraph(vertexIds, edgeIds, connection); + const fileToSave = toJsonFileData(exportData); + await saveFile(fileToSave, fileName); + }, + [connection, vertexIds, edgeIds] + ); + + return exportGraph; +} diff --git a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx index 0fc371599..fe4064cfd 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx @@ -43,6 +43,8 @@ import useGraphStyles from "./useGraphStyles"; import useNodeBadges from "./useNodeBadges"; import { SelectedElements } from "@/components/Graph/Graph.model"; import { useAutoOpenDetailsSidebar } from "./useAutoOpenDetailsSidebar"; +import { ImportGraphButton } from "./ImportGraphButton"; +import { ExportGraphButton } from "./ExportGraphButton"; import { BadgeInfoIcon, CircleSlash2, @@ -204,6 +206,8 @@ export default function GraphViewer({ icon={} onActionClick={onSaveScreenshot} /> + + { + it("should create a completion notification with 1 node and 1 edge", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + fetchResult.entities.vertices = fetchResult.entities.vertices.slice(0, 1); + fetchResult.entities.edges = fetchResult.entities.edges.slice(0, 1); + + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("success"); + expect(notification.message).toBe( + "Finished importing 1 node and 1 edge from the graph file." + ); + }); + + it("should create a completion notification with multiple nodes and edges", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + const nodeCount = fetchResult.entities.vertices.length.toLocaleString(); + const edgeCount = fetchResult.entities.edges.length.toLocaleString(); + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("success"); + expect(notification.message).toBe( + `Finished importing ${nodeCount} nodes and ${edgeCount} edges from the graph file.` + ); + }); + + it("should create a completion notification when some nodes and edges were not found", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + fetchResult.counts.notFound.vertices = createRandomInteger(); + fetchResult.counts.notFound.edges = createRandomInteger(); + fetchResult.counts.notFound.total = + fetchResult.counts.notFound.vertices + fetchResult.counts.notFound.edges; + const nodeCount = fetchResult.counts.notFound.vertices.toLocaleString(); + const edgeCount = fetchResult.counts.notFound.edges.toLocaleString(); + + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("info"); + expect(notification.message).toBe( + `Finished importing the graph, but ${nodeCount} nodes and ${edgeCount} edges were not found.` + ); + }); + + it("should create a completion notification when some nodes and edges had errors", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + fetchResult.counts.errors.vertices = createRandomInteger(); + fetchResult.counts.errors.edges = createRandomInteger(); + fetchResult.counts.errors.total = + fetchResult.counts.errors.vertices + fetchResult.counts.errors.edges; + const nodeCount = fetchResult.counts.errors.vertices.toLocaleString(); + const edgeCount = fetchResult.counts.errors.edges.toLocaleString(); + + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `Finished importing the graph, but ${nodeCount} nodes and ${edgeCount} edges encountered an error.` + ); + }); + + it("should create a completion notification when no nodes or edges were imported", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + fetchResult.entities.vertices = []; + fetchResult.entities.edges = []; + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `Finished importing the graph, but no nodes or edges were imported.` + ); + }); +}); + +describe("createErrorNotification", () => { + it("should use generic error for an unrecognized error", () => { + const error = new Error("test"); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + "Failed to import the graph because an error occurred." + ); + }); + + it("should use parsing error for a zod error", () => { + const error = new ZodError([]); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `Parsing the file "${file.name}" failed. Please ensure the file was exported from Graph Explorer and is not corrupt.` + ); + }); + + it("should show the db url and gremlin query engine when no match is found", () => { + const connection = createRandomExportedGraphConnection(); + connection.queryEngine = "gremlin"; + const error = new InvalidConnectionError("test", connection); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `The graph file requires a connection to ${connection.dbUrl} using the graph type PG-Gremlin.` + ); + }); + + it("should show the db url and sparql query engine when no match is found", () => { + const connection = createRandomExportedGraphConnection(); + connection.queryEngine = "sparql"; + const error = new InvalidConnectionError("test", connection); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `The graph file requires a connection to ${connection.dbUrl} using the graph type RDF-SPARQL.` + ); + }); + + it("should show the db url and openCypher query engine when no match is found", () => { + const connection = createRandomExportedGraphConnection(); + connection.queryEngine = "openCypher"; + const error = new InvalidConnectionError("test", connection); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `The graph file requires a connection to ${connection.dbUrl} using the graph type PG-openCypher.` + ); + }); + + it("should show the connection name when a connection using the proxy server is a match", () => { + const connection = createRandomExportedGraphConnection(); + const error = new InvalidConnectionError("test", connection); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + allConnections[0].graphDbUrl = connection.dbUrl; + allConnections[0].proxyConnection = true; + allConnections[0].queryEngine = connection.queryEngine; + const matchingConnectionName = allConnections[0].displayLabel; + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `The graph file requires switching to connection ${matchingConnectionName}.` + ); + }); + + it("should show the connection name when a connection not using the proxy server is a match", () => { + const connection = createRandomExportedGraphConnection(); + const error = new InvalidConnectionError("test", connection); + const file = createRandomFile(); + const allConnections = createRandomAllConnections(); + allConnections[0].url = connection.dbUrl; + allConnections[0].proxyConnection = false; + allConnections[0].queryEngine = connection.queryEngine; + const matchingConnectionName = allConnections[0].displayLabel; + + const notification = createErrorNotification(error, file, allConnections); + + expect(notification.type).toBe("error"); + expect(notification.message).toBe( + `The graph file requires switching to connection ${matchingConnectionName}.` + ); + }); +}); + +function createRandomFetchEntityDetailsResult(): FetchEntityDetailsResult { + const entities = createRandomEntities(); + return { + entities: { + vertices: entities.nodes.values().toArray(), + edges: entities.edges.values().toArray(), + }, + counts: { + notFound: { + vertices: 0, + edges: 0, + total: 0, + }, + errors: { + vertices: 0, + edges: 0, + total: 0, + }, + }, + }; +} + +function createRandomAllConnections() { + return createArray(3, () => { + const config = createRandomRawConfiguration(); + return { + ...config.connection!, + id: config.id, + displayLabel: config.displayLabel, + }; + }); +} diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx new file mode 100644 index 000000000..28012b42e --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx @@ -0,0 +1,337 @@ +import { FileButton, PanelHeaderActionButton, Spinner } from "@/components"; +import { vertexDetailsQuery, edgeDetailsQuery, Explorer } from "@/connector"; +import { + useExplorer, + nodesAtom, + edgesAtom, + configurationAtom, + VertexId, + EdgeId, + toNodeMap, + toEdgeMap, +} from "@/core"; +import { logger } from "@/utils"; +import { fromFileToJson } from "@/utils/fileData"; +import { + useQueryClient, + useMutation, + QueryClient, +} from "@tanstack/react-query"; +import { FolderOpenIcon } from "lucide-react"; +import { useRecoilValue, useSetRecoilState } from "recoil"; +import { + ExportedGraphConnection, + exportedGraphSchema, + isMatchingConnection, +} from "./exportedGraph"; +import { useNotification } from "@/components/NotificationProvider"; +import { ZodError } from "zod"; +import { startTransition } from "react"; +import { Notification } from "@/components/NotificationProvider/reducer"; +import { ConnectionWithId } from "@shared/types"; +import { getTranslation } from "@/hooks/useTranslations"; + +export function ImportGraphButton() { + const importGraph = useImportGraphMutation(); + + return ( + payload && importGraph.mutate(payload[0])} + accept="application/json" + asChild + > + : } + label="Load graph from file" + disabled={importGraph.isPending} + /> + + ); +} + +function useImportGraphMutation() { + const queryClient = useQueryClient(); + const explorer = useExplorer(); + const setVerticesAdded = useSetRecoilState(nodesAtom); + const setEdgesAdded = useSetRecoilState(edgesAtom); + const allConfigs = useRecoilValue(configurationAtom); + const allConnections = allConfigs + .values() + .map(config => + config.connection + ? { + ...config.connection, + id: config.id, + displayLabel: config.displayLabel, + } + : null + ) + .filter(c => c != null) + .toArray(); + + const { enqueueNotification, clearNotification } = useNotification(); + + const notificationTitle = "Importing Graph"; + + const mutation = useMutation({ + mutationFn: async (file: File) => { + // 1. Parse the file + const data = await fromFileToJson(file); + const parsed = await exportedGraphSchema.parseAsync(data); + + // 2. Check connection + if (!isMatchingConnection(explorer.connection, parsed.data.connection)) { + throw new InvalidConnectionError( + "Connection must match active connection", + parsed.data.connection + ); + } + + // 3. Get the vertex and edge details from the database + const vertices = new Set(parsed.data.vertices); + const edges = new Set(parsed.data.edges); + const entityCountMessage = formatCount(vertices.size, edges.size); + + const progressNotificationId = enqueueNotification({ + title: notificationTitle, + message: `Importing the graph with ${entityCountMessage} from the file "${file.name}"`, + type: "loading", + autoHideDuration: null, + }); + + const result = await fetchEntityDetails( + vertices, + edges, + queryClient, + explorer + ); + + clearNotification(progressNotificationId); + + return result; + }, + onSuccess: result => { + // 4. Update Graph Explorer state + startTransition(() => { + setVerticesAdded( + prev => new Map([...prev, ...toNodeMap(result.entities.vertices)]) + ); + setEdgesAdded( + prev => new Map([...prev, ...toEdgeMap(result.entities.edges)]) + ); + }); + + // 5. Notify user of completion + const finalNotification = createCompletionNotification(result); + enqueueNotification({ + ...finalNotification, + title: notificationTitle, + }); + }, + onError: (error, file) => { + const notification = createErrorNotification(error, file, allConnections); + enqueueNotification({ + ...notification, + title: notificationTitle, + }); + }, + }); + return mutation; +} + +export function createCompletionNotification( + result: FetchEntityDetailsResult +): Notification { + if (result.counts.errors.total > 0) { + const errorMessage = formatCount( + result.counts.errors.vertices, + result.counts.errors.edges + ); + + return { + message: `Finished importing the graph, but ${errorMessage} encountered an error.`, + type: "error", + }; + } + + const anyImported = + result.entities.vertices.length + result.entities.edges.length > 0; + if (!anyImported) { + return { + message: `Finished importing the graph, but no nodes or edges were imported.`, + type: "error", + }; + } + + if (result.counts.notFound.total > 0) { + const errorMessage = formatCount( + result.counts.notFound.vertices, + result.counts.notFound.edges + ); + return { + message: `Finished importing the graph, but ${errorMessage} were not found.`, + type: "info", + }; + } + + const entityCountMessage = formatCount( + result.entities.vertices.length, + result.entities.edges.length + ); + return { + message: `Finished importing ${entityCountMessage} from the graph file.`, + type: "success", + }; +} + +export function createErrorNotification( + error: Error, + file: File, + allConnections: ConnectionWithId[] +): Notification { + if (error instanceof ZodError) { + // Parsing has failed + logger.error(`Failed to parse the file "${file.name}"`, error.format()); + return { + message: `Parsing the file "${file.name}" failed. Please ensure the file was exported from Graph Explorer and is not corrupt.`, + type: "error", + }; + } else if (error instanceof InvalidConnectionError) { + // Invalid connection + const matchingByUrlAndQueryEngine = allConnections.filter(connection => + isMatchingConnection(connection, error.connection) + ); + + // Get the display label for the given query engine + const displayQueryEngine = getTranslation( + "available-connections.graph-type", + error.connection.queryEngine + ); + + if (matchingByUrlAndQueryEngine.length > 0) { + const matchingConnection = matchingByUrlAndQueryEngine[0]; + return { + message: `The graph file requires switching to connection ${matchingConnection.displayLabel}.`, + type: "error", + }; + } else { + const dbUrl = error.connection.dbUrl; + return { + message: `The graph file requires a connection to ${dbUrl} using the graph type ${displayQueryEngine}.`, + type: "error", + }; + } + } + return { + message: `Failed to import the graph because an error occurred.`, + type: "error", + }; +} + +async function fetchEntityDetails( + vertices: Set, + edges: Set, + queryClient: QueryClient, + explorer: Explorer +) { + const vertexResults = await Promise.allSettled( + vertices + .values() + .map(id => + queryClient.ensureQueryData( + vertexDetailsQuery({ vertexId: id }, explorer) + ) + ) + ); + const edgeResults = await Promise.allSettled( + edges + .values() + .map(id => + queryClient.ensureQueryData(edgeDetailsQuery({ edgeId: id }, explorer)) + ) + ); + + const vertexDetails = vertexResults + .filter(result => result.status === "fulfilled") + .map(result => result.value.vertex) + .filter(v => v != null); + const edgeDetails = edgeResults + .filter(result => result.status === "fulfilled") + .map(result => result.value.edge) + .filter(e => e != null); + + const countOfVertexErrors = vertexResults.reduce((sum, item) => { + return sum + (item.status === "rejected" ? 1 : 0); + }, 0); + const countOfEdgeErrors = edgeResults.reduce((sum, item) => { + return sum + (item.status === "rejected" ? 1 : 0); + }, 0); + + const countOfVertexNotFound = vertexResults.reduce((sum, item) => { + return ( + sum + (item.status === "fulfilled" && item.value.vertex == null ? 1 : 0) + ); + }, 0); + const countOfEdgeNotFound = edgeResults.reduce((sum, item) => { + return ( + sum + (item.status === "fulfilled" && item.value.edge == null ? 1 : 0) + ); + }, 0); + + return { + entities: { + vertices: vertexDetails, + edges: edgeDetails, + }, + counts: { + notFound: { + vertices: countOfVertexNotFound, + edges: countOfEdgeNotFound, + total: countOfVertexNotFound + countOfEdgeNotFound, + }, + errors: { + vertices: countOfVertexErrors, + edges: countOfEdgeErrors, + total: countOfVertexErrors + countOfEdgeErrors, + }, + }, + }; +} + +export type FetchEntityDetailsResult = Awaited< + ReturnType +>; + +function formatCount(vertexCount: number, edgeCount: number) { + return [formatVertexCount(vertexCount), formatEdgeCount(edgeCount)] + .filter(message => message != null) + .join(" and "); +} + +function formatVertexCount(count: number) { + if (count === 0) { + return null; + } + return count > 1 + ? `${count.toLocaleString()} nodes` + : `${count.toLocaleString()} node`; +} + +function formatEdgeCount(count: number) { + if (count === 0) { + return null; + } + return count > 1 + ? `${count.toLocaleString()} edges` + : `${count.toLocaleString()} edge`; +} + +export class InvalidConnectionError extends Error { + connection: ExportedGraphConnection; + constructor(message: string, connection: ExportedGraphConnection) { + super(message); + this.name = "InvalidConnectionError"; + this.connection = connection; + Object.setPrototypeOf(this, InvalidConnectionError.prototype); + } +} diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts new file mode 100644 index 000000000..9315f329a --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts @@ -0,0 +1,152 @@ +import { + createRandomConnectionWithId, + createRandomEdgeId, + createRandomExportedGraphConnection, + createRandomVertexId, +} from "@/utils/testing"; +import { + createExportedConnection, + createExportedGraph, + ExportedGraph, + ExportedGraphConnection, + isMatchingConnection, +} from "./exportedGraph"; +import { + createArray, + createRandomDate, + createRandomInteger, + createRandomUrlString, +} from "@shared/utils/testing"; + +describe("createExportedGraph", () => { + let timestamp: Date; + let appVersion: string; + + beforeEach(() => { + // tell vitest we use mocked time + vi.useFakeTimers(); + + // set the system time to a random Date + timestamp = createRandomDate(); + vi.setSystemTime(timestamp); + + // set a random app version + appVersion = `${createRandomInteger()}.${createRandomInteger()}.${createRandomInteger()}`; + vi.stubGlobal("__GRAPH_EXP_VERSION__", appVersion); + }); + + afterEach(() => { + // restoring date after each test run + vi.useRealTimers(); + }); + + it("should create an exported graph from a vertex and edge ids", () => { + const vertexIds = createArray(3, () => createRandomVertexId()); + const edgeIds = createArray(3, () => createRandomEdgeId()); + const connection = createRandomConnectionWithId(); + const expectedConnection = createExportedConnection(connection); + const expectedMeta = { + kind: "graph-export", + version: "1.0", + timestamp: timestamp, + source: "Graph Explorer", + sourceVersion: appVersion, + } satisfies ExportedGraph["meta"]; + + const graph = createExportedGraph(vertexIds, edgeIds, connection); + + expect(graph.meta).toEqual(expectedMeta); + expect(graph.data.connection).toEqual(expectedConnection); + expect(graph.data.vertices).toEqual(vertexIds); + expect(graph.data.edges).toEqual(edgeIds); + }); +}); + +describe("createExportedConnection", () => { + it("should map graphDbUrl when using proxy server", () => { + const connection = createRandomConnectionWithId(); + connection.proxyConnection = true; + connection.graphDbUrl = createRandomUrlString(); + + const exportedConnection = createExportedConnection(connection); + + expect(exportedConnection).toEqual({ + dbUrl: connection.graphDbUrl, + queryEngine: connection.queryEngine!, + } satisfies ExportedGraphConnection); + }); + + it("should map url when not using proxy server", () => { + const connection = createRandomConnectionWithId(); + connection.proxyConnection = false; + + const exportedConnection = createExportedConnection(connection); + + expect(exportedConnection).toEqual({ + dbUrl: connection.url, + queryEngine: connection.queryEngine!, + } satisfies ExportedGraphConnection); + }); + + it("should default to gremlin when no query engine is provided", () => { + const connection = createRandomConnectionWithId(); + connection.proxyConnection = true; + connection.graphDbUrl = createRandomUrlString(); + delete connection.queryEngine; + + const exportedConnection = createExportedConnection(connection); + + expect(exportedConnection).toEqual({ + dbUrl: connection.graphDbUrl, + queryEngine: "gremlin", + } satisfies ExportedGraphConnection); + }); +}); + +describe("isMatchingConnection", () => { + it("should return true when connection matches", () => { + const connection = createRandomConnectionWithId(); + const exportedConnection = createExportedConnection(connection); + + expect(isMatchingConnection(connection, exportedConnection)).toBeTruthy(); + }); + + it("should return false when completely different", () => { + const connection = createRandomConnectionWithId(); + const exportedConnection = createRandomExportedGraphConnection(); + + expect(isMatchingConnection(connection, exportedConnection)).toBeFalsy(); + }); + + it("should return false when query engine is different", () => { + const connection = createRandomConnectionWithId(); + connection.queryEngine = "gremlin"; + const exportedConnection = createExportedConnection({ + ...connection, + queryEngine: "sparql", + }); + + expect(isMatchingConnection(connection, exportedConnection)).toBeFalsy(); + }); + + it("should return false when graph db url is different", () => { + const connection = createRandomConnectionWithId(); + connection.proxyConnection = true; + connection.graphDbUrl = createRandomUrlString(); + const exportedConnection = createRandomExportedGraphConnection(); + exportedConnection.dbUrl = connection.url; + exportedConnection.queryEngine = connection.queryEngine!; + + expect(isMatchingConnection(connection, exportedConnection)).toBeFalsy(); + }); + + it("should return false when url is different", () => { + const connection = createRandomConnectionWithId(); + connection.proxyConnection = false; + const exportedConnection = createRandomExportedGraphConnection(); + exportedConnection.dbUrl = createRandomUrlString(); + exportedConnection.queryEngine = connection.queryEngine!; + + expect(isMatchingConnection(connection, exportedConnection)).toBeFalsy(); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts new file mode 100644 index 000000000..e7b1e7885 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts @@ -0,0 +1,78 @@ +import { createVertexId, createEdgeId, EdgeId, VertexId } from "@/core"; +import { APP_NAME } from "@/utils"; +import { ConnectionConfig, queryEngineOptions } from "@shared/types"; +import { z } from "zod"; + +export const exportedGraphSchema = z.object({ + meta: z.object({ + kind: z.literal("graph-export"), + version: z.literal("1.0"), + timestamp: z.coerce.date(), + source: z.string(), + sourceVersion: z.string(), + }), + data: z.object({ + connection: z.object({ + dbUrl: z.string(), + queryEngine: z.enum(queryEngineOptions), + }), + vertices: z + .array(z.union([z.string(), z.number()])) + .transform(ids => ids.map(id => createVertexId(id))), + edges: z + .array(z.union([z.string(), z.number()])) + .transform(ids => ids.map(id => createEdgeId(id))), + }), +}); + +export type ExportedGraph = z.infer; +export type ExportedGraphConnection = ExportedGraph["data"]["connection"]; + +/** Creates an exported graph suitable for saving to a file. */ +export function createExportedGraph( + vertexIds: VertexId[], + edgeIds: EdgeId[], + connection: ConnectionConfig +): ExportedGraph { + return { + meta: { + kind: "graph-export", + source: APP_NAME, + sourceVersion: __GRAPH_EXP_VERSION__, + version: "1.0", + timestamp: new Date(), + }, + data: { + connection: createExportedConnection(connection), + vertices: vertexIds, + edges: edgeIds, + }, + }; +} + +/** Creates an exported connection object from the given connection config. */ +export function createExportedConnection( + connection: ConnectionConfig +): ExportedGraphConnection { + const dbUrl = ( + (connection.proxyConnection ? connection.graphDbUrl : connection.url) ?? "" + ).toLowerCase(); + const queryEngine = connection.queryEngine ?? "gremlin"; + + return { + dbUrl: dbUrl, + queryEngine: queryEngine, + }; +} + +/** Compares the connection config to the exported connection. */ +export function isMatchingConnection( + connection: ConnectionConfig, + exportedConnection: ExportedGraphConnection +) { + const compareTo = createExportedConnection(connection); + return ( + compareTo.dbUrl === exportedConnection.dbUrl && + compareTo.queryEngine === exportedConnection.queryEngine + ); +} diff --git a/packages/graph-explorer/src/utils/testing/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts index e5917502a..be0e273f6 100644 --- a/packages/graph-explorer/src/utils/testing/randomData.ts +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -31,11 +31,13 @@ import { import { toNodeMap } from "@/core/StateProvider/nodes"; import { toEdgeMap } from "@/core/StateProvider/edges"; import { + ConnectionWithId, NeptuneServiceType, neptuneServiceTypeOptions, QueryEngine, queryEngineOptions, } from "@shared/types"; +import { ExportedGraphConnection } from "@/modules/GraphViewer/exportedGraph"; /* @@ -211,11 +213,24 @@ function pickRandomElement(array: T[]): T { return array[Math.floor(Math.random() * array.length)]; } -/** - * Creates a random RawConfiguration object. - * @returns A random RawConfiguration object. - */ -export function createRandomRawConfiguration(): RawConfiguration { +export function createRandomExportedGraphConnection(): ExportedGraphConnection { + const dbUrl = createRandomUrlString(); + const queryEngine = createRandomQueryEngine(); + return { + dbUrl, + queryEngine, + }; +} + +export function createRandomFile(): File { + const fileName = createRandomName("File"); + const contentsString = createRandomName("Contents"); + const fileContent = new Blob([contentsString], { type: "text/plain" }); + const file = new File([fileContent], fileName, { type: "text/plain" }); + return file; +} + +export function createRandomConnectionWithId(): ConnectionWithId { const isProxyConnection = createRandomBoolean(); const isIamEnabled = createRandomBoolean(); const fetchTimeoutMs = randomlyUndefined(createRandomInteger()); @@ -226,19 +241,31 @@ export function createRandomRawConfiguration(): RawConfiguration { return { id: createRandomName("id"), displayLabel: createRandomName("displayLabel"), - connection: { - url: createRandomUrlString(), - ...(isProxyConnection && { graphDbUrl: createRandomUrlString() }), - queryEngine, - proxyConnection: isProxyConnection, - ...(isIamEnabled && { awsAuthEnabled: createRandomBoolean() }), - ...(isIamEnabled && { - awsRegion: createRandomAwsRegion(), - }), - ...(fetchTimeoutMs && { fetchTimeoutMs }), - ...(nodeExpansionLimit && { nodeExpansionLimit }), - ...(serviceType && { serviceType }), - }, + url: createRandomUrlString(), + ...(isProxyConnection && { graphDbUrl: createRandomUrlString() }), + queryEngine, + proxyConnection: isProxyConnection, + ...(isIamEnabled && { awsAuthEnabled: createRandomBoolean() }), + ...(isIamEnabled && { + awsRegion: createRandomAwsRegion(), + }), + ...(fetchTimeoutMs && { fetchTimeoutMs }), + ...(nodeExpansionLimit && { nodeExpansionLimit }), + ...(serviceType && { serviceType }), + }; +} + +/** + * Creates a random RawConfiguration object. + * @returns A random RawConfiguration object. + */ +export function createRandomRawConfiguration(): RawConfiguration { + const { id, displayLabel, ...connection } = createRandomConnectionWithId(); + + return { + id, + displayLabel, + connection, }; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index bd13eddbb..3f139166f 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -50,3 +50,16 @@ export type ConnectionConfig = { */ nodeExpansionLimit?: number; }; + +/** + * Represents a connection config with the ID and display label integrated in to + * the type. + * + * This makes it a bit easier to deal with compared to the connection inside the + * `RawConfiguration` type since that one has a bunch of other properties and + * the connection is optional. + */ +export type ConnectionWithId = ConnectionConfig & { + id: string; + displayLabel?: string; +}; From a464d0c3ea9af4faba69e41c26efbf0c174bb551 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 6 Feb 2025 15:06:05 -0600 Subject: [PATCH 06/10] Update changelog --- Changelog.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Changelog.md b/Changelog.md index 57cadab6a..e996eba43 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,18 @@ ## Upcoming +- **Added** ability to save the rendered graph to a file, allowing for reloading + the graph later or sharing the graph with other users who have the same + connection ([#756](https://github.com/aws/graph-explorer/pull/756), + [#758](https://github.com/aws/graph-explorer/pull/758), + [#761](https://github.com/aws/graph-explorer/pull/761), + [#762](https://github.com/aws/graph-explorer/pull/762), + [#767](https://github.com/aws/graph-explorer/pull/767), + [#768](https://github.com/aws/graph-explorer/pull/768), + [#769](https://github.com/aws/graph-explorer/pull/769), + [#770](https://github.com/aws/graph-explorer/pull/770), + [#775](https://github.com/aws/graph-explorer/pull/775), + [#781](https://github.com/aws/graph-explorer/pull/781)) - **Updated** UI labels to refer to node & edge "labels" instead of "types" ([#766](https://github.com/aws/graph-explorer/pull/766)) - **Improved** neighbor count retrieval to be more efficient @@ -15,16 +27,6 @@ ([#743](https://github.com/aws/graph-explorer/pull/743)) - **Improved** pagination controls by using a single shared component ([#742](https://github.com/aws/graph-explorer/pull/742)) -- **Updated** graph foundations to accommodate loading a graph from a set of IDs - ([#756](https://github.com/aws/graph-explorer/pull/756), - [#758](https://github.com/aws/graph-explorer/pull/758), - [#761](https://github.com/aws/graph-explorer/pull/761), - [#762](https://github.com/aws/graph-explorer/pull/762), - [#767](https://github.com/aws/graph-explorer/pull/767), - [#768](https://github.com/aws/graph-explorer/pull/768), - [#769](https://github.com/aws/graph-explorer/pull/769), - [#770](https://github.com/aws/graph-explorer/pull/770), - [#775](https://github.com/aws/graph-explorer/pull/775)) - **Updated** styling across the app ([#777](https://github.com/aws/graph-explorer/pull/777), [#743](https://github.com/aws/graph-explorer/pull/743), From 6c0670a21ba0273c0986eb1e74d861b5f2c5802d Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 7 Feb 2025 13:22:23 -0600 Subject: [PATCH 07/10] =?UTF-8?q?Change=20=E2=80=9Cimport=E2=80=9D=20to=20?= =?UTF-8?q?=E2=80=9Cload=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GraphViewer/ImportGraphButton.test.tsx | 14 +++++++------- .../modules/GraphViewer/ImportGraphButton.tsx | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx index 28b37a796..31cffa2da 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx @@ -23,7 +23,7 @@ describe("createCompletionNotification", () => { expect(notification.type).toBe("success"); expect(notification.message).toBe( - "Finished importing 1 node and 1 edge from the graph file." + "Finished loading 1 node and 1 edge from the graph file." ); }); @@ -35,7 +35,7 @@ describe("createCompletionNotification", () => { expect(notification.type).toBe("success"); expect(notification.message).toBe( - `Finished importing ${nodeCount} nodes and ${edgeCount} edges from the graph file.` + `Finished loading ${nodeCount} nodes and ${edgeCount} edges from the graph file.` ); }); @@ -52,7 +52,7 @@ describe("createCompletionNotification", () => { expect(notification.type).toBe("info"); expect(notification.message).toBe( - `Finished importing the graph, but ${nodeCount} nodes and ${edgeCount} edges were not found.` + `Finished loading the graph, but ${nodeCount} nodes and ${edgeCount} edges were not found.` ); }); @@ -69,7 +69,7 @@ describe("createCompletionNotification", () => { expect(notification.type).toBe("error"); expect(notification.message).toBe( - `Finished importing the graph, but ${nodeCount} nodes and ${edgeCount} edges encountered an error.` + `Finished loading the graph, but ${nodeCount} nodes and ${edgeCount} edges encountered an error.` ); }); @@ -81,7 +81,7 @@ describe("createCompletionNotification", () => { expect(notification.type).toBe("error"); expect(notification.message).toBe( - `Finished importing the graph, but no nodes or edges were imported.` + `Finished loading the graph, but no nodes or edges were loaded.` ); }); }); @@ -96,7 +96,7 @@ describe("createErrorNotification", () => { expect(notification.type).toBe("error"); expect(notification.message).toBe( - "Failed to import the graph because an error occurred." + "Failed to load the graph because an error occurred." ); }); @@ -109,7 +109,7 @@ describe("createErrorNotification", () => { expect(notification.type).toBe("error"); expect(notification.message).toBe( - `Parsing the file "${file.name}" failed. Please ensure the file was exported from Graph Explorer and is not corrupt.` + `Parsing the file "${file.name}" failed. Please ensure the file was originally saved from Graph Explorer and is not corrupt.` ); }); diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx index 28012b42e..e269260a7 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx @@ -71,7 +71,7 @@ function useImportGraphMutation() { const { enqueueNotification, clearNotification } = useNotification(); - const notificationTitle = "Importing Graph"; + const notificationTitle = "Loading Graph"; const mutation = useMutation({ mutationFn: async (file: File) => { @@ -94,7 +94,7 @@ function useImportGraphMutation() { const progressNotificationId = enqueueNotification({ title: notificationTitle, - message: `Importing the graph with ${entityCountMessage} from the file "${file.name}"`, + message: `Loading the graph with ${entityCountMessage} from the file "${file.name}"`, type: "loading", autoHideDuration: null, }); @@ -149,7 +149,7 @@ export function createCompletionNotification( ); return { - message: `Finished importing the graph, but ${errorMessage} encountered an error.`, + message: `Finished loading the graph, but ${errorMessage} encountered an error.`, type: "error", }; } @@ -158,7 +158,7 @@ export function createCompletionNotification( result.entities.vertices.length + result.entities.edges.length > 0; if (!anyImported) { return { - message: `Finished importing the graph, but no nodes or edges were imported.`, + message: `Finished loading the graph, but no nodes or edges were loaded.`, type: "error", }; } @@ -169,7 +169,7 @@ export function createCompletionNotification( result.counts.notFound.edges ); return { - message: `Finished importing the graph, but ${errorMessage} were not found.`, + message: `Finished loading the graph, but ${errorMessage} were not found.`, type: "info", }; } @@ -179,7 +179,7 @@ export function createCompletionNotification( result.entities.edges.length ); return { - message: `Finished importing ${entityCountMessage} from the graph file.`, + message: `Finished loading ${entityCountMessage} from the graph file.`, type: "success", }; } @@ -193,7 +193,7 @@ export function createErrorNotification( // Parsing has failed logger.error(`Failed to parse the file "${file.name}"`, error.format()); return { - message: `Parsing the file "${file.name}" failed. Please ensure the file was exported from Graph Explorer and is not corrupt.`, + message: `Parsing the file "${file.name}" failed. Please ensure the file was originally saved from Graph Explorer and is not corrupt.`, type: "error", }; } else if (error instanceof InvalidConnectionError) { @@ -223,7 +223,7 @@ export function createErrorNotification( } } return { - message: `Failed to import the graph because an error occurred.`, + message: `Failed to load the graph because an error occurred.`, type: "error", }; } From a8f7de24be5017ae16da47c7fdd079e6a90280c0 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 7 Feb 2025 13:27:27 -0600 Subject: [PATCH 08/10] Prefer not found message when all not found --- .../GraphViewer/ImportGraphButton.test.tsx | 19 +++++++++++++++++++ .../modules/GraphViewer/ImportGraphButton.tsx | 18 +++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx index 31cffa2da..62ca4e206 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx @@ -56,6 +56,25 @@ describe("createCompletionNotification", () => { ); }); + it("should create a completion notification when all nodes and edges were not found", () => { + const fetchResult = createRandomFetchEntityDetailsResult(); + fetchResult.entities.vertices = []; + fetchResult.entities.edges = []; + fetchResult.counts.notFound.vertices = createRandomInteger(); + fetchResult.counts.notFound.edges = createRandomInteger(); + fetchResult.counts.notFound.total = + fetchResult.counts.notFound.vertices + fetchResult.counts.notFound.edges; + const nodeCount = fetchResult.counts.notFound.vertices.toLocaleString(); + const edgeCount = fetchResult.counts.notFound.edges.toLocaleString(); + + const notification = createCompletionNotification(fetchResult); + + expect(notification.type).toBe("info"); + expect(notification.message).toBe( + `Finished loading the graph, but ${nodeCount} nodes and ${edgeCount} edges were not found.` + ); + }); + it("should create a completion notification when some nodes and edges had errors", () => { const fetchResult = createRandomFetchEntityDetailsResult(); fetchResult.counts.errors.vertices = createRandomInteger(); diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx index e269260a7..f610fa13c 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx @@ -154,15 +154,6 @@ export function createCompletionNotification( }; } - const anyImported = - result.entities.vertices.length + result.entities.edges.length > 0; - if (!anyImported) { - return { - message: `Finished loading the graph, but no nodes or edges were loaded.`, - type: "error", - }; - } - if (result.counts.notFound.total > 0) { const errorMessage = formatCount( result.counts.notFound.vertices, @@ -174,6 +165,15 @@ export function createCompletionNotification( }; } + const anyImported = + result.entities.vertices.length + result.entities.edges.length > 0; + if (!anyImported) { + return { + message: `Finished loading the graph, but no nodes or edges were loaded.`, + type: "error", + }; + } + const entityCountMessage = formatCount( result.entities.vertices.length, result.entities.edges.length From 916ffcc1e59aae6b15a05c6327a7f8678ab035ba Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 7 Feb 2025 17:59:29 -0600 Subject: [PATCH 09/10] Improved default file name --- .../modules/GraphViewer/ExportGraphButton.tsx | 23 +++---- .../modules/GraphViewer/exportedGraph.test.ts | 62 +++++++++++++++++++ .../src/modules/GraphViewer/exportedGraph.ts | 32 +++++++++- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx index fa6440e8c..2e9bc8990 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx @@ -1,10 +1,10 @@ import { useCallback } from "react"; import { useRecoilValue } from "recoil"; import { SaveIcon } from "lucide-react"; -import { nodesAtom, edgesAtom, useExplorer } from "@/core"; +import { nodesAtom, edgesAtom, useExplorer, useConfiguration } from "@/core"; import { saveFile, toJsonFileData } from "@/utils/fileData"; import { PanelHeaderActionButton } from "@/components"; -import { createExportedGraph } from "./exportedGraph"; +import { createDefaultFileName, createExportedGraph } from "./exportedGraph"; export function ExportGraphButton() { const exportGraph = useExportGraph(); @@ -13,7 +13,7 @@ export function ExportGraphButton() { } label="Save graph to file" - onActionClick={() => exportGraph("graph-export.json")} + onActionClick={() => exportGraph()} /> ); } @@ -22,15 +22,16 @@ export function useExportGraph() { const vertexIds = useRecoilValue(nodesAtom).keys().toArray(); const edgeIds = useRecoilValue(edgesAtom).keys().toArray(); const connection = useExplorer().connection; + const config = useConfiguration(); - const exportGraph = useCallback( - async (fileName: string) => { - const exportData = createExportedGraph(vertexIds, edgeIds, connection); - const fileToSave = toJsonFileData(exportData); - await saveFile(fileToSave, fileName); - }, - [connection, vertexIds, edgeIds] - ); + const exportGraph = useCallback(async () => { + const fileName = createDefaultFileName( + config?.displayLabel ?? "Connection" + ); + const exportData = createExportedGraph(vertexIds, edgeIds, connection); + const fileToSave = toJsonFileData(exportData); + await saveFile(fileToSave, fileName); + }, [config?.displayLabel, connection, vertexIds, edgeIds]); return exportGraph; } diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts index 9315f329a..5262a2c3e 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts @@ -5,10 +5,12 @@ import { createRandomVertexId, } from "@/utils/testing"; import { + createDefaultFileName, createExportedConnection, createExportedGraph, ExportedGraph, ExportedGraphConnection, + createFileSafeTimestamp, isMatchingConnection, } from "./exportedGraph"; import { @@ -150,3 +152,63 @@ describe("isMatchingConnection", () => { expect(isMatchingConnection(connection, exportedConnection)).toBeFalsy(); }); }); + +describe("getSafeTimestamp", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return a file safe timestamp", () => { + vi.setSystemTime(new Date("2025-02-07T01:01:01.000Z")); + const timestamp = createFileSafeTimestamp(); + expect(timestamp).toBe("20250207010101"); + }); +}); + +describe("createDefaultFileName", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-02-07T01:01:01.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should create a default file name with a connection name", () => { + const fileName = createDefaultFileName("default"); + expect(fileName).toBe(`default.20250207010101.graph.json`); + }); + + it("should replace spaces with dashes", () => { + const fileName = createDefaultFileName("My Connection to the Database"); + expect(fileName).toBe( + `my-connection-to-the-database.20250207010101.graph.json` + ); + }); + + it("should remove special characters", () => { + const connectionName = "connection !@#$%^&*()"; + const fileName = createDefaultFileName(connectionName); + + expect(fileName).toBe(`connection.20250207010101.graph.json`); + }); + + it("should convert to lowercase", () => { + const connectionName = "CONnECtiOn"; + const fileName = createDefaultFileName(connectionName); + + expect(fileName).toBe(`connection.20250207010101.graph.json`); + }); + + it("should remove hyphens from connection name", () => { + const connectionName = "connection - gremlin"; + const fileName = createDefaultFileName(connectionName); + + expect(fileName).toBe(`connection-gremlin.20250207010101.graph.json`); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts index e7b1e7885..77e4e232b 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts @@ -1,6 +1,10 @@ import { createVertexId, createEdgeId, EdgeId, VertexId } from "@/core"; import { APP_NAME } from "@/utils"; -import { ConnectionConfig, queryEngineOptions } from "@shared/types"; +import { + ConnectionConfig, + QueryEngine, + queryEngineOptions, +} from "@shared/types"; import { z } from "zod"; export const exportedGraphSchema = z.object({ @@ -76,3 +80,29 @@ export function isMatchingConnection( compareTo.queryEngine === exportedConnection.queryEngine ); } + +export function createFileSafeTimestamp() { + const now = new Date(); + + // Format the date as YYYYMMDDHHMMSS + const timestamp = now + .toISOString() + .replace(/[^0-9]/g, "") + .slice(0, 14); + + return timestamp; +} + +/** Creates a default file name for the given connection. */ +export function createDefaultFileName(connectionName: string) { + // Replace spaces with dashes, remove special characters other than hyphen, and convert to lowercase + const modifiedConnectionName = connectionName + .replace(/[^a-zA-Z0-9\s+]/g, "") + .trim() + .replace(/\s+/g, "-") + .toLowerCase(); + + const timestamp = createFileSafeTimestamp(); + + return `${modifiedConnectionName}.${timestamp}.graph.json`; +} From f12c6e346fd3d9f14adae50af677f761c2befd45 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 10 Feb 2025 09:32:00 -0600 Subject: [PATCH 10/10] Fix lint error --- .../graph-explorer/src/modules/GraphViewer/exportedGraph.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts index 77e4e232b..233a27f1b 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts @@ -1,10 +1,6 @@ import { createVertexId, createEdgeId, EdgeId, VertexId } from "@/core"; import { APP_NAME } from "@/utils"; -import { - ConnectionConfig, - QueryEngine, - queryEngineOptions, -} from "@shared/types"; +import { ConnectionConfig, queryEngineOptions } from "@shared/types"; import { z } from "zod"; export const exportedGraphSchema = z.object({