diff --git a/apps/web/src/components/navbar/AddDatabaseForm/mockDatabases.ts b/apps/web/src/components/navbar/AddDatabaseForm/mockDatabases.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d6d868e70474a5848fa396eb0a6253bc958e11c --- /dev/null +++ b/apps/web/src/components/navbar/AddDatabaseForm/mockDatabases.ts @@ -0,0 +1,70 @@ +import { DatabaseType } from '@graphpolaris/shared/lib/data-access'; + +export const mockDatabases = [ + { + name: 'Recommendations', + subtitle: 'Hosted by Neo4j', + username: 'recommendations', + password: 'recommendations', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internal_database_name: 'recommendations', + type: DatabaseType.Neo4j, + }, + { + name: 'Movies', + subtitle: 'Hosted by Neo4j', + username: 'movies', + password: 'movies', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internal_database_name: 'movies', + type: DatabaseType.Neo4j, + }, + { + name: 'Northwind', + subtitle: 'Hosted by Neo4j', + username: 'northwind', + password: 'northwind', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j://', + internal_database_name: 'northwind', + type: DatabaseType.Neo4j, + }, + { + name: 'Fincen', + subtitle: 'Hosted by Neo4j', + username: 'fincen', + password: 'fincen', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j://', + internal_database_name: 'fincen', + type: DatabaseType.Neo4j, + }, + { + name: 'Slack', + subtitle: 'Hosted by Neo4j', + username: 'slack', + password: 'slack', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j://', + internal_database_name: 'slack', + type: DatabaseType.Neo4j, + }, + { + name: 'Game of Thrones', + subtitle: 'Hosted by Neo4j', + username: 'gameofthrones', + password: 'gameofthrones', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j://', + internal_database_name: 'gameofthrones', + type: DatabaseType.Neo4j, + }, +]; diff --git a/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx b/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx index 45289f80738d9bc60cf0f97243b8a4e3958daf71..57418e0dc27b18e15a160d899ee3d8711ef06685 100644 --- a/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx +++ b/apps/web/src/components/navbar/AddDatabaseForm/newdatabaseform.tsx @@ -10,45 +10,61 @@ import { 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>({ - // username: 'root', - // password: 'DikkeDraak', - // url: 'https://datastrophe.science.uu.nl/', - // port: 8529, - // name: 'Tweede Kamer Dataset', - // internal_database_name: 'TweedeKamer', - // type: DatabaseType.ArangoDB, - - // username: 'neo4j', - // password: 'oL3nNlebrx4le2A0zxaFVqAo3HAvodHxwEiI_7_2JxI', - // url: '635176c8.databases.neo4j.io', - // port: 7687, - // name: 'neo4j', - // internal_database_name: 'neo4j', - // type: DatabaseType.Neo4j, - - username: 'neo4j', - password: 'DevOnlyPass', - url: 'localhost', - port: 7687, - name: 'neo4j', - protocol: 'neo4j://', - internal_database_name: 'neo4j', - type: DatabaseType.Neo4j, - }); + 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) { @@ -58,203 +74,294 @@ export const NewDatabaseForm = (props: { onClose(): void; open: boolean }) => { } }, [state.type]); - /** - * Validates if the port value is numerical. Only then will the state be updated. - * @param port The new port value. - */ - function handlePortChanged(port: string): void { - if (!isNaN(Number(port))) setState({ ...state, port: Number(port) }); + function handleInputChange(field: string, value: unknown) { + setState({ + ...state, + [field]: value, + }); } - /** Handles the submit button click. Calls the onSubmit in the props with all the fields. */ - function handleSubmitClicked(): void { - if (!Object.values(hasError).some((e) => e === true)) { + async function testDatabaseConnection() { + setConnection(() => ({ + connecting: true, + status: 'Verifying database connection', + verified: null, + })); + + try { api - .AddDatabase(state, { updateDatabaseCache: true, setAsCurrent: true }) - .then(() => { - schemaApi.RequestSchema(state.name); + .TestDatabaseConnection(state) + .then((res: any) => { + setConnection((prevState) => ({ + ...prevState, + status: res.message, + verified: res.verified, + })); }) - .catch((e) => { - dispatch(addError(e.message)); + .catch((error) => { + setConnection((prevState) => ({ + ...prevState, + connecting: false, + status: 'Database connection failed', + verified: false, + })); }); - props.onClose(); + } 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(); - handleSubmitClicked(); + testDatabaseConnection(); }} > - <h1 className="card-title">New Database</h1> - <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) => - setState({ - ...state, - 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) => - setState({ - ...state, - internal_database_name: value, - }) - } - type="internalDatabaseName" - /> + <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> - <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> + {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> - <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, - }); + ) : ( + <> + <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; }} - > - {databaseProtocolMapping.map((protocol) => ( - <option value={protocol} key={protocol}> - {protocol} - </option> - ))} - </select> - </div> - </div> + onChange={(value) => handleInputChange('name', value)} + type="text" + /> - <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) => - setState({ - ...state, - url: value, - }) - } - type="hostname" - /> + <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" + /> - <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"> + <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="Username" - placeHolder="username" - value={state.username} - validate={(v) => { - setHasError({ ...hasError, username: v.length === 0 }); - return v.length > 0; - }} - onChange={(value) => - setState({ - ...state, - 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) => - setState({ - ...state, - password: value, - }) - } - type="password" - /> - </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" + /> - <div className="card-actions w-full justify-center"> - <button className={`btn btn-primary ${Object.values(hasError).some((e) => e === true) ? 'btn-disabled' : ''}`}>Submit</button> - <button - className="btn btn-outline" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - props.onClose(); - }} - > - Cancel - </button> - </div> + <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/favicon.ico b/apps/web/src/favicon.ico index 317ebcb2336e0833a22dddf0ab287849f26fda57..a2bfa7f2cf83af624fcaff27ccf20f10b4f1aa00 100644 Binary files a/apps/web/src/favicon.ico and b/apps/web/src/favicon.ico differ diff --git a/libs/shared/lib/data-access/api/database.ts b/libs/shared/lib/data-access/api/database.ts index 546c173ade3737ed08c86c44e7911995bfbd7ffc..02f645993dab45f96d8ff33e34844a74fd58928a 100644 --- a/libs/shared/lib/data-access/api/database.ts +++ b/libs/shared/lib/data-access/api/database.ts @@ -119,5 +119,25 @@ export const useDatabaseAPI = () => { }); } - return { DatabaseType, AddDatabase, GetAllDatabases, DeleteDatabase }; + function TestDatabaseConnection(request: AddDatabaseRequest): Promise<void> { + return new Promise((resolve, reject) => { + fetch(`${domain}${useruri}/database/test-connection`, { + method: 'POST', + credentials: 'same-origin', + headers: new Headers({ + Authorization: 'Bearer ' + accessToken, + }), + body: JSON.stringify(request), + }) + .then((response: Response) => { + if (!response.ok) { + reject(response.statusText); + } + resolve(response.json()); + }) + .catch(reject); + }); + } + + return { DatabaseType, AddDatabase, GetAllDatabases, DeleteDatabase, TestDatabaseConnection }; };