diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx index 36535788f..d51972307 100644 --- a/packages/graph-explorer/src/core/AppStatusLoader.tsx +++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx @@ -4,6 +4,7 @@ import { type PropsWithChildren, startTransition, Suspense, + useState, useEffect, } from "react"; @@ -12,6 +13,15 @@ import { logger } from "@/utils"; import { fetchDefaultConnection } from "./defaultConnection"; import { activeConfigurationAtom, configurationAtom } from "./StateProvider"; +import { UrlConnectionDialog } from "./UrlConnectionDialog"; +import { + parseUrlConnectionParams, + findMatchingConnection, + buildConnectionFromParams, +} from "./urlConnectionParams"; + +// Read URL params once at module level +const initialUrlParams = parseUrlConnectionParams(window.location.search); function AppStatusLoader({ children }: PropsWithChildren) { return ( @@ -24,6 +34,7 @@ function AppStatusLoader({ children }: PropsWithChildren) { function LoadDefaultConfig({ children }: PropsWithChildren) { const [activeConfig, setActiveConfig] = useAtom(activeConfigurationAtom); const [configuration, setConfiguration] = useAtom(configurationAtom); + const [urlHandled, setUrlHandled] = useState(!initialUrlParams); const defaultConfigQuery = useQuery({ queryKey: ["default-connection"], @@ -65,6 +76,53 @@ function LoadDefaultConfig({ children }: PropsWithChildren) { defaultConnectionConfigs, ]); + // Compute dialog state during render + const existingMatch = + !urlHandled && initialUrlParams + ? findMatchingConnection(configuration, initialUrlParams) + : null; + const pendingConnection = + !urlHandled && initialUrlParams && !existingMatch + ? buildConnectionFromParams(initialUrlParams, window.location.origin) + : null; + + function handleConfirm() { + if (existingMatch) { + logger.debug( + "Activating matching connection from URL params", + existingMatch.id, + ); + startTransition(() => { + setActiveConfig(existingMatch.id); + }); + } else if (pendingConnection) { + logger.debug("Adding connection from URL params", pendingConnection); + startTransition(() => { + setConfiguration(prev => { + const updated = new Map(prev); + updated.set(pendingConnection.id, pendingConnection); + return updated; + }); + setActiveConfig(pendingConnection.id); + }); + } + setUrlHandled(true); + window.history.replaceState( + {}, + "", + window.location.pathname + window.location.hash, + ); + } + + function handleCancel() { + setUrlHandled(true); + window.history.replaceState( + {}, + "", + window.location.pathname + window.location.hash, + ); + } + if (configuration.size === 0 && defaultConfigQuery.isLoading) { return ( {children}; + return ( + <> + {children} + + + ); } function PreparingEnvironment() { diff --git a/packages/graph-explorer/src/core/UrlConnectionDialog.tsx b/packages/graph-explorer/src/core/UrlConnectionDialog.tsx new file mode 100644 index 000000000..a1f77aba3 --- /dev/null +++ b/packages/graph-explorer/src/core/UrlConnectionDialog.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogFooter, +} from "@/components/Dialog"; + +import type { RawConfiguration } from "./ConfigurationProvider"; + +interface UrlConnectionDialogProps { + open: boolean; + connection: RawConfiguration | null; + isExisting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function UrlConnectionDialog({ + open, + connection, + isExisting, + onConfirm, + onCancel, +}: UrlConnectionDialogProps) { + return ( + !o && onCancel()}> + + + + {isExisting ? "Activate Connection" : "Create Connection"} + + + + {connection && ( +
+

+ Name:{" "} + {connection.displayLabel} +

+

+ Endpoint:{" "} + {connection.connection?.graphDbUrl} +

+

+ Query Engine:{" "} + {connection.connection?.queryEngine} +

