├── src ├── vite-env.d.ts ├── main.tsx ├── connection │ ├── Driver.tsx │ ├── ConnectionButton.tsx │ └── ConnectionModal.tsx ├── index.css ├── builder │ ├── Announcements.tsx │ ├── container │ │ ├── FooterContainer.tsx │ │ ├── TemplateContainer.tsx │ │ ├── QueryContainer.tsx │ │ └── SideContainer.tsx │ ├── sampling │ │ └── SamplingModal.tsx │ ├── sortable │ │ └── SortableBlock.tsx │ ├── editor │ │ └── CodeEditor.tsx │ ├── logic │ │ ├── DefinitionsLogic.tsx │ │ ├── SchemaLogic.ts │ │ ├── ComparisonsLogic.tsx │ │ ├── SamplingLogic.ts │ │ ├── VariablesLogic.ts │ │ ├── BuilderLogic.tsx │ │ ├── WizardLogic.ts │ │ └── TemplatesLogic.tsx │ ├── block │ │ ├── components │ │ │ ├── BlockOption.tsx │ │ │ └── BlockSelect.tsx │ │ ├── BlockType.tsx │ │ └── Block.tsx │ ├── templates │ │ └── TemplatesModal.tsx │ ├── BuilderEventHandler.ts │ └── Builder.tsx └── application │ ├── Tutorial.tsx │ ├── Application.tsx │ └── Header.tsx ├── tsconfig.node.tsbuildinfo ├── postcss.config.js ├── vite.config.ts ├── tsconfig.json ├── .gitignore ├── tsconfig.node.json ├── tailwind.config.js ├── index.html ├── eslint.config.js ├── tsconfig.app.json ├── tsconfig.app.tsbuildinfo ├── package.json ├── README.md └── LICENSE /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.node.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./vite.config.ts"],"version":"5.6.3"} -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {} 4 | }, 5 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "noUnusedLocals": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | data -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | // We need to include the base CSS in the root of the app so all of our components can inherit the styles 5 | import '@neo4j-ndl/base/lib/neo4j-ds-styles.css'; 6 | import Application from './application/Application'; 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // This is the only line you need to include as a preset 3 | presets: [require('@neo4j-ndl/base').tailwindConfig], 4 | // By default this configuration will have a prefix "n-" 5 | // for all utility classes. If you want to remove it you can 6 | // do with an empty string as a prefix, which is convenient 7 | // for existing tailwind projects 8 | prefix: '', 9 | // Be sure to disable preflight, 10 | // as we provide our own Preflight (CSS Reset) 11 | // with Needle out of the box 12 | corePlugins: { 13 | preflight: false, 14 | }, 15 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Visual Cypher Builder 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /src/connection/Driver.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import neo4j, { Driver } from 'neo4j-driver'; 3 | 4 | export let driver: Driver; 5 | 6 | export async function setDriver(connectionURI: string, username: string, password: string) { 7 | try { 8 | driver = neo4j.driver(connectionURI, neo4j.auth.basic(username, password)); 9 | await driver.getServerInfo(); 10 | localStorage.setItem( 11 | 'neo4j.connection', 12 | JSON.stringify({ uri: connectionURI, user: username, password: password }) 13 | ); 14 | return true; 15 | } catch (err) { 16 | console.error(`Connection error\n${err}\nCause: ${err as Error}`); 17 | return false; 18 | } 19 | } 20 | 21 | export async function disconnect() { 22 | try { 23 | await driver.close(); 24 | return true; 25 | } catch (err) { 26 | console.error(`Disconnection error\n${err}\nCause: ${err as Error}`); 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* Injecting some fonts */ 2 | @import url('https://fonts.googleapis.com/css?family=Fira+Code'); 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | /** TODO: hacking in some colors because I can't get needle to override select colors, somehow. */ 9 | .red-text .ndl-placeholder, .red-text input, .red-text .ndl-select-option { 10 | color: #cb4b16 !important; 11 | } 12 | 13 | .blue-text .ndl-placeholder, .blue-text input, .blue-text .ndl-select-option { 14 | color: #268bd2 !important; 15 | } 16 | 17 | .orange-text .ndl-placeholder, .orange-text input, .orange-text .ndl-select-option { 18 | color: #b58900 !important; 19 | } 20 | 21 | .grey-text .ndl-placeholder, .grey-text input, .grey-text .ndl-select-option { 22 | color: #586e75 !important; 23 | } 24 | 25 | .green-text .ndl-placeholder, .green-text input, .green-text .ndl-select-option { 26 | color: #2aa198 !important; 27 | } 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/builder/Announcements.tsx: -------------------------------------------------------------------------------- 1 | // const defaultAnnouncements = { 2 | // onDragStart(id) { 3 | // console.log(`Picked up draggable item ${id}.`); 4 | // }, 5 | // onDragOver(id, overId) { 6 | // if (overId) { 7 | // console.log( 8 | // `Draggable item ${id} was moved over droppable area ${overId}.` 9 | // ); 10 | // return; 11 | // } 12 | 13 | // console.log(`Draggable item ${id} is no longer over a droppable area.`); 14 | // }, 15 | // onDragEnd(id, overId) { 16 | // if (overId) { 17 | // console.log( 18 | // `Draggable item ${id} was dropped over droppable area ${overId}` 19 | // ); 20 | // return; 21 | // } 22 | 23 | // console.log(`Draggable item ${id} was dropped.`); 24 | // }, 25 | // onDragCancel(id) { 26 | // console.log(`Dragging was cancelled. Draggable item ${id} was dropped.`); 27 | // }, 28 | // }; 29 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": false, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": false, // Allow 'any' type, which might prevent TS2322 23 | "noImplicitThis": false, // Allow 'this' to have an implicit 'any' 24 | "strictNullChecks": false, // Relax checks for null and undefined 25 | "strictFunctionTypes": false, // Allow function parameter type mismatch 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.app.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/application/application.tsx","./src/application/header.tsx","./src/application/tutorial.tsx","./src/builder/announcements.tsx","./src/builder/builder.tsx","./src/builder/buildereventhandler.ts","./src/builder/block/block.tsx","./src/builder/block/blocktype.tsx","./src/builder/block/components/blockoption.tsx","./src/builder/block/components/blockselect.tsx","./src/builder/container/footercontainer.tsx","./src/builder/container/querycontainer.tsx","./src/builder/container/sidecontainer.tsx","./src/builder/container/templatecontainer.tsx","./src/builder/editor/codeeditor.tsx","./src/builder/logic/builderlogic.tsx","./src/builder/logic/comparisonslogic.tsx","./src/builder/logic/definitionslogic.tsx","./src/builder/logic/samplinglogic.ts","./src/builder/logic/schemalogic.ts","./src/builder/logic/templateslogic.tsx","./src/builder/logic/variableslogic.ts","./src/builder/logic/wizardlogic.ts","./src/builder/sampling/samplingmodal.tsx","./src/builder/sortable/sortableblock.tsx","./src/builder/templates/templatesmodal.tsx","./src/connection/connectionbutton.tsx","./src/connection/connectionmodal.tsx","./src/connection/driver.tsx"],"version":"5.6.3"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visual-query-builder", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "build-dev": "tsc -b && vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@dnd-kit/core": "^6.1.0", 15 | "@dnd-kit/modifiers": "^7.0.0", 16 | "@dnd-kit/sortable": "^8.0.0", 17 | "@neo4j-cypher/react-codemirror": "^1.0.4", 18 | "@neo4j-ndl/base": "^3.0.10", 19 | "@neo4j-ndl/react": "^3.0.16", 20 | "@neo4j/cypher-builder": "^1.22.3", 21 | "@tanstack/react-table": "^8.20.5", 22 | "antlr4": "^4.13.2", 23 | "neo4j-driver": "^5.27.0", 24 | "postcss": "^8.4.47", 25 | "nanoid": "^3.3.8", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "tailwindcss": "^3.4.14" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.11.1", 32 | "@types/react": "^18.3.10", 33 | "@types/react-dom": "^18.3.0", 34 | "@vitejs/plugin-react": "^4.3.2", 35 | "eslint": "^9.11.1", 36 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 37 | "eslint-plugin-react-refresh": "^0.4.12", 38 | "@eslint/plugin-kit": "^0.2.3", 39 | "cross-spawn": "^7.0.5", 40 | "globals": "^15.9.0", 41 | "typescript": "^5.5.3", 42 | "typescript-eslint": "^8.7.0", 43 | "vite": "^5.4.8" 44 | } 45 | } -------------------------------------------------------------------------------- /src/builder/container/FooterContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDroppable } from "@dnd-kit/core"; 3 | import { 4 | horizontalListSortingStrategy, 5 | SortableContext, 6 | } from "@dnd-kit/sortable"; 7 | import SortableBlock from "../sortable/SortableBlock"; 8 | 9 | 10 | const containerStyle = { 11 | background: "#fcfcfc", 12 | padding: 10, 13 | width: "100%", 14 | // flex: 1, 15 | minHeight: 50, // Fixed height 16 | display: "flex", // Flexbox layout 17 | flexDirection: "row", // Horizontal item layout 18 | gap: 10, // Spacing between items 19 | flexWrap: "wrap", // Allow items to wrap to the next line 20 | alignItems: "center", // Vertically center items 21 | justifyContent: "flex-start", // Align items to the start horizontally 22 | }; 23 | 24 | 25 | /** 26 | * A footer container for the query builder, which displays the wizard's suggestions. 27 | * 28 | * FooterContainer is a SortableContext which allows the user to reorder the items in the footer. 29 | * It renders a row of SortableBlock components, representing the query results. 30 | * 31 | * @param {Object} props 32 | * @param {string | number} [props.id] The id of the container 33 | * @param {Array} [props.containerItems] The items in the container 34 | * @param {Object} [props.elements] The elements in the container 35 | * @param {function} [props.setElement] A function to update the elements in the container 36 | * @param {function} [props.onClick] A function to call when an item in the container is clicked 37 | * @returns {React.ReactElement} 38 | */ 39 | export default function FooterContainer(props: { onClick?: any; id?: any; containerItems?: any; elements?: any; setElement: any }) { 40 | const { id, containerItems, elements, setElement } = props; 41 | 42 | const { setNodeRef } = useDroppable({ 43 | id, 44 | }); 45 | 46 | return ( 47 | 52 | {/* @ts-ignore */} 53 |
54 | {containerItems?.map((itemId: string) => ( 55 | setElement(itemId, element)} 61 | onClick={props.onClick} 62 | onShiftClick={undefined} 63 | schema={undefined} 64 | variables={undefined} 65 | interactive={false} 66 | /> 67 | ))} 68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/connection/ConnectionButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Menu, Typography, IconButton, Avatar } from '@neo4j-ndl/react'; 3 | import { ChevronDownIconOutline } from '@neo4j-ndl/react/icons'; 4 | 5 | const settings = ['Profile', 'Logout']; 6 | 7 | /** 8 | * A button component that displays a menu with options to connect to a Neo4j database 9 | * when clicked. 10 | * 11 | * @param {Object} props - Component props 12 | * @param {boolean} [props.connected=false] - Whether the user is connected to a database 13 | * @param {string} [props.uri=''] - The URI of the database the user is connected to 14 | * @param {Function} [props.onClick] - A callback function that is called when the button is clicked 15 | * 16 | * @returns {React.ReactElement} - The rendered ConnectButton component 17 | */ 18 | export default function ConnectButton({ 19 | connected = false, 20 | uri = '', 21 | onClick = () => { }, 22 | }: { 23 | connected: boolean; 24 | uri: string; 25 | onClick?: () => void; 26 | }) { 27 | const [anchorEl, ConnectionButton] = useState(null); 28 | const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => { 29 | onClick(); 30 | }; 31 | const handleClose = () => { 32 | ConnectionButton(null); 33 | }; 34 | 35 | const menuSelect = (e: string) => { 36 | window.alert(e); 37 | handleClose(); 38 | }; 39 | 40 | const open = Boolean(anchorEl); 41 | 42 | return ( 43 |
48 | {/* */} 49 | 50 |
51 | 52 | {connected ? 'Connected ✅' : 'Not connected ⚠️'} 53 | 54 | 55 | 56 | 57 | 58 | {connected ? uri : 'Connect to a database'} 59 | 60 | {/* @ts-ignore */} 61 | 62 | 63 | {settings.map((setting) => ( 64 | menuSelect(setting)} title={setting} /> 65 | ))} 66 | 67 | 68 |
69 | 72 | 73 | 74 |
75 | ); 76 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Visual Cypher Builder 🔨 2 | The **Visual Cypher Builder** is a user-friendly tool that simplifies the process of building graph queries in Neo4j's Cypher language. With an intuitive drag-and-drop interface, it helps users — from beginners to experts — visually construct Cypher queries. 3 | 4 | Whether you're just starting with graph databases or looking for a faster way to build complex queries, this tool bridges the gap between abstract syntax and intuitive design. 5 | 6 | You can try the prototype in your browser: 7 | 👉 [Demo Environment](https://nielsdejong.nl/cypher-builder) 8 | 9 | ## Main Features 10 | #### Build Queries Visually 11 | - Drag-and-drop blocks to create query components such as nodes, relationships, and clauses. 12 | - Rearrange and edit blocks to customize your query. 13 | 14 | #### Smart Wizard Suggestions 15 | - Schema-aware guidance helps you select the next logical block. 16 | - Offers context-sensitive suggestions to streamline query construction. 17 | 18 | #### Templates for Learning 19 | - Explore pre-built queries to understand common Cypher patterns. 20 | - Generate examples dynamically based on your graph schema. 21 | 22 | #### Schema-Aware Design 23 | - Auto-generate nodes, relationships, and properties based on your database schema. 24 | - Real-time sampling to provide accurate suggestions. 25 | 26 | 27 | ## Getting Started 28 | Follow these steps to run the project locally: 29 | 30 | 1. Clone the repository and navigate to the project folder: 31 | ```bash 32 | git clone https://github.com/neo4j-labs/visual-cypher-builder 33 | cd visual-cypher-builder 34 | ``` 35 | 36 | 2. Install dependencies: 37 | ```bash 38 | yarn install 39 | ``` 40 | 41 | 3. Start the development server: 42 | ```bash 43 | yarn run dev 44 | ``` 45 | 46 | 4. Open your browser and navigate to: 47 | [http://localhost:5173/](http://localhost:5173/) 48 | 49 | 50 | 51 | ## Documentation 52 | Check out the blog post: 53 | 👉 [Visual Cypher Builder - Querying Neo4j for Everyone](https://medium.com/p/85cdbcd6dbb1/) 54 | 55 | 56 | ## Feedback & Contributions 57 | We’re eager to hear from you: 58 | 59 | - Join the community forums: [Neo4j Community](https://community.neo4j.com) 60 | - Open an issue or submit a pull request on GitHub. 61 | 62 | **Note:** This project is part of Neo4j Labs, an incubator for experimental tools. While not officially supported, feedback and suggestions are welcome. 63 | 64 | 65 | ## Future Ideas 66 | Ideas for future versions include: 67 | - Advanced patterns with quantified path expressions. 68 | - Live validation and syntax correction. 69 | - Integrated query runner for visualizing results. 70 | 71 | 72 | ## License 73 | This project is licensed under the [Apache 2.0](LICENSE) License. 74 | 75 | -------------------------------------------------------------------------------- /src/builder/container/TemplateContainer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | rectSortingStrategy, // Use rectSortingStrategy for wrapping 3 | SortableContext, 4 | } from "@dnd-kit/sortable"; 5 | import { BlockComponent } from "../block/Block"; 6 | 7 | 8 | const containerStyle = { 9 | background: "#fcfcfc", 10 | padding: 0, 11 | margin: 2, 12 | 13 | width: "calc(100%+28px)", // Full width to allow wrapping 14 | height: "auto", // Dynamic height based on content 15 | flex: 1, 16 | // marginTop: 15, 17 | minHeight: 50, // Fixed height 18 | paddingTop: 5, 19 | paddingLeft: 5, 20 | paddingBottom: 5, 21 | paddingRight: 5, 22 | marginBottom: 0, 23 | marginLeft: '-12px', 24 | marginRight: '-12px', 25 | display: "flex", // Flexbox layout 26 | flexDirection: "row", // Horizontal layout 27 | flexWrap: "wrap", // Allow items to wrap to the next line 28 | rowGap: 5, // Space between items 29 | columnGap: 10, // Space between items 30 | userSelect: "none", 31 | alignItems: "flex-start", // Align items to the top 32 | justifyContent: "flex-start", // Align items to the start horizontally 33 | }; 34 | 35 | /** 36 | * A container for rendering a row of items inside the template window. 37 | * 38 | * TemplateContainer is a SortableContext which allows the user to reorder the items in the query block. 39 | * It renders a row of SortableBlock components, representing the query elements. 40 | * It is logically minimal, as blocks in the template window are neither resortable nor interactive. 41 | * 42 | * @param {Object} props 43 | * @param {string | number} [props.id] The id of the container 44 | * @param {Array} [props.containerItems] The items in the container 45 | * @param {Object} [props.elements] The elements in the container 46 | * @param {function} [props.setElement] A function to update the elements in the container 47 | * @param {boolean} [props.dragging] Whether the component is currently being dragged 48 | * @returns {ReactElement} 49 | */ 50 | export default function TemplateContainer(props: { onClick?: any; id?: any; containerItems?: any; elements?: any; setElement: any; dragging: boolean }) { 51 | const { id, containerItems, elements, setElement } = props; 52 | return ( 53 | 59 | {/* @ts-ignore */} 60 |
61 | {containerItems?.map((itemId: string | number) => { 62 | return { }} 70 | interactive={false} 71 | /> 72 | } 73 | )} 74 | 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/builder/sampling/SamplingModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, TextInput, Banner, Dropzone, Select, LoadingSpinner } from '@neo4j-ndl/react'; 2 | import { useEffect, useState } from 'react'; 3 | import { doSampling } from '../logic/SamplingLogic'; 4 | 5 | 6 | 7 | interface SamplingModalProps { 8 | open: boolean; 9 | setOpen: (arg: boolean) => void; 10 | setConnection: (arg: any) => void; 11 | setConnected: (arg: boolean) => void; 12 | connection: any; 13 | setSchema: any; 14 | setTemplates: any; 15 | } 16 | 17 | /** 18 | * A modal component that manages the connection to a Neo4j database and performs schema sampling. 19 | * 20 | * @param {boolean} open - Determines if the modal is open or closed. 21 | * @param {Function} setOpen - Callback to set the open state of the modal. 22 | * @param {Function} setSchema - Callback to update the schema state. 23 | * @param {Function} setConnection - Callback to update the connection state. 24 | * @param {Function} setConnected - Callback to set the connected state. 25 | * @param {Object} connection - Object containing the connection details. 26 | * @param {Function} setTemplates - Callback to update the query templates. 27 | * 28 | * @returns {JSX.Element} The rendered ConnectionModal component. 29 | */ 30 | export default function ConnectionModal({ 31 | open, 32 | setOpen, 33 | setSchema, 34 | setConnection, 35 | setConnected, 36 | connection, 37 | setTemplates 38 | }: SamplingModalProps) { 39 | 40 | const [message, setMessage] = useState(""); 41 | const [loading, setLoading] = useState(false); 42 | const [step, setStep] = useState(1); 43 | 44 | useEffect(() => { 45 | 46 | if (open && !loading) { 47 | console.log("Schema sampling...") 48 | setLoading(true); 49 | doSampling(connection, setMessage, setStep, setOpen, setLoading, setSchema, setTemplates); 50 | } 51 | }, [open]) 52 | return ( 53 | <> 54 | { 57 | setOpen(false) 58 | }}> 59 | {/* Connect to Neo4j */} 60 | 63 | 64 | 65 |

Sampling database ({step}/6)

66 | 67 | {message} 68 |

69 | 70 | {message?.startsWith('Error executing sampling:') ? 71 | : null} 76 |
77 |
78 | 79 | ); 80 | } -------------------------------------------------------------------------------- /src/builder/container/QueryContainer.tsx: -------------------------------------------------------------------------------- 1 | import { DragOverlay, useDroppable } from "@dnd-kit/core"; 2 | import { 3 | horizontalListSortingStrategy, 4 | rectSortingStrategy, 5 | SortableContext, 6 | } from "@dnd-kit/sortable"; 7 | import SortableBlock from "../sortable/SortableBlock"; 8 | 9 | const containerStyle = { 10 | background: "#fcfcfc", 11 | margin: 2, 12 | width: "100%", 13 | minHeight: 50, // Fixed height 14 | padding: 5, 15 | display: "flex", // Flexbox layout 16 | flexDirection: "row", // Horizontal item layout 17 | flexWrap: "wrap", // Allow items to wrap to the next line 18 | gap: '5px 10px', // Spacing between items 19 | 20 | alignItems: "center", // Vertically center items 21 | justifyContent: "flex-start", // Align items to the start horizontally 22 | }; 23 | 24 | /** 25 | * A container for rendering a row inside the main query builder interface. 26 | * 27 | * QueryContainer is a SortableContext which allows the user to reorder the items in the query block. 28 | * It renders a row of SortableBlock components, representing the query elements. 29 | * 30 | * @param {Object} props 31 | * @param {string | number} [props.id] The id of the container 32 | * @param {Array} [props.containerItems] The items in the container 33 | * @param {Object} [props.elements] The elements in the container 34 | * @param {function} [props.setElement] A function to update the elements in the container 35 | * @param {function} [props.onClick] A function to call when an item in the container is clicked 36 | * @param {function} [props.onShiftClick] A function to call when an item in the container is shift-clicked 37 | * @param {boolean} [props.dragging] Whether the component is currently being dragged 38 | * @param {string | number} [props.activeId] The id of the currently active element 39 | * @returns {ReactElement} 40 | */ 41 | export default function QueryContainer(props: { onClick?: any; onShiftClick: any; id?: any; dragging: boolean; schema: any; variables: any; containerItems?: any; elements?: any; setElement: any, activeId?: any; }) { 42 | const { id, containerItems, elements, setElement, schema, variables, activeId, dragging } = props; 43 | 44 | const { setNodeRef } = useDroppable({ 45 | id, 46 | }); 47 | return ( 48 | 53 | {/* @ts-ignore */} 54 |
55 | {id} 56 | {containerItems?.map((itemId: string | number) => ( 57 | setElement(itemId, element)} 65 | onClick={props.onClick} 66 | onShiftClick={props.onShiftClick} 67 | interactive={!dragging} 68 | /> 69 | ))} 70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/builder/sortable/SortableBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useSortable } from "@dnd-kit/sortable"; 3 | import { CSS } from "@dnd-kit/utilities"; 4 | import { BlockComponent } from "../block/Block"; 5 | 6 | 7 | /** 8 | * A SortableBlock is a block that can be rearranged in a list by dragging it. 9 | * It takes the following props: 10 | * - onClick: a function that is called when the block is clicked. 11 | * - onShiftClick: a function that is called when the block is clicked with the shift key. 12 | * - id: an identifier for the block. 13 | * - element: the block element. 14 | * - schema: the schema of the block. 15 | * - variables: the variables of the block. 16 | * - hasTooltip: whether the block should render a tooltip. 17 | * - setElement: a function that is called when the element of the block changes. 18 | * - interactive: whether the block is interactive. 19 | * The block will trigger an animation when clicked. 20 | * The position of the animation is handled by the root level component. 21 | * The block will also change its style when it is being dragged (opacity, margin left). 22 | * The block will also change its z-index when it is being dragged to be on top of other blocks. 23 | */ 24 | export default function SortableBlock(props: { onClick: any; onShiftClick: any; id?: any; element?: any; schema: any, variables: any; hasTooltip: boolean; setElement: any; interactive: boolean }) { 25 | const { id, element, hasTooltip, onClick, onShiftClick, schema, variables, interactive, setElement } = props; 26 | const [isMoving, setIsMoving] = useState(false); 27 | const [smoothing, setSmoothing] = useState(false); 28 | const [inFocus, setInFocus] = useState(false); 29 | 30 | // 31 | if (element?.animated && !isMoving) { 32 | setIsMoving(true); 33 | // This is a workaround to prevent the animation from being triggered again 34 | setTimeout(() => { 35 | setIsMoving(false); 36 | setSmoothing(false); 37 | }, 0.1); 38 | // After 250 allow for regular smooth rearrangement of the tile again 39 | setTimeout(() => { 40 | setSmoothing(true); 41 | }, 275); 42 | } 43 | const transformStyle = { 44 | transform: `translate( 45 | ${element?.animated ? element?.animationDeltaX : 0}px, 46 | ${element?.animated ? element?.animationDeltaY : 0}px)`, 47 | transition: "transform 0.275s ease", 48 | }; 49 | 50 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = 51 | useSortable({ 52 | id 53 | }); 54 | 55 | 56 | const style = { 57 | ...transformStyle, 58 | opacity: isDragging ? 0.5 : 1, 59 | // marginLeft: isDragging ? "2px" : "0px", 60 | transform: !smoothing ? transformStyle.transform : CSS.Translate.toString(transform), 61 | transition: !smoothing ? transformStyle.transition : transition, 62 | zIndex: inFocus ? 99 : 0 // Workaround to always put focused block on top. 63 | }; 64 | 65 | return ( 66 |
{ 72 | // If an element is clicked, we trigger an animation. 73 | // The position of the animation is handled by the root level component. 74 | // The transformStyle represents the temporary style for the transformation animation. 75 | if (onShiftClick && e.shiftKey) { 76 | onShiftClick(id, e.currentTarget.getBoundingClientRect(), () => { }) 77 | } else { 78 | onClick(id, e.currentTarget.getBoundingClientRect(), () => { }) 79 | } 80 | }} 81 | 82 | > 83 | {/* /TODO can we remove the placeholder item from each row now? */} 84 | 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/builder/container/SideContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDroppable } from "@dnd-kit/core"; 3 | import { 4 | rectSortingStrategy, // Use rectSortingStrategy for wrapping 5 | SortableContext, 6 | } from "@dnd-kit/sortable"; 7 | import SortableBlock from "../sortable/SortableBlock"; 8 | import { BlockComponent } from "../block/Block"; 9 | 10 | 11 | const containerStyle = { 12 | background: "#fcfcfc", 13 | padding: 0, 14 | margin: 2, 15 | width: "calc(100%+28px)", // Full width to allow wrapping 16 | height: "auto", // Dynamic height based on content 17 | flex: 1, 18 | // marginTop: 15, 19 | minHeight: 50, // Fixed height 20 | paddingTop: 5, 21 | paddingLeft: 5, 22 | paddingBottom: 5, 23 | paddingRight: 5, 24 | marginBottom: 0, 25 | marginLeft: '-12px', 26 | marginRight: '-12px', 27 | display: "flex", // Flexbox layout 28 | flexDirection: "row", // Horizontal layout 29 | flexWrap: "wrap", // Allow items to wrap to the next line 30 | rowGap: 5, // Space between items 31 | columnGap: 10, // Space between items 32 | userSelect: "none", 33 | alignItems: "flex-start", // Align items to the top 34 | justifyContent: "flex-start", // Align items to the start horizontally 35 | }; 36 | 37 | /** 38 | * A container for rendering a row of items inside the sidebar of blocks. 39 | * 40 | * SideContainer is a SortableContext which allows the user to reorder the items in the sidebar. 41 | * It renders a row of SortableBlock components, representing the query elements. 42 | * 43 | * @param {Object} props 44 | * @param {string | number} [props.id] The id of the container 45 | * @param {Array} [props.containerItems] The items in the container 46 | * @param {Object} [props.elements] The elements in the container 47 | * @param {function} [props.setElement] A function to update the elements in the container 48 | * @param {function} [props.onClick] A function to call when an item in the container is clicked 49 | * @param {boolean} [props.dragging] Whether the component is currently being dragged 50 | */ 51 | export default function SideContainer(props: { onClick?: any; id?: any; containerItems?: any; elements?: any; setElement: any; dragging: boolean }) { 52 | const { id, containerItems, elements, setElement } = props; 53 | 54 | const [hovering, setHovering] = React.useState(false); 55 | const { setNodeRef } = useDroppable({ 56 | id, 57 | }); 58 | 59 | return ( 60 | 66 | {/* @ts-ignore */} 67 |
setHovering(true)} onMouseOut={() => setHovering(false)}> 69 | {containerItems.length == 0 ? 'Add patterns to show suggestions here...' : ''} 70 | {containerItems?.map((itemId: string | number) => { 71 | // This is an optimization trick. We only render the 'grabbable' block if the user is hovering over the container. 72 | if (hovering) { 73 | return setElement(itemId, element)} 79 | interactive={false} 80 | onClick={props.onClick} 81 | onShiftClick={undefined} 82 | schema={undefined} 83 | variables={undefined} 84 | /> 85 | } else { 86 | return { }} 94 | interactive={false} 95 | /> 96 | } 97 | } 98 | 99 | 100 | )} 101 | 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/builder/editor/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import "@neo4j-cypher/codemirror/css/cypher-codemirror.css"; 2 | import { CypherEditor, CypherEditorProps } from '@neo4j-cypher/react-codemirror'; 3 | import { FOOTER_CONTAINER_COUNT, SIDEBAR_CONTAINER_COUNT } from "../logic/BuilderLogic"; 4 | import { BlockType } from "../block/BlockType"; 5 | 6 | /** 7 | * A CodeMirror component that renders a read-only cypher query in a Monaco-like interface. 8 | * 9 | * This component is used to display the final rendered query in the main query builder interface. 10 | * 11 | * @param {Object} props The component props. 12 | * @param {string} props.cypher The Cypher query to render. 13 | */ 14 | export default function CodeEditor(props: { cypher: string }) { 15 | const editorProps: CypherEditorProps = { autocomplete: false, lineWrapping: true, value: props.cypher, readOnly: true, style: { fontSize: 18, lineHeight: 40, verticalAlign: 'center' } }; 16 | return ( 17 | 18 | ); 19 | } 20 | 21 | 22 | /** 23 | * Generate a Cypher query from a list of containers and elements. 24 | * 25 | * The containers contain the blocks that the user has dragged and dropped into the query builder. 26 | * The elements are the properties of the blocks. 27 | * 28 | * The function will iterate over the containers and elements, and generate a Cypher query based on the content. 29 | * 30 | * @param {any[]} items The containers. 31 | * @param {any} elements The properties of the blocks. 32 | * @param {number} skip The number of containers to skip. Defaults to the number of containers in the sidebar and footer. 33 | * @returns {string} The generated Cypher query. 34 | */ 35 | export function generateCypher(items: any, elements: any, skip = SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT) { 36 | let displayText = ''; 37 | let lastClause = undefined; 38 | 39 | items.slice(skip).forEach((container: any, index: any) => { 40 | let line = ''; 41 | container.forEach((item: any, itemIndex: number) => { 42 | const type = elements[item]?.type; 43 | const components = BlockType[type]?.components || []; 44 | const textArray = elements[item]?.text || []; 45 | 46 | if (type == 'CLAUSE') { 47 | // Clause is always a single text element. 48 | // We remember the last clause so we can do some conditional rendering. 49 | lastClause = textArray[0]; 50 | } 51 | 52 | textArray.forEach((text: any, index: number) => { 53 | // No dependencies, always render 54 | if (!components[index]?.dependson) { 55 | line += text; 56 | } 57 | // Has a dependency on an index, only render if that index is not empty. i.e. the ':' in (n:Node). 58 | if (components[index]?.dependson && textArray[components[index]?.dependson] !== '') { 59 | line += text; 60 | } 61 | }); 62 | 63 | // Add comma between elements in the return and with clauses, but not between elements in other clauses. 64 | const nextItem = container[itemIndex + 1]; 65 | const nextType = elements[nextItem]?.type; 66 | if ( 67 | (lastClause == 'RETURN' || lastClause == 'WITH' || lastClause == 'ORDER BY') && 68 | (nextType == 'VARIABLE' || nextType == 'COMPARISON' || nextType == 'STRING_COMPARISON' || nextType == 'NULL_COMPARISON' || nextType == 'FUNCTION') && 69 | (type !== 'CLAUSE' && type !== 'OPERATOR' && type !== 'BRACKET') 70 | ) { 71 | line += ','; 72 | } 73 | 74 | // Always add a space after each block. 75 | if (type !== 'OPERATOR' && nextType !== 'OPERATOR') { 76 | line += ' '; 77 | } 78 | 79 | 80 | }); 81 | displayText += line; 82 | 83 | // Add a newline to all but the last container. 84 | if (index < items.slice(skip).length - 1) { 85 | displayText += '\n'; 86 | } 87 | }); 88 | 89 | // NIcely format nodes connected to rels 90 | // @ts-ignore 91 | displayText = displayText.replaceAll(') -[', ')-['); 92 | // @ts-ignore 93 | displayText = displayText.replaceAll(') <-[', ')<-['); 94 | // @ts-ignore 95 | displayText = displayText.replaceAll(']-> (', ']->('); 96 | // @ts-ignore 97 | displayText = displayText.replaceAll(']- (', ']-('); 98 | 99 | // Nicely format nodes and nodes 100 | // @ts-ignore 101 | displayText = displayText.replaceAll(') (', '), ('); 102 | return displayText; 103 | } 104 | 105 | -------------------------------------------------------------------------------- /src/builder/logic/DefinitionsLogic.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { BlockType } from "../block/BlockType"; 3 | 4 | /** 5 | * Sanitizes a value so that it can be safely inserted into a Cypher string. 6 | * If the value is an object, it will be converted to a string and wrapped in 7 | * single quotes. Otherwise, the value is returned as is. 8 | * @param {any} value The value to sanitize 9 | * @returns {any} The sanitized value 10 | */ 11 | function sanitize(value: any) { 12 | if (typeof value === 'object') { 13 | return "'" + value.toString() + "'"; 14 | } 15 | return value; 16 | } 17 | 18 | export function insertNodeDefinition(elements: any, alias: string, label: string) { 19 | const id = crypto.randomUUID(); 20 | const text = ['(', alias, ':', label, ')']; 21 | elements[id] = { 22 | type: 'NODE', 23 | text: text 24 | }; 25 | return id; 26 | } 27 | 28 | export function insertRelationshipDefinition(elements: any, alias: string, type: string, direction: string = '') { 29 | 30 | const id = crypto.randomUUID(); 31 | const text = ['-', '[', alias, ':', type, ']', '-']; 32 | if (direction == 'INCOMING') { 33 | text[0] = '<-'; 34 | } 35 | if (direction == 'OUTGOING') { 36 | text[6] = '->'; 37 | } 38 | elements[id] = { 39 | type: 'RELATIONSHIP', 40 | text: text 41 | }; 42 | return id; 43 | } 44 | 45 | export function insertComparisonDefinition(elements: any, var1: string, var2: string, comparison: string = '=') { 46 | const id = crypto.randomUUID(); 47 | const text = [sanitize(var1), ' ' + comparison + ' ', sanitize(var2)]; 48 | elements[id] = { 49 | type: 'COMPARISON', 50 | text: text 51 | }; 52 | return id; 53 | } 54 | 55 | export function insertStringComparisonDefinition(elements: any, var1: string, var2: string, comparison = ' CONTAINS ') { 56 | const id = crypto.randomUUID(); 57 | const text = [sanitize(var1), comparison, sanitize(var2)]; 58 | elements[id] = { 59 | type: 'STRING_COMPARISON', 60 | text: text 61 | }; 62 | return id; 63 | } 64 | 65 | export function insertNullComparisonDefinition(elements: any, var1: string) { 66 | const id = crypto.randomUUID(); 67 | const text = [sanitize(var1), ' IS NULL ']; 68 | elements[id] = { 69 | type: 'NULL_COMPARISON', 70 | text: text 71 | }; 72 | return id; 73 | } 74 | 75 | export function insertVariableDefinition(elements: any, variable: string) { 76 | const id = crypto.randomUUID(); 77 | const text = [sanitize(variable)]; 78 | elements[id] = { 79 | type: 'VARIABLE', 80 | text: text 81 | }; 82 | return id; 83 | } 84 | 85 | export function insertTransformationDefinition(elements: any, variable: string, value = '') { 86 | const id = crypto.randomUUID(); 87 | const text = [sanitize(variable), value]; 88 | elements[id] = { 89 | type: 'TRANSFORMER', 90 | text: text 91 | }; 92 | return id; 93 | } 94 | 95 | 96 | export function insertClauseDefinition(elements: any, clause: string) { 97 | const id = crypto.randomUUID(); 98 | const text = [clause]; 99 | elements[id] = { 100 | type: 'CLAUSE', 101 | text: text 102 | }; 103 | return id; 104 | } 105 | 106 | export function insertOperatorDefinition(elements: any, clause: string) { 107 | const id = crypto.randomUUID(); 108 | const text = [clause]; 109 | elements[id] = { 110 | type: 'OPERATOR', 111 | text: text 112 | }; 113 | return id; 114 | } 115 | 116 | export function insertBracketDefinition(elements: any, clause: string) { 117 | const id = crypto.randomUUID(); 118 | const text = [clause]; 119 | elements[id] = { 120 | type: 'BRACKET', 121 | text: text 122 | }; 123 | return id; 124 | } 125 | 126 | export function createElementsDefinition(words: any[], properties?: any) { 127 | const elements = {}; 128 | words.map((word: any) => { 129 | const id = crypto.randomUUID(); 130 | const components = BlockType[properties?.type]?.components ?? []; 131 | 132 | const text = components.map((component: any) => { 133 | if (component.type === 'fixed' || component.type === 'select') { 134 | return component.text; 135 | } else if (component.type === 'name') { 136 | return word; 137 | } 138 | }) 139 | elements[id] = { 140 | type: properties?.type, 141 | text: text 142 | }; 143 | }); 144 | return elements; 145 | } 146 | 147 | export function insertFunctionDefinition(elements: any, name: string, alias: string = '') { 148 | const id = crypto.randomUUID(); 149 | const text = [name, '(', alias, ')']; 150 | elements[id] = { 151 | type: 'FUNCTION', 152 | text: text 153 | }; 154 | return id; 155 | } -------------------------------------------------------------------------------- /src/application/Tutorial.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Dialog, Button } from "@neo4j-ndl/react"; 3 | import { CursorArrowRaysIconOutline, CursorArrowRaysIconSolid, CursorArrowRippleIconSolid, LightBulbIconOutline, WrenchScrewdriverIconSolid } from "@neo4j-ndl/react/icons"; 4 | 5 | /** 6 | * A modal that appears when the user opens the application for the first time, 7 | * explaining the main features of the application. 8 | * It can also be opened inside the header. 9 | * 10 | * @param {Object} props - The props for this component. 11 | * @prop {boolean} isOpen - Whether the tutorial is open. 12 | * @prop {boolean} connected - Whether the user is currently connected to a database. 13 | * @prop {function} setIsOpen - A function to close the tutorial. 14 | * @prop {function} setConnectionOpen - A function to open the connection modal. 15 | * @prop {function} setConnected - A function to set the connected state. 16 | * @prop {function} setConnection - A function to set the connection state. 17 | */ 18 | export default function Tutorial(props: { isOpen: boolean, connected: boolean, setIsOpen: any, setConnectionOpen: any, setConnected: any, setConnection: any }) { 19 | const { isOpen, connected, setIsOpen, setConnectionOpen, setConnected, setConnection } = props; 20 | const handleClose = () => setIsOpen(false); 21 | 22 | return ( 23 | 27 | Visual Cypher Builder — Getting started 28 | A visual query editor for Neo4j queries (v0.1) 29 | 30 | 31 | Get started by adding blocks to your query: 32 |
33 |
    34 |
  • Click or drag a block from the left sidebar to move it to your query builder.
  • 35 |
  • Click a block from the wizard 🧙 to add it to the end of your query.
  • 36 |
37 |
38 | Then, build your query: 39 |
    40 |
  • Drag a block inside your query builder to construct the query.
  • 41 |
  • Shift-click a block inside your query to delete it. You can also drag it to the left of the screen.
  • 42 |
  • Click inside a block to change variables, labels, types and directions.
  • 43 |
44 |
45 | 46 | You can also use query templates: 47 |
    48 |
  • • Open up the Template menu and find a query template to start with.
  • 49 |
  • • Load it into your query builder and modify it to your needs!
  • 50 |
51 |
52 | 53 | For best results, connect to your own Neo4j instance. This tool is free to use under the Neo4j Labs conditions. 54 |

55 | 56 | 78 |    79 | 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/application/Application.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Builder from "../builder/Builder"; 3 | import Header from "./Header"; 4 | import Tutorial from "./Tutorial"; 5 | import ConnectionModal from "../connection/ConnectionModal"; 6 | import { setDriver } from "../connection/Driver"; 7 | import SamplingModal from "../builder/sampling/SamplingModal"; 8 | 9 | /** 10 | * This component renders the main application, consisting of a header, a query 11 | * builder, and a footer. It also handles the state of the connection to the Neo4j 12 | * instance and the sampling modal. 13 | * 14 | * @returns The JSX element representing the application. 15 | */ 16 | export default function Application() { 17 | 18 | const [tutorialIsOpen, setTutorialIsOpen] = useState(() => { 19 | // Initialize state from localStorage if available 20 | const savedTutorialIsOpen = localStorage.getItem("tutorial"); 21 | return savedTutorialIsOpen ? false : true; 22 | }); 23 | const [connected, setConnected] = useState(false); 24 | const [connection, setConnection] = useState(() => { 25 | // Initialize state from localStorage if available 26 | const savedConnection = localStorage.getItem("connection"); 27 | return savedConnection ? JSON.parse(savedConnection) : {}; 28 | }); 29 | const [connectionModalIsOpen, setConnectionModalIsOpen] = useState(false); 30 | const [samplingModalIsOpen, setSamplingModalIsOpen] = useState(false); 31 | 32 | const [schema, setSchema] = useState({}); 33 | const [queryTemplates, setQueryTemplates] = useState([]); 34 | 35 | const styles = { 36 | container: { 37 | display: "flex", 38 | flexDirection: "column", 39 | height: "100vh", // Full height of the viewport 40 | margin: 0, 41 | }, 42 | header: { 43 | // height: "100px", // Fixed height for the header 44 | backgroundColor: "#f4f4f4", 45 | }, 46 | main: { 47 | flex: 1, // Makes this element fill the remaining space 48 | backgroundColor: "#ddd", 49 | // overflow: "hidden", // Adds scrollbars if content is too tall 50 | }, 51 | }; 52 | 53 | 54 | // Store the active connecting in localstorage if it changes. 55 | useEffect(() => { 56 | localStorage.setItem("connection", JSON.stringify(connection)); 57 | }, [connection]); 58 | 59 | // If a new Neo4j connection is made, refresh the sample schema. 60 | useEffect(() => { 61 | if (connected) { 62 | setSamplingModalIsOpen(true); 63 | } 64 | }, [connected]); 65 | 66 | // Save tutorial state to localStorage. 67 | useEffect(() => { 68 | localStorage.setItem("tutorial", JSON.stringify(tutorialIsOpen)); 69 | }, [tutorialIsOpen]); 70 | 71 | // Attempt to establish a connection when the component mounts 72 | useEffect(() => { 73 | const initializeConnection = async () => { 74 | if (connection.uri && connection.user && connection.password) { 75 | const connectionURI = `${connection.protocol}://${connection.uri}:${connection.port}`; 76 | const isConnected = await setDriver(connectionURI, connection.user, connection.password); 77 | setConnected(isConnected); 78 | if (!isConnected) { 79 | setConnectionModalIsOpen(true); 80 | } 81 | } else { 82 | 83 | } 84 | }; 85 | initializeConnection(); 86 | }, []); 87 | 88 | return ( 89 |
93 |
94 |
setConnectionModalIsOpen(true)} 100 | onHelpClick={() => setTutorialIsOpen(true)} /> 101 |
102 | 103 |
104 | 109 |
110 | 117 | 118 | 125 | 126 | 134 | 135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /src/builder/block/components/BlockOption.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Select } from "@neo4j-ndl/react"; 3 | 4 | /** 5 | * A component that renders a dropdown select, or a non-interactive text label, depending on the value of `interactive`. 6 | * The component is used to render a part of a query block. 7 | * The component is supposed to be used in a `BlockComponent`. 8 | * @param {object} props - The component props. 9 | * @param {any[]} props.options - The options of the select. 10 | * @param {any} props.value - The value of the select. 11 | * @param {(newValue: string) => void} props.setValue - The function to call when the value of the select changes. 12 | * @param {(focus: boolean) => void} props.setInFocus - The function to call when the component is focused. 13 | * @param {boolean} props.interactive - Whether the component is interactive. 14 | * @param {object} [props.style] - The style of the component. 15 | */ 16 | export function BlockOption(props: { options: any[], value: any, setValue: (newValue: string) => void, setInFocus: (focus: boolean) => void, interactive: boolean, style?: any }) { 17 | const { options, value, setValue, interactive, style } = props; 18 | const baseOptions = options.map(option => ({ label: option, value: option })); 19 | 20 | // If the element is not rendered interactively, it's a 'fake' selector. This is for optimization reasons. 21 | if (!interactive) { 22 | return
40 | {value ? value : '...'} 41 |
42 | } else { 43 | 44 | // If it is interactive, render the normal selector. 45 | return { 120 | if (label?.includes('.')) { 121 | const [prefix, suffix] = label.split('.'); 122 | return ( 123 | 124 | {prefix} 125 | {'.' + suffix} 126 | 127 | ); 128 | } else { 129 | return label 130 | } 131 | 132 | }, 133 | 134 | onFocus: () => { 135 | setInFocus(true); 136 | props.setInFocus(true); 137 | }, 138 | onInputChange: (newValue, { action }) => { 139 | if (action === 'input-change') { 140 | setValue(newValue); 141 | } 142 | if (action === 'set-value') { 143 | setValue(value); 144 | } 145 | }, 146 | tabSelectsValue: false, 147 | tabIndex: -1, 148 | onChange: (value) => { 149 | setValue(value.value) 150 | setInFocus(false); 151 | props.setInFocus(false); 152 | }, 153 | onBlur: () => { 154 | value && setValue(value); 155 | setInFocus(false); 156 | props.setInFocus(false); 157 | }, 158 | classNames: { 159 | input: () => { 160 | return `${colorClass} ndl-input-container` 161 | } 162 | }, 163 | 164 | styles: { 165 | 166 | input: (provided) => ({ 167 | ...provided, 168 | marginRight: '-11px', 169 | marginLeft: '-1px', 170 | maxWidth: '400px', 171 | // overflow: 'hidden', 172 | content: '"' + value + '"', 173 | textOverflow: 'ellipsis', 174 | }), 175 | control: (provided) => ({ 176 | ...provided, 177 | fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace', 178 | fontSize: 'var(--font-size-body-large)', 179 | lineHeight: '18px', 180 | minHeight: '12px', 181 | opacity: '1 !important', 182 | background: '#fdfdfd', 183 | zIndex: 1 184 | }), 185 | 186 | dropdownIndicator: (provided) => ({ 187 | ...provided, 188 | display: 'none' 189 | }), 190 | indicatorSeparator: (provided) => ({ 191 | ...provided, 192 | display: 'none' 193 | }), 194 | 195 | placeholder: (provided) => ({ 196 | ...provided, 197 | // color: '#cb4b16 !important', 198 | marginRight: '-1px', 199 | paddingLeft: 0, 200 | paddingRight: 0, 201 | 202 | maxWidth: '400px', 203 | marginLeft: '-1px' 204 | }), 205 | 206 | menu: (provided) => ({ 207 | ...provided, 208 | width: 'auto', 209 | minWidth: '150px' 210 | }), 211 | option: (provided) => ({ 212 | ...provided, 213 | fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace', 214 | 215 | }) 216 | } 217 | }} /> 218 | ; 219 | } 220 | 221 | 222 | } 223 | -------------------------------------------------------------------------------- /src/builder/logic/VariablesLogic.ts: -------------------------------------------------------------------------------- 1 | 2 | import { BlockType } from "../block/BlockType"; 3 | import { FOOTER_CONTAINER_COUNT, SIDEBAR_CONTAINER_COUNT, VAR_LIMIT_SIDEBAR } from "./BuilderLogic"; 4 | import { createComparisons } from "./ComparisonsLogic"; 5 | import { insertVariableDefinition } from "./DefinitionsLogic"; 6 | 7 | 8 | /** 9 | * Updates the builder's variables based on a new set of variables. 10 | * The function takes the current state of the builder, an array of variables, and a schema. 11 | * It then constructs new element definitions for the variables and comparisons. 12 | * The function finally combines all definitions and returns the updated items and elements. 13 | * 14 | * @param {any[][]} oldItems - The current state of the builder, an array of arrays of element UUIDs. 15 | * @param {Object} oldElements - The current state of the builder, an object of element definitions. 16 | * @param {any[]} variables - The new set of variables. 17 | * @param {any} schema - The schema containing nodes and relationships. 18 | * @returns {Object} - The updated state of the builder, with properties `newItems` and `newElements`. 19 | * @property {Array>} newItems - The updated array of arrays of element UUIDs. 20 | * @property {Object} newElements - The updated object of element definitions. 21 | */ 22 | export function updateBuilderVariables(oldItems: any[][], oldElements: {}, variables: any[], schema: any) { 23 | 24 | let newElements = { ...oldElements }; 25 | const newItems = [...oldItems]; 26 | 27 | // Iterate over oldItems and delete each UUID from newElements 28 | newItems[3].forEach((uuid: string | number) => { // Comparisons 29 | if (newElements.hasOwnProperty(uuid)) { 30 | delete newElements[uuid]; 31 | } 32 | }); 33 | newItems[4].forEach((uuid: string | number) => { // Advanced Comparisons 34 | if (newElements.hasOwnProperty(uuid)) { 35 | delete newElements[uuid]; 36 | } 37 | }); 38 | newItems[6].forEach((uuid: string | number) => { // Variables 39 | if (newElements.hasOwnProperty(uuid)) { 40 | delete newElements[uuid]; 41 | } 42 | }); 43 | // Variables 44 | [...variables].reverse(); 45 | const uniqueVariableElements = constructComplexVariables(variables, schema); 46 | newItems[6] = [...Object.keys(uniqueVariableElements)].slice(0, VAR_LIMIT_SIDEBAR); 47 | // Comparisons 48 | const { comparisonElements, advancedComparisonElements } = createComparisons(variables, schema); 49 | newItems[3] = [...Object.keys(comparisonElements)].slice(0, VAR_LIMIT_SIDEBAR); 50 | newItems[4] = [...Object.keys(advancedComparisonElements)].slice(0, VAR_LIMIT_SIDEBAR); 51 | 52 | // Merge all element definitions 53 | newElements = { ...newElements, ...uniqueVariableElements, ...comparisonElements, ...advancedComparisonElements }; 54 | 55 | return { newItems, newElements } 56 | } 57 | 58 | /** 59 | * Reusable function to go over all variables, check if some are nodes/rels, and add the properties for each as seperate variable. 60 | * i.e. "n" --> "n.name", "n.born" 61 | */ 62 | export function constructComplexVariables(variables: any[], schema: any, maxNested = 1000, addDescriptors = false) { 63 | const variableElements = {}; 64 | variables.forEach(variable => { 65 | insertVariableDefinition(variableElements, variable.text); 66 | }); 67 | variables.forEach(variable => { 68 | variable.classes.forEach((className) => { 69 | if (className == 'label' && schema.nodes) { 70 | variable.types.forEach((variableType) => { 71 | const schemaDefinition = schema.nodes[variableType]; 72 | schemaDefinition && schemaDefinition.properties.slice(0, maxNested).forEach((property: any) => { 73 | insertVariableDefinition(variableElements, variable.text + '.' + property.key); 74 | }); 75 | if (addDescriptors) { 76 | if (schemaDefinition && schemaDefinition.properties.map(p => p.key).includes('name')) { 77 | insertVariableDefinition(variableElements, variable.text + '.name'); 78 | } 79 | if (schemaDefinition && schemaDefinition.properties.map(p => p.key).includes('title')) { 80 | insertVariableDefinition(variableElements, variable.text + '.title'); 81 | } 82 | } 83 | }); 84 | } 85 | if (className == 'reltype' && schema.relationships) { 86 | variable.types.forEach((variableType) => { 87 | const schemaDefinition = schema.relationships[variableType]; 88 | schemaDefinition && schemaDefinition.properties.slice(0, maxNested).forEach((property: any) => { 89 | insertVariableDefinition(variableElements, variable.text + '.' + property.key); 90 | }); 91 | }); 92 | } 93 | }); 94 | }); 95 | 96 | const uniqueElements = Object.fromEntries( 97 | // @ts-ignore 98 | Object.entries(variableElements).filter(([, value], index, self) => !self.slice(0, index).some(([_, v]) => v.text.join() === value.text.join()) 99 | ) 100 | ); 101 | return uniqueElements; 102 | } 103 | 104 | /** 105 | * Extracts and categorizes variables from provided items and elements. 106 | * 107 | * This function analyzes blocks of items and elements to infer variables and 108 | * their associated types and classes. It processes the blocks to identify 109 | * and map out variables, labels, and relationship types, assigning types to 110 | * variables based on detected patterns and rules. 111 | * 112 | * @param {any[]} items - An array of items containing block definitions. 113 | * @param {{ [x: string]: any }} elements - A mapping of element UUIDs to their data. 114 | * @returns {Array} - Returns a list of objects, each representing a unique variable 115 | * with its associated classes and types. 116 | */ 117 | export function extractVariables(items: any[], elements: { [x: string]: any; }) { 118 | 119 | // Infer variables by looking at all the blocks 120 | const builderElements = items.slice(SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT).flat().map((uuid: any) => elements[uuid]); 121 | const typedBuilderElements = builderElements.map((element: any) => { return { 'text': element?.text, 'type': BlockType[element?.type]?.components }; }); 122 | 123 | const variablesAndClasses = typedBuilderElements.map((item: any) => { 124 | return item?.text?.map((value: any, index: string | number) => ({ 125 | text: value, 126 | class: item.type[index].class 127 | })).filter((item: { class: undefined; }) => item.class !== undefined); 128 | }); 129 | 130 | const pairsList = variablesAndClasses.map((subarray: any[]) => { 131 | // Extract labels or reltypes 132 | if (!subarray) { 133 | return []; 134 | } 135 | const typeItems = subarray.filter((item: { class: string; }) => item.class === 'label' || item.class === 'reltype'); 136 | const types = typeItems.map((item: { text: any; }) => item.text); 137 | const newClass = typeItems.length > 0 ? typeItems[0].class : 'text'; // Use label/reltype or default to 'text' 138 | 139 | // Determine type assignments 140 | return subarray 141 | .filter((item: { class: string; }) => item.class !== 'label' && item.class !== 'reltype') // Exclude labels and reltypes 142 | .map((item: { class: string; }) => { 143 | if (item.class === 'variable') { 144 | if (types.length === 0) { 145 | // Rule 1: No label or reltype -> type is 'string' 146 | return { ...item, class: newClass, type: 'text' }; 147 | } else if (types.length === 1) { 148 | // Rule 2: Single label or reltype -> assign that type 149 | return { ...item, class: newClass, type: types[0] }; 150 | } else { 151 | // Rule 3: Multiple labels or reltypes -> assign full set of types 152 | return { ...item, class: newClass, type: types.join(', ') }; 153 | } 154 | } 155 | return item; // Non-variable items remain unchanged 156 | }); 157 | }); 158 | 159 | const flatList = pairsList.flat() 160 | // @ts-ignore 161 | .filter((item) => isNaN(item.text)) 162 | // @ts-ignore 163 | .filter((item) => !item.text.includes('"') && !item.text.includes("'") && !item.text.includes('`')); 164 | const merged = {}; 165 | 166 | flatList.forEach((item: { text: string | number; class: any; type: any; }) => { 167 | if (!merged[item.text]) { 168 | merged[item.text] = { text: item.text, classes: [], types: [] }; 169 | } 170 | 171 | if (!merged[item.text].classes.includes(item.class)) { 172 | merged[item.text].classes.push(item.class); 173 | } 174 | 175 | if (!merged[item.text].types.includes(item.type)) { 176 | merged[item.text].types.push(item.type); 177 | } 178 | }); 179 | 180 | return Object.values(merged); 181 | } -------------------------------------------------------------------------------- /src/connection/ConnectionModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, TextInput, Banner, Dropzone, Select } from '@neo4j-ndl/react'; 2 | import { useEffect, useState } from 'react'; 3 | import { setDriver } from './Driver'; 4 | 5 | interface Message { 6 | type: 'success' | 'info' | 'warning' | 'danger' | 'neutral'; 7 | content: string; 8 | } 9 | 10 | interface ConnectionModalProps { 11 | open: boolean; 12 | connection: any; 13 | setOpen: (arg: boolean) => void; 14 | setConnection: (connection: any) => void; 15 | setConnected: (status: boolean) => void; 16 | message?: Message; 17 | } 18 | 19 | /** 20 | * The ConnectionModal component manages the connection to a Neo4j database. 21 | * 22 | * @param {boolean} open - Determines if the modal is open or closed. 23 | * @param {Object} connection - Object containing the connection details. 24 | * @param {Function} setOpen - Callback to set the open state of the modal. 25 | * @param {Function} setConnected - Callback to set the connected state. 26 | * @param {Function} setConnection - Callback to update the connection state. 27 | * @param {Message} message - Message to show in the modal, or null to show no message. 28 | * @returns {JSX.Element} The rendered ConnectionModal component. 29 | */ 30 | export default function ConnectionModal({ 31 | open, 32 | connection, 33 | setOpen, 34 | setConnected, 35 | setConnection, 36 | message, 37 | }: ConnectionModalProps) { 38 | const protocols = ['neo4j', 'neo4j+s', 'neo4j+ssc']; 39 | const [protocol, setProtocol] = useState(connection?.protocol ? connection.protocol : 'neo4j+s'); 40 | const [URI, setURI] = useState(connection?.uri ? connection.uri : 'localhost'); 41 | const [port, setPort] = useState(connection?.port ? connection.port : '7687'); 42 | const [database, setDatabase] = useState(connection?.database ? connection.database : 'neo4j'); 43 | const [username, setUsername] = useState(connection?.user ? connection.user : 'neo4j'); 44 | const [password, setPassword] = useState(connection?.password ? connection.password : ''); 45 | const [connectionMessage, setMessage] = useState(null); 46 | 47 | const [isLoading, setIsLoading] = useState(false); 48 | 49 | 50 | useEffect(() => { 51 | // @ts-ignore 52 | setMessage(''); 53 | }, [open]) 54 | 55 | const parseAndSetURI = (uri: string) => { 56 | const uriParts = uri.split('://'); 57 | const uriHost = uriParts.pop() || URI; 58 | setURI(uriHost); 59 | const uriProtocol = uriParts.pop() || protocol; 60 | setProtocol(uriProtocol); 61 | const uriPort = Number(uriParts.pop()) || port; 62 | setPort(uriPort); 63 | }; 64 | 65 | const handleHostPasteChange: React.ClipboardEventHandler = (event) => { 66 | event.clipboardData.items[0]?.getAsString((value) => { 67 | parseAndSetURI(value); 68 | }); 69 | }; 70 | 71 | 72 | function submitConnection() { 73 | setIsLoading(true); 74 | const connectionURI = `${protocol}://${URI}${URI.split(':')[1] ? '' : `:${port}`}`; 75 | setDriver(connectionURI, username, password).then((isSuccessful) => { 76 | // setConnectionStatus(isSuccessful); 77 | if (isSuccessful) { 78 | setConnected(true); 79 | setConnection({ 80 | protocol: protocol, 81 | uri: URI, 82 | port: port, 83 | database: database, 84 | user: username, 85 | password: password, 86 | }); 87 | setOpen(false); 88 | setIsLoading(false); 89 | } else { 90 | setMessage({ 91 | type: 'danger', 92 | content: 'Unable to connect to your database. Please check your credentials.', 93 | }); 94 | setIsLoading(false); 95 | } 96 | 97 | }); 98 | } 99 | 100 | return ( 101 | <> 102 | { 105 | setMessage(null); 106 | setOpen(false) 107 | }}> 108 | Connect to Neo4j 109 | 110 | {message && {message.content}} 111 | {connectionMessage && {connectionMessage.content}} 112 | 113 |
114 |