Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • graphpolaris/frontend-v2
  • rijkheere/frontend-v-2-reordering-paoh
2 results
Show changes
Commits on Source (7)
Showing
with 1393 additions and 965 deletions
......@@ -246,11 +246,14 @@ export const MenuTrigger = React.forwardRef<HTMLElement, MenuTriggerProps>(
};
delete referenceProps.onMouseDown;
delete referenceProps.onPointerDown;
referenceProps.onClick = (event: React.MouseEvent) => {
event.preventDefault();
console.log('Click event!!!', context.isOpen);
context.setIsOpen(false);
};
delete referenceProps.onClick;
if (context.isOpen) {
referenceProps.onClick = (event: React.MouseEvent) => {
event.preventDefault();
context.setIsOpen(false);
};
}
}
const element = React.cloneElement(children as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>, {
......
......@@ -14,7 +14,14 @@ import {
wsSchemaStatsRequest,
wsSchemaSubscription,
} from '@/lib/data-access';
import { Broker, wsQueryCountSubscription, wsQueryErrorSubscription, wsQueryRequest, wsQuerySubscription } from '@/lib/data-access/broker';
import {
Broker,
wsQueryCountSubscription,
wsQueryErrorSubscription,
wsQueryRequest,
wsQuerySubscription,
wsQueryTranslationSubscription,
} from '@/lib/data-access/broker';
import { addError, addInfo } from '@/lib/data-access/store/configSlice';
import { setMLResult } from '@/lib/data-access/store/mlSlice';
import { isEqual } from 'lodash-es';
......@@ -51,6 +58,7 @@ import {
setFetchingSaveStates,
setQueryState,
setQuerybuilderNodes,
setQuerybuilderSettings,
testedSaveState,
updateSaveStateList,
updateSelectedSaveState,
......@@ -140,6 +148,16 @@ export const EventBus = (props: { onRunQuery: (useCached: boolean) => void; onAu
}),
);
unsubs.push(
wsQueryTranslationSubscription(({ data, status }) => {
if (!data || status !== 'success') {
dispatch(addError('Failed to translate query'));
return;
}
dispatch(setQuerybuilderSettings({ cypher: data.result }));
}),
);
unsubs.push(
wsQuerySubscription(({ data, status, callID }) => {
if (status === 'aborted') {
......
......@@ -9,6 +9,7 @@ export const wsQueryRequest: WsFrontendCall<{
queryID: number;
useCached: boolean;
callID: string;
manualQuery?: string;
}> = params => {
const mlEnabled = Object.entries(params.ml)
.filter(([_, value]) => value.enabled)
......@@ -21,6 +22,7 @@ export const wsQueryRequest: WsFrontendCall<{
queryID: params.queryID,
ml: mlEnabled,
useCached: params.useCached,
manualQuery: params.manualQuery,
},
callID: params.callID,
});
......@@ -103,14 +105,6 @@ export const wsDeleteQuerySubscription = (callback: ResponseCallback<wsReturnKey
};
};
export const wsManualQueryRequest: WsFrontendCall<{ query: string }> = params => {
Broker.instance().sendMessage({
key: wsKeys.query,
subKey: wsSubKeys.manual,
body: JSON.stringify({ query: params.query }),
});
};
export function wsQueryTranslationSubscription(callback: ResponseCallback<wsReturnKey.queryStatusTranslationResult>) {
const id = Broker.instance().subscribe(callback, wsReturnKey.queryStatusTranslationResult);
return () => {
......
......@@ -11,8 +11,9 @@ import {
QueryMultiGraph,
QueryState,
QueryUnionType,
QueryViewMode,
SaveStateAuthorizationHeaders,
SaveStateWithAuthorization,
SaveStateWithAuthorization
} from 'ts-common';
import { getParam, setParam, URLParams } from '../api/url';
import { wsUpdateQueryOrder } from '../broker/wsQuery';
......@@ -130,10 +131,10 @@ export const sessionSlice = createSlice({
activeQuery.graphCounts = { nodeCounts: { updatedAt: 0 } };
}
},
setQuerybuilderSettings: (state: SessionCacheI, action: PayloadAction<QueryBuilderSettings>) => {
setQuerybuilderSettings: (state: SessionCacheI, action: PayloadAction<Partial<QueryBuilderSettings>>) => {
const activeQuery = getActiveQuery(state);
if (activeQuery) {
activeQuery.settings = action.payload;
activeQuery.settings = { ...activeQuery.settings, ...action.payload };
}
},
attributeShownToggle: (state: SessionCacheI, action: PayloadAction<QueryGraphEdgeHandle>) => {
......@@ -162,6 +163,12 @@ export const sessionSlice = createSlice({
activeQuery.name = action.payload;
}
},
setQueryViewMode: (state: SessionCacheI, action: PayloadAction<QueryViewMode>) => {
const activeQuery = getActiveQuery(state);
if (activeQuery) {
activeQuery.settings.mode = action.payload;
}
},
addNewQuery: (state: SessionCacheI, payload: PayloadAction<Query>) => {
if (!payload.payload.id) {
console.error('No query id found');
......@@ -268,6 +275,7 @@ export const sessionSlice = createSlice({
layout: 'manual',
autocompleteRelation: true,
unionTypes: {},
mode: QueryViewMode.Visual,
};
activeQuery.attributesBeingShown = [];
} else {
......@@ -291,6 +299,7 @@ export const {
setActiveQueryID,
addNewQuery,
setQueryName,
setQueryViewMode,
clearQB,
removeQueryByID,
setQueryState,
......@@ -380,6 +389,8 @@ export const selectQuerybuilderHash = (state: RootState): string => {
return node;
});
const { cypher, ...settings } = activeQuery.settings;
const ret = {
nodes: hashedNodes,
edges: activeQuery.graph.edges
......@@ -392,7 +403,7 @@ export const selectQuerybuilderHash = (state: RootState): string => {
source: n.source,
target: n.target,
})),
settings: activeQuery.settings,
settings: settings,
};
return objectHash(ret);
};
import { useState } from 'react';
import { Button } from '../../components';
type ManualQueryDialogProps = {
onSubmit: (query: string) => void;
};
export const ManualQueryDialog = ({ onSubmit }: ManualQueryDialogProps) => {
const [query, setQuery] = useState('');
const handleSubmit = () => {
if (query.trim() !== '') {
onSubmit(query);
setQuery('');
}
};
return (
<div className="flex flex-col w-full gap-2 p-2">
<label className="text-xs font-bold">Manual Query</label>
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Enter your Cypher query"
className="w-full h-32 border rounded p-2"
/>
<Button variantType="primary" onClick={handleSubmit}>
Run Query
</Button>
</div>
);
};
import { Button, Input, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger } from '@/lib/components';
// import { wsAddQuery, wsManualQueryRequest } from '@/lib/data-access/broker';
import { addError } from '@/lib/data-access/store/configSlice';
import { addNewQuery, clearQB, setQuerybuilderSettings } from '@/lib/data-access/store/sessionSlice';
import { useAppDispatch } from '../../data-access';
// import { ManualQueryDialog } from './ManualQueryDialog';
import { wsAddQuery } from '@/lib/data-access/broker';
import { QueryMLDialog } from './querysidepanel/QueryMLDialog';
import { QuerySettings } from './querysidepanel/QuerySettings';
// ----- Begin Button Components -----
interface TooltipButtonProps {
tooltip: string;
iconComponent: string;
onClick: (e: React.MouseEvent) => void;
variantType?: 'primary' | 'secondary' | 'danger';
variant?: 'solid' | 'outline' | 'ghost';
size?: 'xs' | '3xs';
disabled?: boolean;
className?: string;
disableTooltip?: boolean;
}
export const TooltipButton = ({
tooltip,
iconComponent,
onClick,
variantType = 'secondary' as 'primary' | 'secondary' | 'danger',
variant = 'ghost',
size = 'xs',
disabled = false,
className = '',
disableTooltip = false,
}: TooltipButtonProps) => (
<Tooltip>
<TooltipTrigger>
<Button
variantType={variantType}
variant={variant}
size={size}
iconComponent={iconComponent}
onClick={onClick}
disabled={disabled}
className={className}
/>
</TooltipTrigger>
<TooltipContent disabled={disableTooltip}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
interface PopoverButtonProps extends TooltipButtonProps {
popoverContent: React.ReactNode;
}
export const PopoverButton = ({
tooltip,
iconComponent,
onClick,
popoverContent,
variantType = 'secondary',
variant = 'ghost',
size = 'xs',
disabled = false,
className = '',
disableTooltip = false,
}: PopoverButtonProps) => (
<Popover>
<PopoverTrigger disabled={disabled}>
<Tooltip>
<TooltipTrigger>
<Button
variantType={variantType}
variant={variant}
size={size}
iconComponent={iconComponent}
onClick={onClick}
disabled={disabled}
className={className}
/>
</TooltipTrigger>
<TooltipContent disabled={disableTooltip}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>{popoverContent}</PopoverContent>
</Popover>
);
interface AddQueryButtonProps {
disabled: boolean;
saveStateId: string;
}
export const AddQueryButton = ({ disabled, saveStateId }: AddQueryButtonProps) => {
const dispatch = useAppDispatch();
const handleAddQuery = async () => {
wsAddQuery({ saveStateID: saveStateId }, ({ data, status }) => {
if (status !== 'success' || data == null || !data.id || data.id < 0) {
console.error('Failed to add query');
addError('Failed to add query');
return;
}
console.log('Query added', data);
dispatch(addNewQuery(data));
});
};
return <TooltipButton tooltip="Add query" iconComponent="icon-[ic--baseline-add]" onClick={handleAddQuery} disabled={disabled} />;
};
interface FitViewButtonProps {
onFitView: () => void;
}
export const FitViewButton = ({ onFitView }: FitViewButtonProps) => (
<TooltipButton tooltip="Fit to screen" iconComponent="icon-[ic--baseline-fullscreen]" onClick={onFitView} />
);
interface ClearNodesButtonProps {
disabled: boolean;
}
export const ClearNodesButton = ({ disabled }: ClearNodesButtonProps) => {
const dispatch = useAppDispatch();
const handleClearNodes = () => {
dispatch(clearQB());
};
return (
<TooltipButton tooltip="Clear query panel" iconComponent="icon-[ic--baseline-delete]" onClick={handleClearNodes} disabled={disabled} />
);
};
interface ScreenshotButtonProps {
onScreenshot: () => void;
}
export const ScreenshotButton = ({ onScreenshot }: ScreenshotButtonProps) => (
<TooltipButton tooltip="Capture screen" iconComponent="icon-[ic--baseline-camera-alt]" onClick={onScreenshot} />
);
interface SettingsButtonProps {
disabled: boolean;
}
export const SettingsButton = ({ disabled }: SettingsButtonProps) => (
<PopoverButton
tooltip="Query builder settings"
iconComponent="icon-[ic--baseline-settings]"
onClick={() => {}}
disabled={disabled}
className="query-settings"
popoverContent={<QuerySettings />}
/>
);
interface RunQueryButtonProps {
onRunQuery: (useCached: boolean) => void;
}
export const RunQueryButton = ({ onRunQuery }: RunQueryButtonProps) => (
<TooltipButton
tooltip="Rerun query"
iconComponent="icon-[ic--baseline-cached]"
onClick={() => onRunQuery(true)}
// Note: The original had onDoubleClick which we need to handle differently
/>
);
interface LogicButtonProps {
disabled: boolean;
onLogic: () => void;
isActive: boolean;
}
export const LogicButton = ({ disabled, onLogic, isActive }: LogicButtonProps) => (
<TooltipButton
tooltip="Logic settings"
iconComponent="icon-[ic--baseline-difference]"
onClick={onLogic}
disabled={disabled}
disableTooltip={isActive}
/>
);
interface MLButtonProps {
disabled: boolean;
mlEnabled: boolean;
isActive: boolean;
mlData: any;
}
export const MLButton = ({ disabled, mlEnabled, isActive, mlData }: MLButtonProps) => {
const ml = mlData;
const renderMLTooltipContent = () =>
mlEnabled ? (
<>
<p className="font-bold text-base">Machine learning</p>
<p className="mb-2">Algorithms detected the following results:</p>
{ml.linkPrediction.enabled && ml.linkPrediction.result && (
<>
<p className="mt-2 font-semibold">Link prediction</p>
<p>{ml.linkPrediction.result.length} links</p>{' '}
</>
)}
{ml.centrality.enabled && Object.values(ml.centrality.result).length > 0 && (
<>
<p className="mt-2 font-semibold">Centrality</p>
<p>{(Object.values(ml.centrality.result) as number[]).reduce((a, b) => b + a).toFixed(2)} sum of centers</p>
</>
)}
{ml.communityDetection.enabled && ml.communityDetection.result && (
<>
<p className="mt-2 font-semibold">Community detection</p>
<p>{ml.communityDetection.result.length} communities</p>
</>
)}
{ml.shortestPath.enabled && (
<>
<p className="mt-2 font-semibold">Shortest path</p>
{ml.shortestPath.result?.length > 0 && <p># of hops: {ml.shortestPath.result.length}</p>}
{!ml.shortestPath.srcNode ? <p>Please select source node</p> : !ml.shortestPath.trtNode && <p>Please select target node</p>}
</>
)}
</>
) : (
<p>Machine learning</p>
);
return (
<PopoverButton
tooltip="Machine learning"
iconComponent="icon-[ic--baseline-lightbulb]"
onClick={() => {}}
variantType={mlEnabled ? 'primary' : 'secondary'}
variant={mlEnabled ? 'outline' : 'ghost'}
disabled={disabled}
disableTooltip={isActive}
popoverContent={<QueryMLDialog />}
/>
);
};
interface LimitButtonProps {
disabled: boolean;
activeQuery: any;
resultSize: number;
totalSize: number;
}
export const LimitButton = ({ disabled, activeQuery, resultSize, totalSize }: LimitButtonProps) => {
const dispatch = useAppDispatch();
const limitReached = activeQuery.settings.limit <= resultSize;
const renderLimitTooltipContent = () => (
<>
<p className="font-bold text-base">Limit</p>
<p>Limits the number of edges retrieved from the database.</p>
<p>Required to manage performance.</p>
<p className={`font-semibold${limitReached ? ' text-primary-200' : ''}`}>
Fetched {resultSize} out of {totalSize} nodes
</p>
</>
);
const handleLimitChange = (value: any) => {
dispatch(setQuerybuilderSettings({ ...activeQuery.settings, limit: Number(value) }));
};
return (
<Popover placement="bottom">
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={limitReached ? 'primary' : 'secondary'}
variant={limitReached ? 'outline' : 'ghost'}
size="xs"
disabled={disabled}
iconComponent="icon-[ic--baseline-filter-alt]"
/>
</TooltipTrigger>
<TooltipContent>{renderLimitTooltipContent()}</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<Input
type="number"
size="sm"
label="Limit"
value={activeQuery.settings.limit}
lazy
onChange={handleLimitChange}
className={`w-24${limitReached ? ' border-danger-600' : ''}`}
containerClassName="p-2"
/>
</PopoverContent>
</Popover>
);
};
interface ManualQueryButtonProps {
disabled: boolean;
}
// export const ManualQueryButton = ({ disabled }: ManualQueryButtonProps) => {
// const handleManualQuery = (query: string): void => {
// wsManualQueryRequest({ query });
// };
// return (
// <PopoverButton
// tooltip="Manual Query"
// iconComponent="icon-[ic--baseline-search]"
// onClick={() => {}}
// disabled={disabled}
// popoverContent={<ManualQueryDialog onSubmit={handleManualQuery} />}
// />
// );
// };
// ----- End Button Components -----
// Also export all interfaces for external use
export type {
AddQueryButtonProps,
ClearNodesButtonProps,
FitViewButtonProps,
LimitButtonProps,
LogicButtonProps,
// ManualQueryButtonProps,
MLButtonProps,
PopoverButtonProps,
RunQueryButtonProps,
ScreenshotButtonProps,
SettingsButtonProps,
TooltipButtonProps,
};
import { Button, ControlContainer, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/lib/components';
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from '@/lib/components/menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/components/popover';
import { Tab, Tabs } from '@/lib/components/tabs';
import { addError } from '@/lib/data-access/store/configSlice';
import objectHash from 'object-hash';
import { useEffect, useMemo, useRef, useState } from 'react';
import Sortable from 'sortablejs';
import { useActiveQuery, useActiveSaveState, useAppDispatch, useGraphQuery, useML } from '../../data-access';
import { wsAddQuery, wsDeleteQuery, wsManualQueryRequest, wsUpdateQuery } from '../../data-access/broker';
import {
addNewQuery,
clearQB,
removeQueryByID,
reorderQueryState,
setActiveQueryID,
setQuerybuilderSettings,
setQueryName,
} from '../../data-access/store/sessionSlice';
import { ManualQueryDialog } from './ManualQueryDialog';
import { QueryMLDialog } from './querysidepanel/QueryMLDialog';
import { QuerySettings } from './querysidepanel/QuerySettings';
export type QueryBuilderToggleSettings = 'settings' | 'ml' | 'logic' | 'relatedNodes' | undefined;
export type QueryBuilderNavProps = {
toggleSettings: QueryBuilderToggleSettings;
onFitView: () => void;
onRunQuery: (useCached: boolean) => void;
onScreenshot: () => void;
onLogic: () => void;
};
export const QueryBuilderNav = (props: QueryBuilderNavProps) => {
const dispatch = useAppDispatch();
const activeQuery = useActiveQuery();
const ss = useActiveSaveState();
const graphQuery = useGraphQuery();
const resultSize = useMemo(() => {
if (!graphQuery) return 0;
return graphQuery.graph.nodes.length;
}, [graphQuery]);
const totalSize = useMemo(() => {
if (!activeQuery || !graphQuery || !ss) return 0;
const nodeCounts = activeQuery.graph.nodes
.filter(x => x.attributes.type == 'entity')
.map(x => graphQuery.graphCounts?.nodeCounts[`${x.key}_count`] ?? 0);
return nodeCounts.reduce((a, b) => a + b, 0);
}, [graphQuery]);
const ml = useML();
const [editingIdx, setEditingIdx] = useState<{ idx: number; text: string } | null>(null);
/**
* Clears all nodes in the graph.
*/
function clearAllNodes() {
dispatch(clearQB());
}
const tabsRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ss || !tabsRef.current) return;
const sortable = new Sortable(tabsRef.current, {
animation: 150,
draggable: '[data-type="tab"]',
ghostClass: 'bg-secondary-300',
dragClass: 'bg-secondary-100',
onEnd: evt => {
if (evt.oldIndex != null && evt.newIndex != null && evt.oldIndex !== evt.newIndex) {
dispatch(
reorderQueryState({
oldIndex: evt.oldIndex,
newIndex: evt.newIndex,
}),
);
}
},
});
const sortedQueries = ss.queryStates.openQueryArray
.filter(query => query.id != null)
.sort((a, b) => {
return a.order < b.order ? -1 : 1;
});
sortable.sort(sortedQueries.map(x => String(x.id)));
return () => {
sortable.destroy();
};
}, [ss ? objectHash(Object.fromEntries(ss?.queryStates.openQueryArray.map(x => [x.id, x.order]))) : null]);
const mlEnabled = ml.linkPrediction.enabled || ml.centrality.enabled || ml.communityDetection.enabled || ml.shortestPath.enabled;
const handleManualQuery = (query: string) => {
wsManualQueryRequest({ query: query });
};
if (!ss || !activeQuery) {
console.debug('No active query found in query nav');
return null;
}
function updateQueryName(text: string) {
if (!ss || !activeQuery) return;
wsUpdateQuery(
{
saveStateID: ss.id,
query: { ...activeQuery, name: text },
},
({ status }) => {
if (status !== 'success') {
addError('Failed to update query');
}
dispatch(setQueryName(text));
setEditingIdx(null);
},
);
}
return (
<div className="sticky shrink-0 top-0 left-0 right-0 flex items-stretch justify-start h-7 bg-secondary-100 border-b border-secondary-200 max-w-full">
<div className="flex items-center px-2">
<h1 className="text-xs font-semibold text-secondary-600 truncate">Query builder</h1>
</div>
<div className="flex items-center px-0.5 gap-1 border-l border-secondary-200">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
as="a"
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-add]"
disabled={!ss?.authorization.database?.W}
onClick={async () => {
wsAddQuery({ saveStateID: ss.id }, ({ data, status }) => {
if (status !== 'success' || data == null || !data.id || data.id < 0) {
console.error('Failed to add query');
addError('Failed to add query');
return;
}
console.log('Query added', data);
dispatch(addNewQuery(data));
});
}}
/>
</TooltipTrigger>
<TooltipContent>
<p>Add query</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Tabs ref={tabsRef} className={`-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x`}>
{ss.queryStates.openQueryArray
.filter(query => query.id != null)
.sort((a, b) => {
return a.order < b.order ? -1 : 1;
})
.map((query, i) => (
<Menu atCursor>
<MenuTrigger className="-mb-px" rightClick>
<Tab
text=""
activeTab={query.id === activeQuery?.id}
key={i}
data-id={query.id}
onClick={() => {
if (query.id == null) return;
dispatch(setActiveQueryID(query.id));
}}
>
<>
{editingIdx?.idx === i ? (
<Input
type="text"
size="xs"
value={editingIdx.text}
label=""
onChange={e => {
setEditingIdx({ idx: i, text: e });
}}
onBlur={() => {
updateQueryName(editingIdx.text);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
updateQueryName(editingIdx.text);
}
}}
className="w-20"
style={{
border: 'none',
boxShadow: 'none',
background: 'none',
}}
autoFocus
/>
) : (
<div
onDoubleClick={e => {
e.stopPropagation();
dispatch(setActiveQueryID(query.id || -1));
setEditingIdx({ idx: i, text: query.name ?? '' });
}}
>
{query.name ?? 'Query'}
</div>
)}
{ss.queryStates.openQueryArray.filter(query => query.id != null).length > 1 && (
<Button
variantType="secondary"
variant="ghost"
disabled={!ss.authorization.database?.W}
rounded
size="3xs"
iconComponent="icon-[ic--baseline-close]"
onClick={e => {
e.stopPropagation();
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
)}
</>
</Tab>
</MenuTrigger>
<MenuContent>
{/* <Menu>
<MenuTrigger label="Change mode" />
<MenuContent>
<MenuRadioGroup value={'visual'} onValueChange={() => {}}>
<MenuRadioItem value="visual" label="Visual" />
<MenuRadioItem value="cypher" label="Cypher" />
</MenuRadioGroup>
</MenuContent>
</Menu> */}
<MenuItem
label="Rename"
closeOnClick={true}
onClick={e => {
e.stopPropagation();
setTimeout(() => {
dispatch(setActiveQueryID(query.id || -1));
setEditingIdx({ idx: i, text: query.name ?? '' });
}, 1);
}}
/>
<MenuSeparator />
<MenuItem
label="Remove"
className="text-danger"
onClick={() => {
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
</MenuContent>
</Menu>
))}
</Tabs>
<div className="sticky right-0 px-0.5 ml-auto items-center flex truncate">
<ControlContainer>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-fullscreen]"
onClick={props.onFitView}
/>
</TooltipTrigger>
<TooltipContent>
<p>Fit to screen</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-delete]"
onClick={() => {
if (ss?.authorization.query.W) clearAllNodes();
}}
/>
</TooltipTrigger>
<TooltipContent>
<p>Clear query panel</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-camera-alt]"
onClick={props.onScreenshot}
/>
</TooltipTrigger>
<TooltipContent>
<p>Capture screen</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-settings]"
className="query-settings"
/>
</TooltipTrigger>
<TooltipContent>
<p>Query builder settings</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<QuerySettings />
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-cached]"
onClick={() => props.onRunQuery(true)}
onDoubleClick={() => props.onRunQuery(false)}
/>
</TooltipTrigger>
<TooltipContent>
<p>Rerun query</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-difference]"
onClick={props.onLogic}
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'logic'}>
<p>Logic settings</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={mlEnabled ? 'primary' : 'secondary'}
variant={mlEnabled ? 'outline' : 'ghost'}
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-lightbulb]"
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'ml'}>
{mlEnabled ? (
<>
<p className="font-bold text-base">Machine learning</p>
<p className="mb-2">Algorithms detected the following results:</p>
{ml.linkPrediction.enabled && ml.linkPrediction.result && (
<>
<p className="mt-2 font-semibold">Link prediction</p>
<p>{ml.linkPrediction.result.length} links</p>{' '}
</>
)}
{ml.centrality.enabled && Object.values(ml.centrality.result).length > 0 && (
<>
<p className="mt-2 font-semibold">Centrality</p>
<p>
{Object.values(ml.centrality.result)
.reduce((a, b) => b + a)
.toFixed(2)}{' '}
sum of centers
</p>
</>
)}
{ml.communityDetection.enabled && ml.communityDetection.result && (
<>
<p className="mt-2 font-semibold">Community detection</p>
<p>{ml.communityDetection.result.length} communities</p>
</>
)}
{ml.shortestPath.enabled && (
<>
<p className="mt-2 font-semibold">Shortest path</p>
{ml.shortestPath.result?.length > 0 && <p># of hops: {ml.shortestPath.result.length}</p>}
{!ml.shortestPath.srcNode ? (
<p>Please select source node</p>
) : (
!ml.shortestPath.trtNode && <p>Please select target node</p>
)}
</>
)}
</>
) : (
<p>Machine learning</p>
)}
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<QueryMLDialog />
</PopoverContent>
</Popover>
<Popover placement="bottom">
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={activeQuery.settings.limit <= resultSize ? 'primary' : 'secondary'}
variant={activeQuery.settings.limit <= resultSize ? 'outline' : 'ghost'}
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-filter-alt]"
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'ml'}>
<p className="font-bold text-base">Limit</p>
<p>Limits the number of edges retrieved from the database.</p>
<p>Required to manage performance.</p>
<p className={`font-semibold${activeQuery.settings.limit <= resultSize ? ' text-primary-200' : ''}`}>
Fetched {resultSize} out of {totalSize} nodes
</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<Input
type="number"
size="sm"
label="Limit"
value={activeQuery.settings.limit}
lazy
onChange={e => {
dispatch(setQuerybuilderSettings({ ...activeQuery.settings, limit: Number(e) }));
}}
className={`w-24${activeQuery.settings.limit <= resultSize ? ' border-danger-600' : ''}`}
containerClassName="p-2"
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger disabled={!ss?.authorization.query.W}>
<Tooltip>
<TooltipTrigger>
<Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-search]" />
</TooltipTrigger>
<TooltipContent>
<p>Manual Query</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<ManualQueryDialog onSubmit={handleManualQuery} />
</PopoverContent>
</Popover>
</TooltipProvider>
</ControlContainer>
</div>
</div>
);
};
import { Button } from '@/lib/components';
import React, { useState } from 'react';
type QueryInputCypherProps = {
cypher: string | undefined;
onCypherChange: (value: string) => void;
onRunQuery: () => void;
};
type ViewMode = 'raw' | 'readable';
export const QueryInputCypher: React.FC<QueryInputCypherProps> = ({ cypher, onCypherChange, onRunQuery }) => {
const [viewMode, setViewMode] = useState<ViewMode>('raw');
console.log('QueryInputCypher', cypher);
const formatCypherQuery = (query: string): React.ReactNode => {
if (!query) return null;
// Split the query by common Cypher keywords
const keywords = [
'MATCH',
'WHERE',
'RETURN',
'ORDER BY',
'LIMIT',
'WITH',
'CREATE',
'MERGE',
'DELETE',
'SET',
'REMOVE',
'OPTIONAL MATCH',
];
// Create a regex pattern that matches any of the keywords
const keywordPattern = new RegExp(`(${keywords.join('|')})`, 'gi');
// Split the query by keywords and preserve the keywords
const parts = query.split(keywordPattern);
return (
<div className="font-mono text-sm py-2">
{parts.map((part, index) => {
// Check if this part is a keyword
const isKeyword = keywords.some(keyword => part.trim().toUpperCase() === keyword);
return (
<div key={index} className={`${isKeyword ? 'text-blue-600 font-bold' : ''} ${isKeyword ? 'ml-0' : 'ml-8'} mb-1`}>
{part}
</div>
);
})}
</div>
);
};
return (
<div className="bg-neutral-50 h-full p-2 flex flex-col">
<div className="flex justify-between items-center mb-3">
<div>
<h3 className="font-semibold">Cypher query</h3>
<p className="text-neutral-500 text-sm">
Below you find the Cypher query as executed by the backend.
{viewMode === 'raw' && ' You can edit directly, but changes to the query are not persisted.'}
</p>
</div>
<div className="flex space-x-2">
<Button variantType="secondary" variant={viewMode === 'raw' ? 'solid' : 'outline'} onClick={() => setViewMode('raw')} size="sm">
Raw Edit
</Button>
<Button
variantType="secondary"
variant={viewMode === 'readable' ? 'solid' : 'outline'}
onClick={() => setViewMode('readable')}
size="sm"
>
Readable
</Button>
</div>
</div>
<div className="flex-grow overflow-auto border rounded">
{viewMode === 'raw' ? (
<textarea className="w-full h-full p-2 font-mono text-sm" value={cypher} onChange={e => onCypherChange(e.target.value)} />
) : (
<div className="p-2 h-full overflow-auto">{formatCypherQuery(cypher || '')}</div>
)}
</div>
<div className="mt-3">
<Button variantType="primary" variant="solid" onClick={onRunQuery}>
Run
</Button>
</div>
</div>
);
};
import { useAppDispatch, useQuerybuilderHash } from '@/lib/data-access/store';
import { setQuerybuilderGraphology } from '@/lib/data-access/store/sessionSlice';
import { SchemaGraphology } from '@/lib/schema';
import React, { useCallback, useRef, useState } from 'react';
import ReactFlow, {
Background,
Connection,
Edge,
HandleType,
Node,
NodeChange,
NodePositionChange,
OnConnectStartParams,
ReactFlowInstance,
isNode,
useReactFlow,
} from 'reactflow';
import { AllLogicMap, Query, QueryElementTypes, SaveStateWithAuthorization, isLogicHandle } from 'ts-common';
import { ConfigStateI, addError } from '../../../data-access/store/configSlice';
import { QueryMultiGraphology, toHandleData } from '../../model';
import { ConnectionDragLine, ConnectionLine, QueryEntityPill, QueryRelationPill } from '../../pills';
import { QueryLogicPill } from '../../pills/customFlowPills/logicpill/QueryLogicPill';
import { dragPillStarted, movePillTo } from '../../pills/dragging/dragPill';
import styles from '../querybuilder.module.scss';
import { ConnectingNodeDataI } from '../utils/connectorDrop';
export type QueryInputVisualProps = {
reactFlowRef: React.RefObject<HTMLDivElement | null>;
reactFlowWrapper: React.RefObject<HTMLDivElement | null>;
elements: { nodes: Node[]; edges: Edge[] };
graphologyGraph: QueryMultiGraphology;
schema: SchemaGraphology;
activeQuery: Query | undefined;
config: ConfigStateI;
ss: SaveStateWithAuthorization | undefined;
connectingNodeId: React.MutableRefObject<ConnectingNodeDataI | null>;
reactFlowInstanceRef: React.MutableRefObject<ReactFlowInstance | null>;
onSetToggleSettings: (settings?: string) => void;
onMouseDown: (event: React.MouseEvent | React.TouchEvent) => void;
onNodeContextMenu: (event: React.MouseEvent, node: Node) => void;
onAddSchemaToQueryBuilder?: () => void;
};
const nodeTypes = {
entity: QueryEntityPill,
relation: QueryRelationPill,
logic: QueryLogicPill,
};
const edgeTypes = {
connection: ConnectionLine,
attribute_connection: ConnectionLine,
};
export const QueryInputVisual: React.FC<QueryInputVisualProps> = ({
reactFlowRef,
reactFlowWrapper,
elements,
graphologyGraph,
schema,
activeQuery,
config,
ss,
connectingNodeId,
reactFlowInstanceRef,
onSetToggleSettings,
onMouseDown,
onNodeContextMenu,
onAddSchemaToQueryBuilder,
}) => {
const dispatch = useAppDispatch();
const reactFlow = useReactFlow();
const qbHash = useQuerybuilderHash();
const isEdgeUpdating = useRef(false);
const isOnConnect = useRef(false);
const isDraggingPill = useRef(false);
const [allowZoom, setAllowZoom] = useState(true);
const onInit = (reactFlowInstance: ReactFlowInstance) => {
setTimeout(() => reactFlow.fitView(), 0);
};
/**
* TODO?
*/
function onNodesChange(nodes: NodeChange[]) {
nodes.forEach(n => {
if (n.type !== 'position') {
// updated = true;
// graphologyGraph.updateAttributes(n.id, n.data);
} else {
const node = n as NodePositionChange;
// Get the node in the elements list to get the previous location
const pNode = elements.nodes.find(e => e?.id === node?.id);
if (!(pNode && isNode(pNode)) || !node?.position) return;
// This is then used to calculate the delta position
const dx = node.position.x - pNode.position.x;
const dy = node.position.y - pNode.position.y;
// Check if we started dragging, if so, call the drag started usecase
if (!isDraggingPill.current) {
dragPillStarted(node.id, graphologyGraph);
isDraggingPill.current = true;
}
// Call the drag usecase
movePillTo(node.id, graphologyGraph, dx, dy, node.position);
// Dispatch the new graphology object, so reactflow will get rerendered
dispatch(setQuerybuilderGraphology(graphologyGraph));
}
});
}
const onDragOver = (event: React.DragEvent<HTMLDivElement>): void => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
/**
* The onDrop is called when the user drops an element from the schema onto the QueryBuilder.
* In the onDrop query elements will be created based on the data stored in the drag event (datastrasfer).
* @param event Drag event.
*/
const onDrop = (event: React.DragEvent<HTMLDivElement>): void => {
if (!activeQuery) {
console.error('No active query');
return;
}
event.preventDefault();
// The dropped element should be a valid reactflow element
const data: string = event.dataTransfer.getData('application/reactflow');
if (data.length == 0 || !reactFlow) return;
const mouse_x = parseFloat(event.dataTransfer.getData('mouse_x'));
const mouse_y = parseFloat(event.dataTransfer.getData('mouse_y'));
const dragData = JSON.parse(data);
const position = reactFlow.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
switch (dragData.type) {
case QueryElementTypes.Entity:
graphologyGraph.addPill2Graphology(
{
type: QueryElementTypes.Entity,
x: position.x - mouse_x,
y: position.y - mouse_y,
name: dragData.name,
schemaKey: dragData.name,
attributes: [],
},
schema.getNodeAttribute(dragData.name, 'attributes'),
);
dispatch(setQuerybuilderGraphology(graphologyGraph));
break;
// Creates a relation element and will also create the 2 related entities together with the connections
case QueryElementTypes.Relation: {
const relation = graphologyGraph.addPill2Graphology(
{
type: QueryElementTypes.Relation,
x: position.x,
y: position.y,
depth: { min: activeQuery.settings.depth.min, max: activeQuery.settings.depth.max },
name: dragData.collection,
schemaKey: dragData.label,
collection: dragData.collection,
attributes: [],
},
schema.getEdgeAttribute(dragData.label, 'attributes'),
);
if (config.autoSendQueries) {
// sendQuery();
}
if (activeQuery.settings.autocompleteRelation == true) {
let fromNodeID: any = null;
let toNodeID: any = null;
schema.nodes().forEach((node: string) => {
if (node === dragData.from) fromNodeID = node;
if (node === dragData.to) toNodeID = node;
if (fromNodeID && toNodeID) return;
});
if (fromNodeID && toNodeID) {
const fromNode = graphologyGraph.addPill2Graphology(
{
type: QueryElementTypes.Entity,
x: position.x - 180,
y: position.y,
name: fromNodeID,
schemaKey: fromNodeID,
attributes: [],
},
schema.getNodeAttribute(fromNodeID, 'attributes'),
);
const toNode = graphologyGraph.addPill2Graphology(
{
type: QueryElementTypes.Entity,
x: position.x + 250,
y: position.y,
name: toNodeID,
schemaKey: toNodeID,
attributes: [],
},
schema.getNodeAttribute(toNodeID, 'attributes'),
);
graphologyGraph.addEdge2Graphology(fromNode, relation, {
type: 'connection',
sourceHandleData: toHandleData(fromNodeID),
targetHandleData: toHandleData(dragData.collection),
});
graphologyGraph.addEdge2Graphology(relation, toNode, {
type: 'connection',
sourceHandleData: toHandleData(dragData.collection),
targetHandleData: toHandleData(toNodeID),
});
}
}
dispatch(setQuerybuilderGraphology(graphologyGraph));
break;
}
default: {
const logic = dragData.value?.key ? AllLogicMap[dragData.value.key] : null;
if (!logic) return;
const firstLeftLogicInput = logic.input;
if (!firstLeftLogicInput) return;
const logicNode = graphologyGraph.addLogicPill2Graphology({
name: dragData.value.name,
type: QueryElementTypes.Logic,
x: position.x,
y: position.y,
logic: logic,
attributes: [],
inputs: {},
});
dispatch(setQuerybuilderGraphology(graphologyGraph));
}
}
};
const onNodeMouseEnter = (event: React.MouseEvent, node: Node) => {
setAllowZoom(false);
};
const onNodeMouseLeave = (event: React.MouseEvent, node: Node) => {
setAllowZoom(true);
};
const onConnect = useCallback(
(connection: Connection) => {
if (!isEdgeUpdating.current) {
isOnConnect.current = true;
if (!connection.sourceHandle || !connection.targetHandle) throw new Error('Connection has no source or target');
graphologyGraph.addEdge(connection.source, connection.target, {
type: 'connection',
sourceHandleData: toHandleData(connection.sourceHandle),
targetHandleData: toHandleData(connection.targetHandle),
});
dispatch(setQuerybuilderGraphology(graphologyGraph));
}
},
[graphologyGraph, qbHash],
);
const onConnectStart = useCallback(
(event: React.MouseEvent | React.TouchEvent, params: OnConnectStartParams) => {
if (!params?.handleId) return;
const node = graphologyGraph.getNodeAttributes(params.nodeId);
const handleData = toHandleData(params.handleId);
connectingNodeId.current = {
params,
node,
position: { x: 0, y: 0 },
attribute: { handleData: handleData },
};
},
[graphologyGraph, qbHash],
);
const onConnectEnd = useCallback(
(event: any) => {
const targetIsPane = event.target.classList.contains('react-flow__pane');
if (isOnConnect.current) {
isOnConnect.current = false;
return;
}
if (targetIsPane && !isEdgeUpdating.current) {
let clientX: number = 0;
let clientY: number = 0;
if ('touches' in event) clientX = event?.touches?.[0]?.clientX;
else if ('clientX' in event) clientX = event?.clientX;
if ('touches' in event) clientY = event?.touches?.[0]?.clientY;
else if ('clientY' in event) clientY = event?.clientY;
const position = reactFlow.screenToFlowPosition({ x: clientX, y: clientY });
if (connectingNodeId?.current) connectingNodeId.current.position = position;
if (!connectingNodeId?.current?.params?.handleId) {
dispatch(addError('Connection has no source or target handle id'));
return;
} else {
const data = toHandleData(connectingNodeId.current.params.handleId);
if (isLogicHandle(data.handleType)) onSetToggleSettings('logic');
else onSetToggleSettings('relatedNodes');
}
}
},
[reactFlow],
);
const onEdgeUpdateStart = useCallback(() => {
isEdgeUpdating.current = true;
}, []);
const onEdgeUpdate = useCallback(
(oldEdge: Edge, newConnection: Connection) => {
if (isEdgeUpdating.current) {
isEdgeUpdating.current = false;
if (graphologyGraph.hasEdge(oldEdge.id)) {
graphologyGraph.dropEdge(oldEdge.id);
}
if (!newConnection.sourceHandle || !newConnection.targetHandle) throw new Error('Connection has no source or target');
graphologyGraph.addEdge(newConnection.source, newConnection.target, {
type: 'connection',
sourceHandleData: toHandleData(newConnection.sourceHandle),
targetHandleData: toHandleData(newConnection.targetHandle),
});
dispatch(setQuerybuilderGraphology(graphologyGraph));
}
},
[graphologyGraph, qbHash],
);
const onEdgeUpdateEnd = useCallback(
(event: MouseEvent | TouchEvent, edge: Edge, handleType: HandleType) => {
if (isEdgeUpdating.current) {
if (graphologyGraph.hasEdge(edge.id)) {
graphologyGraph.dropEdge(edge.id);
}
dispatch(setQuerybuilderGraphology(graphologyGraph));
}
isEdgeUpdating.current = false;
},
[graphologyGraph, qbHash],
);
return (
<>
<svg height={0}>
<defs>
<marker id="arrowIn" markerWidth="9" markerHeight="10" refX={0} refY="5" orient="auto">
<polygon points="0 0, 9 5, 0 10" />
</marker>
<marker id="arrowOut" markerWidth="10" markerHeight="10" refX={0} refY="5" orient="auto">
<polygon points="0 0, 10 5, 0 10" />
</marker>
</defs>
</svg>
<ReactFlow
ref={reactFlowRef}
edges={elements.edges}
nodes={elements.nodes}
snapGrid={[10, 10]}
zoomOnScroll={allowZoom}
snapToGrid
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={ConnectionDragLine}
onMouseDownCapture={onMouseDown}
// connectionMode={ConnectionMode.Loose}
onInit={reactFlowInstance => {
reactFlowInstanceRef.current = reactFlowInstance;
onInit(reactFlowInstance);
}}
onNodesChange={ss?.authorization.query.W ? onNodesChange : () => {}}
onDragOver={ss?.authorization.query.W ? onDragOver : () => {}}
onConnect={ss?.authorization.query.W ? onConnect : () => {}}
onConnectStart={ss?.authorization.query.W ? onConnectStart : () => {}}
onConnectEnd={ss?.authorization.query.W ? onConnectEnd : () => {}}
// onNodeMouseEnter={onNodeMouseEnter}
// onNodeMouseLeave={onNodeMouseLeave}
onEdgeUpdate={ss?.authorization.query.W ? onEdgeUpdate : () => {}}
onEdgeUpdateStart={ss?.authorization.query.W ? onEdgeUpdateStart : () => {}}
onEdgeUpdateEnd={ss?.authorization.query.W ? onEdgeUpdateEnd : () => {}}
onDrop={ss?.authorization.query.W ? onDrop : () => {}}
// onContextMenu={onContextMenu}
onNodeContextMenu={ss?.authorization.query.W ? onNodeContextMenu : () => {}}
// onNodesDelete={onNodesDelete}
deleteKeyCode="Backspace"
className={styles.reactflow}
proOptions={{ hideAttribution: true }}
>
<Background gap={10} size={0.7} />
</ReactFlow>
</>
);
};
export default QueryInputVisual;
import { ControlContainer, TooltipProvider } from '@/lib/components';
import { Tabs } from '@/lib/components/tabs';
import { wsUpdateQuery } from '@/lib/data-access/broker';
import { addError } from '@/lib/data-access/store/configSlice';
import objectHash from 'object-hash';
import { useEffect, useMemo, useRef } from 'react';
import Sortable from 'sortablejs';
import { useActiveQuery, useActiveSaveState, useAppDispatch, useGraphQuery, useML } from '../../../data-access';
import { clearQB, reorderQueryState, setQueryName } from '../../../data-access/store/sessionSlice';
import {
AddQueryButton,
ClearNodesButton,
FitViewButton,
LimitButton,
LogicButton,
MLButton,
RunQueryButton,
ScreenshotButton,
SettingsButton,
} from '../QueryBuilderControlButtons';
import QueryBuilderTab from './QueryBuilderTab';
export type QueryBuilderToggleSettings = 'settings' | 'ml' | 'logic' | 'relatedNodes' | undefined;
export type QueryBuilderNavProps = {
toggleSettings: QueryBuilderToggleSettings;
onFitView: () => void;
onRunQuery: (useCached: boolean) => void;
onScreenshot: () => void;
onLogic: () => void;
};
export const QueryBuilderNav = (props: QueryBuilderNavProps) => {
const dispatch = useAppDispatch();
const activeQuery = useActiveQuery();
const ss = useActiveSaveState();
const graphQuery = useGraphQuery();
const resultSize = useMemo((): number => {
if (!graphQuery) return 0;
return graphQuery.graph.nodes.length;
}, [graphQuery]);
const totalSize = useMemo((): number => {
if (!activeQuery || !graphQuery || !ss) return 0;
const nodeCounts = activeQuery.graph.nodes
.filter(x => x.attributes.type == 'entity')
.map(x => graphQuery.graphCounts?.nodeCounts[`${x.key}_count`] ?? 0);
return nodeCounts.reduce((a, b) => a + b, 0);
}, [graphQuery]);
const ml = useML();
/**
* Clears all nodes in the graph.
*/
function clearAllNodes(): void {
dispatch(clearQB());
}
const tabsRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ss || !tabsRef.current) return;
const sortable = new Sortable(tabsRef.current, {
animation: 150,
draggable: '[data-type="tab"]',
ghostClass: 'bg-secondary-300',
dragClass: 'bg-secondary-100',
onEnd: evt => {
if (evt.oldIndex != null && evt.newIndex != null && evt.oldIndex !== evt.newIndex) {
dispatch(
reorderQueryState({
oldIndex: evt.oldIndex,
newIndex: evt.newIndex,
}),
);
}
},
});
const sortedQueries = ss.queryStates.openQueryArray
.filter(query => query.id != null)
.sort((a, b) => {
return a.order < b.order ? -1 : 1;
});
sortable.sort(sortedQueries.map(x => String(x.id)));
return () => {
sortable.destroy();
};
}, [ss ? objectHash(Object.fromEntries(ss?.queryStates.openQueryArray.map(x => [x.id, x.order]))) : null]);
const mlEnabled = ml.linkPrediction.enabled || ml.centrality.enabled || ml.communityDetection.enabled || ml.shortestPath.enabled;
// const handleManualQuery = (query: string): void => {
// wsManualQueryRequest({ query: query });
// };
function updateQueryName(queryId: number, text: string): void {
if (!ss || !activeQuery) return;
wsUpdateQuery(
{
saveStateID: ss.id,
query: { ...activeQuery, name: text },
},
({ status }) => {
if (status !== 'success') {
addError('Failed to update query');
}
dispatch(setQueryName(text));
},
);
}
if (!ss || !activeQuery) {
console.debug('No active query found in query nav');
return null;
}
// Get sorted queries
const sortedQueries = ss.queryStates.openQueryArray.filter(query => query.id != null).sort((a, b) => (a.order < b.order ? -1 : 1));
// Check if there's more than one query tab
const hasMultipleQueries = sortedQueries.length > 1;
const canWrite = !!ss?.authorization.database?.W;
const canQuery = !!ss?.authorization.query?.W;
return (
<div className="sticky shrink-0 top-0 left-0 right-0 flex items-stretch justify-start h-7 bg-secondary-100 border-b border-secondary-200 max-w-full">
<div className="flex items-center px-2">
<h1 className="text-xs font-semibold text-secondary-600 truncate">Query builder</h1>
</div>
<div className="flex items-center px-0.5 gap-1 border-l border-secondary-200">
<TooltipProvider>
<AddQueryButton disabled={!canWrite} saveStateId={ss.id} />
</TooltipProvider>
</div>
<Tabs ref={tabsRef} className={`-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x`}>
{sortedQueries.map((query, i) => (
<QueryBuilderTab
key={i}
query={query}
index={i}
activeQueryId={activeQuery?.id || -1}
isActive={query.id === activeQuery?.id}
canWrite={canWrite}
saveStateId={ss.id}
isLastTab={!hasMultipleQueries}
onUpdateQueryName={updateQueryName}
/>
))}
</Tabs>
<div className="sticky right-0 px-0.5 ml-auto items-center flex truncate">
<ControlContainer>
<TooltipProvider>
<FitViewButton onFitView={props.onFitView} />
<ClearNodesButton disabled={!canQuery} />
<ScreenshotButton onScreenshot={props.onScreenshot} />
<SettingsButton disabled={!canQuery} />
<RunQueryButton onRunQuery={props.onRunQuery} />
<LogicButton disabled={!canQuery} onLogic={props.onLogic} isActive={props.toggleSettings === 'logic'} />
<MLButton disabled={!canQuery} mlEnabled={mlEnabled} isActive={props.toggleSettings === 'ml'} mlData={ml} />
<LimitButton disabled={!canQuery} activeQuery={activeQuery} resultSize={resultSize} totalSize={totalSize} />
{/* <ManualQueryButton disabled={!canQuery} /> */}
</TooltipProvider>
</ControlContainer>
</div>
</div>
);
};
import { Button, Icon, Input, Tab, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/lib/components';
import { Menu, MenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from '@/lib/components/menu';
import { useAppDispatch } from '@/lib/data-access';
import { wsDeleteQuery } from '@/lib/data-access/broker';
import { removeQueryByID, setActiveQueryID, setQuerybuilderSettings, setQueryViewMode } from '@/lib/data-access/store/sessionSlice';
import { useState } from 'react';
import { Query, QueryViewMode } from 'ts-common/src/model';
// QueryBuilderTab component to encapsulate tab-related functionality
interface QueryBuilderTabProps {
query: Query;
index: number;
activeQueryId: number | null;
isActive: boolean;
canWrite: boolean;
saveStateId: string;
isLastTab: boolean;
onUpdateQueryName: (queryId: number, name: string) => void;
}
const QueryBuilderTab = ({ query, isActive, canWrite, saveStateId, isLastTab, onUpdateQueryName }: QueryBuilderTabProps) => {
const dispatch = useAppDispatch();
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editText, setEditText] = useState<string>(query.name ?? '');
const [viewMode, setViewMode] = useState<QueryViewMode>(query.settings.mode ?? QueryViewMode.Visual);
// Toggle between the three modes for this query
const toggleViewMode = () => {
let newViewMode: QueryViewMode = QueryViewMode.Visual;
if (viewMode === QueryViewMode.Visual) {
newViewMode = QueryViewMode.Cypher;
} else if (viewMode === QueryViewMode.Cypher) {
newViewMode = QueryViewMode.Visual;
}
setViewMode(newViewMode);
dispatch(setQueryViewMode(newViewMode));
};
// Get icon component based on the view mode
const getViewModeIcon = (viewMode: QueryViewMode): string => {
switch (viewMode) {
case QueryViewMode.Visual:
return 'icon-[ic--baseline-remove-red-eye]';
case QueryViewMode.Cypher:
return 'icon-[ic--baseline-article]';
// case 'chatBot':
// return 'icon-[ic--baseline-smart-toy]';
default:
return 'icon-[ic--baseline-remove-red-eye]';
}
};
// Get tooltip text based on the view mode
const getViewModeTooltip = (): string => {
switch (viewMode) {
case QueryViewMode.Visual:
return 'Visual Query Mode';
case QueryViewMode.Cypher:
return 'Text Query Mode';
// case 'chatBot':
// return 'AI Chat Mode';
default:
return 'Visual Query Mode';
}
};
const handleUpdateName = () => {
if (query.id) {
onUpdateQueryName(query.id, editText);
setIsEditing(false);
}
};
return (
<Menu atCursor>
<MenuTrigger className="-mb-px" rightClick>
<Tab
text=""
activeTab={isActive}
data-id={query.id}
onClick={() => {
if (query.id == null) throw new Error('Query ID is null; Cannot change tabs in query tab navbar');
dispatch(setActiveQueryID(query.id));
}}
IconComponent={() => <Icon size={14} component={getViewModeIcon(viewMode)} />}
>
{isEditing ? (
<Input
type="text"
size="xs"
value={editText}
label=""
onChange={(e: any) => {
setEditText(typeof e === 'string' ? e : e.target.value);
}}
onBlur={handleUpdateName}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleUpdateName();
}
}}
className="w-20"
style={{
border: 'none',
boxShadow: 'none',
background: 'none',
}}
autoFocus
/>
) : (
<>
<div
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (query.id == null) throw new Error('Query ID is null; Cannot change tabs in query tab navbar');
dispatch(setActiveQueryID(query.id));
setIsEditing(true);
}}
>
{query.name ?? 'Query'}
</div>
</>
)}
{!isLastTab && (
<>
<Button
variantType="secondary"
variant="ghost"
disabled={!canWrite}
rounded
size="3xs"
iconComponent="icon-[ic--baseline-close]"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: saveStateId, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
</>
)}
</Tab>
</MenuTrigger>
<MenuContent>
<Menu>
<MenuTrigger label="Change mode" />
<MenuContent>
<MenuRadioGroup
value={query.settings.mode}
onValueChange={value => {
dispatch(setQuerybuilderSettings({ ...query.settings, mode: value as QueryViewMode }));
}}
>
<MenuRadioItem value={QueryViewMode.Visual} label="Visual" closeOnClick={true} />
<MenuRadioItem value={QueryViewMode.Cypher} label="Cypher" closeOnClick={true} />
</MenuRadioGroup>
</MenuContent>
</Menu>
<MenuItem
label="Rename"
closeOnClick={true}
onClick={e => {
e.stopPropagation();
setTimeout(() => {
setIsEditing(true);
}, 1);
}}
/>
<MenuSeparator />
<MenuItem
label="Remove"
className="text-danger"
onClick={() => {
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
</MenuContent>
</Menu>
);
};
export default QueryBuilderTab;