diff --git a/apps/web/src/app/panels/Visualization.tsx b/apps/web/src/app/panels/Visualization.tsx index 295a74012c949a19279e9a76ee6afdebb9e22569..6006b6b639e352e57632cb55ea8eb90a61db6bc2 100644 --- a/apps/web/src/app/panels/Visualization.tsx +++ b/apps/web/src/app/panels/Visualization.tsx @@ -1,16 +1,24 @@ import React, { useMemo } from 'react'; -import { RawJSONVis, NodeLinkVis, PaohVis, TableVis } from '@graphpolaris/shared/lib/vis'; -import { useGraphQueryResult, useQuerybuilderGraph, useVisualizationState } from '@graphpolaris/shared/lib/data-access'; -import { Visualizations } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; +import { RawJSONVis, NodeLinkVis, PaohVis, SemanticSubstrates, TableVis } from '@graphpolaris/shared/lib/vis'; +import { useAppDispatch, useGraphQueryResult, useQuerybuilderGraph, useVisualizationState } from '@graphpolaris/shared/lib/data-access'; import { LoadingSpinner } from '@graphpolaris/shared/lib/components/LoadingSpinner'; +import { Visualizations, setActiveVisualization } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; +import { ArrowDropDown } from '@mui/icons-material'; export const VisualizationPanel = () => { const vis = useVisualizationState(); const graphQueryResult = useGraphQueryResult(); const query = useQuerybuilderGraph(); + const dispatch = useAppDispatch(); const visualizationComponent = useMemo(() => { switch (vis.activeVisualization) { + case Visualizations.Table: + return ( + <div id={Visualizations.Table} className="tabContent w-full h-full"> + <TableVis showBarplot={true} /> + </div> + ); case Visualizations.NodeLink: return ( <div id={Visualizations.NodeLink} className="tabContent w-full h-full"> @@ -29,22 +37,38 @@ export const VisualizationPanel = () => { <PaohVis rowHeight={30} hyperedgeColumnWidth={30} gapBetweenRanges={3} /> </div> ); - case Visualizations.Table: - return ( - <div id={Visualizations.Table} className="tabContent w-full h-full"> - <TableVis showBarplot={true} /> - </div> - ); default: return null; } - }, [graphQueryResult, vis.activeVisualization]); + }, [graphQueryResult]); return ( <div className="vis-panel h-full w-full overflow-y-auto" style={graphQueryResult.nodes.length === 0 ? { overflow: 'hidden' } : {}}> <h1> - <span>Visualization Panel | </span> - <span className="text-sm">{vis.activeVisualization}</span> + <span className="mr-2">Visualization Panel</span> + <div className="dropdown"> + <label + tabIndex={0} + className="text-sm s-1 bg-slate-100 hover:bg-slate-200 transition-colors duration-300 rounded cursor-pointer px-2 py-1" + > + {vis.activeVisualization} + <ArrowDropDown /> + </label> + <ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> + <li onClick={() => dispatch(setActiveVisualization(Visualizations.Table))}> + <a>Table</a> + </li> + <li onClick={() => dispatch(setActiveVisualization(Visualizations.NodeLink))}> + <a>Node Link</a> + </li> + <li onClick={() => dispatch(setActiveVisualization(Visualizations.Paohvis))}> + <a>PaohVis</a> + </li> + <li onClick={() => dispatch(setActiveVisualization(Visualizations.RawJSON))}> + <a>JSON Structure</a> + </li> + </ul> + </div> </h1> <div className="h-[calc(100%-2rem)]"> {graphQueryResult.queryingBackend && ( diff --git a/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx b/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx deleted file mode 100644 index 57418e0dc27b18e15a160d899ee3d8711ef06685..0000000000000000000000000000000000000000 --- a/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { - AddDatabaseRequest, - DatabaseType, - databaseNameMapping, - databaseProtocolMapping, - useAppDispatch, - useDatabaseAPI, - useSchemaAPI, -} from '@graphpolaris/shared/lib/data-access'; -import React, { useEffect, useRef, useState } from 'react'; -import { RequiredInput } from './requiredinput'; -import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; -import { ArrowBack, ErrorOutline } from '@mui/icons-material'; -import { mockDatabases } from './mockDatabases'; - -const INITIAL_DB_STATE = { - username: 'neo4j', - password: 'DevOnlyPass', - url: 'localhost', - port: 7687, - name: 'neo4j', - protocol: 'neo4j://', - internal_database_name: 'neo4j', - type: DatabaseType.Neo4j, -}; - -interface Connection { - connecting: boolean; - status: null | string; - verified: boolean | null; -} - -export const NewDatabaseForm = (props: { onClose(): void; open: boolean }) => { - const dispatch = useAppDispatch(); - const ref = useRef<HTMLDialogElement>(null); - const [state, setState] = useState<AddDatabaseRequest>(INITIAL_DB_STATE); - const api = useDatabaseAPI(); - const schemaApi = useSchemaAPI(); - const [hasError, setHasError] = useState({}); - const [sampleData, setSampleData] = useState<boolean | null>(false); - const [connection, setConnection] = useState<Connection>({ - connecting: false, - status: null, - verified: null, - }); - - useEffect(() => { - if (props.open) ref.current?.showModal(); - else ref.current?.close(); - }, [props.open]); - - useEffect(() => { - function handleOverlayClick(event: MouseEvent) { - if (event.target === ref.current) { - closeDialog(); - } - } - - if (props.open) { - ref.current?.addEventListener('click', handleOverlayClick); - } - - return () => { - ref.current?.removeEventListener('click', handleOverlayClick); - }; - }, [props.open]); - - useEffect(() => { - if (!state) return; - if (state.type === DatabaseType.ArangoDB && state.port === 7687) { - setState({ ...state, port: 8529 }); - } else if (state.type === DatabaseType.Neo4j && state.port === 8529) { - setState({ ...state, port: 7687 }); - } - }, [state.type]); - - function handleInputChange(field: string, value: unknown) { - setState({ - ...state, - [field]: value, - }); - } - - async function testDatabaseConnection() { - setConnection(() => ({ - connecting: true, - status: 'Verifying database connection', - verified: null, - })); - - try { - api - .TestDatabaseConnection(state) - .then((res: any) => { - setConnection((prevState) => ({ - ...prevState, - status: res.message, - verified: res.verified, - })); - }) - .catch((error) => { - setConnection((prevState) => ({ - ...prevState, - connecting: false, - status: 'Database connection failed', - verified: false, - })); - }); - } catch (e) { - setConnection((prevState) => ({ - ...prevState, - status: 'Database connection failed', - verified: false, - })); - } - } - - function loadMockDatabase({ username, password, url, port, name, protocol, internal_database_name, type }: AddDatabaseRequest) { - setState((prevState) => ({ - ...prevState, - username, - password, - url, - port, - name, - protocol, - internal_database_name, - type, - })); - setSampleData(false); - } - - function handlePortChanged(port: string): void { - if (!isNaN(Number(port))) setState({ ...state, port: Number(port) }); - } - - useEffect(() => { - if (connection.verified === true) { - try { - api - .AddDatabase(state, { updateDatabaseCache: true, setAsCurrent: true }) - .then(() => { - schemaApi.RequestSchema(state.name); - }) - .catch((e) => { - dispatch(addError(e.message)); - }); - } catch (e: any) { - dispatch(addError(e.message)); - } finally { - closeDialog(); - } - } - }, [connection.verified]); - - function closeDialog(): void { - setConnection({ - connecting: false, - status: null, - verified: null, - }); - setState(INITIAL_DB_STATE); - setSampleData(false); - props.onClose(); - ref.current?.close(); - } - - return ( - <dialog ref={ref}> - <form - className="card flex gap-4 p-4 rounded-sm" - onSubmit={(event: React.FormEvent) => { - event.preventDefault(); - testDatabaseConnection(); - }} - > - <div className="flex justify-between align-center"> - <h1 className="card-title">New Database</h1> - <div> - {sampleData ? ( - <button className="btn" onClick={() => setSampleData(false)}> - <ArrowBack /> - Go back - </button> - ) : ( - <> - <h1 className="font-light text-xs">No data?</h1> - <p className="font-light text-sm cursor-pointer underline" onClick={() => setSampleData(true)}> - Try sample data - </p> - </> - )} - </div> - </div> - - {sampleData ? ( - <div className="grid grid-cols-3 gap-2"> - {mockDatabases.map((sample) => ( - <div key={sample.name} className="card hover:bg-base-100 cursor-pointer mb-2 border" onClick={() => loadMockDatabase(sample)}> - <div className="card-body"> - <h2 className="card-title">{sample.name}</h2> - <p className="font-light text-slate-400">{sample.subtitle}</p> - </div> - </div> - ))} - </div> - ) : ( - <> - <RequiredInput - errorText="This field is required" - label="Name of the database" - placeHolder="neo4j" - value={state.name} - validate={(v) => { - setHasError({ ...hasError, name: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => handleInputChange('name', value)} - type="text" - /> - - <RequiredInput - errorText="This field is required" - label="Internal database name" - placeHolder="internal_database_name" - value={state.internal_database_name} - validate={(v) => { - setHasError({ ...hasError, internal_database_name: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => handleInputChange('internal_database_name', value)} - type="internalDatabaseName" - /> - - <div className="flex w-full gap-2"> - <div className="w-full"> - <label className="label"> - <span className="label-text">Database Type</span> - </label> - <select - className="select select-bordered w-full max-w-xs" - value={databaseNameMapping[state.type]} - onChange={(event) => { - setState({ - ...state, - type: databaseNameMapping.indexOf(event.currentTarget.value), - }); - }} - > - {databaseNameMapping.map((dbName) => ( - <option value={dbName} key={dbName}> - {dbName} - </option> - ))} - </select> - </div> - <div className="w-full"> - <label className="label"> - <span className="label-text">Database Protocol</span> - </label> - <select - className="select select-bordered w-full max-w-xs" - value={state.protocol} - onChange={(event) => { - setState({ - ...state, - protocol: event.currentTarget.value, - }); - }} - > - {databaseProtocolMapping.map((protocol) => ( - <option value={protocol} key={protocol}> - {protocol} - </option> - ))} - </select> - </div> - </div> - - <div className="flex w-full gap-2"> - <RequiredInput - errorText="This field is required" - label="Hostname/IP" - placeHolder="neo4j" - value={state.url} - validate={(v) => { - setHasError({ ...hasError, url: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => handleInputChange('url', value)} - type="hostname" - /> - - <RequiredInput - errorText="Must be between 1 and 9999" - label="Port" - placeHolder="neo4j" - value={state.port} - validate={(v) => { - setHasError({ ...hasError, port: !(v <= 9999 && v > 0) }); - return v <= 9999 && v > 0; - }} - onChange={(value) => handlePortChanged(value)} - type="port" - /> - </div> - - <div className="flex w-full gap-2"> - <RequiredInput - errorText="This field is required" - label="Username" - placeHolder="username" - value={state.username} - validate={(v) => { - setHasError({ ...hasError, username: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => handleInputChange('username', value)} - type="text" - /> - <RequiredInput - errorText="This field is required" - label="Password" - placeHolder="password" - value={state.password} - validate={(v) => { - setHasError({ ...hasError, password: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => handleInputChange('password', value)} - type="password" - /> - </div> - - {!(connection.status === null) && ( - <div className={`flex flex-col justify-center items-center`}> - <div className="flex justify-center items-center"> - {connection.verified === false && <ErrorOutline className="text-slate-400" />} - <p className="font-light text-sm text-slate-400 ">{connection.status}</p> - </div> - {connection.verified === null && <progress className="progress w-56"></progress>} - </div> - )} - <div className="card-actions w-full justify-center items-center"> - <button - className={`btn btn-primary ${Object.values(hasError).some((e) => e === true) ? 'btn-disabled' : ''}`} - type="button" - disabled={connection.connecting || Object.values(hasError).some((e) => e === true)} - onClick={testDatabaseConnection} - > - {connection.connecting ? 'Connecting...' : 'Connect'} - </button> - - <button - className="btn btn-outline" - onClick={() => { - closeDialog(); - }} - > - Cancel - </button> - </div> - </> - )} - </form> - </dialog> - ); -}; diff --git a/apps/web/src/components/navbar/DatabaseManagement/DatabaseSelector.tsx b/apps/web/src/components/navbar/DatabaseManagement/DatabaseSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c376e4a26e7b4ef2e9947024f8031db419e20107 --- /dev/null +++ b/apps/web/src/components/navbar/DatabaseManagement/DatabaseSelector.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { Add, ArrowDropDown, Delete, Settings } from '@mui/icons-material'; +import { DatabaseInfo, useAppDispatch, useDatabaseAPI, useSchemaGraph, useSessionCache } from '@graphpolaris/shared/lib/data-access'; +import { updateCurrentDatabase } from '@graphpolaris/shared/lib/data-access/store/sessionSlice'; +import { SettingsForm } from './forms/settings'; +import { NewDatabaseForm } from './forms/AddDatabase/newdatabase'; +import { LoadingSpinner } from '@graphpolaris/shared/lib/components/LoadingSpinner'; + +export default function DatabaseSelector({}) { + const dispatch = useAppDispatch(); + const api = useDatabaseAPI(); + const session = useSessionCache(); + const schemaGraph = useSchemaGraph(); + const dbSelectionMenuRef = React.useRef<HTMLDivElement>(null); + const [hovered, setHovered] = useState<string | null>(null); + const [connecting, setConnecting] = useState<boolean>(false); + const [dbSelectionMenuOpen, setDbSelectionMenuOpen] = useState<boolean>(false); + const [settingsMenuOpen, setSettingsMenuOpen] = useState<boolean>(false); + const [selectedDatabase, setSelectedDatabase] = useState<DatabaseInfo | null>(null); + const [addDatabaseFormOpen, setAddDatabaseFormOpen] = useState(false); + + useEffect(() => { + const handleClickOutside = ({ target }: MouseEvent) => { + if (dbSelectionMenuRef.current && !dbSelectionMenuRef.current.contains(target as Node)) { + setDbSelectionMenuOpen(false); + } + }; + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); + + useEffect(() => { + setConnecting(false); + }, [schemaGraph]); + + return ( + <> + <SettingsForm + open={settingsMenuOpen} + database={selectedDatabase} + onClose={() => { + setSettingsMenuOpen(false); + }} + /> + <NewDatabaseForm + open={addDatabaseFormOpen} + onClose={() => { + setAddDatabaseFormOpen(false); + }} + /> + <div className="relative border w-[20rem] ml-auto mr-auto cursor-pointer" ref={dbSelectionMenuRef}> + <div + className="flex w-full justify-between items-center px-4 py-2 hover:bg-slate-200 transition-colors duration-300" + onClick={() => setDbSelectionMenuOpen(!dbSelectionMenuOpen)} + > + <div className="flex items-center w-full"> + {connecting ? ( + <LoadingSpinner /> + ) : session.currentDatabase ? ( + <div className="h-2 w-2 rounded-full bg-green-500" /> + ) : ( + <div className="h-2 w-2 rounded-full bg-slate-500" /> + )} + {connecting ? ( + <p className="ml-2 truncate">Connecting to {session.currentDatabase}</p> + ) : session.currentDatabase ? ( + <p className="ml-2 truncate">Connected DB: {session.currentDatabase}</p> + ) : ( + <p className="ml-2">Select a database</p> + )} + </div> + <ArrowDropDown /> + </div> + {dbSelectionMenuOpen && ( + <div className="absolute w-full top-11 z-50 bg-slate-100 border"> + <div + className="flex items-center p-2 hover:bg-slate-200" + onClick={(e) => { + e.preventDefault(); + setDbSelectionMenuOpen(false); + setConnecting(false); + setAddDatabaseFormOpen(true); + }} + title="Add new database" + > + <Add /> + <p className="ml-2">Add database</p> + </div> + {session.databases.map((db) => ( + <div + key={db.Name} + className="flex justify-between items-center px-4 py-2 hover:bg-slate-200" + onClick={(e) => { + if (db.Name !== session.currentDatabase) { + e.preventDefault(); + setDbSelectionMenuOpen(false); + setConnecting(true); + dispatch(updateCurrentDatabase(db.Name)); + } else { + setDbSelectionMenuOpen(false); + } + }} + onMouseEnter={() => setHovered(db.Name)} + onMouseLeave={() => setHovered(null)} + title={`Connect to ${db.Name}`} + > + <div className="flex items-center"> + <div className={`h-2 w-2 rounded-full mr-2 ${db.status ? 'bg-green-500' : 'bg-red-500'}`} /> + <div> + <p className="ml-2">{db.Name}</p> + <p className="ml-2 text-xs text-slate-400"> + <span className="border border-slate-300 px-1">{db.Protocol}</span> {db.URL} + </p> + </div> + </div> + {hovered === db.Name && ( + <div className="flex items-center"> + <div + className="text-slate-700 hover:text-slate-400 transition-colors duration-300" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSettingsMenuOpen(true); + setSelectedDatabase(db); + }} + > + <Settings /> + </div> + <div + className="text-slate-700 hover:text-slate-400 transition-colors duration-300" + onClick={(e) => { + e.preventDefault(); + dispatch(updateCurrentDatabase('')); + api.DeleteDatabase(db.Name); + }} + title="Delete database" + > + <Delete /> + </div> + </div> + )} + </div> + ))} + </div> + )} + </div> + </> + ); +} diff --git a/apps/web/src/components/navbar/AddDatabaseForm/mockDatabases.ts b/apps/web/src/components/navbar/DatabaseManagement/forms/AddDatabase/mockDatabases.ts similarity index 100% rename from apps/web/src/components/navbar/AddDatabaseForm/mockDatabases.ts rename to apps/web/src/components/navbar/DatabaseManagement/forms/AddDatabase/mockDatabases.ts diff --git a/apps/web/src/components/navbar/DatabaseManagement/forms/AddDatabase/newdatabase.tsx b/apps/web/src/components/navbar/DatabaseManagement/forms/AddDatabase/newdatabase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd6ffb4a98ad29a3f236e970f45a27bf1728ac97 --- /dev/null +++ b/apps/web/src/components/navbar/DatabaseManagement/forms/AddDatabase/newdatabase.tsx @@ -0,0 +1,344 @@ +import { + AddDatabaseRequest, + DatabaseType, + databaseNameMapping, + databaseProtocolMapping, + useAppDispatch, + useDatabaseAPI, + useSchemaAPI, +} from '@graphpolaris/shared/lib/data-access'; +import React, { useEffect, useRef, useState } from 'react'; +import { RequiredInput } from '@graphpolaris/shared/lib/components/forms/requiredinput'; +import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; +import { ArrowBack, ErrorOutline } from '@mui/icons-material'; +import { mockDatabases } from './mockDatabases'; +import { Dialog } from '@graphpolaris/shared/lib/components/Dialog'; + +const INITIAL_DB_STATE = { + username: 'neo4j', + password: 'DevOnlyPass', + url: 'localhost', + port: 7687, + name: 'neo4j', + protocol: 'neo4j://', + internal_database_name: 'neo4j', + type: DatabaseType.Neo4j, +}; + +interface Connection { + connecting: boolean; + status: null | string; + verified: boolean | null; +} + +export const NewDatabaseForm = (props: { onClose(): void; open: boolean }) => { + const dispatch = useAppDispatch(); + const ref = useRef<HTMLDialogElement>(null); + const [state, setState] = useState<AddDatabaseRequest>(INITIAL_DB_STATE); + const api = useDatabaseAPI(); + const schemaApi = useSchemaAPI(); + const [hasError, setHasError] = useState({}); + const [sampleData, setSampleData] = useState<boolean | null>(false); + const [connection, setConnection] = useState<Connection>({ + connecting: false, + status: null, + verified: null, + }); + + useEffect(() => { + if (!state) return; + if (state.type === DatabaseType.ArangoDB && state.port === 7687) { + setState({ ...state, port: 8529 }); + } else if (state.type === DatabaseType.Neo4j && state.port === 8529) { + setState({ ...state, port: 7687 }); + } + }, [state.type]); + + function handleInputChange(field: string, value: unknown) { + setState({ + ...state, + [field]: value, + }); + } + + async function testDatabaseConnection() { + setConnection(() => ({ + connecting: true, + status: 'Verifying database connection', + verified: null, + })); + + try { + api + .TestDatabaseConnection(state) + .then((res: any) => { + setConnection((prevState) => ({ + ...prevState, + status: res.message, + verified: res.verified, + })); + }) + .catch((error) => { + setConnection((prevState) => ({ + ...prevState, + connecting: false, + status: 'Database connection failed', + verified: false, + })); + }); + } catch (e) { + setConnection((prevState) => ({ + ...prevState, + status: 'Database connection failed', + verified: false, + })); + } + } + + function loadMockDatabase({ username, password, url, port, name, protocol, internal_database_name, type }: AddDatabaseRequest) { + setState((prevState) => ({ + ...prevState, + username, + password, + url, + port, + name, + protocol, + internal_database_name, + type, + })); + setSampleData(false); + } + + function handlePortChanged(port: string): void { + if (!isNaN(Number(port))) setState({ ...state, port: Number(port) }); + } + + useEffect(() => { + if (connection.verified === true) { + try { + api + .AddDatabase(state, { updateDatabaseCache: true, setAsCurrent: true }) + .then(() => { + schemaApi.RequestSchema(state.name); + }) + .catch((e) => { + dispatch(addError(e.message)); + }); + } catch (e: any) { + dispatch(addError(e.message)); + } finally { + closeDialog(); + } + } + }, [connection.verified]); + + function closeDialog(): void { + setConnection({ + connecting: false, + status: null, + verified: null, + }); + setState(INITIAL_DB_STATE); + setSampleData(false); + props.onClose(); + ref.current?.close(); + } + + return ( + <Dialog open={props.open} onClose={props.onClose}> + <div className="flex justify-between align-center"> + <h1 className="card-title">New Database</h1> + <div> + {sampleData ? ( + <button className="btn" onClick={() => setSampleData(false)}> + <ArrowBack /> + Go back + </button> + ) : ( + <> + <h1 className="font-light text-xs">No data?</h1> + <p className="font-light text-sm cursor-pointer underline" onClick={() => setSampleData(true)}> + Try sample data + </p> + </> + )} + </div> + </div> + + {sampleData ? ( + <div className="grid grid-cols-3 gap-2"> + {mockDatabases.map((sample) => ( + <div key={sample.name} className="card hover:bg-base-100 cursor-pointer mb-2 border" onClick={() => loadMockDatabase(sample)}> + <div className="card-body"> + <h2 className="card-title">{sample.name}</h2> + <p className="font-light text-slate-400">{sample.subtitle}</p> + </div> + </div> + ))} + </div> + ) : ( + <> + <RequiredInput + errorText="This field is required" + label="Name of the database" + placeHolder="neo4j" + value={state.name} + validate={(v) => { + setHasError({ ...hasError, name: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('name', value)} + type="text" + /> + + <RequiredInput + errorText="This field is required" + label="Internal database name" + placeHolder="internal_database_name" + value={state.internal_database_name} + validate={(v) => { + setHasError({ ...hasError, internal_database_name: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('internal_database_name', value)} + type="internalDatabaseName" + /> + + <div className="flex w-full gap-2"> + <div className="w-full"> + <label className="label"> + <span className="label-text">Database Type</span> + </label> + <select + className="select select-bordered w-full max-w-xs" + value={databaseNameMapping[state.type]} + onChange={(event) => { + setState({ + ...state, + type: databaseNameMapping.indexOf(event.currentTarget.value), + }); + }} + > + {databaseNameMapping.map((dbName) => ( + <option value={dbName} key={dbName}> + {dbName} + </option> + ))} + </select> + </div> + <div className="w-full"> + <label className="label"> + <span className="label-text">Database Protocol</span> + </label> + <select + className="select select-bordered w-full max-w-xs" + value={state.protocol} + onChange={(event) => { + setState({ + ...state, + protocol: event.currentTarget.value, + }); + }} + > + {databaseProtocolMapping.map((protocol) => ( + <option value={protocol} key={protocol}> + {protocol} + </option> + ))} + </select> + </div> + </div> + + <div className="flex w-full gap-2"> + <RequiredInput + errorText="This field is required" + label="Hostname/IP" + placeHolder="neo4j" + value={state.url} + validate={(v) => { + setHasError({ ...hasError, url: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('url', value)} + type="hostname" + /> + + <RequiredInput + errorText="Must be between 1 and 9999" + label="Port" + placeHolder="neo4j" + value={state.port} + validate={(v) => { + setHasError({ ...hasError, port: !(v <= 9999 && v > 0) }); + return v <= 9999 && v > 0; + }} + onChange={(value) => handlePortChanged(value)} + type="port" + /> + </div> + + <div className="flex w-full gap-2"> + <RequiredInput + errorText="This field is required" + label="Username" + placeHolder="username" + value={state.username} + validate={(v) => { + setHasError({ ...hasError, username: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('username', value)} + type="text" + /> + <RequiredInput + errorText="This field is required" + label="Password" + placeHolder="password" + value={state.password} + validate={(v) => { + setHasError({ ...hasError, password: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('password', value)} + type="password" + /> + </div> + + {!(connection.status === null) && ( + <div className={`flex flex-col justify-center items-center`}> + <div className="flex justify-center items-center"> + {connection.verified === false && <ErrorOutline className="text-slate-400" />} + <p className="font-light text-sm text-slate-400 ">{connection.status}</p> + </div> + {connection.verified === null && <progress className="progress w-56"></progress>} + </div> + )} + <div className="card-actions w-full justify-center items-center"> + <button + className={`btn btn-primary ${Object.values(hasError).some((e) => e === true) ? 'btn-disabled' : ''}`} + type="button" + disabled={connection.connecting || Object.values(hasError).some((e) => e === true)} + onClick={(event) => { + event.preventDefault(); + testDatabaseConnection(); + }} + > + {connection.connecting ? 'Connecting...' : 'Connect'} + </button> + + <button + className="btn btn-outline" + onClick={(e) => { + e.preventDefault(); + closeDialog(); + }} + > + Cancel + </button> + </div> + </> + )} + </Dialog> + ); +}; diff --git a/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..177dce55cf54e603df9d60cd4797714eb1858066 --- /dev/null +++ b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx @@ -0,0 +1,297 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + AddDatabaseRequest, + databaseNameMapping, + databaseProtocolMapping, + useAppDispatch, + useDatabaseAPI, + DatabaseInfo, +} from '@graphpolaris/shared/lib/data-access'; +import { RequiredInput } from '@graphpolaris/shared/lib/components/forms/requiredinput'; +import { ErrorOutline } from '@mui/icons-material'; +import { Dialog } from '@graphpolaris/shared/lib/components/Dialog'; +import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; + +interface Connection { + updating: boolean; + status: null | string; + verified: boolean | null; +} + +const DEFAULT_DB: AddDatabaseRequest = { + name: '', + internal_database_name: '', + url: '', + port: 7687, + protocol: '', + username: '', + password: '', + type: 0, +}; + +export const SettingsForm = (props: { onClose(): void; open: boolean; database: DatabaseInfo | null }) => { + const dispatch = useAppDispatch(); + const ref = useRef<HTMLDialogElement>(null); + const [state, setState] = useState<AddDatabaseRequest>(DEFAULT_DB); + const api = useDatabaseAPI(); + const [hasError, setHasError] = useState({}); + const [connection, setConnection] = useState<Connection>({ + updating: false, + status: null, + verified: null, + }); + + useEffect(() => { + const db = props.database; + if (db) { + setState({ + name: db.Name, + internal_database_name: db.InternalDatabaseName, + url: db.URL, + port: db.Port, + protocol: db.Protocol, + username: db.Username, + password: db.Password, + type: db.Type, + }); + } + }, [props.database]); + + useEffect(() => { + if (connection.verified) { + api.GetAllDatabases().catch((e) => console.debug(e)); + closeDialog(); + } + }, [connection.verified]); + + function handleInputChange(field: keyof AddDatabaseRequest, value: string) { + if (field != 'port' && field != 'type') { + setState((prevState) => ({ ...prevState, [field]: value })); + } + } + + async function handleSubmit() { + setConnection(() => ({ + updating: true, + status: 'Updating database credentials', + verified: null, + })); + + if (props.database != null) { + try { + api + .UpdateDatabase({ + name: props.database.Name, + internal_database_name: state.internal_database_name, + password: state.password, + port: state.port, + protocol: state.protocol, + type: state.type, + url: state.url, + username: state.username, + }) + .then(() => { + setConnection(() => ({ + updating: false, + status: 'Worked', + verified: true, + })); + }) + .catch((e) => { + dispatch(addError('Database updating went wrong')); + setConnection((prevState) => ({ + ...prevState, + updating: false, + status: 'Database updating went wrong', + verified: false, + })); + }); + } catch (e) { + dispatch(addError('An error occured')); + setConnection((prevState) => ({ + updating: false, + status: 'An error occured', + verified: false, + })); + } + } + } + + function handlePortChanged(port: string): void { + if (!isNaN(Number(port))) setState({ ...state, port: Number(port) }); + } + + function closeDialog(): void { + setConnection({ + updating: false, + status: null, + verified: null, + }); + setState(DEFAULT_DB); + props.onClose(); + ref.current?.close(); + } + + return ( + <Dialog open={props.open} onClose={props.onClose}> + <div className="flex justify-between align-center"> + <h1 className="card-title">Update Credentials for {state.name} Database</h1> + </div> + + <> + <div className="form-control w-full "> + <label className="label"> + <span className="label-text">Name of database</span> + </label> + <input type="text" className={`input input-bordered w-full`} value={state.name} disabled={true} /> + </div> + + <RequiredInput + errorText="This field is required" + label="Internal database name" + placeHolder="internal_database_name" + value={state.internal_database_name} + validate={(v) => { + setHasError({ ...hasError, internal_database_name: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('internal_database_name', value)} + type="text" + /> + + <div className="flex w-full gap-2"> + <div className="w-full"> + <label className="label"> + <span className="label-text">Database Type</span> + </label> + <select + className="select select-bordered w-full max-w-xs" + value={databaseNameMapping[state.type]} + onChange={(event) => { + setState({ + ...state, + type: databaseNameMapping.indexOf(event.currentTarget.value), + }); + }} + > + {databaseNameMapping.map((dbName) => ( + <option value={dbName} key={dbName}> + {dbName} + </option> + ))} + </select> + </div> + <div className="w-full"> + <label className="label"> + <span className="label-text">Database Protocol</span> + </label> + <select + className="select select-bordered w-full max-w-xs" + value={state.protocol} + onChange={(event) => { + setState({ + ...state, + protocol: event.currentTarget.value, + }); + }} + > + {databaseProtocolMapping.map((protocol) => ( + <option value={protocol} key={protocol}> + {protocol} + </option> + ))} + </select> + </div> + </div> + + <div className="flex w-full gap-2"> + <RequiredInput + errorText="This field is required" + label="Hostname/IP" + placeHolder="neo4j" + value={state.url} + validate={(v) => { + setHasError({ ...hasError, url: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('url', value)} + type="hostname" + /> + + <RequiredInput + errorText="Must be between 1 and 9999" + label="Port" + placeHolder="neo4j" + value={state.port} + validate={(v) => { + setHasError({ ...hasError, port: !(v <= 9999 && v > 0) }); + return v <= 9999 && v > 0; + }} + onChange={(value) => handlePortChanged(value)} + type="port" + /> + </div> + + <div className="flex w-full gap-2"> + <RequiredInput + errorText="This field is required" + label="Username" + placeHolder="username" + value={state.username} + validate={(v) => { + setHasError({ ...hasError, username: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('username', value)} + type="text" + /> + <RequiredInput + errorText="This field is required" + label="Password" + placeHolder="password" + value={state.password} + validate={(v) => { + setHasError({ ...hasError, password: v.length === 0 }); + return v.length > 0; + }} + onChange={(value) => handleInputChange('password', value)} + type="password" + /> + </div> + + {!(connection.status === null) && ( + <div className={`flex flex-col justify-center items-center`}> + <div className="flex justify-center items-center"> + {connection.verified === false && <ErrorOutline className="text-slate-400" />} + <p className="font-light text-sm text-slate-400 ">{connection.status}</p> + </div> + {connection.verified === null && <progress className="progress w-56"></progress>} + </div> + )} + <div className="card-actions w-full justify-center items-center"> + <button + className={`btn btn-primary ${Object.values(hasError).some((e) => e === true) ? 'btn-disabled' : ''}`} + type="button" + disabled={connection.updating || Object.values(hasError).some((e) => e === true)} + onClick={(event) => { + event.preventDefault(); + handleSubmit(); + }} + > + {connection.updating ? 'Updating...' : 'Update'} + </button> + + <button + className="btn btn-outline" + onClick={(e) => { + e.preventDefault(); + closeDialog(); + }} + > + Cancel + </button> + </div> + </> + </Dialog> + ); +}; diff --git a/apps/web/src/components/navbar/databasemenu.tsx b/apps/web/src/components/navbar/databasemenu.tsx index ff3e41e47c255cbab2e0c0f3bdee2fc7f4ea25b8..964309b1adf039b4db7d38ca3516602a2e518571 100644 --- a/apps/web/src/components/navbar/databasemenu.tsx +++ b/apps/web/src/components/navbar/databasemenu.tsx @@ -7,8 +7,8 @@ export const DatabaseMenu = (props: { onClick: (database: string) => void }) => return ( <ul className="menu dropdown-content absolute right-48 z-[1] p-2 shadow-xl bg-offwhite-100 rounded-box w-52" tabIndex={0}> {session.databases.map((db: any) => ( - <li key={db}> - <button onClick={() => props.onClick(db)}>{db}</button> + <li key={db.Name}> + <button onClick={() => props.onClick(db.Name)}>{db.Name}</button> </li> ))} </ul> diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index 44a609b544fb4fff56515416f5951c14c4690669..67ed65a4866340626f1540c53c0b8fef2a76c179 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -8,28 +8,18 @@ /* The comment above was added so the code coverage wouldn't count this file towards code coverage. * We do not test components/renderfunctions/styling files. * See testing plan for more details.*/ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { AccountCircle } from '@mui/icons-material'; -import logo from './gp-logo.svg'; import logo_white from './gp-logo-white.svg'; +import logo from './gp-logo.svg'; -import { updateCurrentDatabase, updateDatabaseList } from '@graphpolaris/shared/lib/data-access/store/sessionSlice'; -import { - AddDatabaseRequest, - useAppDispatch, - useAuthorizationCache, - useDatabaseAPI, - useSchemaAPI, - useSessionCache, - useVisualizationState, -} from '@graphpolaris/shared/lib/data-access'; +import { updateCurrentDatabase } from '@graphpolaris/shared/lib/data-access/store/sessionSlice'; +import { useAppDispatch, useAuthorizationCache, useDatabaseAPI, useSessionCache } from '@graphpolaris/shared/lib/data-access'; import { DatabaseMenu } from './databasemenu'; -import { NewDatabaseForm } from './AddDatabaseForm/newdatabaseform'; import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; import { SearchBar } from './search/SearchBar'; +import DatabaseSelector from './DatabaseManagement/DatabaseSelector'; -/** NavbarComponentProps is an interface containing the NavbarViewModel. */ -import { Visualizations, setActiveVisualization } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; export interface NavbarComponentProps { // changeColourPalette: () => void; FIXME move to redux } @@ -54,12 +44,8 @@ export interface NavbarSubComponentState { export const Navbar = (props: NavbarComponentProps) => { const auth = useAuthorizationCache(); const session = useSessionCache(); - const vis = useVisualizationState(); const api = useDatabaseAPI(); - const schemaApi = useSchemaAPI(); const dispatch = useAppDispatch(); - - const [addDatabaseFormOpen, setAddDatabaseFormOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [subMenuOpen, setSubMenuOpen] = useState<string | undefined>(undefined); @@ -69,236 +55,155 @@ export const Navbar = (props: NavbarComponentProps) => { return ( <div className="w-full h-auto px-5"> - <NewDatabaseForm - open={addDatabaseFormOpen} - onClose={() => { - setAddDatabaseFormOpen(false); - }} - /> - <div title="GraphPolaris" className="navbar w-full"> - <a href="https://graphpolaris.com/" className="mr-auto" target="_blank"> + <div title="GraphPolaris" className="navbar flex items-center justify-between w-full"> + <a href="https://graphpolaris.com/" target="_blank"> <img src={currentLogo} alt="GraphPolaris" className="h-9" /> </a> - <SearchBar /> - <div className="dropdown"> - <label tabIndex={0} className="btn s-1"> - Vis - </label> - <ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> - <li onClick={() => dispatch(setActiveVisualization(Visualizations.Table))}> - <a>Table</a> - </li> - <li onClick={() => dispatch(setActiveVisualization(Visualizations.NodeLink))}> - <a>Node Link</a> - </li> - <li onClick={() => dispatch(setActiveVisualization(Visualizations.Paohvis))}> - <a>PaohVis</a> - </li> - <li onClick={() => dispatch(setActiveVisualization(Visualizations.RawJSON))}> - <a>JSON Structure</a> - </li> - {/* <li><a>Semantic Substrates</a></li> */} - </ul> - </div> - <div className="dropdown"> - <label tabIndex={1} className="btn s-1"> - Data - </label> - <ul tabIndex={1} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> - <li> + + <DatabaseSelector /> + + <div> + <div className="dropdown"> + <label tabIndex={2} className="btn btn-ghost font-normal s-1"> + Adv + </label> + <ul tabIndex={2} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"></ul> + </div> + + <div className="dropdown"> + <label tabIndex={3} className="btn btn-ghost font-normal s-1"> + Share + </label> + <ul tabIndex={3} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> + <li> + <a>Visual</a> + </li> + <li> + <a>Knowledge Base</a> + </li> + </ul> + </div> + + <SearchBar /> + + <div className="w-fit"> + <div className="menu-walkthrough"> <button - onClick={() => { - setAddDatabaseFormOpen(true); - setMenuOpen(false); + tabIndex={0} + className="btn btn-circle btn-ghost hover:bg-gray-200" + onClick={(event) => { + setMenuOpen(!menuOpen); setSubMenuOpen(undefined); }} > - Add database - </button> - </li> - <li> - <button - onClick={(e) => { - e.stopPropagation(); - setSubMenuOpen(subMenuOpen === 'changeDb' ? undefined : 'changeDb'); - }} - className={`${session.databases.length === 0 ? 'btn-disabled' : ''} ${subMenuOpen === 'changeDb' ? 'btn-active' : ''}`} - > - Change Database + <AccountCircle htmlColor="black" /> </button> - {subMenuOpen === 'changeDb' && ( - <DatabaseMenu - onClick={(db) => { - if (session.currentDatabase != db) { - dispatch(updateCurrentDatabase(db)); - } - setSubMenuOpen(undefined); - setMenuOpen(false); - }} - /> - )} - </li> - <li> - <button - onClick={() => setSubMenuOpen(subMenuOpen === 'deleteDb' ? undefined : 'deleteDb')} - className={`${session.databases.length === 0 ? 'btn-disabled' : ''} ${subMenuOpen === 'deleteDb' ? 'btn-active' : ''}`} - > - Delete Database - </button> - {subMenuOpen === 'deleteDb' && ( - <DatabaseMenu - onClick={(db) => { - if (session.currentDatabase === db) { - dispatch(updateCurrentDatabase('')); - } - api.DeleteDatabase(db); - setSubMenuOpen(undefined); - setMenuOpen(false); - }} - /> - )} - </li> - </ul> - </div> - <div className="dropdown"> - <label tabIndex={2} className="btn s-1"> - Adv - </label> - <ul tabIndex={2} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"></ul> - </div> - <div className="dropdown"> - <label tabIndex={3} className="btn s-1"> - Share - </label> - <ul tabIndex={3} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> - <li> - <a>Visual</a> - </li> - <li> - <a>Knowledge Base</a> - </li> - </ul> - </div> - - <div className="w-fit"> - <div className="menu-walkthrough"> - <button - tabIndex={0} - className="btn btn-circle btn-ghost hover:bg-gray-200" - onClick={(event) => { - setMenuOpen(!menuOpen); - setSubMenuOpen(undefined); - }} - > - <AccountCircle htmlColor="black" /> - </button> - {menuOpen && ( - <> - <div - className="z-10 bg-transparent absolute w-screen h-screen top-0 left-0" - onClick={() => { - setMenuOpen(false); - setSubMenuOpen(undefined); - }} - ></div> - <ul tabIndex={0} className="z-20 dropdown-content menu absolute right-4 p-2 shadow-xl bg-offwhite-100 rounded-box w-52"> - {auth.authorized ? ( - <> - <div className="menu-title"> - <h2>user: {auth.username}</h2> - <h3 className="text-xs">session: {auth.sessionID}</h3> - </div> - <li> - <button - onClick={() => { - setAddDatabaseFormOpen(true); - setMenuOpen(false); - setSubMenuOpen(undefined); - console.log('add database', addDatabaseFormOpen, menuOpen, subMenuOpen); - }} - > - Add database - </button> - </li> - <li> - <button - onClick={(e) => { - e.stopPropagation(); - setSubMenuOpen(subMenuOpen === 'changeDb' ? undefined : 'changeDb'); - }} - className={`${session.databases.length === 0 ? 'btn-disabled text-gray-300' : ''} ${ - subMenuOpen === 'changeDb' ? 'btn-active' : '' - }`} - > - Change Database - </button> - {subMenuOpen === 'changeDb' && ( - <DatabaseMenu - onClick={(db) => { - if (session.currentDatabase != db) { - dispatch(updateCurrentDatabase(db)); - } - setSubMenuOpen(undefined); + {menuOpen && ( + <> + <div + className="z-10 bg-transparent absolute w-screen h-screen top-0 left-0" + onClick={() => { + setMenuOpen(false); + setSubMenuOpen(undefined); + }} + ></div> + <ul tabIndex={0} className="z-20 dropdown-content menu absolute right-4 p-2 shadow-xl bg-offwhite-100 rounded-box w-52"> + {auth.authorized ? ( + <> + <div className="menu-title"> + <h2>user: {auth.username}</h2> + <h3 className="text-xs">session: {auth.sessionID}</h3> + </div> + <li> + <button + onClick={() => { setMenuOpen(false); - }} - /> - )} - </li> - <li> - <button - onClick={() => setSubMenuOpen(subMenuOpen === 'deleteDb' ? undefined : 'deleteDb')} - className={`${session.databases.length === 0 ? 'btn-disabled text-gray-300' : ''} ${ - subMenuOpen === 'deleteDb' ? 'btn-active' : '' - }`} - > - Delete Database - </button> - {subMenuOpen === 'deleteDb' && ( - <DatabaseMenu - onClick={(db) => { - if (session.currentDatabase === db) { - dispatch(updateCurrentDatabase('')); - } - api.DeleteDatabase(db).catch((e) => { - dispatch(addError(e.message)); - }); setSubMenuOpen(undefined); + }} + > + Add database + </button> + </li> + <li> + <button + onClick={(e) => { + e.stopPropagation(); + setSubMenuOpen(subMenuOpen === 'changeDb' ? undefined : 'changeDb'); + }} + className={`${session.databases.length === 0 ? 'btn-disabled text-gray-300' : ''} ${ + subMenuOpen === 'changeDb' ? 'btn-active' : '' + }`} + > + Change Database + </button> + {subMenuOpen === 'changeDb' && ( + <DatabaseMenu + onClick={(db) => { + if (session.currentDatabase != db) { + dispatch(updateCurrentDatabase(db)); + } + setSubMenuOpen(undefined); + setMenuOpen(false); + }} + /> + )} + </li> + <li> + <button + onClick={() => setSubMenuOpen(subMenuOpen === 'deleteDb' ? undefined : 'deleteDb')} + className={`${session.databases.length === 0 ? 'btn-disabled text-gray-300' : ''} ${ + subMenuOpen === 'deleteDb' ? 'btn-active' : '' + }`} + > + Delete Database + </button> + {subMenuOpen === 'deleteDb' && ( + <DatabaseMenu + onClick={(db) => { + if (session.currentDatabase === db) { + dispatch(updateCurrentDatabase('')); + } + api.DeleteDatabase(db).catch((e) => { + dispatch(addError(e.message)); + }); + setSubMenuOpen(undefined); + setMenuOpen(false); + }} + /> + )} + </li> + <div className="menu-title"> + <div className="absolute left-0 h-0.5 w-full bg-offwhite-300"></div> + <h3 className="text-xs mt-3">Version: {buildInfo}</h3> + </div> + </> + ) : ( + <> + <div className="menu-title"> + <h2>user: {auth.username}</h2> + <h3 className="text-xs">session: {auth.sessionID}</h3> + </div> + <div> + <button + className="btn btn-ghost" + onClick={() => { setMenuOpen(false); }} - /> - )} - </li> - <div className="menu-title"> - <div className="absolute left-0 h-0.5 w-full bg-offwhite-300"></div> - <h3 className="text-xs mt-3">Version: {buildInfo}</h3> - </div> - </> - ) : ( - <> - <div className="menu-title"> - <h2>user: {auth.username}</h2> - <h3 className="text-xs">session: {auth.sessionID}</h3> - </div> - <div> - <button - className="btn btn-ghost" - onClick={() => { - setMenuOpen(false); - }} - > - <span>Login</span> - {/* !TODO */} - </button> - </div> - <div className="menu-title"> - <div className="absolute left-0 h-0.5 w-full bg-offwhite-300"></div> - <h3 className="text-xs mt-3">Version: {buildInfo}</h3> - </div> - </> - )} - </ul> - </> - )} + > + <span>Login</span> + {/* !TODO */} + </button> + </div> + <div className="menu-title"> + <div className="absolute left-0 h-0.5 w-full bg-offwhite-300"></div> + <h3 className="text-xs mt-3">Version: {buildInfo}</h3> + </div> + </> + )} + </ul> + </> + )} + </div> </div> </div> </div> diff --git a/apps/web/src/components/navbar/search/SearchBar.tsx b/apps/web/src/components/navbar/search/SearchBar.tsx index 969774e74c80b254ea2273b27632ff634918c592..cebe1c1fc4c1ed0e61929542ba4ae3d2f54542ce 100644 --- a/apps/web/src/components/navbar/search/SearchBar.tsx +++ b/apps/web/src/components/navbar/search/SearchBar.tsx @@ -15,7 +15,7 @@ import { resetSearchResults, CATEGORY_KEYS, } from '@graphpolaris/shared/lib/data-access/store/searchResultSlice'; -import { Close } from '@mui/icons-material'; +import { Close, Search } from '@mui/icons-material'; const SIMILARITY_THRESHOLD = 0.7; @@ -37,13 +37,14 @@ const SEARCH_CATEGORIES: CATEGORY_KEYS[] = Object.keys(CATEGORY_ACTIONS) as CATE export function SearchBar({}) { const inputRef = React.useRef<HTMLInputElement>(null); + const searchbarRef = React.useRef<HTMLDivElement>(null); const dispatch = useAppDispatch(); const results = useSearchResult(); const schema = useSchemaGraph(); const graphData = useGraphQueryResult(); const querybuilderData = useQuerybuilderGraph(); const [search, setSearch] = React.useState<string>(''); - const [inputActive, setInputActive] = React.useState<boolean>(false); + const [searchOpen, setSearchOpen] = React.useState<boolean>(false); const dataSources: { [key: string]: { nodes: Record<string, any>[]; edges: Record<string, any>[] }; @@ -95,10 +96,11 @@ export function SearchBar({}) { React.useEffect(() => { const handleClickOutside = ({ target }: MouseEvent) => { - if (inputRef.current && target && !inputRef.current.contains(target as Node)) { + if (inputRef.current && target && !inputRef.current.contains(target as Node) && !searchbarRef?.current?.contains(target as Node)) { setSearch(''); + setSearchOpen(false); // dispatch(resetSearchResults()); - inputRef.current.blur(); + // inputRef.current.blur(); } }; document.addEventListener('click', handleClickOutside); @@ -108,74 +110,94 @@ export function SearchBar({}) { }, []); return ( - <div className={`searchbar relative form-control mr-4 ${inputActive || search !== '' ? 'w-96' : 'w-56'} transition-width duration-300`}> - <div className="relative rounded-md shadow-sm w-full"> - <input - ref={inputRef} - value={search} - onChange={(e) => setSearch(e.target.value)} - type="text" - onFocus={() => setInputActive(true)} - onBlur={() => setInputActive(false)} - placeholder="Search…" - className="block w-full rounded-md border-0 py-1.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 sm:text-sm sm:leading-6" - /> - {search !== '' && ( - <div - className="absolute inset-y-0 right-0 flex items-center cursor-pointer" - onClick={() => { - dispatch(resetSearchResults()); - setSearch(''); - }} - > - <div className="py-0 px-2"> - <span className="text-gray-400 text-xs"> - <Close /> - </span> + <div className="relative" ref={searchbarRef}> + <a + className="btn btn-circle btn-ghost hover:bg-gray-200" + onClick={() => { + setSearchOpen(!searchOpen); + if (inputRef.current) { + inputRef.current.focus(); + } + }} + > + <Search /> + </a> + + <div className={`searchbar ${searchOpen ? 'absolute' : 'hidden'} right-0 -bottom-12 form-control w-[30rem]`}> + <div className="relative shadow-sm w-full"> + <input + ref={inputRef} + value={search} + onChange={(e) => setSearch(e.target.value)} + type="text" + placeholder="Search…" + className="block w-full border-0 py-1.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 sm:text-sm sm:leading-6" + /> + {search !== '' && ( + <div + className="absolute inset-y-0 right-0 flex items-center cursor-pointer" + onClick={() => { + dispatch(resetSearchResults()); + setSearch(''); + }} + > + <div className="py-0 px-2"> + <span className="text-gray-400 text-xs"> + <Close /> + </span> + </div> </div> + )} + </div> + {search !== '' && ( + <div className="absolute top-[calc(100%+0.5rem)] z-10 bg-white rounded-none card-bordered w-full overflow-auto max-h-[60vh]"> + {SEARCH_CATEGORIES.every((category) => results[category].nodes.length === 0 && results[category].edges.length === 0) ? ( + <div className="p-2 text-lg"> + <p className="text-entity-500 font-bold">Found no matches...</p> + </div> + ) : ( + SEARCH_CATEGORIES.map((category, index) => { + if (results[category].nodes.length > 0 || results[category].edges.length > 0) { + return ( + <div key={index}> + <div className="flex justify-between p-2 text-lg"> + <p className="text-entity-500 font-bold">{category.charAt(0).toUpperCase() + category.slice(1)}</p> + <p className="font-light text-slate-800"> + {results[category].nodes.length + results[category].edges.length} results + </p> + </div> + <div className="h-[1px] w-full bg-offwhite-300"></div> + {Object.values(Object.values(results[category])) + .flat() + .map((item, index) => ( + <div + key={index} + className="flex flex-col hover:bg-slate-300 p-2 cursor-pointer" + title={JSON.stringify(item)} + onClick={() => { + CATEGORY_ACTIONS[category]( + { + nodes: results[category].nodes.includes(item) ? [item] : [], + edges: results[category].edges.includes(item) ? [item] : [], + }, + dispatch, + ); + }} + > + <div className="font-semibold text-md"> + {item?.key?.slice(0, 18) || item?.id?.slice(0, 18) || Object.values(item)?.[0]?.slice(0, 18)} + </div> + <div className="font-light text-slate-800 text-xs">{JSON.stringify(item).substring(0, 40)}...</div> + </div> + ))} + </div> + ); + } else return <></>; + }) + )} </div> )} </div> - {search !== '' && ( - <div className="absolute top-[calc(100%+0.5rem)] z-10 bg-white rounded-none card-bordered w-full overflow-auto max-h-[60vh]"> - {SEARCH_CATEGORIES.map((category, index) => { - if (results[category].nodes.length > 0 || results[category].edges.length > 0) { - return ( - <div key={index}> - <div className="flex justify-between p-2 text-lg"> - <p className="text-entity-500 font-bold">{category.charAt(0).toUpperCase() + category.slice(1)}</p> - <p className="font-light text-slate-800">{results[category].nodes.length + results[category].edges.length} results</p> - </div> - <div className="h-[1px] w-full bg-offwhite-300"></div> - {Object.values(Object.values(results[category])) - .flat() - .map((item, index) => ( - <div - key={index} - className="flex flex-col hover:bg-slate-300 p-2 cursor-pointer" - title={JSON.stringify(item)} - onClick={() => { - CATEGORY_ACTIONS[category]( - { - nodes: results[category].nodes.includes(item) ? [item] : [], - edges: results[category].edges.includes(item) ? [item] : [], - }, - dispatch, - ); - }} - > - <div className="font-semibold text-md"> - {item?.key?.slice(0, 18) || item?.id?.slice(0, 18) || Object.values(item)?.[0]?.slice(0, 18)} - </div> - <div className="font-light text-slate-800 text-xs">{JSON.stringify(item).substring(0, 40)}...</div> - </div> - ))} - </div> - ); - } else return <></>; - })} - </div> - )} </div> ); } diff --git a/apps/web/src/components/onboarding/onboarding.tsx b/apps/web/src/components/onboarding/onboarding.tsx index 3b06afe13195b520ea87ce3bafa341262ef6b785..17a45905b5aaa5f2bf5974b74f1e7e037b871c80 100644 --- a/apps/web/src/components/onboarding/onboarding.tsx +++ b/apps/web/src/components/onboarding/onboarding.tsx @@ -13,7 +13,7 @@ interface OnboardingState { export default function Onboarding({}) { const location = useLocation(); const auth = useAuthorizationCache(); - const [showWalkthrough, setShowWalkthrough] = useState<boolean>(true); + const [showWalkthrough, setShowWalkthrough] = useState<boolean>(false); const [onboarding, setOnboarding] = useState<OnboardingState>({ run: false, stepIndex: 0, @@ -22,8 +22,8 @@ export default function Onboarding({}) { useEffect(() => { // Check whether walkthrough cookie exists and if user is logged in const isWalkthroughCompleted = document.cookie.includes('walkthroughCompleted=true'); - if (isWalkthroughCompleted || auth.username) { - setShowWalkthrough(false); + if (!isWalkthroughCompleted) { + setShowWalkthrough(true); } }, []); diff --git a/libs/shared/lib/components/LoadingSpinner.tsx b/libs/shared/lib/components/LoadingSpinner.tsx index 69e5c8101edc64b69f9c654bde67d123dbcbb129..32094a661309468b6a89ad20d986c1c9d2b15c77 100644 --- a/libs/shared/lib/components/LoadingSpinner.tsx +++ b/libs/shared/lib/components/LoadingSpinner.tsx @@ -19,7 +19,7 @@ export const LoadingSpinner = (props: PropsWithChildren) => { fill="currentFill" /> </svg> - {props.children ? props.children : 'Connecting...'} + {props.children && props.children} </div> ); }; diff --git a/apps/web/src/components/navbar/AddDatabaseForm/requiredinput.tsx b/libs/shared/lib/components/forms/requiredinput.tsx similarity index 100% rename from apps/web/src/components/navbar/AddDatabaseForm/requiredinput.tsx rename to libs/shared/lib/components/forms/requiredinput.tsx diff --git a/libs/shared/lib/data-access/api/database.ts b/libs/shared/lib/data-access/api/database.ts index f05ab32460ce874d09b0a8df95320f825e041e8e..434d430f1690d1a6f9c15457ef36382bd08d58db 100644 --- a/libs/shared/lib/data-access/api/database.ts +++ b/libs/shared/lib/data-access/api/database.ts @@ -23,6 +23,18 @@ export type AddDatabaseRequest = { type: DatabaseType; // Database type. 0 = ArangoDB, 1 = Neo4j }; +export type DatabaseInfo = { + Name: string; + InternalDatabaseName: string; + URL: string; + Port: number; + Protocol: string; + Username: string; + Password: string; + Type: number; + status: boolean; +}; + export type AddDatabaseOptions = { setAsCurrent?: boolean; updateDatabaseCache?: boolean; @@ -36,6 +48,8 @@ export type DeleteDatabasesOptions = { updateSessionCache?: boolean; }; +export type VerifyConnectionRequest = string; + export const useDatabaseAPI = () => { const cache = useSessionCache(); const dispatch = useAppDispatch(); @@ -52,7 +66,6 @@ export const useDatabaseAPI = () => { }) .then((response: Response) => { console.info('Added Database', response); - if (!response.ok) { reject(response.statusText); } @@ -68,7 +81,6 @@ export const useDatabaseAPI = () => { function GetAllDatabases(options: GetDatabasesOptions = {}): Promise<Array<string>> { const { updateSessionCache: updateDatabaseCache = true } = options; return new Promise((resolve, reject) => { - // fetch(`${domain}${useruri}/database`) fetchAuthenticated(`${domain}${useruri}/database`) .then((response: Response) => { if (!response.ok) { @@ -123,5 +135,28 @@ export const useDatabaseAPI = () => { }); } - return { DatabaseType, AddDatabase, GetAllDatabases, DeleteDatabase, TestDatabaseConnection }; + function UpdateDatabase(request: AddDatabaseRequest): Promise<void> { + return new Promise((resolve, reject) => { + fetchAuthenticated(`${domain}${useruri}/database/update`, { + method: 'PATCH', + body: JSON.stringify(request), + }) + .then((response: Response) => { + if (!response.ok) { + reject(response.statusText); + } + resolve(); + }) + .catch(reject); + }); + } + + return { + DatabaseType, + AddDatabase, + GetAllDatabases, + DeleteDatabase, + TestDatabaseConnection, + UpdateDatabase, + }; }; diff --git a/libs/shared/lib/data-access/store/sessionSlice.ts b/libs/shared/lib/data-access/store/sessionSlice.ts index d77dff617ef5d8ab5dcd821245b188aaf46eb2d0..e32fcf1d94c6ec6e7a194e894c4226ebf5a6405f 100644 --- a/libs/shared/lib/data-access/store/sessionSlice.ts +++ b/libs/shared/lib/data-access/store/sessionSlice.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from './store'; import { BackendQueryFormat } from '../../querybuilder'; +import { DatabaseInfo } from '../api'; /** Message format of the error message from the backend */ export type ErrorMessage = { @@ -11,7 +12,7 @@ export type ErrorMessage = { /** Cache type */ export type SessionCacheI = { currentDatabase?: string; - databases: string[]; + databases: DatabaseInfo[]; }; // Define the initial state using that type @@ -28,12 +29,16 @@ export const sessionSlice = createSlice({ updateCurrentDatabase(state, action: PayloadAction<string>) { state.currentDatabase = action.payload; }, - updateDatabaseList(state, action: PayloadAction<string[]>) { + updateDatabaseList(state, action: PayloadAction<DatabaseInfo[]>) { console.debug('Updating database list', action); state.databases = action.payload; if (state.databases.length > 0) { - if (!state.currentDatabase || !state.databases.includes(state.currentDatabase)) state.currentDatabase = state.databases[0]; - else state.currentDatabase = undefined; + const foundDatabase = state.databases.find((db) => db.Name === state.currentDatabase); + if (!foundDatabase) { + state.currentDatabase = state.databases[0].Name; + } else { + state.currentDatabase = undefined; + } } }, }, diff --git a/libs/shared/lib/schema/panel/schema.tsx b/libs/shared/lib/schema/panel/schema.tsx index 73ddfe61f4828ee8b1c4a2093226c4c946c0634b..26e8cc5127891474026b8c12be2d72afb2e043a6 100644 --- a/libs/shared/lib/schema/panel/schema.tsx +++ b/libs/shared/lib/schema/panel/schema.tsx @@ -36,7 +36,6 @@ import SelfEdge from '../pills/edges/self-edge'; import { useSchemaAPI } from '../../data-access'; import { SchemaDialog } from './schemaDialog'; import { toSchemaGraphology } from '../../data-access/store/schemaSlice'; -import { LoadingSpinner } from '../../components/LoadingSpinner'; interface Props { content?: string; @@ -88,17 +87,11 @@ export const Schema = (props: Props) => { const schemaGraphology = useMemo(() => toSchemaGraphology(schemaGraph), [schemaGraph]); const layout = useRef<AlgorithmToLayoutProvider<AllLayoutAlgorithms>>(); - const [loading, setLoading] = useState(false); - function updateLayout() { const layoutFactory = new LayoutFactory(); layout.current = layoutFactory.createLayout(settings.layoutName); } - useEffect(() => { - setLoading(true); - }, [session.currentDatabase]); - useEffect(() => { updateLayout(); sessionStorage.setItem('firstUserConnection', firstUserConnection.toString()); @@ -109,8 +102,6 @@ export const Schema = (props: Props) => { }, [props.auth]); useEffect(() => { - setLoading(false); - if (schemaGraphology === undefined || schemaGraphology.order == 0) { setNodes([]); setEdges([]); @@ -167,27 +158,11 @@ export const Schema = (props: Props) => { <div className="schema-panel w-full h-full"> <SchemaDialog open={toggleSchemaSettings} onClose={() => setToggleSchemaSettings(false)} ref={dialogRef} /> <div className="flex flex-col h-[1rem]"> - <h1> - Schema{' '} - {loading ? ( - <span> - <LoadingSpinner>Connecting to {session.currentDatabase}...</LoadingSpinner> - </span> - ) : ( - <> - {session.currentDatabase && ( - <> - {' | '} - <span className="text-sm"> Connected to: {session.currentDatabase}</span> - </> - )} - </> - )} - </h1> + <h1>Schema</h1> </div> - {nodes.length === 0 && !loading ? ( + {nodes.length === 0 ? ( <p className="text-sm">No Elements</p> - ) : loading ? null : ( + ) : ( <ReactFlowProvider> <div className="h-[calc(100%-.8rem)] w-full"> <ReactFlow