import { SmartBezierEdge, SmartStepEdge, SmartStraightEdge } from '@tisoap/react-flow-smart-edge'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import ReactFlow, { Edge, MiniMap, Node, ReactFlowInstance, ReactFlowProvider, useEdgesState, useNodesState } from 'reactflow'; import 'reactflow/dist/style.css'; import { Icon, Panel } from '../../components'; import { Button } from '../../components/buttons'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/layout/Popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '../../components/tooltip/Tooltip'; import { useSchema, useSchemaGraph, useSchemaSettings, useSearchResultSchema } from '../../data-access'; import { resultSetFocus } from '../../data-access/store/interactionSlice'; import { toSchemaGraphology } from '../../data-access/store/schemaSlice'; import { AlgorithmToLayoutProvider, AllLayoutAlgorithms, LayoutFactory } from '../../graph-layout'; import { ConnectionDragLine, ConnectionLine } from '../../querybuilder'; import { NodeEdge } from '../pills/edges/node-edge'; import { SelfEdge } from '../pills/edges/self-edge'; import { SchemaEntityPill } from '../pills/nodes/entity/SchemaEntityPill'; import { SchemaListEntityPill } from '../pills/nodes/entity/SchemaListEntityPill'; import { SchemaListRelationPill } from '../pills/nodes/relation/SchemaListRelationPill'; import { SchemaRelationPill } from '../pills/nodes/relation/SchemaRelationPill'; import { schemaExpandRelation, schemaGraphology2Reactflow } from '../schema-utils'; import { SchemaSettings } from './SchemaSettings'; interface Props { content?: string; auth?: boolean; onRemove?: () => void; } const graphEntityPillNodeTypes = { entity: SchemaEntityPill, relation: SchemaRelationPill, }; const listEntityPillNodeTypes = { entity: SchemaListEntityPill, relation: SchemaListRelationPill, }; const edgeTypes = { nodeEdge: NodeEdge, selfEdge: SelfEdge, bezier: SmartBezierEdge, connection: ConnectionLine, straight: SmartStraightEdge, step: SmartStepEdge, }; export enum SchemaViewState { SchemaGraphS = 'Schema Graph Small', SchemaGraphM = 'Schema Graph Medium', SchemaGraphL = 'Schema Graph Large', SchemaListS = 'Schema List Small', SchemaListM = 'Schema List Medium', SchemaListL = 'Schema List Large', } export const Schema = (props: Props) => { const settings = useSchemaSettings(); const searchResults = useSearchResultSchema(); const dispatch = useDispatch(); const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); const [nodeTypes, setNodeTypes] = useState<{ entity: React.FC<any>; relation: React.FC<any>; }>(graphEntityPillNodeTypes); // viewport const initialViewportRef = useRef<{ x: number; y: number; zoom: number } | null>(null); const [hasLayoutBeenRun, setHasLayoutBeenRun] = useState(false); // Time threshold for distinguishing between a click and a drag const isPillClicked = useRef<boolean>(false); const reactFlowInstanceRef = useRef<ReactFlowInstance | null>(null); const reactFlowRef = useRef<HTMLDivElement>(null); // In case the schema is updated const schema = useSchema(); const schemaGraphology = useMemo(() => toSchemaGraphology(schema.graph), [schema.graph]); const layout = useRef<AlgorithmToLayoutProvider<AllLayoutAlgorithms>>(); const [viewSelected, setViewState] = useState<SchemaViewState>(SchemaViewState.SchemaListS); function updateLayout() { const layoutFactory = new LayoutFactory(); layout.current = layoutFactory.createLayout(settings.layoutName); } const maxZoom = 1.2; const fitView = () => { if (reactFlowInstanceRef.current) { reactFlowInstanceRef.current.fitView({maxZoom}); } }; useEffect(() => { updateLayout(); if (sessionStorage.getItem('firstUserConnection') === 'true') { sessionStorage.setItem('firstUserConnection', 'false'); } else { sessionStorage.setItem('firstUserConnection', 'true'); } }, []); async function layoutGraph() { setNodeTypes(graphEntityPillNodeTypes); updateLayout(); const expandedSchema = schemaExpandRelation(schemaGraphology); const bounds = reactFlowRef.current?.getBoundingClientRect(); const xy = bounds ? { x1: 50, x2: bounds.width - 50, y1: 50, y2: bounds.height - 200 } : { x1: 0, x2: 500, y1: 0, y2: 1000 }; // layout.current?.setVerbose(true); await layout.current?.layout(expandedSchema, xy); const schemaFlow = schemaGraphology2Reactflow(expandedSchema, settings.connectionType, settings.animatedEdges); let nodesWithRef, edgesWithRef; if (!hasLayoutBeenRun) { nodesWithRef = schemaFlow.nodes.map((node) => { return { ...node, data: { ...node.data, reactFlowRef, tooltipClose: false }, }; }); edgesWithRef = schemaFlow.edges.map((edge) => { return { ...edge, data: { ...edge.data, reactFlowRef, tooltipClose: false }, }; }); setHasLayoutBeenRun(true); } else { nodesWithRef = schemaFlow.nodes.map((node) => { return { ...node, data: { ...node.data }, }; }); edgesWithRef = schemaFlow.edges.map((edge) => { return { ...edge, data: { ...edge.data }, }; }); } setNodes(nodesWithRef); setEdges(edgesWithRef); setTimeout(() => fitView(), 100); } async function layoutList() { setNodeTypes(listEntityPillNodeTypes); updateLayout(); const expandedSchema = schemaExpandRelation(schemaGraphology); const bounds = reactFlowRef.current?.getBoundingClientRect(); const xy = bounds ? { x1: 50, x2: bounds.width - 50, y1: 50, y2: bounds.height - 200 } : { x1: 0, x2: 500, y1: 0, y2: 1000 }; await layout.current?.layout(expandedSchema, xy); const schemaFlow = schemaGraphology2Reactflow(expandedSchema, settings.connectionType, settings.animatedEdges); schemaFlow.nodes = schemaFlow.nodes.filter((node) => !node.id.toLowerCase().includes('bloom')); schemaFlow.edges = schemaFlow.edges.filter((edge) => !edge.id.toLowerCase().includes('bloom')); schemaFlow.nodes = schemaFlow.nodes.filter((node) => !node.id.toLowerCase().includes('bloom')); schemaFlow.edges = schemaFlow.edges.filter((edge) => !edge.id.toLowerCase().includes('bloom')); let nodesWithRef, edgesWithRef; if (!hasLayoutBeenRun) { nodesWithRef = schemaFlow.nodes.map((node) => { return { ...node, data: { ...node.data, reactFlowRef, tooltipClose: false }, }; }); edgesWithRef = schemaFlow.edges.map((edge) => { return { ...edge, data: { ...edge.data, reactFlowRef, tooltipClose: false }, }; }); setHasLayoutBeenRun(true); } else { nodesWithRef = schemaFlow.nodes.map((node) => { return { ...node, data: { ...node.data }, }; }); edgesWithRef = schemaFlow.edges.map((edge) => { return { ...edge, data: { ...edge.data }, }; }); } setNodes(nodesWithRef); setEdges(edgesWithRef); setTimeout(() => fitView(), 100); } useEffect(() => { setViewState(settings.schemaViewState); if (schemaGraphology === undefined || schemaGraphology.order == 0) { setNodes([]); setEdges([]); return; } if ( settings.schemaViewState === SchemaViewState.SchemaGraphS || settings.schemaViewState === SchemaViewState.SchemaGraphM || settings.schemaViewState === SchemaViewState.SchemaGraphL ) { layoutGraph(); } else { layoutList(); } }, [schema.graph, settings]); useEffect(() => { setNodes((nds) => nds.map((node) => ({ ...node, selected: searchResults.includes(node.id) || searchResults.includes(node.data.label), })), ); }, [searchResults]); const nodeColor = (node: any) => { switch (node.type) { case 'entity': return 'hsl(var(--clr-node))'; case 'relation': return 'hsl(var(--clr-relation))'; default: return '#ff0072'; } }; const handleOnClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => { const target = event.target as HTMLElement; const clickedOutsideNode = target.classList.contains('react-flow__pane'); setNodes((nds) => nds.map((node) => ({ ...node, data: { ...node.data, tooltipClose: clickedOutsideNode, }, })), ); setEdges((edg) => edg.map((edge) => ({ ...edge, data: { ...edge.data, tooltipClose: clickedOutsideNode, }, })), ); }; return ( <Panel title="Schema" className="schema-panel" tooltips={ <> <Tooltip> <TooltipTrigger> <Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-remove]" onClick={() => { if (props.onRemove) props.onRemove(); }} /> </TooltipTrigger> <TooltipContent> <p>Hide</p> </TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger> <Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-content-copy]" onClick={() => { // Copy the schema to the clipboard navigator.clipboard.writeText(JSON.stringify(schema.graph, null, 2)); }} /> </TooltipTrigger> <TooltipContent> <p>Copy Schema to Clipboard</p> </TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger> <Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-fullscreen]" onClick={() => { fitView(); }} /> </TooltipTrigger> <TooltipContent> <p>Fit to screen</p> </TooltipContent> </Tooltip> <Popover> <PopoverTrigger> <Tooltip> <TooltipTrigger> <Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-settings]" className="schema-settings" /> </TooltipTrigger> <TooltipContent> <p>Schema Settings</p> </TooltipContent> </Tooltip> </PopoverTrigger> <PopoverContent> <SchemaSettings /> </PopoverContent> </Popover> </> } > <div className="w-full h-full flex flex-col justify-between" ref={reactFlowRef}> {schema.loading ? ( <div className="h-full flex flex-col items-center justify-center"> <Icon component="icon-[mingcute--loading-line]" size={56} className="w-15 h-15 animate-spin " /> </div> ) : nodes.length === 0 ? ( <p className="m-3 text-xl font-bold">No Elements</p> ) : ( <ReactFlowProvider> <ReactFlow snapGrid={[10, 10]} snapToGrid onlyRenderVisibleElements={false} nodesDraggable={true} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionLineComponent={ConnectionDragLine} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onMouseDownCapture={() => dispatch(resultSetFocus({ focusType: 'schema' }))} nodes={nodes} edges={edges} onInit={(reactFlowInstance) => { reactFlowInstanceRef.current = reactFlowInstance; setTimeout(() => fitView(), 100); }} onClick={handleOnClick} proOptions={{ hideAttribution: true }} > {settings.showMinimap && <MiniMap nodeColor={nodeColor} />} </ReactFlow> </ReactFlowProvider> )} </div> </Panel> ); };