Allow client side request cancelling (#55)

* prevent graph select from wrapping and removed unnecessary iteration for id

* remove debug output statement

* add signal to request

* track active cypher requests

* allow long requests to be cancelled

* cleaned up code and removed logging statements

* merge with main update

* move styling to separate module and update select to fit/shrink when necessary

* Update SidebarComponents.jsx

Fix eslint issue

Co-authored-by: Hanbyeol Shin /  David Shin / 신한별 <76985229+shinhanbyeol@users.noreply.github.com>
diff --git a/frontend/src/components/contents/containers/Editor.js b/frontend/src/components/contents/containers/Editor.js
index a17cc3d..4b1c3e4 100644
--- a/frontend/src/components/contents/containers/Editor.js
+++ b/frontend/src/components/contents/containers/Editor.js
@@ -33,6 +33,7 @@
   database: state.database,
   command: state.editor.command,
   isActive: state.navigator.isActive,
+  activeRequests: state.cypher.activeRequests,
 });
 
 const mapDispatchToProps = {
diff --git a/frontend/src/components/contents/presentations/Editor.jsx b/frontend/src/components/contents/presentations/Editor.jsx
index 3411dbc..9b50d8c 100644
--- a/frontend/src/components/contents/presentations/Editor.jsx
+++ b/frontend/src/components/contents/presentations/Editor.jsx
@@ -31,6 +31,7 @@
 
 const Editor = ({
   setCommand,
+  activeRequests,
   command,
   addFrame,
   trimFrame,
@@ -41,11 +42,11 @@
   executeCypherQuery,
   addCommandHistory,
   toggleMenu,
-  getMetaData,
   // addCommandFavorites,
 }) => {
   const dispatch = useDispatch();
   const [alerts, setAlerts] = useState([]);
+  const [activePromises, setPromises] = useState({});
 
   // const favoritesCommand = () => {
   //   dispatch(() => addCommandFavorites(command));
@@ -56,7 +57,6 @@
   };
 
   const onClick = () => {
-    console.log('in editor presentation command is ', command);
     const refKey = uuid();
     if (command.toUpperCase().startsWith(':PLAY')) {
       dispatch(() => addFrame(command, 'Contents', refKey));
@@ -91,17 +91,31 @@
       }
     } else if (database.status === 'connected') {
       addFrame(command, 'CypherResultFrame', refKey);
-      dispatch(() => executeCypherQuery([refKey, command]).then((response) => {
+      const req = dispatch(() => executeCypherQuery([refKey, command]));
+      req.then((response) => {
         if (response.type === 'cypher/executeCypherQuery/rejected') {
-          dispatch(() => addAlert('ErrorCypherQuery'));
-        } else { dispatch(() => getMetaData()); }
-      }));
+          if (response.error.name !== 'AbortError') {
+            dispatch(() => addAlert('ErrorCypherQuery'));
+          }
+        }
+      });
+      activePromises[refKey] = req;
+      setPromises({ ...activePromises });
     }
     dispatch(() => addCommandHistory(command));
     clearCommand();
   };
 
   useEffect(() => {
+    const reqCancel = Object.keys(activePromises).filter((ref) => !activeRequests.includes(ref));
+    reqCancel.forEach((ref) => {
+      activePromises[ref].abort();
+      delete activePromises[ref];
+    });
+    setPromises({ ...activePromises });
+  }, [activeRequests]);
+
+  useEffect(() => {
     setAlerts(
       alertList.map((alert) => (
         <AlertContainers
@@ -190,6 +204,7 @@
 
 Editor.propTypes = {
   setCommand: PropTypes.func.isRequired,
+  activeRequests: PropTypes.arrayOf(PropTypes.string).isRequired,
   command: PropTypes.string.isRequired,
   addFrame: PropTypes.func.isRequired,
   trimFrame: PropTypes.func.isRequired,
@@ -210,7 +225,6 @@
   executeCypherQuery: PropTypes.func.isRequired,
   addCommandHistory: PropTypes.func.isRequired,
   toggleMenu: PropTypes.func.isRequired,
-  getMetaData: PropTypes.func.isRequired,
   // addCommandFavorites: PropTypes.func.isRequired,
 };
 
diff --git a/frontend/src/components/frame/Frame.jsx b/frontend/src/components/frame/Frame.jsx
index aa585fa..c4be9a0 100644
--- a/frontend/src/components/frame/Frame.jsx
+++ b/frontend/src/components/frame/Frame.jsx
@@ -27,6 +27,7 @@
 import { useDispatch } from 'react-redux';
 import styles from './Frame.module.scss';
 import { removeFrame } from '../../features/frame/FrameSlice';
+import { removeActiveRequests } from '../../features/cypher/CypherSlice';
 import EdgeWeight from '../../icons/EdgeWeight';
 import IconFilter from '../../icons/IconFilter';
 import IconSearchCancel from '../../icons/IconSearchCancel';
@@ -176,7 +177,10 @@
             size="large"
             type="link"
             className={`${styles.FrameButton}`}
-            onClick={() => dispatch(removeFrame(refKey))}
+            onClick={() => {
+              dispatch(removeFrame(refKey));
+              dispatch(removeActiveRequests(refKey));
+            }}
             title="Close Window"
           >
             <FontAwesomeIcon
diff --git a/frontend/src/components/sidebar/presentations/Components.scss b/frontend/src/components/sidebar/presentations/Components.scss
new file mode 100644
index 0000000..927d2cd
--- /dev/null
+++ b/frontend/src/components/sidebar/presentations/Components.scss
@@ -0,0 +1,13 @@
+
+
+#graphSelectionContainer {
+    position: relative;
+}
+
+#graphSelection {
+    display: flex;
+}
+
+.ant-select{
+    display: flex;
+}
\ No newline at end of file
diff --git a/frontend/src/components/sidebar/presentations/SidebarComponents.jsx b/frontend/src/components/sidebar/presentations/SidebarComponents.jsx
index 5cfa812..59151bb 100644
--- a/frontend/src/components/sidebar/presentations/SidebarComponents.jsx
+++ b/frontend/src/components/sidebar/presentations/SidebarComponents.jsx
@@ -19,7 +19,9 @@
 
 import React from 'react';
 import { Select } from 'antd';
+import { Col } from 'react-bootstrap';
 import PropTypes from 'prop-types';
+import './Components.scss';
 
 const StyleTextRight = {
   marginBottom: '10px', textAlign: 'right', fontSize: '13px', fontWeight: 'bold',
@@ -96,17 +98,18 @@
     marginTop: '1rem',
     display: 'block',
   };
-  const handleGraphClick = (e) => {
-    const graphName = graphs.find((graph) => graph[1] === e)[0];
-    changeCurrentGraph({ id: e });
-    changeGraphDB({ graphName });
+  const handleGraphClick = (_, e) => {
+    changeCurrentGraph({ id: e['data-gid'] });
+    changeGraphDB({ graphName: e.value });
   };
 
   const options = (
-    graphs.map(([gname, graphId]) => (<option value={graphId}>{gname}</option>))
+    graphs.map(([gname, graphId]) => (
+      <Select.Option value={gname} data-gid={graphId}>{gname}</Select.Option>
+    ))
   );
   return (
-    <div id="graphSelectDropdown">
+    <Col id="graphSelectionContainer">
       <Select onChange={handleGraphClick} placeholder="Select Graph" style={selectStyle} value={currentGraph}>
         {options}
       </Select>
@@ -114,7 +117,7 @@
       <b>
         Current Graph
       </b>
-    </div>
+    </Col>
   );
 };
 
diff --git a/frontend/src/features/alert/AlertSlice.js b/frontend/src/features/alert/AlertSlice.js
index 2e55f9e..c1ae501 100644
--- a/frontend/src/features/alert/AlertSlice.js
+++ b/frontend/src/features/alert/AlertSlice.js
@@ -26,13 +26,10 @@
   reducers: {
     addAlert: {
       reducer: (state, action) => {
-        const { alertName } = action.payload;
+        const { alertName, message: errorMessage = '' } = action.payload;
         let alertType = 'Notice';
-        let errorMessage = '';
-
         if (['ErrorServerConnectFail', 'ErrorNoDatabaseConnected', 'ErrorPlayLoadFail'].includes(alertName)) {
           alertType = 'Error';
-          errorMessage = action.payload.message;
         }
 
         state.push({ alertName, alertProps: { key: uuid(), alertType, errorMessage } });
diff --git a/frontend/src/features/cypher/CypherSlice.js b/frontend/src/features/cypher/CypherSlice.js
index 78c2950..0b89909 100644
--- a/frontend/src/features/cypher/CypherSlice.js
+++ b/frontend/src/features/cypher/CypherSlice.js
@@ -48,11 +48,8 @@
 
 export const executeCypherQuery = createAsyncThunk(
   'cypher/executeCypherQuery',
-  async (args) => {
+  async (args, thunkAPI) => {
     try {
-      // validateSamePathVariableReturn(args[1]);
-      // validateVlePathVariableReturn(args[1]);
-
       const response = await fetch('/api/v1/cypher',
         {
           method: 'POST',
@@ -61,6 +58,7 @@
             'Content-Type': 'application/json',
           },
           body: JSON.stringify({ cmd: args[1] }),
+          signal: thunkAPI.signal,
         });
       if (response.ok) {
         const res = await response.json();
@@ -80,10 +78,15 @@
 
 );
 
+const removeActive = (state, key) => {
+  state.activeRequests = state.activeRequests.filter((ref) => ref !== key);
+};
+
 const CypherSlice = createSlice({
   name: 'cypher',
   initialState: {
     queryResult: {},
+    activeRequests: [],
     labels: { nodeLabels: {}, edgeLabels: {} },
   },
   reducers: {
@@ -105,26 +108,30 @@
       },
       prepare: (elementType, label, property) => ({ payload: { elementType, label, property } }),
     },
+    removeActiveRequests: (state, action) => removeActive(state, action.payload),
   },
   extraReducers: {
     [executeCypherQuery.fulfilled]: (state, action) => {
-      // state.queryResult[action.payload.key].response = action.payload
       Object.assign(state.queryResult[action.payload.key], {
         ...action.payload,
         complete: true,
       });
+      removeActive(state, action.payload.key);
     },
     [executeCypherQuery.pending]: (state, action) => {
       const key = action.meta.arg[0];
       const command = action.meta.arg[1];
+      const rid = action.meta.requestId;
       state.queryResult[key] = {};
+      state.activeRequests = [...state.activeRequests, key];
       Object.assign(state.queryResult[key], {
         command,
         complete: false,
-        requestId: action.meta.requestId,
+        requestId: rid,
       });
     },
     [executeCypherQuery.rejected]: (state, action) => {
+      removeActive(state, action.meta.arg[0]);
       state.queryResult[action.meta.arg[0]] = {
         command: 'ERROR',
         query: action.meta.arg[1],
@@ -136,6 +143,6 @@
   },
 });
 
-export const { setLabels } = CypherSlice.actions;
+export const { setLabels, removeActiveRequests } = CypherSlice.actions;
 
 export default CypherSlice.reducer;