From 3571c31984e4b07518332bcd8ce39c7f0d97424c Mon Sep 17 00:00:00 2001 From: Milho001 <l.milhomemfrancochristino@uu.nl> Date: Thu, 31 Oct 2024 21:42:07 +0000 Subject: [PATCH] feat: moving user management service to django --- apps/web/.env | 24 +++ apps/web/src/components/navbar/navbar.tsx | 8 +- .../shared/lib/components/tooltip/Tooltip.tsx | 10 +- libs/shared/lib/data-access/api/eventBus.tsx | 22 +- libs/shared/lib/data-access/broker/broker.tsx | 2 +- libs/shared/lib/data-access/broker/wsQuery.ts | 4 +- .../shared/lib/data-access/broker/wsState.tsx | 27 ++- .../security/useAuthentication.tsx | 7 +- .../data-access/store/querybuilderSlice.ts | 9 + .../data-access/store/visualizationSlice.ts | 4 +- .../lib/inspector/ConnectionInspector.tsx | 8 +- libs/shared/lib/inspector/InspectorPanel.tsx | 1 - .../lib/management/database/DatabaseForm.tsx | 62 +++--- .../lib/management/database/Databases.tsx | 7 +- .../management/database/MockSaveStates.tsx | 192 ++++++++++-------- .../management/database/UpsertDatabase.tsx | 8 +- .../management/database/useHandleDatabase.ts | 7 +- .../lib/querybuilder/panel/ContextMenu.tsx | 133 ++++++++++++ .../lib/querybuilder/panel/QueryBuilder.tsx | 39 +++- .../entitypill/QueryEntityPill.tsx | 102 ---------- .../config/VisualizationSettings.tsx | 18 +- 21 files changed, 407 insertions(+), 287 deletions(-) create mode 100644 apps/web/.env create mode 100644 libs/shared/lib/querybuilder/panel/ContextMenu.tsx diff --git a/apps/web/.env b/apps/web/.env new file mode 100644 index 000000000..2458b1b74 --- /dev/null +++ b/apps/web/.env @@ -0,0 +1,24 @@ +GRAPHPOLARIS_VERSION=dev +BACKEND_URL=http://localhost +BACKEND_WSS_URL=ws://localhost:3001/ws +STAGING=dev +SKIP_LOGIN=true +BACKEND_USER=:3001 +GRAPHPOLARIS_VERSION=dev + +SENTRY_ENABLED=false +SENTRY_URL= + +GP_AUTH_URL= + +WIP_TABLEVIS=false +WIP_NODELINKVIS=false +WIP_RAWJSONVIS=false +WIP_PAOHVIS=true +WIP_MATRIXVIS=true +WIP_SEMANTICSUBSTRATESVIS=true +WIP_MAPVIS=true + +WIP_INSIGHT_SHARING=true +WIP_VIEWER_PERMISSIONS=true +WIP_SHARABLE_EXPLORATION=true diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index e5512cb2c..5aa6d09c6 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -8,16 +8,14 @@ /* 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, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useAuthCache, useAuthentication } from '@graphpolaris/shared/lib/data-access'; import { DropdownItem } from '@graphpolaris/shared/lib/components/dropdowns'; import GpLogo from './gp-logo'; import { Popover, PopoverContent, PopoverTrigger } from '@graphpolaris/shared/lib/components/layout/Popover'; -import { useDispatch } from 'react-redux'; -import { showManagePermissions, showSharableExploration } from 'config'; -import { Button, Dialog, DialogContent, DialogTrigger, useActiveSaveStateAuthorization, useSessionCache } from '@graphpolaris/shared'; +import { showSharableExploration } from 'config'; +import { Button, useActiveSaveStateAuthorization } from '@graphpolaris/shared'; import { ManagementTrigger, ManagementViews } from '@graphpolaris/shared/lib/management'; -import { Members } from '@graphpolaris/shared/lib/management/Members'; export const Navbar = () => { const dropdownRef = useRef<HTMLDivElement>(null); diff --git a/libs/shared/lib/components/tooltip/Tooltip.tsx b/libs/shared/lib/components/tooltip/Tooltip.tsx index ac37a2506..2dc2869aa 100644 --- a/libs/shared/lib/components/tooltip/Tooltip.tsx +++ b/libs/shared/lib/components/tooltip/Tooltip.tsx @@ -24,7 +24,7 @@ interface TooltipOptions { placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; - boundaryElement?: React.RefObject<HTMLElement> | null; + boundaryElement?: React.RefObject<HTMLElement> | HTMLElement | null; showArrow?: boolean; interactive?: boolean; } @@ -60,14 +60,14 @@ export function useTooltip({ flip({ crossAxis: placement.includes('-'), fallbackAxisSideDirection: 'start', - padding: 5 + padding: 5, }), shift({ padding: 5 }), ], }; if (boundaryElement != null) { - const boundary = boundaryElement?.current ?? undefined; + const boundary = boundaryElement instanceof HTMLElement ? (boundaryElement ?? undefined) : (boundaryElement?.current ?? undefined); config.middleware.find((x) => x.name == 'flip')!.options[0].boundary = boundary; config.middleware.find((x) => x.name == 'shift')!.options[0].boundary = boundary; config.middleware.push(hide({ boundary })); @@ -125,9 +125,7 @@ export function Tooltip({ children, ...options }: { children: React.ReactNode } // or other positioning options. const tooltip = useTooltip(options); - return <TooltipContext.Provider value={tooltip}> - {children} - </TooltipContext.Provider>; + return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>; } export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean; x?: number; y?: number }>( diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx index bc471755d..d48c70bc6 100644 --- a/libs/shared/lib/data-access/api/eventBus.tsx +++ b/libs/shared/lib/data-access/api/eventBus.tsx @@ -77,11 +77,13 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } console.debug('Setting state fetched from database', saveStateID, saveStates); const state = saveStates[saveStateID]; if (state) { - dispatch(setQuerybuilderNodes(state.queryBuilder)); + if (state.queries && state.queries.length > 0) { + dispatch(setQuerybuilderNodes(state.queries[0])); + } dispatch( setVisualizationState( - Object.keys(state.visualization).length !== 0 // should only occur in mock data - ? (state.visualization as VisState) + Object.keys(state.visualizations).length !== 0 // should only occur in mock data + ? (state.visualizations as VisState) : { activeVisualizationIndex: -1, openVisualizationArray: [], @@ -189,7 +191,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } if (response && response.result) { dispatch(setQueryText(response)); } - }, 'query_translation_result'); + }, 'queryTranslation_result'); login(); @@ -199,7 +201,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } unsubs.forEach((unsub) => { unsub(); }); - Broker.instance().unSubscribeAll('query_translation_result'); + Broker.instance().unSubscribeAll('queryTranslation_result'); Broker.instance().unSubscribeAll('schema_stats_result'); Broker.instance().unSubscribeAll('schema_inference'); }; @@ -208,9 +210,9 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } useEffect(() => { if (session.currentSaveState) { let state = { ...session.saveStates[session.currentSaveState] }; - if (!isEqual(state.queryBuilder, queryBuilder) && state.queryBuilder?.graph?.nodes) { - console.debug('Updating queryBuilder state', state.queryBuilder, queryBuilder); - state.queryBuilder = { ...queryBuilder }; + if (!isEqual(state.queries?.[0], queryBuilder)) { + console.debug('Updating queryBuilder state', state.queries, queryBuilder); + state.queries = [{ ...queryBuilder }]; dispatch(updateSelectedSaveState(state)); wsUpdateState(state); } @@ -220,9 +222,9 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } useEffect(() => { if (session.currentSaveState) { let state = { ...session.saveStates[session.currentSaveState] }; - if (!isEqual(state.visualization, visState)) { + if (!isEqual(state.visualizations, visState)) { console.debug('Updating visState state', visState); - state.visualization = { ...visState }; + state.visualizations = { ...visState }; dispatch(updateSelectedSaveState(state)); wsUpdateState(state); } diff --git a/libs/shared/lib/data-access/broker/broker.tsx b/libs/shared/lib/data-access/broker/broker.tsx index ab9739aad..f915a2f24 100644 --- a/libs/shared/lib/data-access/broker/broker.tsx +++ b/libs/shared/lib/data-access/broker/broker.tsx @@ -12,7 +12,7 @@ import { ReceiveMessageI, SendMessageI, SendMessageWithSessionI } from './types' * It works with routingkeys, a listener can subscribe to messages from the backend with a specific routingkey. * Possible routingkeys: * - query_result: Contains an object with nodes and edges or a numerical result. - * - query_translation_result: Contains the query translated to the database language. + * - queryTranslation_result: Contains the query translated to the database language. * - schema_result: Contains the schema of the users database. * - query_status_update: Contains an update to if a query is being executed. * - query_database_error: Contains the error received from the database. diff --git a/libs/shared/lib/data-access/broker/wsQuery.ts b/libs/shared/lib/data-access/broker/wsQuery.ts index ca1aac6fc..6f8bc2c3a 100644 --- a/libs/shared/lib/data-access/broker/wsQuery.ts +++ b/libs/shared/lib/data-access/broker/wsQuery.ts @@ -24,9 +24,9 @@ export function wsManualQueryRequest(query: string) { type QueryTranslationResponse = (data: QueryBuilderText) => void; export function wsQueryTranslationSubscription(callback: QueryTranslationResponse) { - const id = Broker.instance().subscribe(callback, 'query_translation_result'); + const id = Broker.instance().subscribe(callback, 'queryTranslation_result'); return () => { - Broker.instance().unSubscribe('query_translation_result', id); + Broker.instance().unSubscribe('queryTranslation_result', id); }; } diff --git a/libs/shared/lib/data-access/broker/wsState.tsx b/libs/shared/lib/data-access/broker/wsState.tsx index d938c9f2a..24a87ed3f 100644 --- a/libs/shared/lib/data-access/broker/wsState.tsx +++ b/libs/shared/lib/data-access/broker/wsState.tsx @@ -1,4 +1,4 @@ -import { QueryBuilderState } from '../store/querybuilderSlice'; +import { QueryBuilderState, SchemaState } from '../store/querybuilderSlice'; import { URLParams, setParam } from '../api/url'; import { Broker } from './broker'; import { DateStringStatement } from '../../querybuilder/model/logic/general'; @@ -9,8 +9,8 @@ export const databaseNameMapping: string[] = ['arangodb', 'neo4j']; export const databaseProtocolMapping: string[] = ['neo4j://', 'neo4j+s://', 'bolt://', 'bolt+s://']; export enum DatabaseType { - ArangoDB = 0, - Neo4j = 1, + ArangoDB = 'arango', + Neo4j = 'neo4j', } export enum DatabaseStatus { @@ -26,7 +26,7 @@ export type DatabaseInfo = { protocol: string; username: string; password: string; - type: number; + type: string; }; export const SaveStateAuthorizationObjectsArray = ['database', 'visualization', 'query', 'schema'] as const; @@ -42,13 +42,15 @@ export const nilUUID = '00000000-0000-0000-0000-000000000000'; export type SaveStateI = { id: string; - user_id: string; + userId: string; name: string; - db: DatabaseInfo; - schema: any; - queryBuilder: QueryBuilderState; - visualization: VisState | {}; - share_state: any; + dbConnections: DatabaseInfo[]; + schemas: SchemaState[]; + queries: QueryBuilderState[]; + visualizations: VisState; + createdAt: string; + updatedAt: string; + shareState: any; }; type GetStateResponse = (data: SaveStateI) => void; @@ -169,6 +171,11 @@ export function wsUpdateState(request: SaveStateI, callback?: GetStateResponse) } export function wsTestDatabaseConnection(dbConnection: DatabaseInfo, callback?: TestSaveStateConnectionResponse) { + if (!dbConnection) { + console.warn('dbConnection is undefined on wsTestDatabaseConnection'); + if (callback) callback({ status: 'fail', saveStateID: '' }); + return; + } Broker.instance().sendMessage( { key: 'dbConnection', diff --git a/libs/shared/lib/data-access/security/useAuthentication.tsx b/libs/shared/lib/data-access/security/useAuthentication.tsx index f4718b51c..fcb507b86 100644 --- a/libs/shared/lib/data-access/security/useAuthentication.tsx +++ b/libs/shared/lib/data-access/security/useAuthentication.tsx @@ -20,10 +20,11 @@ export const useAuthentication = () => { const login = () => { fetch(`${domain}${userURI}/headers`, fetchSettings) - .then((res) => + .then((res) => { res .json() .then((res: UserAuthenticationHeader) => { + console.log(res, 'headers'); dispatch( authenticated({ username: res.username, @@ -35,8 +36,8 @@ export const useAuthentication = () => { }), ); }) - .catch(handleError), - ) + .catch(handleError); + }) .catch(handleError); }; diff --git a/libs/shared/lib/data-access/store/querybuilderSlice.ts b/libs/shared/lib/data-access/store/querybuilderSlice.ts index bfa417fda..d6b771370 100644 --- a/libs/shared/lib/data-access/store/querybuilderSlice.ts +++ b/libs/shared/lib/data-access/store/querybuilderSlice.ts @@ -36,6 +36,10 @@ export type QueryBuilderState = { unionTypes: { [nodeId: string]: QueryUnionType }; }; +export type SchemaState = { + settings: Record<string, any>; +} + // Define the initial state using that type export const initialState: QueryBuilderState = { graph: defaultGraph(), @@ -65,6 +69,11 @@ export const querybuilderSlice = createSlice({ state.graph = action.payload; state.ignoreReactivity = false; }, + /** + * Sets the querybuilder nodes, settings, and attributes being shown, + * if the payload contains the required information. + * @param {QueryBuilderState} action.payload the payload with the new state + */ setQuerybuilderNodes: (state: QueryBuilderState, action: PayloadAction<QueryBuilderState>) => { if (action.payload.graph?.nodes && action.payload.graph?.edges) { state.graph = action.payload.graph; diff --git a/libs/shared/lib/data-access/store/visualizationSlice.ts b/libs/shared/lib/data-access/store/visualizationSlice.ts index 1f206d932..1f9600963 100644 --- a/libs/shared/lib/data-access/store/visualizationSlice.ts +++ b/libs/shared/lib/data-access/store/visualizationSlice.ts @@ -5,8 +5,8 @@ import { isEqual } from 'lodash-es'; export type VisStateSettings = VisualizationSettingsType[]; export type VisState = { - activeVisualizationIndex: number; - openVisualizationArray: VisStateSettings; + activeVisualizationIndex: number; // uses underscore_case to match data model from backend + openVisualizationArray: VisStateSettings; // uses underscore_case to match data model from backend }; export const initialState: VisState = { diff --git a/libs/shared/lib/inspector/ConnectionInspector.tsx b/libs/shared/lib/inspector/ConnectionInspector.tsx index 16988b41f..0268472f1 100644 --- a/libs/shared/lib/inspector/ConnectionInspector.tsx +++ b/libs/shared/lib/inspector/ConnectionInspector.tsx @@ -12,13 +12,13 @@ export function ConnectionInspector() { <span className="text-xs font-semibold">Name</span> <span className="text-xs">{session.saveStates[session.currentSaveState].name}</span> <span className="text-xs font-semibold">Database</span> - <span className="text-xs">{session.saveStates[session.currentSaveState].db.internalDatabaseName}</span> + <span className="text-xs">{session.saveStates[session.currentSaveState].dbConnections?.[0]?.internalDatabaseName}</span> <span className="text-xs font-semibold">Protocol</span> - <span className="text-xs">{session.saveStates[session.currentSaveState].db.protocol}</span> + <span className="text-xs">{session.saveStates[session.currentSaveState].dbConnections?.[0]?.protocol}</span> <span className="text-xs font-semibold">Hostname</span> - <span className="text-xs">{session.saveStates[session.currentSaveState].db.url}</span> + <span className="text-xs">{session.saveStates[session.currentSaveState].dbConnections?.[0]?.url}</span> <span className="text-xs font-semibold">Port</span> - <span className="text-xs">{session.saveStates[session.currentSaveState].db.port}</span> + <span className="text-xs">{session.saveStates[session.currentSaveState].dbConnections?.[0]?.port}</span> </div> )} </div> diff --git a/libs/shared/lib/inspector/InspectorPanel.tsx b/libs/shared/lib/inspector/InspectorPanel.tsx index a803dd5cc..cbc35aefc 100644 --- a/libs/shared/lib/inspector/InspectorPanel.tsx +++ b/libs/shared/lib/inspector/InspectorPanel.tsx @@ -15,7 +15,6 @@ export function InspectorPanel(props: { children?: React.ReactNode }) { const selection = useSelection(); const focus = useFocus(); const dispatch = useDispatch(); - const { activeVisualizationIndex } = useVisualization(); const inspector = useMemo(() => { if (selection) return <SelectionConfig />; diff --git a/libs/shared/lib/management/database/DatabaseForm.tsx b/libs/shared/lib/management/database/DatabaseForm.tsx index 1f49601f3..b4d8e3045 100644 --- a/libs/shared/lib/management/database/DatabaseForm.tsx +++ b/libs/shared/lib/management/database/DatabaseForm.tsx @@ -6,20 +6,24 @@ import { databaseNameMapping, databaseProtocolMapping, DatabaseType, Input, nilU export const INITIAL_SAVE_STATE: SaveStateI = { id: nilUUID, name: 'Untitled', - db: { - username: 'neo4j', - password: 'DevOnlyPass', - url: 'localhost', - port: 7687, - protocol: 'neo4j://', - internalDatabaseName: 'neo4j', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'neo4j', + password: 'DevOnlyPass', + url: 'localhost', + port: 7687, + protocol: 'neo4j://', + internalDatabaseName: 'neo4j', + type: DatabaseType.Neo4j, + }, + ], + schemas: [{ settings: {} }], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + createdAt: '', + updatedAt: '', + userId: '', }; export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveStateI, error: boolean) => void }) => { @@ -29,7 +33,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta function handlePortChanged(port: string): void { if (!isNaN(Number(port))) setFormData((draft) => { - draft.db.port = Number(port); + draft.dbConnections[0].port = Number(port); return draft; }); } @@ -54,7 +58,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta <Input type="text" label="Internal database name" - value={formData.db.internalDatabaseName} + value={formData.dbConnections[0].internalDatabaseName} placeholder="internalDatabaseName" required errorText="This field is required" @@ -64,7 +68,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta }} onChange={(value: string) => setFormData((draft) => { - draft.db.internalDatabaseName = value; + draft.dbConnections[0].internalDatabaseName = value; }) } /> @@ -75,11 +79,11 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta className="w-full" label="Database Type" required - value={databaseNameMapping[formData.db.type]} + value={formData.dbConnections[0].type} options={databaseNameMapping} onChange={(value: string | number) => { setFormData((draft) => { - draft.db.type = databaseNameMapping.indexOf(value.toString()); + draft.dbConnections[0].type = value.toString(); }); }} /> @@ -88,12 +92,12 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta type="dropdown" label="Database Protocol" required - value={formData.db.protocol} + value={formData.dbConnections[0].protocol} options={databaseProtocolMapping} info="Protocol via which the database connection will be established" onChange={(value: string | number) => { setFormData((draft) => { - draft.db.protocol = value.toString(); + draft.dbConnections[0].protocol = value.toString(); }); }} /> @@ -103,7 +107,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta <Input type="text" label="Hostname/IP" - value={formData.db.url} + value={formData.dbConnections[0].url} placeholder="neo4j" required errorText="This field is required" @@ -114,7 +118,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta }} onChange={(value: string) => { setFormData((draft) => { - draft.db.url = value; + draft.dbConnections[0].url = value; }); }} /> @@ -122,7 +126,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta <Input type="text" label="Port" - value={formData.db.port.toString()} + value={formData.dbConnections[0].port.toString()} placeholder="neo4j" required errorText="Must be between 1 and 9999" @@ -133,7 +137,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta }} onChange={(value: string) => { setFormData((draft) => { - draft.db.port = Number(value); + draft.dbConnections[0].port = Number(value); }); }} /> @@ -143,7 +147,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta <Input type="text" label="Username" - value={formData.db.username} + value={formData.dbConnections[0].username} placeholder="username" required errorText="This field is required" @@ -154,7 +158,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta }} onChange={(value: string) => { setFormData((draft) => { - draft.db.username = value; + draft.dbConnections[0].username = value; }); }} /> @@ -163,7 +167,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta type="text" visible={false} label="Password" - value={formData.db.password} + value={formData.dbConnections[0].password} placeholder="password" required errorText="This field is required" @@ -174,7 +178,7 @@ export const DatabaseForm = (props: { data: SaveStateI; onChange: (data: SaveSta }} onChange={(value: string) => { setFormData((draft) => { - draft.db.password = value; + draft.dbConnections[0].password = value; }); }} /> diff --git a/libs/shared/lib/management/database/Databases.tsx b/libs/shared/lib/management/database/Databases.tsx index a3c327212..786a8ab09 100644 --- a/libs/shared/lib/management/database/Databases.tsx +++ b/libs/shared/lib/management/database/Databases.tsx @@ -66,7 +66,8 @@ export function Databases({ onClose, saveStates, changeActive, setSelectedSaveSt if (saveStates[a].name.toLowerCase() <= saveStates[b].name.toLowerCase()) return dir; else return -dir; } else { - if (saveStates[a].db[orderBy[0]].toLowerCase() <= saveStates[b].db[orderBy[0]].toLowerCase()) return dir; + if (saveStates[a].dbConnections?.[0][orderBy[0]].toLowerCase() <= saveStates[b].dbConnections?.[0][orderBy[0]].toLowerCase()) + return dir; else return -dir; } }), @@ -119,10 +120,10 @@ export function Databases({ onClose, saveStates, changeActive, setSelectedSaveSt </Button> </td> <td className="text-left"> - <span className="font-light">{saveStates[key].db.protocol}</span> + <span className="font-light">{saveStates[key].dbConnections?.[0]?.protocol}</span> </td> <td className="text-left"> - <span className="font-light">{saveStates[key].db.url}</span> + <span className="font-light">{saveStates[key].dbConnections?.[0]?.url}</span> </td> <td className="text-right flex justify-end"> <Button diff --git a/libs/shared/lib/management/database/MockSaveStates.tsx b/libs/shared/lib/management/database/MockSaveStates.tsx index 7fdaa0a19..a23aba28d 100644 --- a/libs/shared/lib/management/database/MockSaveStates.tsx +++ b/libs/shared/lib/management/database/MockSaveStates.tsx @@ -13,120 +13,144 @@ export const sampleSaveStates: Array<SaveStateSampleI> = [ name: 'Recommendations', subtitle: 'Hosted by Neo4j', description: 'Network of movies, actors, directors and reviews by people', - db: { - username: 'recommendations', - password: 'recommendations', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'recommendations', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'recommendations', + password: 'recommendations', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'recommendations', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, { id: nilUUID, name: 'Movies', subtitle: 'Hosted by Neo4j', description: 'Movies and people related to those movies as actors, directors and producers', - db: { - username: 'movies', - password: 'movies', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'movies', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'movies', + password: 'movies', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'movies', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, { id: nilUUID, name: 'Northwind', subtitle: 'Hosted by Neo4j', description: 'Retail-system with products, orders, customers, suppliers and employees', - db: { - username: 'northwind', - password: 'northwind', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'northwind', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'northwind', + password: 'northwind', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'northwind', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, { id: nilUUID, name: 'Fincen', subtitle: 'Hosted by Neo4j', description: 'FinCEN files investigation for banks and countries', - db: { - username: 'fincen', - password: 'fincen', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'fincen', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'fincen', + password: 'fincen', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'fincen', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, { id: nilUUID, name: 'Slack', subtitle: 'Hosted by Neo4j', description: 'Communication network consisting of several types of users and messages', - db: { - username: 'slack', - password: 'slack', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'slack', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'slack', + password: 'slack', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'slack', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, { id: nilUUID, name: 'Game of Thrones', subtitle: 'Hosted by Neo4j', description: 'Character interactions and actors in the Game of Thrones movie', - db: { - username: 'gameofthrones', - password: 'gameofthrones', - url: 'demo.neo4jlabs.com', - port: 7687, - protocol: 'neo4j+s://', - internalDatabaseName: 'gameofthrones', - type: DatabaseType.Neo4j, - }, - schema: {}, - queryBuilder: qbInitialState, - visualization: {}, - share_state: {}, - user_id: '', + dbConnections: [ + { + username: 'gameofthrones', + password: 'gameofthrones', + url: 'demo.neo4jlabs.com', + port: 7687, + protocol: 'neo4j+s://', + internalDatabaseName: 'gameofthrones', + type: DatabaseType.Neo4j, + }, + ], + schemas: [], + queries: [qbInitialState], + visualizations: { activeVisualizationIndex: -1, openVisualizationArray: [] }, + shareState: {}, + userId: '', + createdAt: '', + updatedAt: '', }, ]; diff --git a/libs/shared/lib/management/database/UpsertDatabase.tsx b/libs/shared/lib/management/database/UpsertDatabase.tsx index 5c6fa1513..a3c4fa773 100644 --- a/libs/shared/lib/management/database/UpsertDatabase.tsx +++ b/libs/shared/lib/management/database/UpsertDatabase.tsx @@ -16,10 +16,10 @@ export const UpsertDatabase = (props: { const databaseHandler = useHandleDatabase(); const ref = useRef<HTMLDialogElement>(null); const authCache = useAuthCache(); - const [formData, setFormData] = useImmer( + const [formData, setFormData] = useImmer<SaveStateI>( props.saveState && props.open === 'update' ? props.saveState - : { ...INITIAL_SAVE_STATE, user_id: authCache.authentication?.userID || '' }, + : { ...INITIAL_SAVE_STATE, userId: authCache.authentication?.userID || '' }, ); const [hasError, setHasError] = useState(false); const [sampleDataPanel, setSampleDataPanel] = useState<boolean | null>(false); @@ -42,7 +42,7 @@ export const UpsertDatabase = (props: { } function closeDialog(): void { - setFormData({ ...INITIAL_SAVE_STATE, user_id: authCache.authentication?.userID || '' }); + setFormData({ ...INITIAL_SAVE_STATE, userId: authCache.authentication?.userID || '' }); ref.current?.close(); props.onClose(); } @@ -54,7 +54,7 @@ export const UpsertDatabase = (props: { <SampleDatabaseSelector onClick={(data) => { setHasError(false); - handleSubmit({ ...data, user_id: authCache.authentication?.userID || '' }); + handleSubmit({ ...data, userId: authCache.authentication?.userID || '' }); }} /> ) : ( diff --git a/libs/shared/lib/management/database/useHandleDatabase.ts b/libs/shared/lib/management/database/useHandleDatabase.ts index 2f03281c9..8ee9639a5 100644 --- a/libs/shared/lib/management/database/useHandleDatabase.ts +++ b/libs/shared/lib/management/database/useHandleDatabase.ts @@ -32,20 +32,21 @@ export const useHandleDatabase = () => { forceAdd: boolean = false, concludedCallback: () => void, ): Promise<void> { + console.log('submitDatabaseChange', saveStateData); setConnectionStatus(() => ({ updating: true, status: 'Testing database connection', verified: null, })); - wsTestDatabaseConnection(saveStateData.db, (data) => { + wsTestDatabaseConnection(saveStateData.dbConnections?.[0], (data) => { if (!saveStateData) { console.error('formData is null'); return; } - if (saveStateData.user_id !== authCache.authentication?.userID && authCache.authentication?.userID) { + if (saveStateData.userId !== authCache.authentication?.userID && authCache.authentication?.userID) { console.error('user_id is not equal to auth.userID'); - saveStateData.user_id = authCache.authentication.userID; + saveStateData.userId = authCache.authentication.userID; } if (data && data.status === 'success') { setConnectionStatus((prevState) => ({ diff --git a/libs/shared/lib/querybuilder/panel/ContextMenu.tsx b/libs/shared/lib/querybuilder/panel/ContextMenu.tsx new file mode 100644 index 000000000..f13fb451a --- /dev/null +++ b/libs/shared/lib/querybuilder/panel/ContextMenu.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Tooltip, TooltipTrigger, TooltipContent, DropdownItem, TextInput, Icon } from '../../components'; +import { ReactFlowInstance, Node } from 'reactflow'; +import { NodeAttribute, SchemaReactflowEntityNode } from '../model'; +import { + useAppDispatch, + useQuerybuilderAttributesShown, + useQuerybuilderGraph, + useQuerybuilderHash, + useQuerybuilderUnionTypes, +} from '../..'; +import { isEqual } from 'lodash-es'; +import { + attributeShownToggle, + QueryUnionType, + setQuerybuilderGraphology, + setQueryUnionType, + toQuerybuilderGraphology, +} from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; +import { getDataTypeIcon } from '../../components/DataTypeIcon'; + +export const ContextMenu = (props: { + open: boolean; + position?: { x: number; y: number }; + node?: Node; + reactFlowWrapper: React.RefObject<HTMLDivElement>; + reactFlow: ReactFlowInstance; + onClose: () => void; +}) => { + const [filter, setFilter] = useState<string>(''); + const dispatch = useAppDispatch(); + const graph = useQuerybuilderGraph(); + const qbHash = useQuerybuilderHash(); + + const graphologyGraph = useMemo(() => toQuerybuilderGraphology(graph), [graph, qbHash]); + + const state = useMemo(() => { + const divPos = props.reactFlowWrapper.current?.getBoundingClientRect(); + if (!divPos || !props.node) return; + let position = { x: 0, y: 0 }; + if (props.position) { + position = props.position; + } else { + position = props.reactFlow.flowToScreenPosition({ x: props.node.data.x, y: props.node.data.y }); + } + + return { + open: props.open, + x: position.x - divPos.x, + y: position.y - divPos.y + 10, + }; + }, [props.open, props.node]); + + const filteredAttributes = useMemo<NodeAttribute[]>(() => { + if (props.node == null) return []; + if (filter == null || filter.length == 0) return props.node.data.attributes; + + return (props.node.data.attributes as NodeAttribute[]).filter((attr) => { + return attr.handleData.attributeName?.toLocaleLowerCase().includes(filter.toLocaleLowerCase()); + }); + }, [filter, props.node]); + + const attributesBeingShown = useQuerybuilderAttributesShown(); + function isAttributeAdded(attribute: NodeAttribute): boolean { + return attributesBeingShown.some((x) => isEqual(x, attribute.handleData)); + } + + function addAttribute(attribute: NodeAttribute) { + dispatch(attributeShownToggle(attribute.handleData)); + } + + const unionType = useQuerybuilderUnionTypes(); + function setUnionType(unionType: QueryUnionType) { + if (!props.node) return; + dispatch(setQueryUnionType({ nodeId: props.node.id, unionType: unionType })); + } + function removeNode() { + if (!props.node) return; + graphologyGraph.dropNode(props.node.id); + dispatch(setQuerybuilderGraphology(graphologyGraph)); + props.onClose(); + } + + return ( + <Tooltip open={props.open && state !== undefined} interactive={true} showArrow={false} placement="bottom-start"> + <TooltipTrigger x={state ? state.x : 0} y={state ? state.y : 0} /> + <TooltipContent> + <DropdownItem + value={'Add/remove attribute'} + onClick={(e) => {}} + submenu={[ + <TextInput + type={'text'} + placeholder="Filter" + size="xs" + className="mb-1 min-w-40 rounded-sm" + value={filter} + onClick={(e) => e.stopPropagation()} + onChange={(v) => setFilter(v)} + />, + + filteredAttributes.map((attr) => ( + <DropdownItem + key={attr.handleData.attributeName + attr.handleData.nodeId} + value={attr.handleData.attributeName ?? ''} + selected={isAttributeAdded(attr)} + onClick={(_) => addAttribute(attr)} + > + <Icon component={getDataTypeIcon(attr?.handleData?.attributeType)} className="ms-2 float-end" size={16} /> + </DropdownItem> + )), + ]} + /> + <DropdownItem + value="Union type" + submenu={[ + <DropdownItem + value="AND" + onClick={(_) => setUnionType(QueryUnionType.AND)} + selected={props.node ? unionType[props.node.id] != QueryUnionType.OR : false} // Also selected when null + />, + <DropdownItem + value="OR" + onClick={(_) => setUnionType(QueryUnionType.OR)} + selected={props.node ? unionType[props.node.id] == QueryUnionType.OR : false} + />, + ]} + /> + <DropdownItem value="Remove" className="text-danger" onClick={(e) => removeNode()} /> + </TooltipContent> + </Tooltip> + ); +}; diff --git a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx index 10579c534..ad0ff3253 100644 --- a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx +++ b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx @@ -9,7 +9,11 @@ import { useSchemaInference, useSearchResultQB, } from '@graphpolaris/shared/lib/data-access/store'; -import { setQuerybuilderGraphology, toQuerybuilderGraphology } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; +import { + QueryUnionType, + setQuerybuilderGraphology, + toQuerybuilderGraphology, +} from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactFlow, { Background, @@ -22,6 +26,7 @@ import ReactFlow, { OnConnectStartParams, ReactFlowInstance, ReactFlowProvider, + Viewport, isNode, useReactFlow, } from 'reactflow'; @@ -40,6 +45,7 @@ import { ConnectingNodeDataI } from './utils/connectorDrop'; import { resultSetFocus } from '../../data-access/store/interactionSlice'; import { QueryBuilderDispatcherContext } from './QueryBuilderDispatcher'; import { QueryBuilderNav, QueryBuilderToggleSettings } from './QueryBuilderNav'; +import { ContextMenu } from './ContextMenu'; export type QueryBuilderProps = { onRunQuery?: () => void; @@ -82,6 +88,9 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { const searchResults = useSearchResultQB(); const reactFlowInstanceRef = useRef<ReactFlowInstance | null>(null); const [allowZoom, setAllowZoom] = useState(true); + const [contextMenuOpen, setContextMenuOpen] = useState<{ open: boolean; node?: Node; position?: { x: number; y: number } }>({ + open: false, + }); useEffect(() => { const searchResultKeys = new Set([...searchResults.nodes.map((node) => node.key), ...searchResults.edges.map((edge) => edge.key)]); @@ -472,6 +481,21 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { } }; + const onNodeContextMenu = (event: React.MouseEvent, node: Node) => { + if (event.shiftKey) return; + event.preventDefault(); + setContextMenuOpen({ open: true, node: node, position: { x: event.clientX, y: event.clientY } }); + }; + + const onMove = useCallback( + (event: MouseEvent | TouchEvent, viewport: Viewport) => { + if (contextMenuOpen.open) { + setContextMenuOpen({ ...contextMenuOpen, open: false }); + } + }, + [contextMenuOpen], + ); + useEffect(() => { try { applyLayout(); @@ -502,6 +526,16 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { }, }} > + <ContextMenu + open={contextMenuOpen.open} + node={contextMenuOpen.node} + position={contextMenuOpen.position} + reactFlowWrapper={reactFlowWrapper} + reactFlow={reactFlow} + onClose={() => { + setContextMenuOpen({ ...contextMenuOpen, open: false }); + }} + /> <div ref={reactFlowWrapper} className="h-full w-full flex flex-col"> <QueryBuilderNav toggleSettings={toggleSettings} @@ -582,6 +616,7 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { reactFlowInstanceRef.current = reactFlowInstance; onInit(reactFlowInstance); }} + onMove={onMove} onNodesChange={saveStateAuthorization.query.W ? onNodesChange : () => {}} onDragOver={saveStateAuthorization.query.W ? onDragOver : () => {}} onConnect={saveStateAuthorization.query.W ? onConnect : () => {}} @@ -594,7 +629,7 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { onEdgeUpdateEnd={saveStateAuthorization.query.W ? onEdgeUpdateEnd : () => {}} onDrop={saveStateAuthorization.query.W ? onDrop : () => {}} // onContextMenu={onContextMenu} - // onNodeContextMenu={saveStateAuthorization.query.W ? onNodeContextMenu : () => {}} + onNodeContextMenu={saveStateAuthorization.query.W ? onNodeContextMenu : () => {}} // onNodesDelete={onNodesDelete} // onNodesChange={onNodesChange} deleteKeyCode="Backspace" diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx index 97b5865fc..84be53b8d 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx @@ -43,116 +43,14 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { [graph], ); - const [openDropdown, setOpenDropdown] = useState(false); - const [filter, setFilter] = useState<string>(''); - const resource = 'query'; - - const filteredAttributes = useMemo(() => { - if (filter == null || filter.length == 0) return data.attributes; - - return data.attributes.filter((attr) => { - return attr.handleData.attributeName?.toLocaleLowerCase().includes(filter.toLocaleLowerCase()); - }); - }, [filter]); - - const qbHash = useQuerybuilderHash(); - const graphologyGraph = useMemo(() => toQuerybuilderGraphology(graph), [graph, qbHash]); - const dispatch = useDispatch(); - - function removeNode() { - graphologyGraph.dropNode(node.id); - dispatch(setQuerybuilderGraphology(graphologyGraph)); - } - - function addAttribute(attribute: NodeAttribute) { - dispatch(attributeShownToggle(attribute.handleData)); - } - const unionType = useQuerybuilderUnionTypes()[node.id]; - function setUnionType(unionType: QueryUnionType) { - dispatch(setQueryUnionType({ nodeId: node.id, unionType: unionType })); - } - - const attributesBeingShown = useQuerybuilderAttributesShown(); - function isAttributeAdded(attribute: NodeAttribute): boolean { - return attributesBeingShown.some((x) => isEqual(x, attribute.handleData)); - } - return ( <div className="w-fit h-fit nowheel" ref={ref} id="asd"> <EntityPill title={ <div className="flex flex-row justify-between items-center"> <span>{data.name || ''}</span> - - <DropdownContainer> - <DropdownTrigger size="md"> - <Button - variantType="secondary" - variant="ghost" - size="2xs" - iconComponent={openDropdown ? 'icon-[ic--baseline-arrow-drop-up]' : 'icon-[ic--baseline-arrow-drop-down]'} - className={openDropdown ? 'border-secondary-200' : ''} - /> - </DropdownTrigger> - - <DropdownItemContainer> - <PopoverContext.Consumer> - {(popover) => [ - <DropdownItem - value={'Add/remove attribute'} - onClick={(e) => { - popover?.setOpen(false); - setOpenDropdown(false); - }} - submenu={[ - <TextInput - type={'text'} - placeholder="Filter" - size="xs" - className="mb-1 min-w-40 rounded-sm" - value={filter} - onClick={(e) => e.stopPropagation()} - onChange={(v) => setFilter(v)} - />, - - filteredAttributes.map((attr) => ( - <DropdownItem - key={attr.handleData.attributeName + attr.handleData.nodeId} - value={attr.handleData.attributeName ?? ''} - selected={isAttributeAdded(attr)} - onClick={(_) => addAttribute(attr)} - > - <Icon component={getDataTypeIcon(attr?.handleData?.attributeType)} className="ms-2 float-end" size={16} /> - </DropdownItem> - )), - ]} - />, - <DropdownItem - value="Union type" - onClick={(e) => { - popover?.setOpen(false); - setOpenDropdown(false); - }} - submenu={[ - <DropdownItem - value="AND" - onClick={(_) => setUnionType(QueryUnionType.AND)} - selected={unionType != QueryUnionType.OR} // Also selected when null - />, - <DropdownItem - value="OR" - onClick={(_) => setUnionType(QueryUnionType.OR)} - selected={unionType == QueryUnionType.OR} - />, - ]} - />, - ]} - </PopoverContext.Consumer> - <DropdownItem value="Remove" className="text-danger" onClick={(e) => removeNode()} /> - </DropdownItemContainer> - </DropdownContainer> </div> } withHandles="horizontal" diff --git a/libs/shared/lib/vis/components/config/VisualizationSettings.tsx b/libs/shared/lib/vis/components/config/VisualizationSettings.tsx index ec24a2e73..37abf9bf1 100644 --- a/libs/shared/lib/vis/components/config/VisualizationSettings.tsx +++ b/libs/shared/lib/vis/components/config/VisualizationSettings.tsx @@ -1,21 +1,7 @@ import React, { Suspense, useEffect, useMemo, useState } from 'react'; -import { - Button, - Input, - VISUALIZATION_TYPES, - Visualizations, - useAppDispatch, - useGraphQueryResultMeta, - useVisualization, - useActiveVisualization, -} from '../../..'; +import { Button, Input, VISUALIZATION_TYPES, Visualizations, useAppDispatch, useGraphQueryResultMeta, useVisualization } from '../../..'; import { SettingsHeader } from './components'; -import { - removeVisualization, - updateActiveVisualization, - updateVisualization, - updateActiveVisualizationAttributes, -} from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; +import { removeVisualization, updateActiveVisualizationAttributes } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; import { VisualizationSettingsPropTypes, VisualizationSettingsType } from '../../common'; type Props = {}; -- GitLab