├── 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 | {
72 | setConnection({});
73 | setConnected(false);
74 | setOpen(false)
75 | }}>Disconnect : 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 | {
57 |
58 | setConnected(false);
59 | handleClose();
60 | setConnection({
61 | protocol: 'neo4j',
62 | uri: 'demo.neo4jlabs.com',
63 | port: '7687',
64 | user: 'movies',
65 | password: 'movies',
66 | database: 'movies'
67 | });
68 | if (connected) {
69 | setConnected(false);
70 | setConnectionOpen(true);
71 | } else {
72 | setConnected(true);
73 | }
74 |
75 | }} size="medium">
76 | Try with Movies
77 |
78 |
79 | {
80 | handleClose();
81 | setConnected(false);
82 | setConnectionOpen(true);
83 | }} fill="outlined" color="primary" size="medium">
84 | Use your own database
85 |
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 {
55 | props.setInFocus(true);
56 | },
57 | onChange: (value) => {
58 | setValue(value.value)
59 | },
60 | onBlur: () => {
61 | value && setValue(value);
62 | props.setInFocus(false);
63 | },
64 | styles: {
65 | container: (provided) => ({
66 | marginRight: '-2px',
67 | marginLeft: '-7px',
68 | ...provided
69 | }),
70 | input: (provided) => ({
71 | ...provided,
72 | marginLeft: '-5px',
73 | marginRight: '-5px',
74 | }),
75 | control: (provided) => ({
76 | ...provided,
77 | background: 'transparent',
78 | border: style.border ? style.border : '1px solid transparent',
79 | paddingLeft: '-4px',
80 | paddingRight: '-4px',
81 | marginLeft: '4px',
82 | fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace',
83 | fontSize: 'var(--font-size-body-large)',
84 | lineHeight: '18px',
85 | minHeight: '22px',
86 | opacity: '1 !important',
87 | color: 'black',
88 | }),
89 |
90 | dropdownIndicator: (provided) => ({
91 | ...provided,
92 | display: 'none'
93 | }),
94 | indicatorSeparator: (provided) => ({
95 | ...provided,
96 | display: 'none'
97 | }),
98 |
99 | placeholder: (provided) => ({
100 | ...provided,
101 | color: 'black',
102 | margin: 0,
103 | paddingLeft: '-8px',
104 | }),
105 | menu: (provided) => ({
106 | ...provided,
107 | fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace',
108 | minWidth: '80px'
109 | }),
110 | option: (provided) => ({
111 | ...provided,
112 | fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace',
113 | })
114 | }
115 | }} />;
116 | }
117 |
118 |
119 | }
120 |
--------------------------------------------------------------------------------
/src/application/Header.tsx:
--------------------------------------------------------------------------------
1 | import { NewspaperIconOutline, QuestionMarkCircleIconOutline } from '@neo4j-ndl/react/icons';
2 | import { Typography, IconButton, Tabs, Logo, Tooltip } from '@neo4j-ndl/react';
3 | import ConnectButton from '../connection/ConnectionButton';
4 |
5 | /**
6 | * Renders the application header with navigation and connection options.
7 | *
8 | * @param {string} title - The title to display in the header.
9 | * @param {string[]} [navItems=[]] - An array of navigation item labels.
10 | * @param {string} [activeNavItem=navItems[0]] - The currently active navigation item.
11 | * @param {(activeNavItem: string) => void} [setActiveNavItem=() => {}] - Callback to set the active navigation item.
12 | * @param {boolean} [useNeo4jConnect=false] - Flag to determine if Neo4j connection button should be rendered.
13 | * @param {any} connection - The current connection object.
14 | * @param {boolean} [connected=false] - Connection status flag.
15 | * @param {(connected: boolean) => void} [setConnected=() => {}] - Callback to set connection status.
16 | * @param {() => void} [openConnectionModal=() => {}] - Callback to open the connection modal.
17 | * @param {boolean} [userHeader=true] - Flag to determine if user-specific header options should be shown.
18 | * @param {() => void} [onHelpClick=() => {}] - Callback for help button click.
19 | *
20 | * @returns {JSX.Element} The rendered header component.
21 | */
22 | export default function Header({
23 | title,
24 | navItems = [],
25 | activeNavItem = navItems[0],
26 | setActiveNavItem = () => { },
27 | useNeo4jConnect = false,
28 | connected = false,
29 | connection = {},
30 | setConnected = () => { },
31 | openConnectionModal = () => { },
32 | userHeader = true,
33 | onHelpClick = () => { },
34 | }: {
35 | title: string;
36 | navItems?: string[];
37 | activeNavItem?: string;
38 | setActiveNavItem?: (activeNavItem: string) => void;
39 | useNeo4jConnect?: boolean;
40 | connection: any;
41 | connected?: boolean;
42 | setConnected?: (connected: boolean) => void;
43 | openConnectionModal?: () => void;
44 | userHeader?: boolean;
45 | onHelpClick?: () => void;
46 | }) {
47 |
48 | return (
49 |
50 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | {title}
64 |
65 |
66 | {title}
67 |
68 |
69 |
70 |
71 |
72 | setActiveNavItem(e)} value={activeNavItem}>
73 | {navItems.map((item) => (
74 |
75 | {item}
76 |
77 | ))}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | Help
91 |
92 |
93 |
94 | {
95 | window.open('https://neo4j.com/docs/cypher-cheat-sheet/5/all/', '_blank');
96 | }} >
97 |
98 |
99 |
100 | Cypher Guide
101 |
102 |
103 |
104 | {userHeader ? (
105 |
106 | {
107 | setConnected(false);
108 | openConnectionModal();
109 | }} />
110 |
111 | ) : null}
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
--------------------------------------------------------------------------------
/src/builder/block/BlockType.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * A collection of block types.
3 | *
4 | * A block type is an object that defines a set of components and their layout.
5 | * Each component is an object with a `type` property, and possibly a displayed text.
6 | */
7 | export const BlockType = {
8 | 'CLAUSE': {
9 | components: [
10 | {
11 | type: 'name'
12 | }
13 | ]
14 | },
15 | 'OPERATOR': {
16 | components: [
17 | {
18 | type: 'name'
19 | }
20 | ]
21 | },
22 | 'BRACKET': {
23 | components: [
24 | {
25 | type: 'name'
26 | }
27 | ]
28 | },
29 | 'NODE': {
30 | components: [
31 | {
32 | type: 'fixed',
33 | text: '('
34 | },
35 | {
36 | type: 'select',
37 | text: '',
38 | id: 'alias',
39 | class: 'variable'
40 | },
41 | {
42 | type: 'fixed',
43 | text: ':',
44 | dependson: 3
45 | },
46 | {
47 | type: 'select',
48 | text: '',
49 | id: 'label',
50 | class: 'label',
51 | },
52 | {
53 | type: 'fixed',
54 | text: ')'
55 | },
56 | ]
57 | },
58 | 'RELATIONSHIP': {
59 | components: [
60 | {
61 | type: 'option',
62 | text: '-',
63 | options: ['-', '<-']
64 | },
65 | {
66 | type: 'fixed',
67 | text: '['
68 | },
69 | {
70 | type: 'select',
71 | text: '',
72 | id: 'alias',
73 | class: 'variable'
74 | },
75 | {
76 | type: 'fixed',
77 | text: ':',
78 | dependson: 4
79 | },
80 | {
81 | type: 'select',
82 | text: '',
83 | id: 'type',
84 | class: 'reltype'
85 | },
86 | {
87 | type: 'fixed',
88 | text: ']'
89 | },
90 | {
91 | type: 'option',
92 | text: '-',
93 | options: ['-', '->']
94 | },
95 | ]
96 | },
97 | 'COMPARISON': {
98 | components: [
99 | {
100 | type: 'select',
101 | text: '',
102 | id: 'var1',
103 | class: 'variable'
104 | },
105 | {
106 | type: 'option',
107 | text: ' = ',
108 | options: [' < ', ' <= ', ' = ', ' >= ', ' > ', ' <> ']
109 | },
110 | {
111 | type: 'select',
112 | text: '',
113 | id: 'var2',
114 | class: 'variable'
115 | }
116 | ]
117 | },
118 | 'FUNCTION': {
119 | components: [
120 | {
121 | type: 'fixed',
122 | text: '',
123 | id: 'name',
124 | },
125 | {
126 | type: 'fixed',
127 | text: '(',
128 | },
129 | {
130 | type: 'select',
131 | text: '',
132 | id: 'param',
133 | class: 'variable'
134 | },
135 | {
136 | type: 'fixed',
137 | text: ')'
138 | },
139 | ]
140 | },
141 | 'TRANSFORMER': {
142 | components: [
143 | {
144 | type: 'fixed',
145 | text: 'AS '
146 | },
147 | {
148 | type: 'select',
149 | text: '',
150 | id: 'param',
151 | class: 'variable'
152 | }
153 | ]
154 | },
155 | 'NULL_COMPARISON': {
156 | components: [
157 | {
158 | type: 'select',
159 | text: '',
160 | id: 'var',
161 | class: 'variable'
162 | },
163 | {
164 | type: 'option',
165 | text: ' IS NULL ',
166 | options: [' IS NULL ', ' IS NOT NULL ']
167 | }
168 | ]
169 | },
170 | 'STRING_COMPARISON': {
171 | components: [
172 | {
173 | type: 'select',
174 | text: '',
175 | id: 'var1',
176 | class: 'variable'
177 | },
178 | {
179 | type: 'option',
180 | text: ' CONTAINS ',
181 | options: [' CONTAINS ', ' STARTS WITH ', ' ENDS WITH ', ' =~ ']
182 | },
183 | {
184 | type: 'select',
185 | text: '',
186 | id: 'var2',
187 | class: 'variable'
188 | }
189 | ]
190 | },
191 | 'VARIABLE': {
192 | components: [
193 | {
194 | type: 'select',
195 | text: '',
196 | id: 'variable',
197 | class: 'variable'
198 | },
199 | ]
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/builder/logic/SchemaLogic.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 | /**
4 |
5 | This is an example schema definition.
6 | A schema is built by sampling the nodes and rels inside the database.
7 |
8 | const schema = {
9 | nodes:
10 | 'Person': {
11 | properties: [
12 | {
13 | key: 'title',
14 | type: 'string',
15 | value: 'The Matrix'
16 | }
17 | ],
18 | outgoingRelationships: [
19 | 'ACTED_IN'
20 | ],
21 | incomingRelationships: [
22 | 'FOLLOWS'
23 | ]
24 | }
25 |
26 | relationships:
27 | 'ACTED_IN': {
28 | properties: [
29 | {
30 | key: 'role',
31 | type: 'string',
32 | value: 'Morpheus'
33 | }
34 | ],
35 | startNodes: [
36 | 'Person'
37 | ],
38 | endNodes: [
39 | 'Movie'
40 | ]
41 | }
42 | }
43 | **/
44 |
45 |
46 | /**
47 | * Constructs a schema definition from the given labels, relationship types, properties, cardinalities, and indexes.
48 | *
49 | * This function builds a schema object that includes node and relationship definitions.
50 | * Each node and relationship contains properties with metadata such as type, value, and whether
51 | * they are indexed. It also maps node relationships and cardinalities, indicating the direction
52 | * of relationships between nodes.
53 | *
54 | * @param {any[]} labels - An array of node labels in the graph.
55 | * @param {any[]} relTypes - An array of relationship types in the graph.
56 | * @param {any[]} nodeProperties - An array of node properties, each item containing a label and properties.
57 | * @param {any[]} relProperties - An array of relationship properties, each item containing a relType and properties.
58 | * @param {Object[]} cardinalities - An array of objects defining start and end nodes for each relationship type.
59 | * @param {any} indexes - Index information to determine which properties are indexed.
60 | * @returns {Object} - The constructed schema with nodes and relationships data.
61 | */
62 |
63 | export function buildSchemaDefinition(labels: any[], relTypes: any[], nodeProperties: any[], relProperties: any[], cardinalities: { start: any; relType: any; end: any; }[], indexes) {
64 | const schema = {
65 | nodes: {},
66 | relationships: {}
67 | };
68 |
69 | // Indexes
70 | // Extract distinct triplets
71 | const triplets = [
72 | ...new Set(
73 | indexes.flatMap(({ entityType, labelsOrTypes, properties }) =>
74 | labelsOrTypes.flatMap(label =>
75 | properties.map(prop => JSON.stringify([entityType, label, prop]))
76 | )
77 | )
78 | )
79 | // @ts-ignore
80 | ].map(t => JSON.parse(t)); // Parse back into arrays
81 |
82 | // Build nodes schema
83 | labels.forEach((label: string | number) => {
84 | const nodeProps = nodeProperties.find((node: { label: any; }) => node.label === label)?.properties || {};
85 | const properties = Object.keys(nodeProps).map(key => {
86 | const value = nodeProps[key];
87 | return {
88 | key,
89 | type: typeof value === 'object' && value.low !== undefined ? 'integer' : typeof value,
90 | value: value.low !== undefined ? value.low : value,
91 | indexed: triplets.some(triplet => triplet[0] === 'NODE' && triplet[1] === label && triplet[2] === key)
92 | };
93 | // @ts-ignore
94 | }).sort((a, b) => b.indexed - a.indexed); // Sort by indexed=true first
95 |
96 | schema.nodes[label] = {
97 | properties,
98 | outgoingRelationships: [],
99 | incomingRelationships: []
100 | };
101 | });
102 |
103 | // Build relationships schema
104 | relTypes.forEach((relType: string | number) => {
105 | const relProps = relProperties.find((rel: { relType: any; }) => rel.relType === relType)?.properties || {};
106 | const properties = Object.keys(relProps).map(key => {
107 | const value = relProps[key];
108 | const type = Array.isArray(value) ? 'array' : typeof value;
109 | return {
110 | key,
111 | type: Array.isArray(value) ? 'array' : typeof value,
112 | value: Array.isArray(value) ? value : value.low !== undefined ? value.low : value,
113 | // @ts-ignore
114 | indexed: triplets.some(triplet => triplet[0] === 'RELATIONSHIP' && triplet[1] === type && triplet[2] === key)
115 | };
116 | // @ts-ignore
117 | }).sort((a, b) => b.indexed - a.indexed); // Sort by indexed=true first
118 |
119 | schema.relationships[relType] = {
120 | properties,
121 | startNodes: [],
122 | endNodes: []
123 | };
124 | });
125 |
126 | // Map cardinalities to nodes and relationships
127 | cardinalities.forEach(({ start, relType, end }) => {
128 | schema.nodes[start].outgoingRelationships.push(relType);
129 | schema.nodes[end].incomingRelationships.push(relType);
130 | schema.relationships[relType].startNodes.push(start);
131 | schema.relationships[relType].endNodes.push(end);
132 | });
133 | return schema;
134 | }
135 |
136 |
--------------------------------------------------------------------------------
/src/builder/logic/ComparisonsLogic.tsx:
--------------------------------------------------------------------------------
1 | import { insertComparisonDefinition, insertFunctionDefinition, insertNullComparisonDefinition, insertStringComparisonDefinition } from "./DefinitionsLogic";
2 |
3 |
4 | /**
5 | * Create a set of comparisons for each variable.
6 | * the schema holds for each node/reltype, the properties.
7 | * The property in the schema will have this info: {key: 'title', type: 'string', value: 'The Matrix', indexed: true}
8 | * The value is a sample value to be used in the comparison.
9 | * For string values, we can use the sample string as an example for the comparison.
10 | * For numeric values, we can use the sample value as the comparison value, and generate > < = as three options.
11 | */
12 | export function createComparisons(variables, schema, indexedOnly = false, emptyComparisons = true) {
13 | const comparisonElements = {};
14 | const advancedComparisonElements = {};
15 |
16 | // Add simple comparisons
17 | emptyComparisons && variables.forEach(variable => {
18 | variable.classes.forEach(() => {
19 | insertComparisonDefinition(comparisonElements, variable.text, ``, '=');
20 | });
21 | });
22 | // Iterate through each variable to create comparisons based on schema
23 | variables.forEach(variable => {
24 | variable.classes.forEach((className) => {
25 | // Handle node comparisons
26 | if (className === 'label' && schema.nodes) {
27 | variable.types.forEach(variableType => {
28 | const schemaDefinition = schema.nodes[variableType];
29 | if (schemaDefinition) {
30 | schemaDefinition.properties.forEach((property) => {
31 | const propertyKey = `${variable.text}.${property.key}`;
32 | if (indexedOnly && !property.indexed) return;
33 | addPropertyComparisons(comparisonElements, propertyKey, property);
34 | addAdvancedPropertyComparisons(advancedComparisonElements, propertyKey, property);
35 | });
36 | }
37 | });
38 | }
39 |
40 | // Handle relationship comparisons
41 | if (className === 'reltype' && schema.relationships) {
42 | variable.types.forEach(variableType => {
43 | const schemaDefinition = schema.relationships[variableType];
44 | if (schemaDefinition) {
45 | schemaDefinition.properties.forEach((property) => {
46 | const propertyKey = `${variable.text}.${property.key}`;
47 | if (indexedOnly && !property.indexed) return;
48 | addPropertyComparisons(comparisonElements, propertyKey, property);
49 | addAdvancedPropertyComparisons(advancedComparisonElements, propertyKey, property);
50 | });
51 | }
52 | });
53 | }
54 | });
55 | });
56 |
57 | return { comparisonElements, advancedComparisonElements };
58 | }
59 |
60 | /**
61 | * Generates comparisons based on a given property.
62 | * @param {Object} comparisonElements - The object to store the generated comparisons.
63 | * @param {string} propertyKey - The key of the property to generate comparisons for.
64 | * @param {Object} property - The property definition from the schema.
65 | */
66 | function addPropertyComparisons(comparisonElements, propertyKey, property) {
67 | // Generate comparisons based on property type
68 | if (property.type === 'string') {
69 | insertComparisonDefinition(comparisonElements, propertyKey, `"${property.value}"`, '=');
70 | } else if (property.type === 'integer') {
71 | // insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '>');
72 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '=');
73 | } else if (property.type === 'array') {
74 | // insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '>');
75 | insertComparisonDefinition(comparisonElements, propertyKey, `["${property.value}"]`, '=');
76 | } else {
77 | // Default to equality comparison for unsupported types
78 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '=');
79 | }
80 | }
81 |
82 | /**
83 | * Generates advanced comparisons based on a given property.
84 | * @param {Object} comparisonElements - The object to store the generated comparisons.
85 | * @param {string} propertyKey - The key of the property to generate comparisons for.
86 | * @param {Object} property - The property definition from the schema.
87 | */
88 | function addAdvancedPropertyComparisons(comparisonElements, propertyKey, property) {
89 | // Generate comparisons based on property type
90 | if (property.type === 'string') {
91 | insertComparisonDefinition(comparisonElements, propertyKey, `"${property.value}"`, '<>');
92 | insertStringComparisonDefinition(comparisonElements, propertyKey, `"${property.value}"`, ' CONTAINS ');
93 | insertStringComparisonDefinition(comparisonElements, propertyKey, `"${property.value}"`, ' STARTS WITH ');
94 | insertNullComparisonDefinition(comparisonElements, propertyKey);
95 |
96 |
97 | } else if (property.type === 'integer') {
98 | // insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '>');
99 | // insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '=');
100 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '<>');
101 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '<');
102 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '>');
103 | } else if (property.type === 'array') {
104 | // insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '>');
105 | insertFunctionDefinition(comparisonElements, 'size', propertyKey);
106 | } else {
107 | // Default to equality comparison for unsupported types
108 | insertComparisonDefinition(comparisonElements, propertyKey, `${property.value}`, '=');
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/builder/templates/TemplatesModal.tsx:
--------------------------------------------------------------------------------
1 | import { DataGrid, Dialog, IconButton, TextInput } from "@neo4j-ndl/react";
2 | import { PlayCircleIconOutline } from "@neo4j-ndl/react/icons";
3 | import { useReactTable, getCoreRowModel, getSortedRowModel, ColumnDef } from "@tanstack/react-table";
4 | import { useEffect, useMemo, useState } from "react";
5 | import TemplateContainer from "../container/TemplateContainer";
6 | import { FOOTER_CONTAINER_COUNT, SIDEBAR_CONTAINER_COUNT } from "../logic/BuilderLogic";
7 |
8 | /**
9 | * A modal component that displays a table of query templates.
10 | *
11 | * This modal contains a table with three columns: description, query, and use.
12 | * The description column displays the template description, the query column displays the template query, and the use column contains a button to use the template.
13 | * The table is searchable via a text input.
14 | *
15 | * @param {boolean} isOpen - Determines if the modal is open or closed.
16 | * @param {function} setIsOpen - Callback to set the open state of the modal.
17 | * @param {array} templates - The templates to display in the table.
18 | * @param {object} elements - The elements of the query builder.
19 | * @param {array} items - The items of the query builder.
20 | * @param {function} setElements - Callback to update the elements of the query builder.
21 | * @param {function} setItems - Callback to update the items of the query builder.
22 | * @returns {JSX.Element} The rendered TemplatesModal component.
23 | */
24 | export default function TemplatesModal(props: { isOpen: boolean; setIsOpen: any; templates: any; elements: any; items: any; setElements: any, setItems: any }) {
25 | const { isOpen, setIsOpen, templates, elements, items, setElements, setItems } = props;
26 | const [filterText, setFilterText] = useState("");
27 |
28 | const handleClose = () => setIsOpen(false);
29 |
30 | const baseData = templates;
31 | const [filteredData, setFilteredData] = useState(baseData);
32 |
33 | useEffect(() => {
34 | setFilteredData(baseData
35 | .filter(
36 | (row) =>
37 | row?.cypher?.toLowerCase().includes(filterText.toLowerCase()) ||
38 | row?.description?.toLowerCase().includes(filterText.toLowerCase()))
39 | )
40 | }, [filterText, baseData]);
41 | const columns: ColumnDef[] = [
42 | {
43 | accessorKey: "description",
44 | header: "Description",
45 | size: 450,
46 | cell: ({ row }) => (
47 |
48 | {row.original.richDescription}
49 |
50 | ),
51 | },
52 | {
53 | accessorKey: "query",
54 | header: "Query",
55 | // className: "htLef htTop",
56 | // size: 650,
57 | cell: ({ row }) => (
58 |
59 | {
60 | row.original.items.map((container, index) => {
61 | return
62 | })
63 | }
64 |
65 | ),
66 | },
67 | {
68 | id: "use", // Unique ID for this column
69 | header: "Use",
70 | size: 100,
71 | cell: ({ row }) => (
72 |
73 |
{
78 | setElements({ ...elements, ...row.original.elements});
79 | const newItems = items.slice(0,SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT);
80 | row.original.items.map( (item, index) => {
81 | newItems.push(item);
82 | })
83 | newItems.push([]);
84 | setItems(newItems);
85 | setIsOpen(false);
86 | }}
87 | >
88 |
89 |
90 |
91 | ),
92 | },
93 | ];
94 |
95 | // Memoize table instance
96 | const table =
97 | useReactTable({
98 | data
99 | : filteredData
100 | ,
101 | columns,
102 | enableSorting: false,
103 | getCoreRowModel: getCoreRowModel(),
104 | getSortedRowModel: getSortedRowModel(),
105 | });
106 |
107 | return (
108 | {
117 | // if (!e.relatedTarget) {
118 | // handleClose();
119 | // }
120 | // },
121 | }}
122 | onClose={handleClose}
123 | isOpen={isOpen}
124 | size={"unset"}
125 | >
126 | Query Templates ({templates?.length})
127 |
128 | Get started with a template to start building your query.
129 |
130 |
131 | {/* Filter Textbox */}
132 |
133 | setFilterText(e.target.value)}
143 | />
144 |
145 |
146 | {/* Data Grid */}
147 | {isOpen ? : <> >}
160 |
161 |
162 | );
163 | }
164 |
--------------------------------------------------------------------------------
/src/builder/block/Block.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { Tag, Tooltip } from "@neo4j-ndl/react";
3 | import { BlockSelect } from "./components/BlockSelect";
4 | import { BlockType } from "./BlockType";
5 | import { BlockOption } from "./components/BlockOption";
6 | import { useState } from "react";
7 |
8 | /**
9 | * The main component for rendering a query block.
10 | *
11 | * A query block is a collection of components that are laid out horizontally.
12 | * Components can be interactive (selectors) or static (text spans).
13 | * The list components are determined by the `BlockType` of the element.
14 | *
15 | * @param {object} props - The component props.
16 | * @param {any} props.id - The id of the query block.
17 | * @param {boolean} props.interactive - Whether the component is interactive.
18 | * @param {boolean} props.hasTooltip - Whether the component should render a tooltip.
19 | * @param {object} props.schema - The schema of the query block.
20 | * @param {object} props.variables - The variables of the query block.
21 | * @param {object} props.element - The element of the query block.
22 | * @param {function} props.setElement - The function to call when the element changes.
23 | * @param {function} props.setInFocus - The function to call when the component is focused.
24 | * @param {boolean} props.selected - Whether the component is selected.
25 | * @param {boolean} props.dragging - Whether the component is being dragged.
26 | * @param {boolean} props.dropped - Whether the component has been dropped.
27 | * @returns {ReactElement} - The rendered component.
28 | */
29 | export function BlockComponent(props: { id: any; interactive: boolean, hasTooltip: boolean, schema: any, variables: any, element: any, setElement: any, setInFocus: any }) {
30 | const { id, interactive, schema, variables, element, setElement, setInFocus, hasTooltip } = props;
31 | const [tooltipOpen, setTooltipOpen] = useState(false);
32 | const [hoverTimeout, setHoverTimeout] = useState(null);
33 |
34 |
35 | /**
36 | * A function that is called when the user selects a new value for a part of the query block.
37 | * For relationships - If the new value is '<-' or '->', it will convert any existing '-' or '<-' or '->' in the text array to '-'.
38 | * @param {number} index - The index of the part of the query block that was changed.
39 | * @param {string} newValue - The new value of the changed part of the query block.
40 | */
41 | const setValue = (index: number, newValue: string) => {
42 | const newElement = { ...element };
43 | newElement.text = [...element.text];
44 |
45 | // Case for handling inverted relationship directions
46 | if (newValue == '<-') {
47 | const invertableDirection = newElement.text.findIndex((item: any) => item === '->');
48 | if (invertableDirection !== -1) {
49 | newElement.text[invertableDirection] = '-';
50 | }
51 | }
52 | // Case for handling inverted relationship directions
53 | if (newValue == '->') {
54 | const invertableDirection = newElement.text.findIndex((item: any) => item === '<-');
55 | if (invertableDirection !== -1) {
56 | newElement.text[invertableDirection] = '-';
57 | }
58 | }
59 |
60 | newElement.text[index] = newValue;
61 | setElement(newElement);
62 | }
63 |
64 | // Determine the color of the block's text.
65 | let color = 'black';
66 | switch (element?.type) {
67 | case 'CLAUSE':
68 | color = '#718500';
69 | break;
70 |
71 | case 'OPERATOR':
72 | color = '#718500';
73 | break;
74 | case 'FUNCTION':
75 | color = '#6c71c4';
76 | break;
77 | case 'TRANSFORMER':
78 | color = '#718500';
79 | break;
80 | case 'PATTERN':
81 | color = '#657b83';
82 | break;
83 | default:
84 | color = 'black';
85 | }
86 | const style = {
87 | width: "auto",
88 | display: "inline-block",
89 | background: "white !important",
90 | position: 'relative',
91 | fontSize: "auto",
92 | alignItems: "center",
93 | color: color,
94 | justifyContent: "center",
95 | textAlign: "left !important",
96 | cursor: "pointer",
97 | marginLeft: "0px",
98 | marginTop: "0px",
99 | marginBottom: "0px",
100 | columnGap: "2px !important"
101 | };
102 |
103 | const type = element?.type;
104 | const components = BlockType[type]?.components || [];
105 | const marginHorizontal = type?.includes('COMPARISON') ? '1px' : undefined;
106 |
107 |
108 | /**
109 | * When the user hovers over a block, this function sets a timeout
110 | * to open the tooltip after a short delay. This is to prevent
111 | * accidental tooltip triggers.
112 | *
113 | * @param {React.MouseEvent} event
114 | */
115 | const handleMouseOver = () => {
116 | const timeout = setTimeout(() => setTooltipOpen(true), 15000); // Delay of 1500ms
117 | setHoverTimeout(timeout);
118 | };
119 |
120 |
121 | /**
122 | * Handle mouse out event.
123 | *
124 | * This function is called when the user moves their mouse out of the block.
125 | * It clears the timeout set by `handleMouseOver` and ensures that the
126 | * tooltip does not open if the hover ends early.
127 | */
128 | const handleMouseOut = () => {
129 | clearTimeout(hoverTimeout); // Clear timeout to prevent tooltip from opening if hover ends early
130 | setTooltipOpen(false); // Ensure tooltip closes immediately on mouse out
131 | };
132 |
133 | const component =
134 |
135 |
136 | {components.length == 0 ? {'???'} : ''}
137 | {components.map((component: { type: string; text: any; class: any }, i: number) => {
138 | if (component.type === 'fixed' || component.type === 'name') {
139 | return {element.text[i] ? element.text[i] : '???'} ;
140 | } else if (component.type === 'option') {
141 | // @ts-ignore
142 | return setValue(i, value)} interactive={interactive} />;
143 | } else if (component.type === 'select') {
144 | return setValue(i, value)} interactive={interactive} />;
145 | } else {
146 | return ??? ;
147 | }
148 | })}
149 |
150 | ;
151 |
152 | const tooltip =
153 | Query Block
154 |
155 | This is a useless tooltip, but it could be useful!
156 |
157 | ;
158 |
159 | return (
160 |
164 | {hasTooltip ?
165 |
166 |
167 | {component}
168 |
169 | {tooltip}
170 |
171 | :
172 | component
173 | }
174 |
175 | );
176 | }
177 |
--------------------------------------------------------------------------------
/src/builder/logic/SamplingLogic.ts:
--------------------------------------------------------------------------------
1 |
2 | import neo4j from 'neo4j-driver';
3 | import { buildTemplateQueries } from './TemplatesLogic';
4 | import { buildSchemaDefinition } from './SchemaLogic';
5 |
6 | /**
7 | * Converts a Neo4j value to a JavaScript value, recursively converting nested
8 | * objects and arrays.
9 | *
10 | * The following conversions are made:
11 | * - Neo4j Integer: converted to a JavaScript number if in the safe range, otherwise
12 | * converted to a string.
13 | * - Neo4j temporal types (Date, Time, DateTime): converted to an ISO string.
14 | * - Arrays: recursively converted.
15 | * - Objects: recursively converted, except for Neo4j nodes and relationships, which
16 | * are left as-is.
17 | * - Other types (e.g. strings, booleans, etc.): left as-is.
18 | *
19 | * @param value The Neo4j value to convert.
20 | * @returns The converted JavaScript value.
21 | */
22 | export function convertNeo4jValue(value) {
23 | if (neo4j.isInt(value)) {
24 | // Convert Neo4j Integer to JavaScript number if in safe range, otherwise to string
25 | return value.inSafeRange() ? value.toNumber() : value.toString();
26 | } else if (neo4j.temporal.isDate(value) || neo4j.temporal.isTime(value) || neo4j.temporal.isDateTime(value)) {
27 | // Convert Neo4j temporal types to ISO string
28 | return value.toString();
29 | } else if (Array.isArray(value)) {
30 | // Recursively convert each item in the array
31 | return value.map(convertNeo4jValue);
32 | } else if (value && typeof value === 'object' && !neo4j.isNode(value) && !neo4j.isRelationship(value)) {
33 | // Recursively convert each property in the object
34 | return Object.fromEntries(
35 | Object.entries(value).map(([key, val]) => [key, convertNeo4jValue(val)])
36 | );
37 | }
38 | // Return the value as-is for other types (e.g., strings, booleans, nodes, relationships)
39 | return value;
40 | }
41 |
42 | /**
43 | * Simplified schema sampling logic for the query builder.
44 | *
45 | * This function takes a connection object and a callback to update the UI,
46 | * and attempts to infer the schema of the graph by running a series of Cypher
47 | * queries. It looks at a small part of the graph and attempts to infer properties
48 | * for each node/rel, as well as which nodes are connected by what rel types.
49 | *
50 | * The function returns a schema definition object, which is then used to build
51 | * a set of template queries.
52 | *
53 | * @param connection A connection object with the following properties:
54 | * - uri: The URI of the Neo4j instance
55 | * - user: The username to use for the connection
56 | * - password: The password to use for the connection
57 | * - database: The name of the database to connect to
58 | * - port: The port number to use for the connection
59 | * - protocol: The protocol to use for the connection
60 | * @param setMessage A callback to update the UI with a message
61 | * @param setStep A callback to update the UI with the current step
62 | * @param setOpen A callback to open or close the modal
63 | * @param setLoading A callback to set the loading state
64 | * @param setSchema A callback to set the schema definition
65 | * @param setQueryTemplates A callback to set the template queries
66 | */
67 | export async function doSampling(
68 | connection: { uri: any; user: any; password: any; database: any; port: any; protocol: any; },
69 | setMessage: any,
70 | setStep: any,
71 | setOpen: any,
72 | setLoading: any,
73 | setSchema: any,
74 | setQueryTemplates: any) {
75 | /**
76 | * This is a simplified schema sampling logic for the query builder.
77 | * It looks at a small part of the graph and attempts to infer properties for each node/rel, as well as which nodes are connected by what rel types.
78 | */
79 |
80 | // Destructure connection details
81 | const { uri, user, password, database, port, protocol } = connection;
82 | const driver = neo4j.driver(protocol + '://' + uri + ':' + port, neo4j.auth.basic(user, password));
83 |
84 | // Create a session
85 | const session = driver.session({ database: database, defaultAccessMode: neo4j.session.READ });
86 |
87 | try {
88 | // Execute the query
89 | setStep(1);
90 | setMessage("Collecting node labels...")
91 |
92 | const result = await session.run('CALL db.labels() YIELD label RETURN label');
93 | const labels = result.records.map(record => record.get('label'));
94 |
95 | setStep(2);
96 | setMessage("Collecting relationship types...")
97 |
98 | const result2 = await session.run('CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType');
99 | const relTypes = result2.records.map(record => record.get('relationshipType'));
100 |
101 | setStep(3);
102 | setMessage("Collecting indexes...")
103 |
104 | const result3 = await session.run('SHOW INDEXES YIELD entityType, labelsOrTypes, properties WHERE properties IS NOT NULL RETURN entityType, labelsOrTypes, properties');
105 | const indexes = result3.records.map(record => {
106 | return {
107 | 'entityType': record.get('entityType'),
108 | 'labelsOrTypes': record.get('labelsOrTypes'),
109 | 'properties': record.get('properties')
110 | }
111 | });
112 |
113 | setStep(4);
114 | setMessage("Collecting node properties...")
115 |
116 | const result4 = await session.run(
117 | `UNWIND $labels as label
118 | CALL apoc.cypher.run('MATCH (n:' + label + ') RETURN properties(n) as properties LIMIT 1', {}) YIELD value
119 | RETURN label, value AS properties`, { labels: labels });
120 | const nodeProperties = result4.records.map(record => {
121 | return {
122 | 'label': record.get('label'),
123 | 'properties': record.get('properties')['properties']
124 | }
125 | });
126 |
127 | setStep(5);
128 | setMessage("Collecting relationship properties...")
129 |
130 | const result5 = await session.run(
131 | `UNWIND $relTypes as relType
132 | CALL apoc.cypher.run('MATCH ()-[n:' + relType + ']->() RETURN properties(n) as properties LIMIT 1', {}) YIELD value
133 | RETURN relType, value AS properties`, { relTypes: relTypes });
134 | const relProperties = result5.records.map(record => {
135 | return {
136 | 'relType': record.get('relType'),
137 | 'properties': record.get('properties')['properties']
138 | }
139 | });
140 |
141 | setStep(6);
142 | setMessage("Collecting schema & building templates...")
143 |
144 | const result6 = await session.run(
145 | `UNWIND $relTypes as relType
146 | CALL apoc.cypher.run('MATCH (n)-[r:' + relType + ']->(m) RETURN {start:labels(n), end:labels(m)} as value LIMIT 1', {}) YIELD value
147 | UNWIND value.value.start as start
148 | UNWIND value.value.end as end
149 | RETURN start, relType, end`, { relTypes: relTypes });
150 | const cardinalities = result6.records.map(record => {
151 | return {
152 | 'start': record.get('start'),
153 | 'relType': record.get('relType'),
154 | 'end': record.get('end'),
155 | }
156 | });
157 |
158 | // setStep(6);
159 | // setMessage("Building templates...")
160 | const schema = buildSchemaDefinition(labels, relTypes, nodeProperties, relProperties, cardinalities, indexes);
161 | const templates = buildTemplateQueries(schema, indexes);
162 | setSchema(schema);
163 | setQueryTemplates(templates);
164 | setOpen(false);
165 |
166 | } catch (error) {
167 | setMessage('Error executing sampling: ' + error);
168 | console.error('Error executing sampling: ', error);
169 | throw error;
170 | } finally {
171 | // Close the session
172 | await session.close();
173 | // Close the driver
174 | await driver.close();
175 | setLoading(false);
176 | }
177 | }
--------------------------------------------------------------------------------
/src/builder/block/components/BlockSelect.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { Select } from "@neo4j-ndl/react";
3 | import { useState } from "react";
4 | import { constructComplexVariables } from "../../logic/VariablesLogic";
5 |
6 | function deduplicateDictList(dictList: any[]) {
7 | const seen = new Set();
8 | return dictList.reduce((result: any[], item: any) => {
9 | const key = JSON.stringify(item);
10 | if (!seen.has(key)) {
11 | seen.add(key);
12 | result.push(item);
13 | }
14 | return result;
15 | }, []);
16 | }
17 |
18 | function renderValue(value: any) {
19 | if (typeof value === 'object') {
20 | return "'" + value.toString() + "'";
21 | }
22 | if (value === undefined) {
23 | return 'undefined';
24 | }
25 | return value;
26 | }
27 |
28 | export function BlockSelect(props: { schema: any, variables: any, class: any, value: any, setValue: (newValue: string) => void, setInFocus: (focus: boolean) => void, interactive: boolean }) {
29 | const { schema, variables, value, setValue, interactive } = props;
30 | const [inFocus, setInFocus] = useState(false);
31 |
32 |
33 | // const baseOptions = [{ label: 'abc', value: 'abc' }, { label: 'def', value: 'def' }]
34 | let baseOptions = [];
35 |
36 | if (interactive && props.class == 'label' && schema && schema.nodes) {
37 | baseOptions = Object.keys(schema.nodes).map(n => { return { label: n, value: n } });
38 | }
39 | if (interactive && props.class == 'reltype' && schema && schema.relationships) {
40 | baseOptions = Object.keys(schema.relationships).map(n => { return { label: n, value: n } });
41 | }
42 | if (interactive && inFocus && props.class == 'variable' && variables) {
43 | // TODO this is some complex computation on every render, we shouldn't do that.
44 | const vars = constructComplexVariables(variables, schema);
45 | // @ts-ignore
46 | baseOptions = Object.values(vars).map(n => { return { label: n.text[0], value: n.text[0] } });
47 | }
48 |
49 | // Determine the color of the block's text.
50 | let colorClass = 'black';
51 | let color = 'black';
52 | switch (props.class) {
53 | case 'label':
54 | color = '#cb4b16';
55 | colorClass = 'red-text';
56 | break;
57 | case 'reltype':
58 | color = '#cb4b16';
59 | colorClass = 'red-text';
60 | break;
61 | case 'variable':
62 | if (value?.includes && value?.includes('.')) {
63 | color = '#586e75';
64 | colorClass = 'grey-text';
65 | } else if (value !== undefined && !isNaN(value)) {
66 | color = '#2aa198';
67 | colorClass = 'green-text';
68 | } else if (value?.startsWith && (value?.startsWith('"') && value?.endsWith('"')) || value?.startsWith("'") && value?.endsWith("'")) {
69 | color = '#b58900';
70 | colorClass = 'orange-text';
71 | } else {
72 | color = '#268bd2';
73 | colorClass = 'blue-text';
74 | }
75 | break;
76 | }
77 | // If the element is not rendered interactively, it's a 'fake' selector. This is for optimization reasons.
78 | if (!interactive) {
79 | return
104 | {value ? renderValue(value) : '…'}
105 |
106 | } else {
107 |
108 | // If it is interactive, render the normal selector.
109 | return <>
110 | {
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 | newValue && setProtocol(newValue.value),
123 | styles: { control: (base) => ({ ...base, height: '25px', padding: 0, width: '150px', marginTop: '-2px', marginBottom: '-4px' }) },
124 | options: protocols.map((option) => ({ label: option, value: option })),
125 | value: { label: protocol, value: protocol },
126 | }}
127 | // className='w-1/4 inline-block'
128 | // @ts-ignore
129 | fluid
130 | />
131 |
132 | setURI(e.target.value)}
144 | // @ts-ignore
145 | onPaste={(e) => handleHostPasteChange(e)}
146 | />
147 |
148 |
149 | setPort(e.target.value)}
160 | // @ts-ignore
161 | onPaste={(e) => handleHostPasteChange(e)}
162 | />
163 |
164 |
165 | ({ ...base, width: '500px' }) }
176 | }}
177 | onChange={(e) => setDatabase(e.target.value)}
178 |
179 | />
180 |
181 |
182 | setUsername(e.target.value)}
191 | />
192 |
193 |
215 |
216 |
217 |
218 | submitConnection()}>Connect
219 |
220 |
221 | >
222 | );
223 | }
--------------------------------------------------------------------------------
/src/builder/logic/BuilderLogic.tsx:
--------------------------------------------------------------------------------
1 | import { createElementsDefinition, insertBracketDefinition, insertClauseDefinition, insertFunctionDefinition, insertNodeDefinition, insertOperatorDefinition, insertRelationshipDefinition, insertTransformationDefinition, insertVariableDefinition } from "./DefinitionsLogic";
2 |
3 | // There are 3 types of containers, on the sidebar, in the main query builder, and in the query builder footer.
4 | // Between these containers, elements can be dragged.
5 | // Containers on the sidebar that are duplicating. This represents the count of the containers.
6 | export const SIDEBAR_CONTAINER_COUNT = 10;
7 | // The number of containers in the footer.
8 | // This is just one (the wizard)
9 | export const FOOTER_CONTAINER_COUNT = 1;
10 |
11 |
12 | export const clauses = ["MATCH", "OPTIONAL MATCH", "WHERE", "WITH", "RETURN"];
13 | export const sidebarCategories = ['Clauses', 'Nodes', 'Relationships', 'Basic Comparisons', 'More Comparisons', 'Operators', 'Variables', 'Constants', 'Query Controls', 'Other']
14 |
15 | export const NODE_LIMIT_SIDEBAR = 20;
16 | export const REL_LIMIT_SIDEBAR = 20;
17 | export const VAR_LIMIT_SIDEBAR = 25;
18 |
19 |
20 |
21 | /**
22 | * Initialize the builder with the following elements:
23 | * - Clause elements
24 | * - Node elements
25 | * - Relationship elements
26 | * - Comparison elements
27 | * - Operator elements
28 | * - Variable elements
29 | * - Constant elements
30 | * - Control elements
31 | * - Misc Cypher elements (CYPHER, PROFILE, EXPLAIN)
32 | * - Wizard elements (MATCH, MERGE by default - but dynamically updated as the query changes)
33 | *
34 | * The function returns an object with two properties: `newItems` and `newElements`.
35 | * `newItems` is an array of arrays, where each inner array represents the items in a container.
36 | * `newElements` is an object with the elements, where each key is the id of the element.
37 | */
38 | export function initializeBuilder() {
39 | const clauseElements = createElementsDefinition(clauses, { type: 'CLAUSE' });
40 | const nodeElements = {};
41 | insertNodeDefinition(nodeElements, 'n', '');
42 |
43 | const relationshipElements = {};
44 | insertRelationshipDefinition(relationshipElements, 'r', '');
45 |
46 | const comparisonElements = {};
47 |
48 | const advancedComparisonElements = {};
49 |
50 | const operatorElements = {};
51 | insertOperatorDefinition(operatorElements, ' AND ');
52 | insertOperatorDefinition(operatorElements, ' OR ');
53 | insertOperatorDefinition(operatorElements, ' XOR ');
54 | insertOperatorDefinition(operatorElements, ' NOT ');
55 | insertOperatorDefinition(operatorElements, ' EXISTS ');
56 | insertOperatorDefinition(operatorElements, '+');
57 | insertOperatorDefinition(operatorElements, '-');
58 | insertOperatorDefinition(operatorElements, '*');
59 | insertOperatorDefinition(operatorElements, '/');
60 | insertOperatorDefinition(operatorElements, '%');
61 | insertOperatorDefinition(operatorElements, '^');
62 | insertBracketDefinition(operatorElements, '(');
63 | insertBracketDefinition(operatorElements, ')');
64 | insertOperatorDefinition(operatorElements, ',');
65 |
66 |
67 | const variableElements = {};
68 |
69 | const constantElements = {};
70 | insertVariableDefinition(constantElements, '"text"');
71 | insertVariableDefinition(constantElements, '0');
72 | insertVariableDefinition(constantElements, '10');
73 | insertVariableDefinition(constantElements, '100');
74 | insertVariableDefinition(constantElements, '[]');
75 | insertVariableDefinition(constantElements, 'null');
76 | const controlElements = {};
77 | insertClauseDefinition(controlElements, 'RETURN');
78 | insertClauseDefinition(controlElements, 'WITH');
79 | insertOperatorDefinition(controlElements, ' DISTINCT ');
80 | insertTransformationDefinition(controlElements, 'SKIP ', '1000');
81 | insertTransformationDefinition(controlElements, 'LIMIT ', '1000');
82 | insertClauseDefinition(controlElements, 'ORDER BY');
83 | insertOperatorDefinition(controlElements, 'ASC');
84 | insertOperatorDefinition(controlElements, 'DESC');
85 | insertClauseDefinition(controlElements, 'UNION');
86 | insertClauseDefinition(controlElements, 'UNWIND');
87 | insertFunctionDefinition(controlElements, 'COLLECT');
88 | insertTransformationDefinition(controlElements, 'AS ');
89 |
90 | // createElementsDefinition(comparisons, { type: 'CLAUSE' });
91 | const allCypherElements = {};
92 | insertOperatorDefinition(allCypherElements, 'CYPHER ');
93 | insertOperatorDefinition(allCypherElements, 'PROFILE ');
94 | insertOperatorDefinition(allCypherElements, 'EXPLAIN ');
95 | const wizardElements = {};
96 | insertClauseDefinition(wizardElements, 'MATCH');
97 | insertClauseDefinition(wizardElements, 'MERGE');
98 | // insertClauseDefinition(wizardElements, 'WITH');
99 |
100 |
101 | const newElements = { ...clauseElements, ...nodeElements, ...relationshipElements, ...operatorElements, ...variableElements, ...constantElements, ...comparisonElements, ...controlElements, ...allCypherElements, ...wizardElements };
102 | const newItems = [
103 | [...Object.keys(clauseElements)],
104 | [...Object.keys(nodeElements)],
105 | [...Object.keys(relationshipElements)],
106 | [...Object.keys(comparisonElements)],
107 | [...Object.keys(advancedComparisonElements)],
108 | [...Object.keys(operatorElements)],
109 | [...Object.keys(variableElements)],
110 | [...Object.keys(constantElements)],
111 | [...Object.keys(controlElements)],
112 | [...Object.keys(allCypherElements)],
113 | [...Object.keys(wizardElements)],
114 | [],
115 | []
116 | ];
117 | return { newItems, newElements };
118 | }
119 |
120 |
121 | /**
122 | * Reset the builder query by deleting all non-sidebar query elements.
123 | *
124 | * This function is used when the user wants to start fresh with a new query.
125 | * It takes the current state of the builder and creates a new one by deleting all
126 | * non-sidebar elements from the old state.
127 | * @param {Object} oldItems The current state of the builder, which is an array of arrays of element UUIDs.
128 | * @param {Object} oldElements The current state of the builder, which is an object of element definitions.
129 | * @returns {Object} The new state of the builder, which is an object with two properties: items and elements.
130 | * @property {Array>} items The new state of the builder, which is an array of arrays of element UUIDs.
131 | * @property {Object} elements The new state of the builder, which is an object of element definitions.
132 | */
133 | export function resetBuilderQuery(oldItems: any[][], oldElements: {}) {
134 |
135 | let newElements = { ...oldElements };
136 | const newItems = [...oldItems];
137 |
138 | // Iterate over oldItems and delete each UUID from newElements
139 | for (let i = SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT; i < newItems.length; i++) {
140 | newItems[i].forEach((uuid: string) => {
141 | if (newElements.hasOwnProperty(uuid)) {
142 | delete newElements[uuid];
143 | }
144 | });
145 | newItems[i] = [];
146 | }
147 |
148 | return { newItems, newElements }
149 | }
150 |
151 |
152 | /**
153 | * Updates the builder's sidebar based on a new schema.
154 | * The function finally combines all definitions and returns the updated items and elements.
155 | *
156 | * @param {any[][]} oldItems - The current state of the builder, an array of arrays of element UUIDs.
157 | * @param {Object} oldElements - The current state of the builder, an object of element definitions.
158 | * @param {Object} schema - The schema containing nodes and relationships to update the builder with.
159 | * @returns {Object} - The updated state of the builder, with properties `newItems` and `newElements`.
160 | * @property {Array>} newItems - The updated array of arrays of element UUIDs.
161 | * @property {Object} newElements - The updated object of element definitions.
162 | */
163 |
164 | export function updateBuilderNodesRelationships(oldItems: any[][], oldElements: {}, schema: { nodes: {}; relationships: {}; }, variables: any[]) {
165 |
166 | let newElements = { ...oldElements };
167 | const newItems = [...oldItems];
168 |
169 | // Iterate over oldItems and delete each UUID from newElements
170 | oldItems[1].forEach((uuid: string | number) => {
171 | if (newElements.hasOwnProperty(uuid)) {
172 | delete newElements[uuid];
173 | }
174 | });
175 | oldItems[2].forEach((uuid: string | number) => {
176 | if (newElements.hasOwnProperty(uuid)) {
177 | delete newElements[uuid];
178 | }
179 | });
180 | // Nodes
181 |
182 | const nodeElements = {};
183 | const varName = assignUniqueVariableName(variables, 'n');
184 | insertNodeDefinition(nodeElements, varName, '');
185 | schema?.nodes && Object.keys(schema.nodes).forEach(nodeLabel => {
186 | if (nodeLabel) {
187 | const varName = assignUniqueVariableName(variables, nodeLabel[0].toLowerCase())
188 | insertNodeDefinition(nodeElements, varName, nodeLabel);
189 | }
190 | })
191 |
192 | newItems[1] = [...Object.keys(nodeElements)].slice(0, NODE_LIMIT_SIDEBAR);
193 |
194 | // Relationship
195 | const relElements = {};
196 | const relVarName = assignUniqueVariableName(variables, 'r');
197 | insertRelationshipDefinition(relElements, relVarName, '');
198 | schema?.relationships && Object.keys(schema.relationships).forEach(relType => {
199 | if (relType) {
200 | const varName = assignUniqueVariableName(variables, relType[0].toLowerCase())
201 | insertRelationshipDefinition(relElements, varName, relType);
202 | }
203 | })
204 |
205 | newItems[2] = [...Object.keys(relElements)].slice(0, REL_LIMIT_SIDEBAR);
206 |
207 |
208 | // Merge all element definitions
209 | newElements = { ...newElements, ...nodeElements, ...relElements };
210 | return { newItems, newElements }
211 | }
212 |
213 | export function assignUniqueVariableName(variables: any[], name: string) {
214 | let newName = name;
215 | let index = 1;
216 | while (variables.findIndex(v => v.text === newName) !== -1) {
217 | index++;
218 | newName = name + index;
219 |
220 | // Escape block if we somehow get stuck.
221 | if(index == 100){
222 | break;
223 | }
224 | }
225 | return newName;
226 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/src/builder/logic/WizardLogic.ts:
--------------------------------------------------------------------------------
1 | import { SIDEBAR_CONTAINER_COUNT, FOOTER_CONTAINER_COUNT, NODE_LIMIT_SIDEBAR, assignUniqueVariableName } from "./BuilderLogic";
2 | import { createComparisons } from "./ComparisonsLogic";
3 | import { insertClauseDefinition, insertNodeDefinition, insertRelationshipDefinition, insertComparisonDefinition, insertOperatorDefinition, insertVariableDefinition, insertTransformationDefinition } from "./DefinitionsLogic";
4 | import { constructComplexVariables } from "./VariablesLogic";
5 |
6 | /**
7 | * Update the builder's suggested blocks.
8 | * The function iterates over the query containers, and generates a wizard based on the content.
9 | * @param {any[][]} oldItems - The current state of the builder, an array of arrays of element UUIDs.
10 | * @param {Object} oldElements - The current state of the builder, an object of element definitions.
11 | * @param {any[]} variables - The variables to be used in the wizard.
12 | * @param {Object} schema - The schema containing nodes and relationships to update the builder with.
13 | * @returns {Object} - The updated state of the builder, with properties `newItems` and `newElements`.
14 | * @property {Array>} newItems - The updated array of arrays of element UUIDs.
15 | * @property {Object} newElements - The updated object of element definitions.
16 | * @property {string} caption - A caption to be displayed in the wizard sidebar.
17 | */
18 | export function updateCypherWizard(oldItems: any[][], oldElements: {}, variables: any[], schema: { nodes: {}; relationships: {}; }) {
19 |
20 | let newElements = { ...oldElements };
21 | const newItems = [...oldItems];
22 |
23 | // Iterate over oldItems and delete each UUID from newElements
24 | oldItems[SIDEBAR_CONTAINER_COUNT].forEach((uuid: string | number) => {
25 | if (newElements.hasOwnProperty(uuid)) {
26 | delete newElements[uuid];
27 | }
28 | });
29 |
30 | // Wizard
31 | const { wizardElements, caption } = generateWizardSuggestions(oldItems, oldElements, variables, schema);
32 | newItems[SIDEBAR_CONTAINER_COUNT] = [...Object.keys(wizardElements)].slice(0, NODE_LIMIT_SIDEBAR);
33 |
34 | // Merge all element definitions
35 | newElements = { ...newElements, ...wizardElements };
36 | return { newItems, newElements, caption }
37 | }
38 |
39 | /**
40 | * Generates wizard suggestions for the query builder based on the current query state.
41 | * The function analyzes the items and elements in the query builder to determine the
42 | * current state of the query and suggests appropriate next steps or blocks to add.
43 | *
44 | * @param {any[][]} items - The current state of the builder, an array of arrays of element UUIDs.
45 | * @param {Object} elements - The current state of the builder, an object of element definitions.
46 | * @param {any[]} variables - The variables available for use in the wizard.
47 | * @param {Object} schema - The schema containing nodes and relationships in the graph.
48 | * @returns {Object} - An object containing the suggested wizard elements and a caption for the sidebar.
49 | * @property {Object} wizardElements - The suggested elements to add to the query based on the current state.
50 | * @property {string} caption - A caption providing guidance on the next step in building the query.
51 | */
52 | function generateWizardSuggestions(items: any[][], elements: {}, variables: any[], schema: { nodes: {}; relationships: {}; }) {
53 | let caption = '';
54 | let wizardElements = {};
55 | const queryContainerLength = SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT;
56 | let lastClause = undefined;
57 | let lastElement = undefined;
58 |
59 | // Find last clause and last element
60 | items.slice(queryContainerLength).forEach((container: any) => {
61 | container.forEach((item: any) => {
62 | const type = elements[item]?.type;
63 | const textArray = elements[item]?.text || [];
64 | lastElement = elements[item];
65 | if (type == 'CLAUSE') {
66 | // Clause is always a single text element.
67 | lastClause = textArray[0];
68 | }
69 | });
70 | });
71 | // Case 1: empty query
72 | if (lastClause == undefined || lastClause == 'UNION') {
73 | caption = 'Start by adding a matching pattern.';
74 | insertClauseDefinition(wizardElements, 'MATCH');
75 | insertClauseDefinition(wizardElements, 'MERGE');
76 | // insertClauseDefinition(wizardElements, 'WITH');
77 | // This is the only case that returns right away.
78 | return { wizardElements: wizardElements, caption: caption };
79 | }
80 |
81 | // Case 2: inside empty matching statement
82 | if (lastElement?.type == 'CLAUSE' && (lastClause == 'MATCH' || lastClause == 'MERGE' || lastClause == 'OPTIONAL MATCH')) {
83 | caption = 'Add the nodes you want to match on.'
84 | const varName = assignUniqueVariableName(variables, 'n');
85 | insertNodeDefinition(wizardElements, varName, '');
86 | schema.nodes && Object.keys(schema.nodes).slice(0, 3).forEach(nodeLabel => {
87 | if (nodeLabel) {
88 | const varName = assignUniqueVariableName(variables, nodeLabel[0].toLowerCase());
89 | insertNodeDefinition(wizardElements, varName, nodeLabel);
90 | }
91 | })
92 | }
93 |
94 | // Case 3: inside non-empty matching statement where last element is a node.
95 | if (lastElement?.type == 'NODE' && (lastClause == 'MATCH' || lastClause == 'MERGE' || lastClause == 'OPTIONAL MATCH')) {
96 | caption = 'Start filtering, or add more nodes and relationships to match.'
97 | insertClauseDefinition(wizardElements, 'WHERE');
98 | insertClauseDefinition(wizardElements, 'WITH');
99 | insertClauseDefinition(wizardElements, 'RETURN');
100 |
101 |
102 | const lastLabel = lastElement.text[3];
103 | const outgoingRels = schema.relationships && Object.keys(schema.relationships).filter(r => schema.relationships[r].startNodes.includes(lastLabel));
104 | const incomingRels = schema.relationships && Object.keys(schema.relationships).filter(r => schema.relationships[r].endNodes.includes(lastLabel));
105 |
106 | if (!schema.relationships) {
107 | insertRelationshipDefinition(wizardElements, '', '', 'OUTGOING');
108 | }
109 |
110 | outgoingRels?.slice(0, 2).forEach(relType => {
111 | if (relType) {
112 | const varName = assignUniqueVariableName(variables, relType[0].toLowerCase());
113 | insertRelationshipDefinition(wizardElements, varName, relType, 'OUTGOING');
114 | }
115 | })
116 | incomingRels?.slice(0, 1).forEach(relType => {
117 | if (relType) {
118 | const varName = assignUniqueVariableName(variables, relType[0].toLowerCase());
119 | insertRelationshipDefinition(wizardElements, varName, relType, 'INCOMING');
120 | }
121 | })
122 | schema.nodes && Object.keys(schema.nodes).slice(0, 2).forEach(nodeLabel => {
123 | if (nodeLabel && nodeLabel != lastLabel) {
124 | const varName = assignUniqueVariableName(variables, nodeLabel[0].toLowerCase());
125 | insertNodeDefinition(wizardElements, varName, nodeLabel);
126 | }
127 | })
128 |
129 | }
130 | // Case 4: inside non-empty matching statement where last element is a relationship.
131 | if (lastElement?.type == 'RELATIONSHIP' && (lastClause == 'MATCH' || lastClause == 'MERGE' || lastClause == 'OPTIONAL MATCH')) {
132 | caption = 'Add a node at the end of the relationship pattern.'
133 | const varName = assignUniqueVariableName(variables, 'n');
134 | insertNodeDefinition(wizardElements, varName, '');
135 |
136 | const lastType = lastElement?.text[4];
137 | if (schema.relationships && schema.relationships[lastType]) {
138 |
139 | const lastDirectionIsInverted = lastElement?.text && lastElement.text[0] == '<-';
140 |
141 | const suggestedNodes = lastDirectionIsInverted ?
142 | schema.relationships[lastType].startNodes : schema.relationships[lastType].endNodes;
143 |
144 | suggestedNodes?.slice(0, 2).forEach(nodeLabel => {
145 | const varName = assignUniqueVariableName(variables, nodeLabel[0].toLowerCase());
146 | insertNodeDefinition(wizardElements, varName, nodeLabel);
147 | });
148 | }
149 | }
150 | // Case 5: inside where statement without a comparison
151 | if (lastClause == 'WHERE' && lastElement?.type !== 'COMPARISON' && lastElement?.type !== 'STRING_COMPARISON') {
152 | caption = 'Add in some filters to narrow down the results.'
153 | variables?.forEach(variable => {
154 | variable.classes.forEach(() => {
155 | insertComparisonDefinition(wizardElements, variable.text, ``, '=');
156 | });
157 | });
158 | const { comparisonElements } = createComparisons(variables, schema, true, false);
159 |
160 | wizardElements = { ...wizardElements, ...comparisonElements }
161 | }
162 | // Case 6: inside where statement with a comparison
163 | if (lastClause == 'WHERE' && (lastElement?.type == 'COMPARISON' || lastElement?.type == 'STRING_COMPARISON')) {
164 | caption = 'Add more filters, or move on to return variables.'
165 | // const { comparisonElements, advancedComparisonElements } = createComparisons(variables, schema, true, false);
166 | insertClauseDefinition(wizardElements, 'RETURN');
167 | insertClauseDefinition(wizardElements, 'WITH');
168 | insertOperatorDefinition(wizardElements, ' AND ');
169 | insertOperatorDefinition(wizardElements, ' OR ');
170 | }
171 | // Case 7: inside empty return/with statement
172 | if ((lastClause == 'RETURN' || lastClause == 'WITH') && lastElement?.text && lastElement?.text[0] == lastClause) {
173 | caption = 'Choose the variables you want to return.'
174 | const uniqueVariableElements = constructComplexVariables(variables, schema, 1, true);
175 | insertOperatorDefinition(wizardElements, ' DISTINCT ');
176 | if (Object.keys(uniqueVariableElements).length == 0) {
177 | insertVariableDefinition(uniqueVariableElements, 'text');
178 | }
179 | wizardElements = { ...wizardElements, ...uniqueVariableElements }
180 | }
181 |
182 | // Case 8: inside return/with/unwind statement ending with variable
183 | if ((lastClause == 'RETURN' || lastClause == 'WITH') && lastElement?.type == 'VARIABLE') {
184 | const varName = lastElement?.text[0];
185 | if (lastClause == 'WITH') {
186 | insertClauseDefinition(wizardElements, 'RETURN')
187 | insertClauseDefinition(wizardElements, 'MATCH')
188 | }
189 |
190 | insertClauseDefinition(wizardElements, 'ORDER BY')
191 |
192 | if (varName.includes('.')) {
193 | caption = 'Give the variable a name, or add more variables.'
194 | const uniqueVarName = assignUniqueVariableName(variables, varName.split('.')[1]);
195 | insertTransformationDefinition(wizardElements, 'AS ', uniqueVarName);
196 | } else {
197 | caption = 'Add more variables, and a limit, if needed. '
198 | }
199 |
200 |
201 | const uniqueVariableElements = constructComplexVariables(variables, schema, 1, true);
202 | insertTransformationDefinition(uniqueVariableElements, 'LIMIT ', '1000');
203 | wizardElements = { ...wizardElements, ...uniqueVariableElements }
204 | }
205 |
206 | // Case 9: inside return/with/unwind statement ending with a non variable
207 | if ((lastClause == 'RETURN' || lastClause == 'WITH') && lastElement?.type !== 'VARIABLE' && lastElement?.text && lastElement?.text[0] != lastClause) {
208 | caption = 'Add more variables, or specify an ordering.'
209 | const varName = lastElement?.text[0];
210 | if (lastElement?.type !== 'OPERATOR') {
211 | insertClauseDefinition(wizardElements, 'ORDER BY')
212 | }
213 | if (lastElement?.type == 'FUNCTION') {
214 | const uniqueVarName = assignUniqueVariableName(variables, varName);
215 | insertTransformationDefinition(wizardElements, 'AS ', uniqueVarName);
216 | }
217 | if (varName?.includes('.')) {
218 | const uniqueVarName = assignUniqueVariableName(variables, varName.split('.')[1]);
219 | insertTransformationDefinition(wizardElements, 'AS ', uniqueVarName);
220 | }
221 |
222 | const uniqueVariableElements = constructComplexVariables(variables, schema, 1, true);
223 | insertTransformationDefinition(uniqueVariableElements, 'LIMIT ', '1000');
224 | wizardElements = { ...wizardElements, ...uniqueVariableElements }
225 | }
226 |
227 | // Case 10: inside empty UNWIND or ORDER BY statement
228 | if ((lastClause == 'UNWIND' || lastClause == 'ORDER BY') && lastElement?.text && lastElement?.text[0] == lastClause) {
229 | if (lastClause == 'ORDER BY') {
230 | caption = 'Select variables to order by.';
231 | } else {
232 | caption = 'Select variables to unwind.';
233 | }
234 |
235 | const uniqueVariableElements = constructComplexVariables(variables, schema, 1, true);
236 | if (variables.length == 0) {
237 | insertVariableDefinition(uniqueVariableElements, '["text"]');
238 | }
239 | wizardElements = { ...wizardElements, ...uniqueVariableElements }
240 | }
241 |
242 | // Case 11: inside non-empty UNWIND or ORDER BY statement
243 | if ((lastClause == 'UNWIND' || lastClause == 'ORDER BY') && lastElement?.text && lastElement?.text[0] !== lastClause) {
244 | if (lastClause == 'ORDER BY') {
245 | caption = 'Select variables to order by.';
246 | } else {
247 | caption = 'Select variables to unwind.';
248 | }
249 |
250 | const uniqueVariableElements = constructComplexVariables(variables, schema, 1, true);
251 | insertTransformationDefinition(wizardElements, 'LIMIT ', '1000');
252 | wizardElements = { ...wizardElements, ...uniqueVariableElements }
253 | }
254 | return { wizardElements: wizardElements, caption: caption };
255 | }
256 |
--------------------------------------------------------------------------------
/src/builder/BuilderEventHandler.ts:
--------------------------------------------------------------------------------
1 | import { DragOverEvent, DragEndEvent, DragStartEvent } from "@dnd-kit/core";
2 | import { FOOTER_CONTAINER_COUNT, SIDEBAR_CONTAINER_COUNT } from "./logic/BuilderLogic";
3 | import { arrayMove } from "@dnd-kit/sortable";
4 | import { updateBuilderVariables } from "./logic/VariablesLogic";
5 |
6 | /**
7 | * This file contains all functions handling events inside the builder.
8 | * This includes:
9 | * - Dragging a block.
10 | * - Dropping a block.
11 | * - Clicking on a block.
12 | * - Right clicking a block.
13 | */
14 |
15 | function findContainer(items: any[], id: string) {
16 | // Find the container index that includes the item with the given id
17 | return items.findIndex((container: string | any[]) => container.includes(id));
18 | }
19 |
20 | export function handleSortableItemDelete(
21 | id: string | number,
22 | items: any[],
23 | elements: { [x: string]: any; },
24 | setItems: (arg0: any[]) => void,
25 | setElements: (arg0: any) => void,
26 | setActiveId: (arg0: any) => void
27 | ) {
28 | // @ts-ignore
29 | const containerIndex = findContainer(items, id);
30 | const container = items[containerIndex];
31 | const index = container.indexOf(id);
32 | const newContainer = [...container];
33 | newContainer.splice(index, 1);
34 | items[containerIndex] = newContainer;
35 | setItems([...items]);
36 |
37 | const newElements = { ...elements };
38 | delete newElements[id];
39 |
40 | setElements(newElements);
41 | setActiveId(id);
42 | // Reset the transform after the animation completes
43 | setTimeout(() => {
44 | setActiveId(undefined);
45 | }, 1); // Match the transition duration
46 | }
47 |
48 | export function handleSortableItemClick(
49 | id: string | number,
50 | itemRect: { width: number; left: number; top: number; height: number; },
51 | items: any[],
52 | elements: { [x: string]: any; },
53 | variables: any,
54 | schema: any,
55 | setActiveId: (arg0: any) => void,
56 | setItems: (arg0: any[]) => void,
57 | setElements: (arg0: any) => void) {
58 |
59 | // Find the container where it should move to. (first non-empty row)
60 | const containerIndex = items.length - 2;
61 | const queryContainerIndex = containerIndex - (SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT) + 1;
62 |
63 | const container = items[containerIndex];
64 | // Return the last element of that array, or undefined if no such array exists
65 | const targetId = container ? container[container.length - 1] : undefined;
66 | let targetElement = document.getElementById("item-" + targetId);
67 | let containerElement = document.getElementById("query-container-" + queryContainerIndex);
68 |
69 | let targetX, targetY;
70 |
71 | // If we end up in a broken state, attempt to recover.
72 | if(elements[id] == null){
73 | const { newItems, newElements } = updateBuilderVariables(items, elements, variables, schema);
74 | setItems(newItems);
75 | setElements(newElements);
76 | return;
77 | }
78 |
79 |
80 | if (targetElement !== null && elements[id]['type'] !== 'CLAUSE') {
81 | const targetRect = targetElement.getBoundingClientRect();
82 | // gap size = 10
83 | targetX = 10 + targetRect.right + itemRect.width / 2;
84 | targetY = (targetRect.top + targetRect.bottom) / 2;
85 |
86 | } else {
87 | const targetRect = containerElement.getBoundingClientRect();
88 |
89 | // gap size = 10
90 | targetX = targetRect.left + itemRect.width / 2 + 54 - 30;
91 | targetY = (targetRect.top + targetRect.bottom + 10) / 2 + 54 - 5;
92 |
93 | // Fix for weird behaviour on first line
94 | if(queryContainerIndex == 1 && container.length == 0){
95 | targetY -= 54;
96 | }
97 | }
98 |
99 | // Calculate the translation needed to center the item on the screen
100 | let deltaX = (itemRect.left + itemRect.width / 2) - targetX;
101 | let deltaY = (itemRect.top + itemRect.height / 2) - targetY;
102 |
103 |
104 | const newId = crypto.randomUUID();
105 | const newElements = { ...elements };
106 | newElements[newId] = { ...elements[id] };
107 | newElements[newId]['animated'] = true;
108 | newElements[newId]['animationDeltaX'] = deltaX;
109 | newElements[newId]['animationDeltaY'] = deltaY;
110 | setElements(newElements);
111 |
112 | let newItems = [...items];
113 | // Always add a clause to a new line
114 | if (elements[id]['type'] == 'CLAUSE' && newItems[containerIndex].length > 0) {
115 | newItems[containerIndex + 1] = [...newItems[containerIndex + 1], newId];
116 | } else {
117 | // Anything else goes at the end of the line
118 | newItems[containerIndex] = [...newItems[containerIndex], newId];
119 | }
120 | newItems = handleAddOrRemoveContainersAtQueryEnd(newItems);
121 | setItems(newItems);
122 | setActiveId(newId);
123 | setElements(newElements);
124 |
125 |
126 |
127 | // Reset the transform after the animation completes
128 |
129 | // TODO pretty sure there's some memory issues here..... not sure how to fix
130 | setTimeout(() => {
131 | setActiveId(undefined)
132 | newElements[newId]['animated'] = false;
133 | newElements[newId]['animationDeltaX'] = 0;
134 | newElements[newId]['animationDeltaY'] = 0;
135 | // setElements(newElements);
136 | }, 5); // Match the transition duration
137 | }
138 |
139 | export function handleDragStart(
140 | event: DragStartEvent,
141 | items: any[],
142 | elements: { [x: string]: any; },
143 | setItems: ((arg0: any[]) => void),
144 | setElements: ((arg0: any) => void),
145 | setActiveId: ((arg0: any) => void)) {
146 |
147 | const { active } = event;
148 | const { id } = active;
149 |
150 | // @ts-ignore
151 | const activeContainerIndex = findContainer(items, id);
152 |
153 | // If we select something from the duplicating containers:
154 | if (
155 | activeContainerIndex <
156 | SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT
157 | ) {
158 | const prev = [...items];
159 | const container = prev[activeContainerIndex];
160 | const activeIndex = container ? container.indexOf(id) : -1;
161 | const newId = crypto.randomUUID();
162 | const updatedContainer = [
163 | ...container.slice(0, activeIndex),
164 | newId,
165 | ...container.slice(activeIndex + 1, container.length),
166 | ];
167 | prev[activeContainerIndex] = updatedContainer;
168 |
169 | // Insert into definition map
170 | const newElements = { ...elements };
171 | newElements[newId] = {...elements[id]};
172 | setElements(newElements);
173 | setItems(prev);
174 | }
175 | setActiveId(id);
176 | }
177 |
178 | export function debounce(func: { apply: (arg0: any, arg1: any[]) => any; }, delay: number | undefined) {
179 | let timeoutId: number | undefined;
180 | return function (...args: any) {
181 | clearTimeout(timeoutId); // Clears the previous timeout if the function is called again
182 | timeoutId = setTimeout(() => func.apply(this, args), delay);
183 | };
184 | }
185 |
186 | export function handleDragOver(
187 | event: DragOverEvent,
188 | activeId: string | undefined,
189 | items: any[],
190 | setItems: ((items: any[]) => void)) {
191 | const { active, over } = event;
192 | const id = activeId;
193 |
194 | if (over == undefined) {
195 | return;
196 | }
197 | const { id: overId } = over;
198 |
199 | const activeContainerIndex = findContainer(items, id);
200 | // @ts-ignore
201 | let overContainerIndex = findContainer(items, overId);
202 |
203 |
204 | if (Number.isInteger(Number(overId))) {
205 | overContainerIndex = SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT + Number(overId) - 1;
206 | }
207 | if (overContainerIndex === -1) {
208 | // Don't move stuff outside of all containers
209 | return;
210 | }
211 |
212 | // Don't handle moves in the same container (handled by library)
213 | // UNLESS the element is freshly created (duplicated from the left columns)
214 | if (activeContainerIndex === overContainerIndex) {
215 | if (over && activeId !== over.id) {
216 | const newItems = [...items];
217 | const container = [...items[activeContainerIndex]];
218 | const oldIndex = container.indexOf(activeId);
219 | const newIndex = container.indexOf(overId);
220 | newItems[activeContainerIndex] = arrayMove(container, oldIndex, newIndex);
221 |
222 | setItems(newItems);
223 | }
224 | return;
225 | }
226 | // @ts-ignore
227 | setItems((prev: any[]) => {
228 | const activeItems = prev[activeContainerIndex];
229 | const overItems = prev[overContainerIndex];
230 |
231 | if (overItems == undefined) {
232 | return items;
233 | }
234 | const activeIndex = activeItems ? activeItems.indexOf(id) : -1;
235 | const overIndex = overItems.indexOf(overId);
236 |
237 | let newIndex;
238 | if (overId === undefined) {
239 | newIndex = overItems.length + 1;
240 | } else {
241 | const isBelowLastItem =
242 | over &&
243 | overIndex === overItems.length - 1 &&
244 | // @ts-ignore
245 | active.rect.offsetTop > over.rect.offsetTop + over.rect.height;
246 | const modifier = isBelowLastItem ? 1 : 0;
247 | newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1;
248 | }
249 |
250 | const updatedActiveItems = activeItems
251 | ? activeItems.filter((item: any) => item !== id)
252 | : [];
253 | const updatedOverItems = [
254 | ...overItems.slice(0, newIndex),
255 | activeId,
256 | ...overItems.slice(newIndex),
257 | ];
258 |
259 | let newItems = prev.map((container: any, idx: number) => {
260 | if (idx === activeContainerIndex) {
261 | // Update the active container index
262 | return updatedActiveItems;
263 | }
264 | // But - only add stuff to the main builder containers
265 | if (idx === overContainerIndex && idx > SIDEBAR_CONTAINER_COUNT) {
266 | return updatedOverItems;
267 | }
268 | return container;
269 | });
270 |
271 | // We always want an empty row at the end.
272 | // If the last two rows are empty, delete the last.
273 | newItems = handleAddOrRemoveContainersAtQueryEnd(newItems);
274 | return newItems;
275 | });
276 | }
277 |
278 | /**
279 | * After reordering items, make sure there is always an empty row at the end.
280 | * If there are two empty rows, delete the last one.
281 | * If the last row is not empty, add an empty one.
282 | * @param newItems The new items array after reordering.
283 | * @returns The modified items array
284 | */
285 | function handleAddOrRemoveContainersAtQueryEnd(newItems: any[]) {
286 | const thresholdIndex = SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT;
287 |
288 | // Remove empty rows in the middle if their index is greater than the threshold
289 | newItems = newItems.filter((row, index) => {
290 | return row.length > 0 || index <= thresholdIndex || index === newItems.length - 1;
291 | });
292 |
293 | if (newItems.length >
294 | SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT + 2 &&
295 | newItems[newItems.length - 2].length == 0 &&
296 | newItems[newItems.length - 1].length == 0) {
297 | newItems = newItems.slice(0, newItems.length - 1);
298 | }
299 | // ALSO: If the last row is non-empty, add an empty one.
300 | if (newItems[newItems.length - 1].length > 0) {
301 | newItems = [...newItems, []];
302 | }
303 | return removeDuplicates(newItems);
304 | }
305 |
306 | export function handleDragEnd(
307 | event: DragEndEvent,
308 | activeId: string | undefined,
309 | items: any[],
310 | setItems: ((items: any[]) => void),
311 | elements: any,
312 | setElements: any,
313 | setActiveId: ((id: string | undefined) => void)) {
314 |
315 | const { active, over } = event;
316 | const id = activeId;
317 | if (over == undefined) {
318 | setActiveId(undefined);
319 | return;
320 | }
321 | const { id: overId } = over;
322 |
323 | const activeContainerIndex = findContainer(items, id);
324 | // @ts-ignore
325 | const overContainerIndex = findContainer(items, overId);
326 |
327 | // If we drag too far left, delete the item:
328 | // @ts-ignore
329 | if (!overContainerIndex && event.activatorEvent.x + event.delta.x < 300){
330 | handleSortableItemDelete(id, items, elements, setItems, setElements, setActiveId);
331 | return
332 | }
333 |
334 | // Dragged out of bounds, do nothing.
335 | if (
336 | activeContainerIndex === -1 ||
337 | overContainerIndex === -1 ||
338 | activeContainerIndex < SIDEBAR_CONTAINER_COUNT + FOOTER_CONTAINER_COUNT || // Never reorganize in the duplicating rows.
339 | activeContainerIndex !== overContainerIndex
340 | ) {
341 | setActiveId(undefined);
342 | return;
343 | }
344 |
345 | const activeIndex = items[activeContainerIndex].indexOf(activeId);
346 | let overIndex = items[overContainerIndex].indexOf(overId);
347 |
348 | if (activeIndex !== overIndex) {
349 | // @ts-ignore
350 | setItems((prevItems: any[]) => {
351 | let newItems = prevItems.map((container: any, idx: any) =>
352 | idx === overContainerIndex
353 | ? arrayMove(container, activeIndex, overIndex)
354 | : [...container]
355 | );
356 | return newItems;
357 | });
358 | }
359 |
360 | setActiveId(undefined);
361 | }
362 |
363 |
364 | function removeDuplicates(arr) {
365 | const seen = new Set();
366 | return arr.map(sublist => sublist.reduce((unique, item) => {
367 | if (!seen.has(item)) {
368 | seen.add(item);
369 | unique.push(item);
370 | }
371 | return unique;
372 | }, []));
373 | }
--------------------------------------------------------------------------------
/src/builder/logic/TemplatesLogic.tsx:
--------------------------------------------------------------------------------
1 | import { generateCypher } from "../editor/CodeEditor";
2 | import { insertClauseDefinition, insertNodeDefinition, insertVariableDefinition, insertComparisonDefinition, insertStringComparisonDefinition, insertOperatorDefinition, insertTransformationDefinition, insertRelationshipDefinition } from "./DefinitionsLogic";
3 |
4 | /**
5 | * Given a schema, builds a list of query templates that can be used to query the graph.
6 | * The templates are divided into several categories:
7 | * - Simple retrieval of nodes
8 | * - WHERE statement on single property
9 | * - Ordering queries
10 | * - String contains queries
11 | * - Node pattern queries for outgoing and incoming relationships
12 | * - Rel prop matching
13 | * @param {any} schema The schema of the graph.
14 | * @returns {any[]} An array of query templates.
15 | */
16 | export function buildTemplateQueries(schema, indexes) {
17 | const templates = [];
18 | schema?.nodes && Object.keys(schema.nodes).forEach((label: any) => {
19 | // Simple retrieval of nodes
20 | templates.push(buildMatchAllQuery(label, schema));
21 |
22 | // WHERE statement on single property.
23 | findMatchableProperties(schema, label).forEach((property: any) => {
24 | templates.push(buildMatchWhereQuery(label, schema, property));
25 | });
26 |
27 | // Ordering queries.
28 | findOrderableProperties(schema, label).slice(0, 1).forEach((property: any) => {
29 | templates.push(buildMatchOrderByQuery(label, schema, property));
30 | })
31 |
32 | // String contains queries. Try getting something else.
33 | findStringProperties(schema, label).reverse().slice(0, 1).forEach((property: any) => {
34 | templates.push(buildMatchWhereStringContainsQuery(label, schema, property));
35 | })
36 | // Outgoing relationships
37 | schema.nodes[label].outgoingRelationships.slice(0, 1).forEach((relType: any) => {
38 | const destinationLabels = schema.relationships[relType].endNodes;
39 | destinationLabels.forEach((destinationLabel: any) => {
40 | findMatchableProperties(schema, label).slice(0, 1).forEach((property: any) => {
41 | templates.push(buildMatchWhereNodePatternQuery(label, schema, property, relType, destinationLabel, true));
42 | });
43 | })
44 |
45 | });
46 |
47 | // Incoming relationships
48 | schema.nodes[label].incomingRelationships.slice(0, 1).forEach((relType: any) => {
49 | const originLabels = schema.relationships[relType].startNodes;
50 | originLabels.forEach((originLabel: any) => {
51 | if (label !== originLabel) {
52 | findMatchableProperties(schema, label).slice(0, 2).forEach((property: any) => {
53 | templates.push(buildMatchWhereNodePatternQuery(label, schema, property, relType, originLabel, false));
54 | });
55 | }
56 | })
57 | });
58 | })
59 |
60 | // Rel prop mtaching
61 | schema?.relationships && Object.keys(schema.relationships)
62 | .filter((relType: any) => schema.relationships[relType].properties.length > 0)
63 | .forEach((relType: any) => {
64 | const startNodes = schema.relationships[relType].startNodes;
65 | const endNodes = schema.relationships[relType].endNodes;
66 | startNodes.forEach((startNode: any) => {
67 | endNodes.forEach((endNode: any) => {
68 | findMatchableRelProperties(schema, relType).slice(0, 1).forEach((property: any) => {
69 | templates.push(buildMatchWhereRelationshipQuery(startNode, schema, property, relType, endNode, true));
70 | });
71 | })
72 | })
73 | })
74 | return templates;
75 | }
76 |
77 |
78 | /**
79 | * Build a query that matches all nodes of a given label and returns all of their properties.
80 | * @param {any} label The label of the nodes to match.
81 | * @param {any[]} schema The schema of the graph.
82 | * @returns An object containing the generated query, a description of the query, a rich description of the query as a React element, a dictionary of query elements, and the items of the query as an array of arrays.
83 | */
84 | function buildMatchAllQuery(label: any, schema: any[]) {
85 | const queryElements = {};
86 | const line1 = [];
87 | const line2 = [];
88 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
89 | const alias = label[0].toLowerCase();
90 | line1.push(insertNodeDefinition(queryElements, alias, label));
91 | line2.push(insertClauseDefinition(queryElements, 'RETURN'));
92 | // @ts-ignore
93 | const properties = schema.nodes[label].properties;
94 | properties.forEach((property: any) => {
95 | line2.push(insertVariableDefinition(queryElements, alias + '.' + property.key));
96 | });
97 | const queryItems = [line1, line2];
98 | const cypher = generateCypher(queryItems, queryElements, 0);
99 | const description = 'Find all ' + label + ' nodes and return their properties.';
100 | const richDescription = Find all {label} nodes and return their properties. ;
101 | return {
102 | description: description,
103 | richDescription: richDescription,
104 | cypher: cypher,
105 | elements: queryElements,
106 | items: queryItems
107 | };
108 | }
109 |
110 |
111 | /**
112 | * Build a query that matches all nodes of a given label, filters all nodes where a given property has a given value, and returns the nodes.
113 | * @param {any} label The label of the nodes to match.
114 | * @param {any[]} schema The schema of the graph.
115 | * @param {string} property The property of the nodes to filter on.
116 | * @returns An object containing the generated query, a description of the query, a rich description of the query as a React element, a dictionary of query elements, and the items of the query as an array of arrays.
117 | */
118 | function buildMatchWhereQuery(label: any, schema: any[], property: string) {
119 |
120 | const queryElements = {};
121 | const line1 = [];
122 | const line2 = [];
123 | const line3 = [];
124 |
125 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
126 | const alias = label[0].toLowerCase();
127 | line1.push(insertNodeDefinition(queryElements, alias, label));
128 |
129 | line2.push(insertClauseDefinition(queryElements, 'WHERE'));
130 | const value = property['type'] == 'string' ? `"${property['value']}"` : property['value'];
131 | line2.push(insertComparisonDefinition(queryElements, alias + '.' + property['key'], value));
132 |
133 | line3.push(insertClauseDefinition(queryElements, 'RETURN'));
134 | line3.push(insertVariableDefinition(queryElements, alias));
135 |
136 | const queryItems = [line1, line2, line3];
137 | const cypher = generateCypher(queryItems, queryElements, 0);
138 | const description = 'Find ' + label + ' nodes where ' + property['key'] + ' is ' + value + '.';
139 | const richDescription = Find {label} nodes where property {' ' + property['key']} is {' ' + value + '.'} ;
140 | return {
141 | description: description,
142 | richDescription: richDescription,
143 | cypher: cypher,
144 | elements: queryElements,
145 | items: queryItems
146 | };
147 | }
148 |
149 |
150 | /**
151 | * Build a query that matches all nodes of a given label, filters all nodes where a given string property contains a given value, and returns the nodes.
152 | * @param {any} label The label of the nodes to match.
153 | * @param {any[]} schema The schema of the graph.
154 | * @param {string} property The property of the nodes to filter on.
155 | * @returns An object containing the generated query, a description of the query, a rich description of the query as a React element, a dictionary of query elements, and the items of the query as an array of arrays.
156 | */
157 | function buildMatchWhereStringContainsQuery(label: any, schema: any[], property: string) {
158 |
159 | const queryElements = {};
160 | const line1 = [];
161 | const line2 = [];
162 | const line3 = [];
163 |
164 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
165 | const alias = label[0].toLowerCase();
166 | line1.push(insertNodeDefinition(queryElements, alias, label));
167 |
168 | line2.push(insertClauseDefinition(queryElements, 'WHERE'));
169 | const value = property['type'] == 'string' ? `"${property['value']}"` : property['value'];
170 | line2.push(insertStringComparisonDefinition(queryElements, alias + '.' + property['key'], value));
171 |
172 | line3.push(insertClauseDefinition(queryElements, 'RETURN'));
173 | line3.push(insertVariableDefinition(queryElements, alias));
174 |
175 | const queryItems = [line1, line2, line3];
176 | const cypher = generateCypher(queryItems, queryElements, 0);
177 | const description = 'Find ' + label + ' nodes where ' + property['key'] + ' contains ' + value + '.';
178 | const richDescription = Find {label} nodes where property {' ' + property['key']} contains {' ' + value + '.'} ;
179 | return {
180 | description: description,
181 | richDescription: richDescription,
182 | cypher: cypher,
183 | elements: queryElements,
184 | items: queryItems
185 | };
186 | }
187 |
188 | /**
189 | * Build a query that matches all nodes of a given label, returns the nodes, and orders them by a given property.
190 | * @param {any} label The label of the nodes to match.
191 | * @param {any[]} schema The schema of the graph.
192 | * @param {string} property The property of the nodes to order by.
193 | * @returns An object containing the generated query, a description of the query, a rich description of the query as a React element, a dictionary of query elements, and the items of the query as an array of arrays.
194 | */
195 | function buildMatchOrderByQuery(label: any, schema: any[], property: string) {
196 |
197 | const queryElements = {};
198 | const line1 = [];
199 | const line2 = [];
200 | const line3 = [];
201 | const line4 = [];
202 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
203 | const alias = label[0].toLowerCase();
204 | line1.push(insertNodeDefinition(queryElements, alias, label));
205 |
206 |
207 | line2.push(insertClauseDefinition(queryElements, 'RETURN'));
208 | // @ts-ignore
209 | findMatchableProperties(schema, label).forEach((property: any) => {
210 | line2.push(insertVariableDefinition(queryElements, alias + '.' + property['key']));
211 | });
212 | if (findMatchableProperties(schema, label).filter((p: any) => p['key'] == property['key']).length == 0) {
213 | line2.push(insertVariableDefinition(queryElements, alias + '.' + property['key']));
214 | }
215 |
216 |
217 | line3.push(insertClauseDefinition(queryElements, 'ORDER BY'));
218 | line3.push(insertVariableDefinition(queryElements, alias + '.' + property['key']));
219 | line3.push(insertOperatorDefinition(queryElements, ' DESC'));
220 | line4.push(insertTransformationDefinition(queryElements, 'LIMIT ', '10'));
221 | const queryItems = [line1, line2, line3, line4];
222 | const cypher = generateCypher(queryItems, queryElements, 0);
223 | const description = 'Find ' + label + ' nodes with the top 10 values for property ' + property['key'] + '.';
224 | const richDescription = Find {label} nodes with the top 10 values for property {' ' + property['key']} . ;
225 | return {
226 | description: description,
227 | richDescription: richDescription,
228 | cypher: cypher,
229 | elements: queryElements,
230 | items: queryItems
231 | };
232 | }
233 |
234 | /**
235 | * Build a query that matches all nodes of a given label that have a given outgoing or incoming relationship of a given type to a node of a given label, and filters those nodes where a given property has a given value, and returns the node, the relationship, and the other node.
236 | * @param {any} label The label of the nodes to match.
237 | * @param {any[]} schema The schema of the graph.
238 | * @param {string} property The property of the nodes to filter on.
239 | * @param {string} relType The type of the relationship.
240 | * @param {string} otherLabel The label of the other node.
241 | * @param {boolean} outgoing Whether the relationship is outgoing or incoming.
242 | * @returns An object containing the generated query, a description of the query, a rich description of the query as a React element, a dictionary of query elements, and the items of the query as an array of arrays.
243 | */
244 | function buildMatchWhereNodePatternQuery(label: any, schema: any[], property: string, relType: string, otherLabel: string, outgoing: boolean) {
245 |
246 | const queryElements = {};
247 | const line1 = [];
248 | const line2 = [];
249 | const line3 = [];
250 |
251 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
252 | const nodeAlias = label[0].toLowerCase();
253 | const relAlias = relType[0].toLowerCase();
254 | const otherNodeAlias = otherLabel[0].toLowerCase();
255 | line1.push(insertNodeDefinition(queryElements, nodeAlias, label));
256 | line1.push(insertRelationshipDefinition(queryElements, relAlias, relType, outgoing ? 'OUTGOING' : 'INCOMING'));
257 | line1.push(insertNodeDefinition(queryElements, otherNodeAlias, otherLabel));
258 |
259 | line2.push(insertClauseDefinition(queryElements, 'WHERE'));
260 | const value = property['type'] == 'string' ? `"${property['value']}"` : property['value'];
261 | line2.push(insertComparisonDefinition(queryElements, nodeAlias + '.' + property['key'], value));
262 |
263 | line3.push(insertClauseDefinition(queryElements, 'RETURN'));
264 | line3.push(insertVariableDefinition(queryElements, nodeAlias));
265 | line3.push(insertVariableDefinition(queryElements, relAlias));
266 | line3.push(insertVariableDefinition(queryElements, otherNodeAlias));
267 | const queryItems = [line1, line2, line3];
268 | const cypher = generateCypher(queryItems, queryElements, 0);
269 | const description = 'Find ' + label + ' nodes that have an ' + (outgoing ? 'outgoing' : 'incoming') + ' relationship of type ' + relType + ' where ' + property['key'] + ' is ' + value + '.';
270 | const richDescription =
271 | Find {label} nodes that have an <>{outgoing ? 'outgoing' : 'incoming'} > relationship of type
272 | {relType} {outgoing ? 'to' : 'from'} {otherLabel} where the {label}'s {' ' + property['key']} property is {' ' + value + '.'}
273 | ;
274 | return {
275 | description: description,
276 | richDescription: richDescription,
277 | cypher: cypher,
278 | elements: queryElements,
279 | items: queryItems
280 | };
281 | }
282 |
283 | function buildMatchWhereRelationshipQuery(label: any, schema: any[], property: string, relType: string, otherLabel: string, outgoing: boolean) {
284 |
285 | const queryElements = {};
286 | const line1 = [];
287 | const line2 = [];
288 | const line3 = [];
289 |
290 | line1.push(insertClauseDefinition(queryElements, 'MATCH'));
291 | const nodeAlias = label[0].toLowerCase();
292 | const relAlias = relType[0].toLowerCase();
293 | const otherNodeAlias = otherLabel[0].toLowerCase();
294 | line1.push(insertNodeDefinition(queryElements, nodeAlias, label));
295 | line1.push(insertRelationshipDefinition(queryElements, relAlias, relType, outgoing ? 'OUTGOING' : 'INCOMING'));
296 | line1.push(insertNodeDefinition(queryElements, otherNodeAlias, otherLabel));
297 |
298 | line2.push(insertClauseDefinition(queryElements, 'WHERE'));
299 | let value = property['type'] == 'string' ? `"${property['value']}"` : property['value'];
300 | value = property['type'] == 'array' ? `["${property['value']}"]` : value;
301 | line2.push(insertComparisonDefinition(queryElements, relAlias + '.' + property['key'], value));
302 |
303 | line3.push(insertClauseDefinition(queryElements, 'RETURN'));
304 | line3.push(insertVariableDefinition(queryElements, nodeAlias));
305 | line3.push(insertVariableDefinition(queryElements, relAlias));
306 | line3.push(insertVariableDefinition(queryElements, otherNodeAlias));
307 | const queryItems = [line1, line2, line3];
308 | const cypher = generateCypher(queryItems, queryElements, 0);
309 | const description = 'Find ' + relType + ' relationships where property ' + property['key'] + ' is ' + value + '.';
310 | const richDescription =
311 | Find {relType} relationships where property {' ' + property['key']} is {' ' + value + '.'}
312 | ;
313 | return {
314 | description: description,
315 | richDescription: richDescription,
316 | cypher: cypher,
317 | elements: queryElements,
318 | items: queryItems
319 | };
320 | }
321 |
322 |
323 | function findMatchableProperties(schema: any, label: any,) {
324 | const properties = schema.nodes[label].properties.filter(p => p.indexed);
325 | if (properties.length > 0) {
326 | return properties;
327 | } else if (schema.nodes[label].properties?.length > 0) {
328 | return [schema.nodes[label].properties[0]];
329 | }
330 | return [];
331 | }
332 |
333 | function findMatchableRelProperties(schema: any, type: any,) {
334 | const properties = schema?.relationships[type].properties.filter(p => p.indexed);
335 | if (properties.length > 0) {
336 | return properties;
337 | } else if (schema?.relationships[type].properties?.length > 0) {
338 | return [schema?.relationships[type].properties[0]];
339 | }
340 | return [];
341 | }
342 |
343 | function findOrderableProperties(schema: any, label: any,) {
344 | const properties = schema.nodes[label].properties.filter(p => p.type == 'integer');
345 | if (properties.length > 0) {
346 | return properties;
347 | } else if (schema.nodes[label].properties?.length > 0) {
348 | return [schema.nodes[label].properties[0]];
349 | }
350 | return [];
351 | }
352 |
353 | function findStringProperties(schema: any, label: any,) {
354 | const properties = schema.nodes[label].properties.filter(p => p.type == 'string');
355 | if (properties.length > 0) {
356 | return properties;
357 | } else if (schema.nodes[label].properties?.length > 0) {
358 | return [schema.nodes[label].properties[0]];
359 | }
360 | return [];
361 | }
362 |
363 |
364 |
--------------------------------------------------------------------------------
/src/builder/Builder.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import {
3 | DndContext,
4 | DragOverlay,
5 | KeyboardSensor,
6 | PointerSensor,
7 | useSensor,
8 | useSensors,
9 | } from "@dnd-kit/core";
10 | import SideContainer from "../builder/container/SideContainer";
11 | import QueryContainer from "../builder/container/QueryContainer";
12 | import FooterContainer from "../builder/container/FooterContainer";
13 | import SortableBlock from "../builder/sortable/SortableBlock";
14 | import { handleDragEnd, handleDragOver, handleDragStart, handleSortableItemClick, handleSortableItemDelete } from "./BuilderEventHandler";
15 | import { resetBuilderQuery, initializeBuilder, SIDEBAR_CONTAINER_COUNT, sidebarCategories, updateBuilderNodesRelationships, FOOTER_CONTAINER_COUNT } from "./logic/BuilderLogic";
16 | import CodeEditor, { generateCypher } from "./editor/CodeEditor";
17 | import { Accordion, DropdownButton, IconButton, Menu, Tag, toast, Toaster, Tooltip } from "@neo4j-ndl/react";
18 | import { ClipboardDocumentIconOutline, PlayCircleIconOutline, QueryBrowserIcon, TrashIconOutline, VariableIconSolid, WrenchScrewdriverIconOutline, WrenchScrewdriverIconSolid } from "@neo4j-ndl/react/icons";
19 | import { extractVariables, updateBuilderVariables } from "./logic/VariablesLogic";
20 | import TemplatesModal from "./templates/TemplatesModal";
21 | import { updateCypherWizard } from "./logic/WizardLogic";
22 |
23 | const wrapperStyle = {
24 | display: "flex",
25 | };
26 |
27 |
28 | /**
29 | * The main Cypher builder component, containing three columns:
30 | * - a sidebar with a list of SideContainers (the gallery of blocks)
31 | * - The builder container, where the user can drag and drop blocks, and has the Wizard under it.
32 | * - The code editor, where the user can view the Cypher query.
33 | *
34 | * @param {Boolean} props.connected - Whether the user is connected to the database
35 | * @param {Object} props.connection - The connection object
36 | * @param {Object} props.schema - The schema of the database
37 | * @param {Object[]} props.queryTemplates - The query templates
38 | * @returns {React.ReactElement} - The Cypher builder component
39 | */
40 | export default function Builder(props: {
41 | connected: boolean,
42 | connection: any,
43 | schema: any,
44 | queryTemplates: any
45 | }) {
46 | const { connected, connection, schema, queryTemplates } = props;
47 |
48 | const [elements, setElements] = useState({});
49 | const [items, setItems] = useState([]);
50 | const [activeId, setActiveId] = useState(undefined);
51 | const prevVariables = useRef([]);
52 | const variables = extractVariables(items, elements);
53 | const prevLastRow = useRef([]);
54 | const lastRow = items[items.length - 2] && items[items.length - 2].map(id => elements[id]);
55 | const [wizardIsActive, setWizardIsActive] = useState(false);
56 | const [wizardCaption, setWizardCaption] = useState('Start by adding a matching pattern.');
57 | const handleExpanded = (value: unknown) => console.info(`Here is the value of the expanded boolean value: ${value}`);
58 | const firstId = [...Array(SIDEBAR_CONTAINER_COUNT).keys()][0];
59 | const [expandedItemIdsValues, setExpandedIdOrIds] = useState([firstId]);
60 | const [variablesExpanded, setVariablesExpanded] = useState(false);
61 | const [templatesModalNeverOpened, setTemplatesModalNeverOpened] = useState(true);
62 | const [templatesModalOpen, setTemplatesModalOpen] = useState(false);
63 |
64 | // Create a ref for the DropdownButton
65 | const variablesButtonRef = useRef(null);
66 |
67 | // TODO - only regenerate on elements change, not every render
68 | const cypher = generateCypher(items, elements);
69 |
70 |
71 | // Initialize the builder
72 | if (Object.keys(elements).length === 0) {
73 | const { newItems, newElements } = initializeBuilder();
74 | setItems(newItems);
75 | setElements(newElements);
76 | }
77 |
78 | // Sensors for the DND layout.
79 | const sensors = useSensors(
80 | useSensor(PointerSensor, {
81 | activationConstraint: {
82 | distance: 2,
83 | },
84 | }),
85 | );
86 |
87 | // Detect whether we need to update the builder blocks based on the latest state.
88 | useEffect(() => {
89 | let { newItems, newElements } = { newItems: items, newElements: elements };
90 |
91 | // Check if variables changed.
92 | const oldVariables = prevVariables.current;
93 | prevVariables.current = variables;
94 |
95 | if (JSON.stringify(oldVariables) !== JSON.stringify(variables)) {
96 | const result = updateBuilderVariables(items, elements, variables, schema);
97 | const result2 = updateBuilderNodesRelationships(result.newItems, result.newElements, schema, variables);
98 |
99 | newItems = result2.newItems;
100 | newElements = result2.newElements;
101 | }
102 |
103 | // Check if last row changed.
104 | const oldLastRow = prevLastRow.current;
105 | prevLastRow.current = lastRow;
106 |
107 | if (JSON.stringify(oldLastRow) !== JSON.stringify(lastRow)) {
108 | const result = updateCypherWizard(newItems, newElements, variables, schema);
109 | setWizardIsActive(true);
110 | setTimeout(() => setWizardIsActive(false), 500);
111 | newItems = result.newItems;
112 | newElements = result.newElements;
113 | setWizardCaption(result.caption);
114 | }
115 |
116 | setElements(newElements);
117 | setItems(newItems);
118 | }, [JSON.stringify(variables), JSON.stringify(lastRow)])
119 |
120 |
121 |
122 |
123 | useEffect(() => {
124 | // Only care about schema if there's at least one node label defined.
125 | if (schema?.nodes) {
126 | const { newItems, newElements } = updateBuilderNodesRelationships(items, elements, schema, variables);
127 | setItems(newItems);
128 | setElements(newElements);
129 | }
130 | }, [JSON.stringify(schema)])
131 |
132 |
133 |
134 | return (
135 |
136 |
handleDragStart(event, items, elements, setItems, setElements, setActiveId)}
140 | onDragOver={(event) => handleDragOver(event, activeId, items, setItems)}
141 | onDragEnd={(event) => handleDragEnd(event, activeId, items, setItems, elements, setElements, setActiveId)}
142 | >
143 |
155 |
156 | {[...Array(SIDEBAR_CONTAINER_COUNT).keys()].map((id, index) => (
157 |
158 | {true ?
159 | setElements({ ...elements, [id]: element })}
166 | dragging={activeId !== undefined}
167 | onClick={(
168 | id: string | number,
169 | itemRect: { width: number; left: number; top: number; height: number; }) => handleSortableItemClick(id, itemRect, items, elements, variables, schema, setActiveId, setItems, setElements)}
170 | /> : <>>
171 | }
172 |
173 | ))}
174 |
175 |
176 |
187 |
Builder
188 |
189 |
190 |
191 |
192 | {
194 | const { newItems, newElements } = resetBuilderQuery(items, elements);
195 | setItems(newItems);
196 | setElements(newElements);
197 | }} >
198 |
199 |
200 |
201 | Reset
202 |
203 |
204 |
205 |
206 | {
207 | setTemplatesModalNeverOpened(false);
208 | setTemplatesModalOpen(true);
209 | }} >
210 |
211 | {templatesModalNeverOpened ?
229 | {queryTemplates?.length}
230 | : <>>}
231 |
232 |
233 | Query templates
234 |
235 |
236 |
237 |
238 | {items
239 | .slice(SIDEBAR_CONTAINER_COUNT + 1)
240 | .map((containerItems, index) => (
241 |
{
249 | const newElements = { ...elements };
250 | newElements[id] = element;
251 | setElements(newElements);
252 | }}
253 | containerItems={containerItems}
254 | onClick={(id: any) => console.log(id)}
255 | onShiftClick={(id: any) => handleSortableItemDelete(id, items, elements, setItems, setElements, setActiveId)}
256 | dragging={activeId !== undefined}
257 | />
258 | ))}
259 |
260 |
261 |
262 | 🧙
263 | {wizardIsActive ?
264 |
265 | :
}
266 | Cypher Wizard:
267 |
268 |
{wizardCaption}
269 |
270 |
271 |
280 |
281 |
292 |
Query
293 |
294 | {/* {variables.length > 0 ?
295 |
296 |
297 |
298 | setVariablesExpanded(old => !old)
303 | }} ariaLabel={"Variables"}>
304 |
305 |
306 |
307 |
setVariablesExpanded(false)}>
308 |
309 | {variables.map((variable: any) => (
310 | {variable.text} {variable.types.filter(v => v !== 'text').join(', ')} } onExpandedChange={(expanded: boolean | ((prevState: boolean) => boolean)) => setVariablesExpanded(expanded)}>
314 |
315 | ))}
316 |
317 |
318 |
319 |
320 | Show variables
321 |
322 | : <>>} */}
323 |
324 |
325 |
326 | {
327 | const id = toast.neutral('Copied to clipboard', { shouldAutoClose: true, isCloseable: true });
328 | navigator.clipboard.writeText(cypher);
329 | toast.close(id);
330 | }} >
331 |
332 |
333 |
334 | Copy to clipboard
335 |
336 |
337 | {connected ?
338 |
339 |
340 | {
341 | window.open('https://browser.neo4j.io/?connectURL=' + connection.protocol + '%2Bs%3A%2F%2F' + connection.user + '%40' + connection.uri + '%3A' + connection.port, '_blank');
342 | }} >
343 |
344 |
345 |
346 | Open Browser
347 | : <>>}
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 | {activeId ? setActiveId(undefined)}
362 | onShiftClick={undefined}
363 | interactive={false}
364 | hasTooltip={false}
365 | setElement={undefined}
366 | schema={undefined}
367 | variables={undefined} /> : null}
368 |
369 |
370 |
371 |
setTemplatesModalOpen(false)}
374 | templates={queryTemplates}
375 | elements={elements}
376 | items={items}
377 | setElements={setElements}
378 | setItems={setItems}
379 | />
380 |
381 | );
382 | }
--------------------------------------------------------------------------------