+ {connection.connection?.awsRegion && ( +

+ Region:{" "} + {connection.connection.awsRegion} +

+ )} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/packages/graph-explorer/src/core/urlConnectionParams.test.ts b/packages/graph-explorer/src/core/urlConnectionParams.test.ts new file mode 100644 index 000000000..e2e13b742 --- /dev/null +++ b/packages/graph-explorer/src/core/urlConnectionParams.test.ts @@ -0,0 +1,185 @@ +import type { + ConfigurationId, + RawConfiguration, +} from "./ConfigurationProvider"; + +import { + parseUrlConnectionParams, + findMatchingConnection, + buildConnectionFromParams, +} from "./urlConnectionParams"; + +describe("parseUrlConnectionParams", () => { + test("returns null when graphDbUrl is missing", () => { + expect(parseUrlConnectionParams("")).toBeNull(); + expect(parseUrlConnectionParams("?queryEngine=openCypher")).toBeNull(); + }); + + test("parses graphDbUrl with defaults", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.us-west-2.neptune-graph.amazonaws.com", + ); + expect(result).toEqual({ + graphDbUrl: "https://g-xxx.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: "", + name: "https://g-xxx.us-west-2.neptune-graph.amazonaws.com", + }); + }); + + test("parses all parameters", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.neptune-graph.amazonaws.com&queryEngine=openCypher&awsRegion=us-west-2&serviceType=neptune-graph&name=My+Graph", + ); + expect(result).toEqual({ + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "us-west-2", + serviceType: "neptune-graph", + name: "My Graph", + }); + }); +}); + +describe("findMatchingConnection", () => { + const configs = new Map([ + [ + "conn-1" as ConfigurationId, + { + id: "conn-1" as ConfigurationId, + displayLabel: "Test", + connection: { + url: "https://localhost", + queryEngine: "openCypher", + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + }, + }, + ], + [ + "conn-2" as ConfigurationId, + { + id: "conn-2" as ConfigurationId, + displayLabel: "Gremlin DB", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: "https://my-cluster.neptune.amazonaws.com", + }, + }, + ], + ]); + + test("finds match by graphDbUrl and queryEngine", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "", + serviceType: "", + name: "", + }); + expect(match?.id).toBe("conn-1"); + }); + + test("matches case-insensitively on graphDbUrl", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://G-ABC.US-WEST-2.NEPTUNE-GRAPH.AMAZONAWS.COM", + queryEngine: "openCypher", + awsRegion: "", + serviceType: "", + name: "", + }); + expect(match?.id).toBe("conn-1"); + }); + + test("returns null when queryEngine differs", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: "", + name: "", + }); + expect(match).toBeNull(); + }); + + test("returns null when no match", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://unknown.neptune.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: "", + name: "", + }); + expect(match).toBeNull(); + }); +}); + +describe("buildConnectionFromParams", () => { + test("builds connection with IAM enabled", () => { + const connection = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "us-west-2", + serviceType: "neptune-graph", + name: "My Graph", + }, + "https://localhost", + ); + + expect(connection.id).toBe( + "url-https://g-xxx.neptune-graph.amazonaws.com-openCypher", + ); + expect(connection.displayLabel).toBe("My Graph"); + expect(connection.connection).toEqual({ + url: "https://localhost", + queryEngine: "openCypher", + proxyConnection: true, + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-graph", + }); + }); + + test("builds connection with IAM disabled when region/serviceType missing", () => { + const connection = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: "", + name: "No IAM", + }, + "https://localhost", + ); + + expect(connection.connection?.awsAuthEnabled).toBe(false); + expect(connection.connection?.serviceType).toBeUndefined(); + }); + + test("generates deterministic ID", () => { + const a = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "", + serviceType: "", + name: "A", + }, + "https://localhost", + ); + const b = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "", + serviceType: "", + name: "B", + }, + "https://localhost", + ); + expect(a.id).toBe(b.id); + }); +}); diff --git a/packages/graph-explorer/src/core/urlConnectionParams.ts b/packages/graph-explorer/src/core/urlConnectionParams.ts new file mode 100644 index 000000000..11ce8bb98 --- /dev/null +++ b/packages/graph-explorer/src/core/urlConnectionParams.ts @@ -0,0 +1,72 @@ +import type { QueryEngine, NeptuneServiceType } from "@shared/types"; + +import type { + ConfigurationId, + RawConfiguration, +} from "./ConfigurationProvider"; + +export interface UrlConnectionParams { + graphDbUrl: string; + queryEngine: QueryEngine; + awsRegion: string; + serviceType: string; + name: string; +} + +/** Parse URL search params into connection params. Returns null if graphDbUrl is missing. */ +export function parseUrlConnectionParams( + search: string, +): UrlConnectionParams | null { + const params = new URLSearchParams(search); + const graphDbUrl = params.get("graphDbUrl"); + if (!graphDbUrl) return null; + + return { + graphDbUrl, + queryEngine: (params.get("queryEngine") ?? "gremlin") as QueryEngine, + awsRegion: params.get("awsRegion") ?? "", + serviceType: params.get("serviceType") ?? "", + name: params.get("name") ?? graphDbUrl, + }; +} + +/** Find an existing connection matching graphDbUrl + queryEngine. */ +export function findMatchingConnection( + configurations: Map, + params: UrlConnectionParams, +): RawConfiguration | null { + for (const config of configurations.values()) { + if ( + config.connection?.graphDbUrl?.toLowerCase() === + params.graphDbUrl.toLowerCase() && + config.connection?.queryEngine === params.queryEngine + ) { + return config; + } + } + return null; +} + +/** Build a RawConfiguration from URL params. */ +export function buildConnectionFromParams( + params: UrlConnectionParams, + origin: string, +): RawConfiguration { + const id = + `url-${params.graphDbUrl}-${params.queryEngine}` as ConfigurationId; + return { + id, + displayLabel: params.name, + connection: { + url: origin, + queryEngine: params.queryEngine, + proxyConnection: true, + graphDbUrl: params.graphDbUrl, + awsAuthEnabled: !!(params.awsRegion && params.serviceType), + awsRegion: params.awsRegion, + serviceType: (params.serviceType || undefined) as + | NeptuneServiceType + | undefined, + }, + }; +}