From beb01ffd19d87f61813d75e67ab6d1bede307a21 Mon Sep 17 00:00:00 2001 From: Milho001 <l.milhomemfrancochristino@uu.nl> Date: Tue, 7 May 2024 18:35:22 +0000 Subject: [PATCH] fix(qb): save active attributes from dropdown and fixes to QB drag and hover --- .../DatabaseManagement/forms/settings.tsx | 22 ++- libs/shared/lib/components/buttons/index.tsx | 4 +- libs/shared/lib/data-access/api/eventBus.tsx | 11 +- .../shared/lib/data-access/broker/wsState.tsx | 1 + libs/shared/lib/data-access/store/hooks.ts | 4 +- .../data-access/store/querybuilderSlice.ts | 21 ++- .../lib/querybuilder/panel/QueryBuilder.tsx | 6 - .../entitypill/QueryEntityPill.tsx | 32 +++- .../pills/pilldropdown/PillDropdown.tsx | 154 ++++++++++-------- 9 files changed, 165 insertions(+), 90 deletions(-) diff --git a/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx index f9bb74087..bf84e5acf 100644 --- a/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx +++ b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx @@ -6,6 +6,7 @@ import { wsTestDatabaseConnection, wsCreateState, useAuthorizationCache, + nilUUID, } from '@graphpolaris/shared/lib/data-access'; import { ErrorOutline } from '@mui/icons-material'; import { Dialog } from '@graphpolaris/shared/lib/components/layout'; @@ -46,7 +47,7 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s } }, [props.saveState]); - async function handleSubmit(saveStateData?: SaveStateI) { + async function handleSubmit(saveStateData?: SaveStateI, forceAdd: boolean = false): Promise<void> { if (!saveStateData) saveStateData = formData; setConnection(() => ({ updating: true, @@ -69,7 +70,7 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s status: 'Database connection verified', verified: true, })); - if (props.open === 'add') { + if (props.open === 'add' || forceAdd) { wsCreateState(saveStateData, (_data) => { dispatch(addSaveState(_data)); dispatch(testedSaveState(_data.id)); @@ -159,11 +160,10 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s </div> )} - <div - className={`grid md:grid-cols-2 gap-3 card-actions w-full justify-stretch items-center ${sampleDataPanel === true && 'hidden'}`} - > + <div className={`flex flex-row gap-3 card-actions w-full justify-stretch items-center ${sampleDataPanel === true && 'hidden'}`}> <Button type="primary" + className="flex-grow" label={connection.updating ? formTitle.slice(0, -1) + 'ing...' : formTitle} onClick={(event) => { event.preventDefault(); @@ -171,8 +171,20 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s }} disabled={connection.updating || hasError} /> + {props.open === 'update' && ( + <Button + type="secondary" + className="flex-grow" + label={'Clone'} + onClick={(event) => { + handleSubmit({ ...formData, name: formData.name + ' (copy)', id: nilUUID }, true); + }} + disabled={connection.updating || hasError} + /> + )} <Button variant="outline" + className="flex-grow" label="Cancel" disabled={props.disableCancel} onClick={(event) => { diff --git a/libs/shared/lib/components/buttons/index.tsx b/libs/shared/lib/components/buttons/index.tsx index 87cc57e16..2cfdfea12 100644 --- a/libs/shared/lib/components/buttons/index.tsx +++ b/libs/shared/lib/components/buttons/index.tsx @@ -114,6 +114,8 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps & React.HT [iconComponent, label, children], ); + additionalClasses = (additionalClasses || '') + (props.className || ''); + if (notAButton) return ( <div @@ -124,12 +126,12 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps & React.HT ); return ( <button - className={`${styles.btn} ${typeClass} ${variantClass} ${sizeClass} ${blockClass} ${roundedClass} ${iconOnlyClass} ${additionalClasses}`} onClick={onClick} disabled={disabled} aria-label={ariaLabel} ref={forwardRef} {...props} + className={`${styles.btn} ${typeClass} ${variantClass} ${sizeClass} ${blockClass} ${roundedClass} ${iconOnlyClass} ${additionalClasses}`} > {iconPosition === 'leading' && icon} {label && <span>{label}</span>} diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx index dfb139648..f49d266e0 100644 --- a/libs/shared/lib/data-access/api/eventBus.tsx +++ b/libs/shared/lib/data-access/api/eventBus.tsx @@ -12,11 +12,17 @@ import { useVisualization, wsSchemaRequest, wsSchemaSubscription, + useQuerybuilderAttributesShown, } from '@graphpolaris/shared/lib/data-access'; import { Broker, wsQuerySubscription, wsQueryTranslationSubscription } from '@graphpolaris/shared/lib/data-access/broker'; import { addInfo } from '@graphpolaris/shared/lib/data-access/store/configSlice'; import { allMLTypes, LinkPredictionInstance, setMLResult } from '@graphpolaris/shared/lib/data-access/store/mlSlice'; -import { QueryBuilderText, setQueryText, setQuerybuilderNodes } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; +import { + QueryBuilderText, + attributeShownToggle, + setQueryText, + setQuerybuilderNodes, +} from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; import { useEffect } from 'react'; import { SaveStateI, @@ -52,6 +58,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } const session = useSessionCache(); const queryHash = useQuerybuilderHash(); const queryBuilder = useQuerybuilder(); + const attributeShown = useQuerybuilderAttributesShown(); const mlHash = useMLEnabledHash(); const visState = useVisualization(); const queryBuilderSettings = useQuerybuilderSettings(); @@ -171,7 +178,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } wsUpdateState(state); } } - }, [queryBuilderSettings, queryHash]); + }, [queryBuilderSettings, queryHash, attributeShown]); useEffect(() => { if (session.currentSaveState) { diff --git a/libs/shared/lib/data-access/broker/wsState.tsx b/libs/shared/lib/data-access/broker/wsState.tsx index 8ad7ca2df..ce016f28d 100644 --- a/libs/shared/lib/data-access/broker/wsState.tsx +++ b/libs/shared/lib/data-access/broker/wsState.tsx @@ -146,6 +146,7 @@ export function wsTestSaveStateConnectionSubscription(callback: TestSaveStateCon } export function wsUpdateState(request: SaveStateI, callback?: GetStateResponse) { + console.log('wsUpdateState', request); Broker.instance().sendMessage( { key: 'state', diff --git a/libs/shared/lib/data-access/store/hooks.ts b/libs/shared/lib/data-access/store/hooks.ts index bcdce1c93..729be1655 100644 --- a/libs/shared/lib/data-access/store/hooks.ts +++ b/libs/shared/lib/data-access/store/hooks.ts @@ -6,6 +6,7 @@ import { ConfigStateI, configState } from '@graphpolaris/shared/lib/data-access/ import { QueryBuilderSettings, QueryBuilderState, + queryBuilderAttributesShown, queryBuilderSettingsState, queryBuilderState, selectQuerybuilderGraph, @@ -25,7 +26,7 @@ import { CategoryDataI, } from './searchResultSlice'; import { AllLayoutAlgorithms } from '../../graph-layout'; -import { QueryMultiGraph } from '../../querybuilder'; +import { QueryGraphEdgeHandle, QueryMultiGraph } from '../../querybuilder'; import { SchemaGraph } from '../../schema'; import { GraphMetadata } from '../statistics'; @@ -48,6 +49,7 @@ export const useQuerybuilderGraph: () => QueryMultiGraph = () => useAppSelector( export const useQuerybuilderHash: () => string = () => useAppSelector(selectQuerybuilderHash); export const useQuerybuilderSettings: () => QueryBuilderSettings = () => useAppSelector(queryBuilderSettingsState); export const useQuerybuilder: () => QueryBuilderState = () => useAppSelector(queryBuilderState); +export const useQuerybuilderAttributesShown: () => QueryGraphEdgeHandle[] = () => useAppSelector(queryBuilderAttributesShown); // Overall Configuration of the app export const useConfig: () => ConfigStateI = () => useAppSelector(configState); diff --git a/libs/shared/lib/data-access/store/querybuilderSlice.ts b/libs/shared/lib/data-access/store/querybuilderSlice.ts index e9e6fc3ab..da7dbe228 100644 --- a/libs/shared/lib/data-access/store/querybuilderSlice.ts +++ b/libs/shared/lib/data-access/store/querybuilderSlice.ts @@ -5,6 +5,9 @@ import Graph, { MultiGraph } from 'graphology'; import { Attributes, SerializedGraph } from 'graphology-types'; import { QueryMultiGraph, QueryMultiGraphology as QueryGraphology } from '../../querybuilder/model/graphology/utils'; import { AllLayoutAlgorithms } from '../../graph-layout'; +import { QueryGraphEdgeHandle } from '../../querybuilder'; +import { isEqual } from 'lodash-es'; +import { settings } from 'pixi.js'; const defaultGraph = () => ({ nodes: [], edges: [], attributes: {}, options: {} }); @@ -20,11 +23,14 @@ export type QueryBuilderText = { result: string; }; +export type QueryBuilderAttributeBeingShown = {}; + export type QueryBuilderState = { graph: QueryMultiGraph; ignoreReactivity: boolean; settings: QueryBuilderSettings; queryTranslation: QueryBuilderText; + attributesBeingShown: QueryGraphEdgeHandle[]; }; // Define the initial state using that type @@ -41,6 +47,7 @@ export const initialState: QueryBuilderState = { queryId: '', result: '', }, + attributesBeingShown: [], // schemaLayout: 'Graphology_noverlap', }; @@ -58,6 +65,7 @@ export const querybuilderSlice = createSlice({ if (action.payload.graph?.nodes && action.payload.graph?.edges) { state.graph = action.payload.graph; state.settings = action.payload.settings; + state.attributesBeingShown = action.payload.attributesBeingShown || []; // state.ignoreReactivity = true; } }, @@ -70,6 +78,14 @@ export const querybuilderSlice = createSlice({ setQueryText: (state: QueryBuilderState, action: PayloadAction<QueryBuilderText>) => { state.queryTranslation = action.payload; }, + attributeShownToggle: (state: QueryBuilderState, action: PayloadAction<QueryGraphEdgeHandle>) => { + const existing = state.attributesBeingShown.findIndex((a) => isEqual(a, action.payload)); + if (existing === -1) { + state.attributesBeingShown.push(action.payload); + } else { + state.attributesBeingShown.splice(existing, 1); + } + }, }, }); @@ -127,4 +143,7 @@ export const selectQuerybuilderHash = (state: RootState): string => { // state.schema.schemaLayout; export default querybuilderSlice.reducer; -export const { setQuerybuilderGraph, clearQB, setQuerybuilderSettings, setQuerybuilderNodes, setQueryText } = querybuilderSlice.actions; +export const { setQuerybuilderGraph, clearQB, setQuerybuilderSettings, setQuerybuilderNodes, setQueryText, attributeShownToggle } = + querybuilderSlice.actions; + +export const queryBuilderAttributesShown = (state: RootState) => state.querybuilder.attributesBeingShown; diff --git a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx index 526868395..6fca5ec41 100644 --- a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx +++ b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx @@ -347,12 +347,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { if ('touches' in event) clientY = event?.touches?.[0]?.clientY; else if ('clientY' in event) clientY = event?.clientY; - if (reactFlowWrapper.current) { - const { top, left } = reactFlowWrapper.current.getBoundingClientRect(); - clientX -= left; - clientY -= top; - } - const position = reactFlow.screenToFlowPosition({ x: clientX, y: clientY }); if (connectingNodeId?.current) connectingNodeId.current.position = position; diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx index 0f842fa97..32d00d60c 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx @@ -1,5 +1,5 @@ import { useQuerybuilderGraph } from '@graphpolaris/shared/lib/data-access'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Handle, Position, useUpdateNodeInternals } from 'reactflow'; import { NodeAttribute, SchemaReactflowEntityNode, toHandleId } from '../../../model'; import { PillDropdown } from '../../pilldropdown/PillDropdown'; @@ -11,6 +11,8 @@ import { EntityPill } from '@graphpolaris/shared/lib/components'; */ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { const updateNodeInternals = useUpdateNodeInternals(); + const ref = useRef<HTMLDivElement | null>(null); + const dropdownActive = useRef(false); const data = node.data; if (!data.leftRelationHandleId) throw new Error('EntityFlowElement: data.leftRelationHandleId is undefined'); @@ -23,16 +25,19 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { ); const [hovered, setHovered] = useState(false); + const [dragging, setDragging] = useState(false); const [handleBeingDragged, setHandleBeingDragged] = useState(-1); const onMouseEnter = (event: React.MouseEvent) => { if (!hovered) setHovered(true); + ref.current?.addEventListener('mousedown', onMouseDown, true); setTimeout(() => { updateNodeInternals(node.id); }, 100); }; const onMouseLeave = (event: React.MouseEvent) => { + ref.current?.removeEventListener('mousedown', onMouseDown, true); if (hovered) setHovered(false); setTimeout(() => { updateNodeInternals(node.id); @@ -49,12 +54,31 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { window.removeEventListener('mouseup', onHandleMouseUp, true); }; + const onMouseDown = React.useCallback(() => { + window.addEventListener('mouseup', onMouseUp, true); + setDragging(true); + }, []); + + const onMouseUp = () => { + setDragging(false); + setHovered(true); + window.removeEventListener('mouseup', onMouseUp, true); + }; + const onConnect = (params: any) => { console.log('EntityPill onConnect', params); }; + const onMouseEnterDropdown = (event: React.MouseEvent) => { + ref.current?.removeEventListener('mousedown', onMouseDown, true); + }; + + const onMouseLeaveDropdown = (event: React.MouseEvent) => { + ref.current?.addEventListener('mousedown', onMouseDown, true); + }; + return ( - <div className="w-fit h-fit nowheel" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> + <div className="w-fit h-fit nowheel" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} ref={ref} id="asd"> <EntityPill title={data.name || ''} withHandles="horizontal" @@ -78,10 +102,12 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { > {data?.attributes && ( <PillDropdown + onMouseEnterDropdown={onMouseEnterDropdown} + onMouseLeaveDropdown={onMouseLeaveDropdown} node={node} attributes={data.attributes} attributeEdges={attributeEdges.map((edge) => edge?.attributes)} - hovered={hovered} + hovered={hovered && !dragging} handleBeingDraggedIdx={handleBeingDragged} onHandleMouseDown={onHandleMouseDown} /> diff --git a/libs/shared/lib/querybuilder/pills/pilldropdown/PillDropdown.tsx b/libs/shared/lib/querybuilder/pills/pilldropdown/PillDropdown.tsx index a5c4e9cb5..fae11e3ea 100644 --- a/libs/shared/lib/querybuilder/pills/pilldropdown/PillDropdown.tsx +++ b/libs/shared/lib/querybuilder/pills/pilldropdown/PillDropdown.tsx @@ -5,7 +5,9 @@ import { Abc, CalendarToday, Map, Numbers, Place, QuestionMarkOutlined } from '@ import Icon from '@graphpolaris/shared/lib/components/icon'; import { PillHandle } from '@graphpolaris/shared/lib/components/pills/PillHandle'; import { pillDropdownPadding } from '@graphpolaris/shared/lib/components/pills/pill.const'; -import { Button, TextInput } from '../../..'; +import { Button, TextInput, useAppDispatch, useQuerybuilderAttributesShown } from '../../..'; +import { attributeShownToggle } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; +import { isEqual } from 'lodash-es'; type PillDropdownProps = { node: SchemaReactflowEntityNode; @@ -14,6 +16,8 @@ type PillDropdownProps = { hovered: boolean; handleBeingDraggedIdx: number; onHandleMouseDown: (attribute: NodeAttribute, i: number, event: React.MouseEvent) => void; + onMouseEnterDropdown?: (event: React.MouseEvent) => void; + onMouseLeaveDropdown?: (event: React.MouseEvent) => void; }; type IconMapType = { @@ -31,82 +35,90 @@ const IconMap: IconMapType = { export const PillDropdown = (props: PillDropdownProps) => { const forceOpen = false; const [filter, setFilter] = useState<string>(''); + const dispatch = useAppDispatch(); + const attributesBeingShown = useQuerybuilderAttributesShown(); - const [attributesOfInterest, setAttributesOfInterest] = useState<Set<number>>(new Set()); + const attributesOfInterest = useMemo(() => { + return props.attributes.map((attribute) => + attributesBeingShown.findIndex((x) => isEqual(x, attribute.handleData)) === -1 ? false : true, + ); + }, [attributesBeingShown]); return ( - <> - <div className={'border-[1px] border-secondary-200 divide-y divide-secondary-200'}> - {attributesOfInterest && - [...attributesOfInterest].map((i) => { - const attribute = props.attributes[i]; - if (attribute.handleData.attributeName === undefined) { - throw new Error('attribute.handleData.attributeName is undefined'); - } + <div + className={'border-[1px] border-secondary-200 divide-y divide-secondary-200'} + onMouseEnter={(e) => { + if (props.onMouseEnterDropdown) props.onMouseEnterDropdown(e); + }} + onMouseLeave={(e) => { + if (props.onMouseLeaveDropdown) props.onMouseLeaveDropdown(e); + }} + > + {attributesOfInterest && + attributesOfInterest.map((showing, i) => { + if (showing === false) return null; - return ( - <div - className="px-2 py-1 bg-secondary-100 flex justify-between items-center" - key={(attribute.handleData.attributeName || '') + i} - onMouseDown={(event: React.MouseEvent) => { - props.onHandleMouseDown(attribute, i, event); - }} + const attribute = props.attributes[i]; + if (attribute.handleData.attributeName === undefined) { + throw new Error('attribute.handleData.attributeName is undefined'); + } + + return ( + <div + className="px-2 py-1 bg-secondary-100 flex justify-between items-center" + key={(attribute.handleData.attributeName || '') + i} + onMouseDown={(event: React.MouseEvent) => { + props.onHandleMouseDown(attribute, i, event); + }} + > + <p className="truncate text-[0.6rem]">{attribute.handleData.attributeName}</p> + {attribute.handleData?.attributeDimension && <Icon component={IconMap[attribute.handleData.attributeDimension]} size={16} />} + <PillHandle + mr={-pillDropdownPadding} + handleTop="auto" + position={Position.Right} + className={'fill-accent-500 stroke-white'} + type="square" > - <p className="truncate text-[0.6rem]">{attribute.handleData.attributeName}</p> - {attribute.handleData?.attributeDimension && ( - <Icon component={IconMap[attribute.handleData.attributeDimension]} size={16} /> - )} - <PillHandle - mr={-pillDropdownPadding} - handleTop="auto" + <Handle + id={toHandleId(handleDataFromReactflowToDataId(props.node, attribute))} + type="source" position={Position.Right} - className={'fill-accent-500 stroke-white'} - type="square" - > - <Handle - id={toHandleId(handleDataFromReactflowToDataId(props.node, attribute))} - type="source" - position={Position.Right} - className={'!rounded-none !bg-transparent !w-full !h-full !right-0 !left-0 !border-0'} - ></Handle> - </PillHandle> - </div> - ); - })} - {(props.hovered || forceOpen) && ( - <> - <h4 className="p-1 bg-white border-t-[2px] font-semibold text-2xs">Available Attributes:</h4> - <TextInput type={'text'} placeholder="Filter" className="!p-0.5" value={filter} onChange={(v) => setFilter(v)} /> - <div className="max-h-28 overflow-auto flex flex-col bg-white"> - {props.attributes.map((attribute, i) => { - if (filter && !attribute.handleData.attributeName?.toLowerCase().includes(filter.toLowerCase())) return null; - if (attribute.handleData.attributeName === undefined) { - throw new Error('attribute.handleData.attributeName is undefined'); - } - - return ( - <Button - key={(attribute.handleData.attributeName || '') + i} - iconComponent={attribute?.handleData?.attributeDimension ? IconMap[attribute.handleData.attributeDimension] : undefined} - iconPosition="trailing" - additionalClasses={`w-full ${attributesOfInterest.has(i) ? 'bg-secondary-100' : 'bg-white'} justify-between rounded-none text-[0.7em] hover:cursor-copy`} - variant="ghost" - size={'xs'} - label={attribute.handleData.attributeName} - onClick={(event: React.MouseEvent) => { - if (attributesOfInterest.has(i)) { - setAttributesOfInterest(new Set([...attributesOfInterest].filter((x) => x !== i))); - } else { - setAttributesOfInterest(new Set([...attributesOfInterest, i])); - } - }} - ></Button> - ); - })} + className={'!rounded-none !bg-transparent !w-full !h-full !right-0 !left-0 !border-0'} + ></Handle> + </PillHandle> </div> - </> - )} - </div> - </> + ); + })} + {(props.hovered || forceOpen) && ( + <> + <h4 className="p-1 bg-white border-t-[2px] font-semibold text-2xs">Available Attributes:</h4> + <TextInput type={'text'} placeholder="Filter" className="!p-0.5" value={filter} onChange={(v) => setFilter(v)} /> + <div className="max-h-28 overflow-auto flex flex-col bg-white"> + {props.attributes.map((attribute, i) => { + if (filter && !attribute.handleData.attributeName?.toLowerCase().includes(filter.toLowerCase())) return null; + if (attribute.handleData.attributeName === undefined) { + throw new Error('attribute.handleData.attributeName is undefined'); + } + + return ( + <Button + key={(attribute.handleData.attributeName || '') + i} + iconComponent={attribute?.handleData?.attributeDimension ? IconMap[attribute.handleData.attributeDimension] : undefined} + iconPosition="trailing" + additionalClasses={`w-full ${attributesOfInterest[i] ? 'bg-secondary-100' : 'bg-white'} justify-between rounded-none text-[0.7em]`} + variant="ghost" + size={'xs'} + label={attribute.handleData.attributeName} + onClick={(event: React.MouseEvent) => { + dispatch(attributeShownToggle(attribute.handleData)); + }} + ></Button> + ); + })} + </div> + </> + )} + </div> ); }; -- GitLab