diff --git a/docker-compose.yml b/docker-compose.yml index ad6c2e5..5e9fdb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: stdin_open: true environment: - WATCHPACK_POLLING=true + - REACT_APP_HOST=gremlin-server networks: QuantumSupplyChain: diff --git a/src/app/store.ts b/src/app/store.ts index 2fb7491..6b5a850 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -3,13 +3,14 @@ import gremlinReducer from '../reducers/gremlinReducer'; import graphReducer from '../reducers/graphReducer'; import optionReducer from '../reducers/optionReducer'; import dialogReducer from '../reducers/dialogReducer'; +import queryBuilderReducer from '../reducers/queryBuilderReducer'; import { useDispatch } from "react-redux"; // const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; export const setupStore = (preloadedState: any = {}) => configureStore({ - reducer: { gremlin: gremlinReducer, graph: graphReducer, options: optionReducer, dialog: dialogReducer }, + reducer: { gremlin: gremlinReducer, graph: graphReducer, options: optionReducer, dialog: dialogReducer, queryBuilder: queryBuilderReducer }, preloadedState // composeEnhancers(applyMiddleware(createLogger())) }); diff --git a/src/components/Details/QueryBuilderComponent.tsx b/src/components/Details/QueryBuilderComponent.tsx new file mode 100644 index 0000000..323f1b1 --- /dev/null +++ b/src/components/Details/QueryBuilderComponent.tsx @@ -0,0 +1,348 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Autocomplete, Box, Button, FormControl, Grid, IconButton, InputLabel, MenuItem, Paper, Select, SelectChangeEvent, Stack, TextField } from '@mui/material'; +import { COMMON_GREMLIN_ERROR, QUERY_ENDPOINT } from '../../constants'; +import { onFetchQuery } from '../../logics/actionHelper'; +import { selectOptions, setLayout } from '../../reducers/optionReducer'; +import _ from 'lodash'; +import { clearGraph, selectGraph } from '../../reducers/graphReducer'; +import { selectGremlin, setError } from '../../reducers/gremlinReducer'; +import axios from 'axios'; +import { applyLayout } from '../../logics/graph'; +import { type } from 'os'; +import { selectDialog } from '../../reducers/dialogReducer'; +import { DIALOG_TYPES } from '../ModalDialog/ModalDialogComponent'; +import Typography from '@mui/material/Typography'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import { EditText } from 'react-edit-text'; +import ClearIcon from '@mui/icons-material/Clear'; +import { highlightNodesAndEdges } from '../../logics/graphImpl/visImpl'; +import { GRAPH_IMPL } from '../../constants'; +import { addWhereField, removeWhereField, selectQueryBuilder, setClause, setPropertyName, setPropertyValue, setSelectedType } from '../../reducers/queryBuilderReducer'; + + +export type WhereField = { + propertyName: string; + whereClause: string; + propertyValue: string; + operator: string; +}; + +export const OPERATORS = { + NONE: 'none', + AND: 'And', + OR: 'Or' +}; +export const CLAUSES = { + GREATER_THAN: '>', + LESS_THAN: '<', + GREATER_THAN_EQ: '>=', + LESS_THAN_EQ: '<=', + EQUAL: '==' +}; + + +export const QueryBuilder = () => { + const { nodeLabels, nodeLimit } = useSelector(selectOptions); + const { suggestions } = useSelector(selectDialog); + const dispatch = useDispatch(); + const { host, port } = useSelector(selectGremlin); + const types = ["Component", "Entity", "Material"]; + const { selectedType, whereFields, whereOptions } = useSelector(selectQueryBuilder); + + + const addFields = (operator: string) => { + dispatch(addWhereField(operator)); + }; + + const removeFields = (index: number) => { + dispatch(removeWhereField(index)); + }; + + + const handleSelectChange = (event: SelectChangeEvent) => { + const { value } = event.target + const selectedType = value; + dispatch(setSelectedType({ selectedType, suggestions })) + }; + + const handleWhereChange = (event: SelectChangeEvent, index: number) => { + const { value } = event.target; + dispatch(setPropertyName({ value, index })); + }; + const handleClauseChange = (event: SelectChangeEvent, index: number) => { + const { value } = event.target; + dispatch(setClause({ value, index })); + }; + + const handleValueChange = (event: React.ChangeEvent, index: number) => { + const { value } = event.target; + dispatch(setPropertyValue({ value, index })) + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + dispatch(clearGraph()); + dispatch(setError(null)); + const query = buildNestedQuery(whereFields) + + if (GRAPH_IMPL === 'vis') { + highlightNodesAndEdges(null, null); + } + axios + .post( + QUERY_ENDPOINT, + { host, port, query, nodeLimit }, + { headers: { 'Content-Type': 'application/json' } } + ) + .then((response) => { + onFetchQuery(response, query, nodeLabels, dispatch); + }) + .catch((error) => { + console.warn(error) + dispatch(setError(COMMON_GREMLIN_ERROR)); + }); + } + + function buildNestedQuery(whereFields: WhereField[]): string { + if (whereFields.length === 0) { + return "g.V()"; + } + function convertClauseToPredicate(clause: string, value: any) { + switch (clause) { + case CLAUSES.GREATER_THAN: + return `P.gt(${value})`; + case CLAUSES.GREATER_THAN_EQ: + return `P.gte(${value})`; + case CLAUSES.LESS_THAN: + return `P.lt(${value})`; + case CLAUSES.LESS_THAN_EQ: + return `P.lte(${value})`; + case CLAUSES.EQUAL: + default: + return `'${value}'`; + } + } + + function buildTraversal(fields: WhereField[], isFirst: boolean): string { + const firstField = fields[0]; + const restFields = fields.slice(1); + + const predicate = convertClauseToPredicate(firstField.whereClause, firstField.propertyValue); + const currentStep = `__.has('${firstField.propertyName}', ${predicate})`; + + const prefix = isFirst == true ? '' : '__'; + + if (restFields.length === 0 && firstField.operator === OPERATORS.NONE) { + if (isFirst) { + return `.has('${firstField.propertyName}', ${predicate})`; + } + else { + return currentStep; + } + } + else if (firstField.operator === OPERATORS.AND) { + return `${prefix}.and(${currentStep}, ${buildTraversal(restFields, false)})`; + } + else if (firstField.operator === OPERATORS.OR) { + return `${prefix}.or(${currentStep}, ${buildTraversal(restFields, false)})`; + } + return ""; + } + let reversedWhereFields = [...whereFields].reverse(); + return `g.V().hasLabel('${selectedType}')${buildTraversal(reversedWhereFields, true)}`; + } + + const whereRow = (form: WhereField, index: number) => { + return ( + + {whereFields[index].operator !== OPERATORS.NONE && ( + + {whereFields[index].operator} + + )} + + + + WHERE + + + + + + + CLAUSE + + + + + handleValueChange(event, index)} + fullWidth + sx={{ + width: "100%", border: '1px solid #ccc', + borderRadius: '4px', + paddingBottom: '8px', margin: '0px', paddingLeft: '10px', boxSizing: 'border-box' + }} + variant="standard" + + InputLabelProps={{ + sx: { + marginLeft: '8px' + } + }} + /> + + removeFields(index)} color="secondary"> + + + + + ) + } + + return ( + + + + SELECT + + + +
+ + } + aria-controls="panel2-content" + id="panel2-header" + > + WHERE + + + + {whereFields.map((form, index) => ( + + {whereRow(form, index)} + + ))} + + + {whereFields.length === 0 ? ( + + ) : ( + <> + + + + )} + + + + +
+
+ ); +}; diff --git a/src/components/Details/QueryComponent.tsx b/src/components/Details/QueryComponent.tsx index 3e9fbd9..cbdbc11 100644 --- a/src/components/Details/QueryComponent.tsx +++ b/src/components/Details/QueryComponent.tsx @@ -9,6 +9,7 @@ import { COMMON_GREMLIN_ERROR, QUERY_ENDPOINT } from "../../constants"; import { onFetchQuery } from "../../logics/actionHelper"; import { RootState } from "../../app/store"; import { highlightNodesAndEdges } from "../../logics/graphImpl/visImpl"; +import { GRAPH_IMPL } from "../../constants"; const Query = ({ }) => { const dispatch = useDispatch() @@ -24,7 +25,9 @@ const Query = ({ }) => { function sendQuery() { dispatch(setError(null)); - highlightNodesAndEdges(null, null); + if (GRAPH_IMPL === 'vis') { + highlightNodesAndEdges(null, null); + } axios .post( QUERY_ENDPOINT, diff --git a/src/components/Details/SidebarComponent.tsx b/src/components/Details/SidebarComponent.tsx index 93c4f25..228005b 100644 --- a/src/components/Details/SidebarComponent.tsx +++ b/src/components/Details/SidebarComponent.tsx @@ -6,6 +6,7 @@ import Query from "./QueryComponent"; import { Settings } from "./SettingsComponent"; import { DetailsComponent } from "./DetailsComponent"; import CollapsibleTable from "./TableComponent"; +import { QueryBuilder } from "./QueryBuilderComponent"; import SavedQueries from "./SavedQueriesComponent"; import EntityTables from "./EntityDetails/EntityTables"; import PlayCircleFilledIcon from '@mui/icons-material/PlayCircleFilled'; @@ -13,6 +14,7 @@ import TocIcon from '@mui/icons-material/Toc'; import SettingsIcon from '@mui/icons-material/Settings'; import GradeIcon from '@mui/icons-material/Grade'; import DatasetIcon from '@mui/icons-material/Dataset'; +import BuildCircleIcon from '@mui/icons-material/BuildCircle'; import BackupTableIcon from '@mui/icons-material/BackupTable'; type QueryHistoryProps = { @@ -110,8 +112,11 @@ export const SidebarComponent = (props: SidebarComponentProps) => { } value={4} /> + + } value={5} /> + - } value={5} /> + } value={6} /> @@ -136,6 +141,9 @@ export const SidebarComponent = (props: SidebarComponentProps) => { + + + diff --git a/src/components/Header/HeaderComponent.tsx b/src/components/Header/HeaderComponent.tsx index cbb3a93..29b99ff 100644 --- a/src/components/Header/HeaderComponent.tsx +++ b/src/components/Header/HeaderComponent.tsx @@ -6,8 +6,8 @@ import { onFetchQuery } from '../../logics/actionHelper'; import { selectOptions, setLayout } from '../../reducers/optionReducer'; import style from './HeaderComponent.module.css';; import _ from 'lodash'; +import { selectGremlin, setError } from '../../reducers/gremlinReducer'; import { clearGraph, selectGraph } from '../../reducers/graphReducer'; -import { selectGremlin } from '../../reducers/gremlinReducer'; import axios from 'axios'; import { applyLayout } from '../../logics/graph'; @@ -18,9 +18,8 @@ interface HeaderComponentProps { export const HeaderComponent = (props: HeaderComponentProps) => { const { nodeLabels, nodeLimit } = useSelector(selectOptions); const { selectorNodes } = useSelector(selectGraph); - const [error, setError] = useState(null); const dispatch = useDispatch(); - const { host, port } = useSelector(selectGremlin); + const { host, port, error } = useSelector(selectGremlin); const componentNames = selectorNodes.filter(node => node.type === 'Component').map(node => node.properties.name); const [selectedComponentNames, setSelectedComponentNames] = React.useState([]); @@ -67,7 +66,7 @@ export const HeaderComponent = (props: HeaderComponentProps) => { dispatch(setLayout("hierarchical")); let queryToSend = ''; let str = ''; - setError(null); + dispatch(setError(null)); if (selectedSupplierNames.length > 0) { str = selectedSupplierNames.map((gr) => `'${gr}'`).join(','); queryToSend = `g.V().has("Entity", "name", within(${str})).emit().repeat(out())`; @@ -101,143 +100,147 @@ export const HeaderComponent = (props: HeaderComponentProps) => { }) .catch((error) => { console.warn(error) - setError(COMMON_GREMLIN_ERROR); + dispatch(setError(COMMON_GREMLIN_ERROR)); }); } return ( - - - - Select Component - - - - - - Select Supplier - - - - - - Select Material - - - - -
- - -
{error}
+ + + + + Select Component + + + + + + Select Supplier + + + + + + Select Material + + + + +
+ + +
+ {error &&
+ {error} +
}
); }; diff --git a/src/constants.js b/src/constants.js index 827fe5b..9b2c36c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,4 +1,5 @@ const SERVER_URL = 'http://localhost:3001'; +export const HOST = process.env.REACT_APP_HOST || 'localhost'; export const QUERY_ENDPOINT = `${SERVER_URL}/query`; export const QUERY_RAW_ENDPOINT = `${SERVER_URL}/query-raw`; export const QUERY_ENTITY_ENDPOINT = `${SERVER_URL}/query-entity-tables`; diff --git a/src/reducers/gremlinReducer.ts b/src/reducers/gremlinReducer.ts index cbd9157..506b3e1 100644 --- a/src/reducers/gremlinReducer.ts +++ b/src/reducers/gremlinReducer.ts @@ -1,8 +1,9 @@ import { createSlice } from '@reduxjs/toolkit'; import { RootState } from '../app/store'; +import {HOST} from '../constants.js' const initialState = { - host: 'gremlin-server', + host: HOST, port: '8182', query: 'g.V()', error: null diff --git a/src/reducers/queryBuilderReducer.ts b/src/reducers/queryBuilderReducer.ts new file mode 100644 index 0000000..80a4bf3 --- /dev/null +++ b/src/reducers/queryBuilderReducer.ts @@ -0,0 +1,87 @@ +// import vis from 'vis-network'; +import { createSlice } from '@reduxjs/toolkit'; +import { RootState } from '../app/store'; +import _ from 'lodash'; +import { CLAUSES, OPERATORS, WhereField } from '../components/Details/QueryBuilderComponent'; +import { DIALOG_TYPES } from '../components/ModalDialog/ModalDialogComponent'; + +type QueryBuilderState = { + selectedType: string, + whereFields: WhereField[], + whereOptions: string[] +}; + +const initialState: QueryBuilderState = { + selectedType: "", + whereFields: [{ propertyName: '', whereClause: CLAUSES.EQUAL, propertyValue: '', operator: OPERATORS.NONE }], + whereOptions: [] + +}; + +const slice = createSlice({ + name: 'queryBuilder', + initialState, + reducers: { + setSelectedType: (state, action) => { + const { selectedType, suggestions } = action.payload + state.selectedType = selectedType + state.whereOptions = suggestions[DIALOG_TYPES.NODE]?.labels[selectedType] ?? []; + }, + setPropertyName: (state, action) => { + const { value, index } = action.payload + state.whereFields = state.whereFields.map((whereField, i) => + i === index ? { ...whereField, propertyName: value } : whereField + ); + }, + setClause: (state, action) => { + const { value, index } = action.payload + state.whereFields = state.whereFields.map((whereField, i) => + i === index ? { ...whereField, whereClause: value } : whereField + ); + }, + setPropertyValue: (state, action) => { + const { value, index } = action.payload + state.whereFields = state.whereFields.map((whereField, i) => + i === index ? { ...whereField, propertyValue: value } : whereField + ); + }, + addWhereField: (state, action) => { + const operator = action.payload + let object = { + propertyName: '', + whereClause: CLAUSES.EQUAL, + propertyValue: '', + operator: operator, + }; + state.whereFields = [...state.whereFields, object]; + }, + removeWhereField: (state, action) => { + const index = action.payload; + let data = [...state.whereFields]; + if (index == 0 && data.length > 1) { + data[1] = { ...data[1], operator: OPERATORS.NONE } + } + data.splice(index, 1); + state.whereFields = data; + }, + setWhereOptions: (state, action) => { + const index = action.payload; + let data = [...state.whereFields]; + data.splice(index, 1); + state.whereFields = data; + }, + }, +}); + +export const { + setSelectedType, + setPropertyName, + setClause, + setPropertyValue, + addWhereField, + removeWhereField +} = slice.actions; + +export const selectQueryBuilder = (state: RootState) => state.queryBuilder; + +export default slice.reducer;