├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── __tests__
├── login.test.tsx
└── server
│ └── routes.ts
├── assets
├── favicon.ico
├── login-uri-sequence.gif
├── login.gif
├── logo-bulb.png
├── logo-bulb.svg
├── logo-extra-padded.png
├── logo-extra-padded.svg
├── logo-main.png
├── logo-main.svg
├── logo-padded.png
├── logo-padded.svg
├── query-diagram-render.gif
├── results-scroll.gif
├── save-history.gif
├── settings-tab.gif
└── smarter-db.png
├── client
├── App.tsx
├── Context.tsx
├── components
│ ├── HomeComponents
│ │ ├── Diagram.tsx
│ │ ├── DiagramLogic
│ │ │ ├── ConditionalSchemaParser.tsx
│ │ │ ├── CustomColumnNode.tsx
│ │ │ ├── CustomTitleNode.tsx
│ │ │ ├── LayoutCalc.tsx
│ │ │ └── ParseNodes.tsx
│ │ ├── Header.tsx
│ │ ├── InputContainer.tsx
│ │ ├── InputContainerComponents
│ │ │ ├── History.tsx
│ │ │ ├── QueryInput.tsx
│ │ │ └── Settings.tsx
│ │ └── QueryResults.tsx
│ ├── Homepage.tsx
│ ├── Login.tsx
│ └── Signup.tsx
├── index.tsx
└── styles
│ ├── InputContainer.scss
│ ├── diagram.scss
│ ├── header.scss
│ ├── login.scss
│ ├── resultbar.scss
│ └── split.scss
├── index.html
├── jest.config.js
├── package.json
├── server
├── app.ts
├── controllers
│ ├── cookieController.ts
│ ├── dbController.ts
│ ├── schemaController.ts
│ └── userController.ts
├── models
│ └── userModel.ts
├── routes
│ └── router.ts
└── server.ts
├── src
└── static
│ ├── diagram-rearranged.png
│ ├── express.png
│ ├── history-2.png
│ ├── history-3.png
│ ├── history.png
│ ├── icons8-react-native-48.png
│ ├── login.png
│ ├── postgresql.png
│ ├── query-people-species.png
│ ├── react-flow.svg
│ ├── redis.png
│ ├── results.png
│ ├── sass.png
│ ├── settings-credentials.png
│ ├── settings-uri.png
│ ├── settings.png
│ ├── smarter-logo-padded.png
│ └── typescript.png
├── tsconfig.json
├── types
├── custom.ts
└── express
│ └── index.d.ts
└── webpack.config.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-typescript",
5 | "@babel/preset-react"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 | package-lock.json
4 | .DS_Store
5 | .env
6 | dump.rdb
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 OSLabs Beta
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
23 |
24 |
25 |
26 |
27 | SQL databases often contain a complex network of interconnected tables. This can pose a challenge for developers when attempting to build or optimize queries that involve multiple tables.
28 |
29 | smartER is a query visualizing tool that works with your postgreSQL database to provide dynamically rendered ER diagrams. It is a web application written in TypeScript that reads a user's database schema and renders ER diagrams based on a given query string. To parse through SQL schemas, it uses the pgsql-ast-parser to produce a typed Abstract Syntax Tree, which is further parsed to build custom nodes with React Flow and finally position them using elkjs .
30 |
31 |
32 |
33 | 🛠 Built With
34 |
35 | - React
36 | - TypeScript
37 | - PostgreSQL
38 | - Express
39 | - Sass
40 | - Redis
41 | - React Flow
42 |
43 | ⚡ Getting started
44 | Our application is pretty simple to get up and running!
45 |
46 | Install redis:
47 |
48 | ```js
49 | brew install redis
50 | ```
51 |
52 | Install other dependencies:
53 |
54 | ```js
55 | npm install
56 | ```
57 |
58 | Set up your database. Ours looks like this:
59 |
60 |
61 | Set your .env variables:
62 |
63 |
64 | DATABASE_API
65 | PORT
66 | JWT_SECRET_KEY
67 | URI_SECRET_KEY
68 |
69 |
70 | Start the application:
71 |
72 | ```js
73 | npm start
74 | ```
75 |
76 | 📝 User guide
77 |
78 | On application load, the user will be prompted to log in before being directed to the homepage. First time users should first create an account
79 |
80 |
81 |
82 | Navigate to the settings tab and input either the URI or credentials for your database
83 |
84 |
85 | Once the database is connected, navigate to the query tab and begin typing your query - notice your ER diagram renders and updates as you type!
86 |
87 |
88 | Scroll through your query results at any time, they are rendered as you type as well
89 |
90 |
91 | Save your query and re-render it later by clicking on it in the History tab
92 |
93 |
94 | 📬 Features:
95 |
96 | smartER aims to provide a seamless user experience, offering:
97 | - Automatic rendering based on a valid query string, with helpful error messages for invalid query strings
98 | - Linking of relationships in the ER diagram via a dotted line
99 | - Linking of JOIN columns from your query with a bolded line
100 | - Highlighting of all columns in your SELECT statement for visual clarity and accessibility
101 | - Support for all postgreSQL SELECT queries, including unions, subqueries, and aggregations
102 | - An interactive and easily rearranged ER diagram for optimal clarity on your database relationships
103 | - A responsive UI that allows the user to decide which features get the most real estate
104 |
105 | 🧠 How to contribute
106 |
107 | smartER is currently in alpha and we would love to hear your feedback, encouragement, advice, suggestions, or problems. If you would like to contribute, please fork, clone, and make pull requests. If you would like to report an issue or submit a feature request, please do so. We would love to hear how we can make smartER more useful for you! If you would like to reach the smartER team directly for any other reason, please email us
108 |
109 | 👥 Contributors
110 |
111 | Joyce Kwak @github @linkedin
112 |
113 |
114 | Melissa McLaughlin @github @linkedin
115 |
116 | Nathan Ngo @github @linkedin
117 |
118 | Brian Vu @github @linkedin
119 |
120 | ☕️ Supporters
121 |
--------------------------------------------------------------------------------
/__tests__/login.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, fireEvent } from '@testing-library/react';
3 | import { MemoryRouter, Route, Routes } from 'react-router-dom';
4 | import Login from '../client/components/Login';
5 | import userEvent from '@testing-library/user-event';
6 | import '@testing-library/jest-dom/extend-expect';
7 |
8 | describe('Unit testing for Login', () => {
9 | beforeEach(() => {
10 | render(
11 |
12 |
13 | } />
14 |
15 |
16 | );
17 | });
18 |
19 | it('Renders the Login component', () => {
20 | const inputs = document.querySelectorAll('input');
21 |
22 | expect(inputs).toHaveLength(2);
23 | expect(inputs[0].placeholder).toBe('email');
24 | expect(inputs[1].placeholder).toBe('password');
25 | });
26 |
27 | it('Does not render incorrect password/email message', () => {
28 | const incorrectMessage = document.getElementsByClassName('small-text');
29 | expect(incorrectMessage).toHaveLength(0);
30 | });
31 |
32 | it('Renders error message on wrong login', () => {
33 | const emailInput = screen.getByPlaceholderText('email');
34 | const passwordInput = screen.getByPlaceholderText('password');
35 |
36 | userEvent.type(emailInput, '');
37 | userEvent.type(passwordInput, '');
38 |
39 | const loginButton = document.querySelector(
40 | 'button[type="submit"]'
41 | );
42 | console.log('loginButton', loginButton);
43 | // userEvent.click(loginButton);
44 | fireEvent.click(loginButton!);
45 |
46 | //expect to see error message "Incorrect Password or Email"
47 | expect(screen.getByText('Incorrect Password or Email')).toBeDefined();
48 | // const errorMessage = document.getElementsByClassName('small-text');
49 | // console.log('errorMessage', errorMessage);
50 | // expect(errorMessage).toHaveLength(1);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/__tests__/server/routes.ts:
--------------------------------------------------------------------------------
1 | import request from 'supertest';
2 | import app from '../../server/app';
3 |
4 | // API Route testing
5 | describe('/api', () => {
6 | describe('/getQueryResults', () => {
7 | describe('POST', () => {
8 | it('responds with 200 status and application/json content type', async () => {
9 | const res = await request(app)
10 | .post('/api/getQueryResults')
11 | .send({ queryString: 'SELECT name from people' });
12 | expect(res.statusCode).toEqual(200);
13 | });
14 | });
15 | });
16 | describe('/getSchema', () => {
17 | describe('GET', () => {});
18 | });
19 | describe('/getHistory', () => {
20 | describe('GET', () => {});
21 | });
22 | describe('/addURI', () => {
23 | describe('POST', () => {});
24 | });
25 | });
26 |
27 | // User route testing
28 | describe('/user', () => {
29 | describe('/emailCheck', () => {
30 | describe('POST', () => {});
31 | });
32 |
33 | describe('/signup', () => {
34 | describe('POST', () => {});
35 | });
36 |
37 | describe('/login', () => {
38 | describe('POST', () => {});
39 | });
40 |
41 | describe('/changePassword', () => {
42 | describe('POST', () => {});
43 | });
44 |
45 | describe('/logout', () => {
46 | describe('POST', () => {});
47 | });
48 |
49 | describe('/authenticate', () => {
50 | describe('GET', () => {});
51 | });
52 | });
53 |
54 | // Catch all 404 testing
55 | describe('/', () => {
56 | it('Should return 404 and status message when sending invalid URL', async () => {
57 | const res = await request(app).get('/DKLSJA');
58 | expect(res.statusCode).toEqual(404);
59 | expect(res.body).toEqual(
60 | `This is not the page you are looking for ¯\\_(ツ)_/¯`
61 | );
62 | return;
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/favicon.ico
--------------------------------------------------------------------------------
/assets/login-uri-sequence.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/login-uri-sequence.gif
--------------------------------------------------------------------------------
/assets/login.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/login.gif
--------------------------------------------------------------------------------
/assets/logo-bulb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/logo-bulb.png
--------------------------------------------------------------------------------
/assets/logo-bulb.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
16 |
--------------------------------------------------------------------------------
/assets/logo-extra-padded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/logo-extra-padded.png
--------------------------------------------------------------------------------
/assets/logo-extra-padded.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
16 |
--------------------------------------------------------------------------------
/assets/logo-main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/logo-main.png
--------------------------------------------------------------------------------
/assets/logo-main.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
16 |
--------------------------------------------------------------------------------
/assets/logo-padded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/logo-padded.png
--------------------------------------------------------------------------------
/assets/logo-padded.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
16 |
--------------------------------------------------------------------------------
/assets/query-diagram-render.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/query-diagram-render.gif
--------------------------------------------------------------------------------
/assets/results-scroll.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/results-scroll.gif
--------------------------------------------------------------------------------
/assets/save-history.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/save-history.gif
--------------------------------------------------------------------------------
/assets/settings-tab.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/settings-tab.gif
--------------------------------------------------------------------------------
/assets/smarter-db.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/assets/smarter-db.png
--------------------------------------------------------------------------------
/client/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Router, Routes, Route } from 'react-router-dom';
3 | import { LoginContext, HomepageContext } from './Context';
4 | import Login from './components/Login';
5 | import Homepage from './components/Homepage';
6 | import Signup from './components/Signup';
7 | import { Navigate } from 'react-router-dom';
8 |
9 | const App = () => {
10 | const [email, setEmail] = useState('');
11 | const [password, setPassword] = useState('');
12 | const [submit, setSubmit] = useState(false);
13 | const [queryString, setQueryString] = useState('');
14 | const [history, setHistory] = useState([]);
15 | const [uri, setUri] = useState('');
16 | const [dbCredentials, setDBCredentials] = useState({
17 | host: '',
18 | port: 0,
19 | dbUsername: '',
20 | dbPassword: '',
21 | database: '',
22 | });
23 | const [queryResponse, setQueryResponse] = useState([]);
24 | const [masterData, setMasterData] = useState({});
25 | const [renderedData, setRenderedData] = useState({});
26 | const [renderedDataPositions, setRenderedDataPositions] = useState({});
27 | const [errorMessages, setErrorMessages] = useState(['']);
28 | const [reset, setReset] = useState(false);
29 |
30 | return (
31 |
39 |
65 |
66 | } />
67 | } />
68 |
73 | ) : (
74 |
75 | )
76 | }
77 | />
78 |
79 |
80 |
81 | );
82 | };
83 |
84 | export default App;
85 |
--------------------------------------------------------------------------------
/client/Context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, Dispatch, SetStateAction } from 'react';
2 |
3 | // Login context
4 | export type LoginContextType = {
5 | email: string;
6 | setEmail: Dispatch>;
7 | password: string;
8 | setPassword: Dispatch>;
9 | };
10 |
11 | const defaultState = {
12 | email: '',
13 | setEmail: () => {},
14 | password: '',
15 | setPassword: () => {},
16 | };
17 |
18 | export const LoginContext = createContext(
19 | defaultState
20 | );
21 |
22 | //Homepage Context
23 | export type HomepageContextType = {
24 | submit: boolean;
25 | setSubmit: Dispatch>;
26 | queryString: string;
27 | setQueryString: Dispatch>;
28 | history: historyType[];
29 | setHistory: Dispatch>;
30 | uri: string;
31 | setUri: Dispatch>;
32 | dbCredentials: dbCredentialsType;
33 | setDBCredentials: Dispatch>;
34 | queryResponse: any;
35 | setQueryResponse: Dispatch>;
36 | masterData: any;
37 | setMasterData: Dispatch>;
38 | renderedData: any;
39 | setRenderedData: Dispatch>;
40 | renderedDataPositions: any;
41 | setRenderedDataPositions: Dispatch>;
42 | errorMessages: string[];
43 | setErrorMessages: Dispatch>;
44 | reset: boolean;
45 | setReset: Dispatch>;
46 | };
47 |
48 | const defaultHomeState = {
49 | submit: false,
50 | setSubmit: () => {},
51 | queryString: '',
52 | setQueryString: () => {},
53 | history: [],
54 | setHistory: () => {},
55 | uri: '',
56 | setUri: () => {},
57 | dbCredentials: {
58 | host: '',
59 | port: 0,
60 | dbUsername: '',
61 | dbPassword: '',
62 | database: '',
63 | },
64 | setDBCredentials: () => {},
65 | queryResponse: [],
66 | setQueryResponse: () => {},
67 | masterData: {},
68 | setMasterData: () => {},
69 | renderedData: {},
70 | setRenderedData: () => {},
71 | renderedDataPositions: [],
72 | setRenderedDataPositions: () => [],
73 | errorMessages: [],
74 | setErrorMessages: () => [],
75 | reset: false,
76 | setReset: () => {},
77 | };
78 |
79 | export const HomepageContext = createContext(
80 | defaultHomeState
81 | );
82 |
83 | //TYPES
84 | export interface historyType {
85 | created_at: string;
86 | query: string;
87 | }
88 |
89 | export interface dbCredentialsType {
90 | host: string;
91 | port: number;
92 | dbUsername: string;
93 | dbPassword: string;
94 | database: string;
95 | }
96 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/Diagram.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useContext, useCallback, useEffect } from 'react';
2 | import ReactFlow, {
3 | Controls,
4 | Background,
5 | useNodesState,
6 | useEdgesState,
7 | addEdge,
8 | ReactFlowProvider,
9 | } from 'reactflow';
10 | import 'reactflow/dist/style.css';
11 | import { HomepageContext } from '../../Context';
12 | import { parseEdges, parseNodes } from './DiagramLogic/ParseNodes';
13 | import CustomColumnNode from './DiagramLogic/CustomColumnNode';
14 | import CustomTitleNode from './DiagramLogic/CustomTitleNode';
15 | import conditionalSchemaParser from './DiagramLogic/ConditionalSchemaParser';
16 | import { getElkData } from './DiagramLogic/LayoutCalc';
17 |
18 | const proOptions = { hideAttribution: true };
19 | const nodeTypes = {
20 | CustomColumnNode: CustomColumnNode,
21 | CustomTitleNode: CustomTitleNode,
22 | };
23 |
24 | const fitViewOptions = {
25 | padding: 10,
26 | };
27 |
28 | const Diagram: FC<{}> = () => {
29 | const [nodes, setNodes, onNodesChange] = useNodesState([]);
30 | const [edges, setEdges, onEdgesChange] = useEdgesState([]);
31 |
32 | const {
33 | queryString,
34 | submit,
35 | masterData,
36 | setMasterData,
37 | renderedData,
38 | setRenderedData,
39 | renderedDataPositions,
40 | setRenderedDataPositions,
41 | errorMessages,
42 | setErrorMessages,
43 | queryResponse,
44 | setQueryResponse,
45 | reset,
46 | setReset,
47 | } = useContext(HomepageContext)!;
48 |
49 | async function getQueryResults() {
50 | //setSubmit to trigger useEffect for re-rendering Diagram.tsx
51 | try {
52 | const created_at = String(Date.now());
53 | const data = await fetch('/api/getQueryResults', {
54 | method: 'POST',
55 | headers: { 'Content-Type': 'application/json' },
56 | body: JSON.stringify({ created_at, queryString }),
57 | });
58 | if (data.status === 200) {
59 | const parsedData = await data.json();
60 | setQueryResponse(parsedData);
61 | }
62 | } catch (error) {
63 | console.log(`Error in QueryInput.tsx ${error}`);
64 | return `Error in QueryInput.tsx ${error}`;
65 | }
66 | }
67 |
68 | const onConnect = useCallback(
69 | (params: any) => setEdges((eds) => addEdge(params, eds)),
70 | [setEdges]
71 | );
72 |
73 | const onNodeDragStop = (e: any, node: any) => {
74 | setRenderedDataPositions([...renderedDataPositions, node]);
75 | };
76 |
77 | const getERDiagram = async () => {
78 | try {
79 | const data = await fetch('/api/getSchema', {
80 | method: 'GET',
81 | headers: { 'Content-Type': 'application/json' },
82 | });
83 | const parsedData = await data.json();
84 | setMasterData(parsedData);
85 | return;
86 | } catch (error) {
87 | console.log(`Error in getERDiagram: ${error}`);
88 | }
89 | };
90 |
91 | useEffect(() => {
92 | getERDiagram();
93 | }, []);
94 |
95 | useEffect(() => {
96 | setNodes([]);
97 | setEdges([]);
98 | }, [reset]);
99 |
100 | // when submit value changes, parse query to conditionally render ER diagram and if no errors are found in the logic,
101 | // invoke getQueryResults function to render query results
102 | useEffect(() => {
103 | if (queryString) {
104 | async function updateNodes() {
105 | setErrorMessages(['']);
106 | const queryParse = conditionalSchemaParser(queryString, masterData);
107 | const errorArr = queryParse.errorArr;
108 | setErrorMessages(errorArr);
109 | if (!errorArr[0]) getQueryResults();
110 |
111 | const defaultNodes = parseNodes(queryParse.mainObj);
112 | const defaultEdges = parseEdges(queryParse.mainObj);
113 |
114 | // if no new tables are being added, retain positions; else recalculate
115 | let positions = [];
116 | const currentTables = Object.keys(renderedData);
117 | const newTables = Object.keys(queryParse.mainObj);
118 | const combinedLength = new Set(currentTables.concat(newTables)).size;
119 | if (
120 | currentTables.length === newTables.length &&
121 | currentTables.length === combinedLength
122 | ) {
123 | positions = renderedDataPositions;
124 | }
125 | const dataElk = await getElkData(defaultNodes, defaultEdges, positions);
126 |
127 | setNodes(dataElk);
128 | setRenderedDataPositions(dataElk);
129 | setEdges(defaultEdges);
130 | setRenderedData(queryParse.mainObj);
131 | }
132 | updateNodes();
133 | }
134 | }, [submit]);
135 |
136 | return (
137 |
138 |
139 |
150 |
151 |
152 |
153 |
154 |
155 | );
156 | };
157 |
158 | export default Diagram;
159 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/DiagramLogic/ConditionalSchemaParser.tsx:
--------------------------------------------------------------------------------
1 | import { Statement, parseFirst } from 'pgsql-ast-parser';
2 |
3 | interface columnObj {
4 | table_name: string;
5 | column_name: string;
6 | data_type: string;
7 | primary_key?: boolean;
8 | foreign_key?: boolean;
9 | linkedTable?: string;
10 | linkedTableColumn?: string;
11 | activeColumn?: boolean;
12 | activeLink?: boolean;
13 | foreign_tables?: string[];
14 | }
15 | type returnObj = {
16 | errorArr: string[];
17 | mainObj: Record>;
18 | };
19 | type tableAlias = {
20 | [key: string]: string;
21 | };
22 | type mainObj = {
23 | [key: string]: Record;
24 | };
25 | type key = {
26 | name: string;
27 | table: { name: string };
28 | type: string;
29 | };
30 |
31 | function conditionalSchemaParser(query: string, schema: any): returnObj {
32 | // errorArr contains errors that we find in the query when running mainFunc
33 | const errorArr: string[] = [];
34 | // mainObj contains a partial copy of the ER diagram with flagged columns and tables
35 | const mainObj: mainObj = {};
36 | const tableAliasLookup: tableAlias = {};
37 | let activeTables: string[];
38 | const columnsWithUndefinedAlias: Record> = {};
39 | let currentSubqueryAlias: string;
40 |
41 | const ast: Statement = parseFirst(query);
42 | const queue: any[] = [];
43 | queue.push(ast);
44 |
45 | const selectHandler = (obj: any) => {
46 | activeTables = [];
47 | tableHandler(obj.from);
48 | columnHandler(obj.columns);
49 |
50 | // cleanup aliases
51 | if (
52 | currentSubqueryAlias &&
53 | columnsWithUndefinedAlias[currentSubqueryAlias]
54 | ) {
55 | delete columnsWithUndefinedAlias[currentSubqueryAlias];
56 | }
57 | currentSubqueryAlias = '';
58 |
59 | for (let key in obj) {
60 | switch (key) {
61 | case 'from':
62 | break;
63 | case 'columns':
64 | break;
65 | case 'type':
66 | break;
67 | case 'where':
68 | break;
69 | default:
70 | queue.push(obj[key]);
71 | }
72 | }
73 | };
74 |
75 | const tableHandler = (arr: any[]) => {
76 | for (let i = 0; i < arr.length; i++) {
77 | const currentTable = arr[i];
78 | const type = currentTable.type;
79 | switch (type) {
80 | case 'table': {
81 | const tableName = currentTable.name.name;
82 | const alias = currentTable.name.alias;
83 | if (schema[tableName as keyof typeof schema]) {
84 | // Deep copy the table from data
85 | if (!mainObj[tableName]) {
86 | mainObj[tableName] = JSON.parse(
87 | JSON.stringify(schema[tableName as keyof typeof schema])
88 | );
89 | }
90 | activeTables.push(tableName);
91 | // Update alias
92 | if (currentTable.join) queue.push(currentTable); // find example of this
93 |
94 | if (alias) tableAliasLookup[alias] = tableName;
95 | else tableAliasLookup[tableName] = tableName;
96 | } else {
97 | // Error to push because the table doesn't exist in our data
98 | errorArr.push(`Table name ${tableName} is not found in database`);
99 | }
100 |
101 | break;
102 | }
103 | default: {
104 | queue.push(currentTable);
105 | break;
106 | }
107 | }
108 | }
109 | };
110 |
111 | const columnHandler = (arr: any[]) => {
112 | for (let i = 0; i < arr.length; i++) {
113 | const currentColumn = arr[i];
114 | const currentColDetails = currentColumn.expr || currentColumn;
115 | const type = currentColDetails.type;
116 |
117 | switch (type) {
118 | // Update master obj with active columns add activeColumn = true
119 | case 'ref':
120 | const columnName = currentColDetails.name;
121 |
122 | // subquery logic: if current col array is part of a subquery and the col is not referenced in the main query, skip it
123 | if (
124 | (currentSubqueryAlias &&
125 | !columnsWithUndefinedAlias[currentSubqueryAlias]) ||
126 | (columnsWithUndefinedAlias[currentSubqueryAlias] &&
127 | !columnsWithUndefinedAlias[currentSubqueryAlias].has(columnName))
128 | ) {
129 | break;
130 | }
131 |
132 | let specifiedTable: string | undefined;
133 | if (currentColDetails.table && currentColDetails.table.name) {
134 | const lookupAlias = currentColDetails.table.name;
135 | // console.log('table alias lookup', tableAliasLookup);
136 | // console.log('lookup value', lookupAlias);
137 | specifiedTable = tableAliasLookup[lookupAlias];
138 |
139 | // if no specified table is found (alias is defined in subquery)
140 | if (!specifiedTable) {
141 | // if column with alias already exists, push to key value pair
142 | if (columnsWithUndefinedAlias[lookupAlias]) {
143 | columnsWithUndefinedAlias[lookupAlias].add(columnName);
144 | } else
145 | columnsWithUndefinedAlias[lookupAlias] = new Set([columnName]);
146 | break;
147 | }
148 | }
149 |
150 | // if tableName is specified, tables is array of tableName, else tables is array of all tables in query
151 | let tables: string[] = [];
152 | if (specifiedTable) {
153 | tables.push(specifiedTable);
154 | } else {
155 | tables = [...activeTables];
156 | }
157 |
158 | let colMatchCount: number = 0;
159 | for (let table of tables) {
160 | if (columnName !== '*') {
161 | if (mainObj[table][columnName]) {
162 | colMatchCount++;
163 | mainObj[table][columnName].activeColumn = true;
164 | }
165 | } else {
166 | for (let column in mainObj[table]) {
167 | mainObj[table][column].activeColumn = true;
168 | }
169 | }
170 | }
171 |
172 | if (colMatchCount === 0 && columnName !== '*')
173 | errorArr.push(`Column ${columnName} does not exist`);
174 | if (colMatchCount > 1)
175 | errorArr.push(`Column ${columnName} exists in more than one table`);
176 | break;
177 |
178 | case 'call':
179 | columnHandler(currentColDetails.args);
180 | break;
181 | default:
182 | queue.push(currentColDetails);
183 | }
184 | }
185 | // if type is select, invoke selectHandler <- have not seen any columns with type select.
186 | // if (columnObj.type && columnObj.type === 'select') {
187 | // selectHandler(columnObj)
188 | // }
189 | };
190 |
191 | const joinHandler = (obj: any) => {
192 | const left = obj.on.left;
193 | const right = obj.on.right;
194 | if (left) flagActiveLinks(left, tableAliasLookup, mainObj, errorArr);
195 | else errorArr.push(`No column found for left position of ${obj.type}`);
196 | if (right) flagActiveLinks(right, tableAliasLookup, mainObj, errorArr);
197 | else errorArr.push(`No column found for right position of ${obj.type}`);
198 | };
199 |
200 | const connectedTablesHandler = (table: string) => {
201 | const isJoinTable = (tableObj: any): boolean => {
202 | for (let column in tableObj) {
203 | const key =
204 | tableObj[column].foreign_key || tableObj[column].primary_key;
205 | if (!key) return false;
206 | }
207 | return true;
208 | };
209 |
210 | // for (const table in mainObj) {
211 | for (const column in mainObj[table]) {
212 | const linkedTable = mainObj[table][column].linkedTable;
213 | const foreignTables = mainObj[table][column].foreign_tables;
214 |
215 | if (foreignTables) {
216 | // iterate through foreign_tables array and copy any missing tables to mainObj
217 | for (const foreignTable of foreignTables) {
218 | if (!mainObj[foreignTable])
219 | mainObj[foreignTable] = {
220 | ...schema[foreignTable as keyof typeof schema],
221 | };
222 |
223 | // if foreign table is a join table, go one layer out
224 | // stretch: user option to toggle this feature on/off?
225 | if (isJoinTable(mainObj[foreignTable]))
226 | connectedTablesHandler(foreignTable);
227 | }
228 | }
229 |
230 | if (linkedTable && !mainObj[linkedTable]) {
231 | mainObj[linkedTable] = JSON.parse(
232 | JSON.stringify(schema[linkedTable as keyof typeof schema])
233 | );
234 |
235 | // if linked table is a join table, go one layer out
236 | if (isJoinTable(mainObj[linkedTable]))
237 | connectedTablesHandler(linkedTable);
238 | }
239 | }
240 | // }
241 | };
242 |
243 | while (queue.length) {
244 | const obj = queue[0];
245 |
246 | let type = obj.type;
247 | if (type) {
248 | type = type.toLowerCase();
249 | if (type === 'select') {
250 | selectHandler(obj);
251 | } else if (type.includes('join')) {
252 | joinHandler(obj);
253 | } else if (type === 'statement') {
254 | currentSubqueryAlias = obj.alias;
255 | for (let key in obj) {
256 | if (key.toLowerCase() !== 'where' && typeof obj[key] === 'object')
257 | queue.push(obj[key]);
258 | }
259 | } else {
260 | for (let key in obj) {
261 | if (key.toLowerCase() !== 'where' && typeof obj[key] === 'object')
262 | queue.push(obj[key]);
263 | }
264 | }
265 | }
266 | queue.shift();
267 | }
268 |
269 | for (const table in mainObj) connectedTablesHandler(table);
270 | return { errorArr: errorArr, mainObj: mainObj };
271 | }
272 |
273 | function flagActiveLinks(
274 | onKey: key,
275 | tableAlias: tableAlias,
276 | mainObj: mainObj,
277 | errorArr: string[]
278 | ) {
279 | if (onKey.table.name) {
280 | // attempt to lookup table name by alias
281 | const tableName = tableAlias[onKey.table.name];
282 | if (tableName) {
283 | // identify col name and flag it in mainObj
284 | const columnName = onKey.name;
285 | mainObj[tableName][columnName].activeLink = true;
286 | } // Potential error push here if tableName was not found?
287 | } else {
288 | // else iterate through mainObj and check for a table that has a column name that matches
289 | const tables = Object.keys(mainObj);
290 | const columnName = onKey.name;
291 | let counter = 0;
292 | for (let i = 0; i < tables.length; i++) {
293 | const tableName = tables[i];
294 | //keep track of matches, if no matches, add to errorArr, if >1 flag both and add to errArr that column exists in more than one table
295 | if (mainObj[tableName][columnName]) {
296 | mainObj[tableName][columnName].activeLink = true;
297 | counter++;
298 | }
299 | // Potential error push here if tableName/columnName was not found?
300 | }
301 | if (counter === 0) errorArr.push('no cols found');
302 | else if (counter > 1) errorArr.push('too many cols');
303 | }
304 | }
305 |
306 | export default conditionalSchemaParser;
307 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/DiagramLogic/CustomColumnNode.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Handle, Position } from 'reactflow';
3 |
4 | const handleStyleLeft = {
5 | opacity: '0',
6 | marginLeft: '5px',
7 | };
8 | const handleStyleRight = {
9 | opacity: '0',
10 | marginRight: '5px',
11 | };
12 |
13 | function CustomColumnNode({ data, isConnectable }: any) {
14 | const onChange = useCallback((evt: any) => {
15 | // console.log(evt.target.value);
16 | }, []);
17 |
18 | return (
19 |
20 |
26 |
27 |
{data.icon}
28 |
29 |
{data.columnName}
30 |
{data.dataType}
31 |
32 |
33 |
40 |
41 | );
42 | }
43 |
44 | export default CustomColumnNode;
45 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/DiagramLogic/CustomTitleNode.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Handle, Position } from 'reactflow';
3 |
4 | const handleStyleLeft = {
5 | opacity: '0',
6 | marginLeft: '5px',
7 | };
8 | const handleStyleRight = {
9 | opacity: '0',
10 | marginRight: '5px',
11 | };
12 |
13 | function CustomTitleNode({ data, isConnectable }: any) {
14 | const onChange = useCallback((evt: any) => {
15 | // console.log(evt.target.value);
16 | }, []);
17 |
18 | return (
19 |
20 |
26 |
27 |
28 | {data.label}
29 |
30 |
31 |
38 |
39 | );
40 | }
41 |
42 | export default CustomTitleNode;
43 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/DiagramLogic/LayoutCalc.tsx:
--------------------------------------------------------------------------------
1 | import Elk from 'elkjs';
2 |
3 | // elk settings
4 | const elk: any = new Elk({
5 | defaultLayoutOptions: {
6 | 'elk.algorithm': 'layered',
7 | 'elk.direction': 'RIGHT',
8 | 'elk.spacing.nodeNode': '100',
9 | 'elk.layered.spacing.nodeNodeBetweenLayers': '110',
10 | 'elk.layered.noOverlap': 'true',
11 | 'elk.layered.spacing.edgeNodeBetweenLayers': '100',
12 | 'elk.padding': '[top=50, bottom=50, left=50, right=50]',
13 | 'elk.edgeRouting': 'SPLINES',
14 | 'elk.layered.nodePlacement.strategy': 'SIMPLE',
15 | // 'elk.edgeRouting.splines.mode': 'CONSERVATIVE',
16 | // 'elk.considerModelOrder.strategy': 'PREFER_NODES',
17 | 'elk.crossingMinimization.strategy': 'LAYER_SWEEP',
18 | // 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX'
19 | },
20 | });
21 |
22 | // grab information for nodes and edges needed for elk
23 | export async function getElkData(nodes: any, edges: any, startPositions: any) {
24 | const elkNodes: any[] = [];
25 | const elkEdges: any[] = [];
26 |
27 | // add node data to elkNodes
28 | nodes.forEach((node: any) => {
29 | elkNodes.push({
30 | id: node.id,
31 | width: node.style.width,
32 | height: node.style.height,
33 | });
34 | });
35 |
36 | // add edges as only groups, not individual column nodes
37 | edges.forEach((edge: any) => {
38 | elkEdges.push({
39 | id: edge.id,
40 | source: `${edge.source.split('.')[1]}.group`,
41 | target: `${edge.target.split('.')[1]}.group`,
42 | });
43 | });
44 |
45 | // run elk
46 | const elkCalculate = await elk.layout({
47 | id: 'root',
48 | children: elkNodes,
49 | edges: elkEdges,
50 | });
51 |
52 | // update nodes with elk positions, switching back from group edges to individual column edges
53 | const positions = nodes.map((node: any) => {
54 | const elkNode = elkCalculate.children.find(
55 | (elkNode: any) => elkNode.id === node.id
56 | );
57 | if (node.id.includes('group')) {
58 | let x = elkNode.x;
59 | let y = elkNode.y;
60 |
61 | // find starting position of current node
62 | let matchingNode;
63 | if (Array.isArray(startPositions)) {
64 | for (let startNode of startPositions) {
65 | // console.log('startNode', startNode);
66 | if (JSON.stringify(startNode.id) === JSON.stringify(node.id)) {
67 | matchingNode = startNode;
68 | }
69 | }
70 | }
71 |
72 | if (matchingNode) {
73 | x = matchingNode.position.x;
74 | y = matchingNode.position.y;
75 | }
76 |
77 | return {
78 | ...node,
79 | position: {
80 | x: x,
81 | y: y,
82 | },
83 | };
84 | } else return node;
85 | });
86 | return positions;
87 | }
88 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/DiagramLogic/ParseNodes.tsx:
--------------------------------------------------------------------------------
1 | import CustomColumnNode from './CustomColumnNode';
2 | import React from 'react';
3 | import IonIcon from '@reacticons/ionicons';
4 |
5 | export function parseNodes(rawData: any): any {
6 | const standardHeight = 25;
7 | // const pkey = ;
8 | const pkey = ;
9 | const fkey = ;
10 |
11 | const nodes: any = [];
12 | let j = 0;
13 | for (const table in rawData) {
14 | // create GROUPS
15 | const newContainer = {
16 | id: `${table}.group`,
17 | type: 'group',
18 | // position: { x: 200 * j, y: Math.random() * 100 }, //Control spacing of tables here, Probably needs an algo
19 | data: { label: table },
20 | style: {
21 | height:
22 | Object.keys(rawData[table]).length * standardHeight +
23 | standardHeight +
24 | 0,
25 | width: 180, //was 152
26 | display: 'flex',
27 | opacity: 0.25,
28 | zIndex: 10,
29 | },
30 | draggable: true,
31 | };
32 | j++;
33 | nodes.push(newContainer);
34 |
35 | // create table name node
36 | const newColumnTitle = {
37 | id: `${table}.columnName`,
38 | type: 'CustomTitleNode', // swap for default of CustomTitleNode
39 | parentNode: `${table}.group`,
40 | extent: 'default',
41 | position: { x: 0, y: 0 + 0 },
42 | data: { label: table },
43 | sourcePosition: 'bottom',
44 | targetPosition: 'bottom',
45 | style: {
46 | background: '#1E3D59',
47 | color: 'F5F0E1',
48 | borderRadius: '5px',
49 | opacity: 1,
50 | transition: 'opacity 250ms ease-in',
51 | width: 180,
52 | height: standardHeight,
53 | },
54 | draggable: false,
55 | };
56 | nodes.push(newColumnTitle);
57 |
58 | //create Column nodes
59 | let i = 0;
60 | for (const columnObj in rawData[table]) {
61 | const column = rawData[table][columnObj];
62 | const newColumnNode = {
63 | id: `${columnObj}.${table}.node`,
64 | type: 'CustomColumnNode', //swap for default or CustomColumnNode
65 | parentNode: `${table}.group`,
66 | extent: 'parent',
67 | position: {
68 | x: 0,
69 | y: i === 0 ? standardHeight : i * standardHeight + standardHeight,
70 | }, //Control spacing of tables here, Probably needs an algo
71 | data: {
72 | label: `${columnObj} | ${column.data_type}`,
73 | icon:
,
74 | columnName: columnObj,
75 | dataType: column.data_type,
76 | },
77 | sourcePosition: 'right',
78 | targetPosition: 'left',
79 | draggable: false,
80 | style: {
81 | background: 'transparent',
82 | borderRadius: '',
83 | opacity: 1,
84 | transition: 'opacity 250ms ease-in',
85 | width: 180,
86 | height: standardHeight,
87 | },
88 | };
89 |
90 | if (column.primary_key) {
91 | newColumnNode.data.icon = pkey;
92 | newColumnNode.data.columnName = `${columnObj}`;
93 | newColumnNode.data.dataType = `${column.data_type}`;
94 | }
95 | if (column.foreign_key) {
96 | newColumnNode.data.icon = fkey;
97 | newColumnNode.data.columnName = `${columnObj}`;
98 | newColumnNode.data.dataType = `${column.data_type}`;
99 | }
100 | //check if active column
101 | if (column.activeColumn) {
102 | newColumnNode.style = {
103 | background: '#FFC13B',
104 | borderRadius: '5px',
105 | width: 180,
106 | opacity: 1,
107 | transition: 'opacity 250ms ease-in',
108 | height: standardHeight,
109 | };
110 | } else {
111 | newColumnNode.style = {
112 | background: '#6B7B8C',
113 | borderRadius: '5px',
114 | width: 180,
115 | opacity: 1,
116 | transition: 'opacity 250ms ease-in',
117 | height: standardHeight,
118 | };
119 | }
120 | nodes.push(newColumnNode);
121 | i++;
122 | }
123 | }
124 | return nodes;
125 | }
126 |
127 | export function parseEdges(data: any): any {
128 | const edges: any = [];
129 | for (const table in data) {
130 | for (const columnObj in data[table]) {
131 | const columnName = data[table][columnObj].column_name;
132 | const column = data[table][columnObj];
133 | if (column.foreign_key) {
134 | const newEdge = {
135 | id: `${table}.${columnName}->${column.linkedTableColumn}.${column.column_name}`,
136 | source: `${columnName}.${table}.node`,
137 | target: `${column.linkedTableColumn}.${column.linkedTable}.node`,
138 | type: 'default',
139 | style: {},
140 | animated: false,
141 | };
142 | if (column.activeLink && data[column.linkedTable]) {
143 | newEdge.animated = false;
144 | newEdge.style = {
145 | stroke: 'white',
146 | strokeWidth: '5',
147 | };
148 | } else {
149 | newEdge.animated = false;
150 | newEdge.style = {
151 | strokeWidth: '3',
152 | stroke: 'grey',
153 | strokeDasharray: '5,5',
154 | };
155 | }
156 | // check if active connection
157 |
158 | edges.push(newEdge);
159 | }
160 | }
161 | }
162 | return edges;
163 | }
164 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/Header.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { LoginContext } from '../../Context';
4 |
5 | // clicking profile modal will openthe option to logout
6 | const Header: React.FC<{}> = () => {
7 | const { email, setEmail, password, setPassword } = useContext(LoginContext)!;
8 | const navigate = useNavigate();
9 |
10 | const handleClick = async (e: any) => {
11 | try {
12 | e.preventDefault();
13 | const data = await fetch('/user/logout', {
14 | method: 'POST',
15 | headers: { 'Content-Type': 'application/json' },
16 | });
17 | setEmail('');
18 | setPassword('');
19 | localStorage.setItem('userIn', 'false');
20 | navigate('/');
21 | } catch (error) {
22 | console.log(`Error in useEffect logoutHandleClick ${error}`);
23 | return `Error in useEffect logoutHandleClick ${error}`;
24 | }
25 | // reset state of email and password to blank string
26 |
27 | //TODO: Add a fetch request delete cookies
28 | };
29 | return (
30 |
31 |
32 |
33 | logout
34 |
35 |
36 | );
37 | };
38 | export default Header;
39 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/InputContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import QueryInput from './InputContainerComponents/QueryInput';
3 | import History from './InputContainerComponents/History';
4 | import Settings from './InputContainerComponents/Settings';
5 |
6 | const InputContainer: React.FC<{}> = () => {
7 | const [tab, setTab] = useState('Query');
8 |
9 | return (
10 |
11 |
12 | setTab('Query')}
15 | >
16 | Query
17 |
18 | setTab('History')}
21 | >
22 | History
23 |
24 | setTab('Settings')}
27 | >
28 | Settings
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |
41 |
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default InputContainer;
52 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/InputContainerComponents/History.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext, useEffect } from 'react';
2 | import { HomepageContext } from '../../../Context';
3 |
4 | interface setTab {
5 | setTab: React.Dispatch>;
6 | }
7 |
8 | const History: React.FC = ({ setTab }) => {
9 | const {
10 | history,
11 | setHistory,
12 | queryString,
13 | setQueryString,
14 | submit,
15 | setSubmit,
16 | } = useContext(HomepageContext)!;
17 | const [historyElements, setHistoryElements] = useState([]);
18 |
19 | const getHistory = async () => {
20 | try {
21 | const data = await fetch('api/getHistory', {
22 | method: 'GET',
23 | headers: { 'Content-Type': 'application/json' },
24 | });
25 | const parsedHistory = await data.json();
26 | setHistory((prev: any) => {
27 | prev.push(...parsedHistory);
28 | return prev;
29 | });
30 |
31 | makeHistoryElements();
32 | } catch (error) {
33 | console.log('history error', error);
34 | }
35 | };
36 |
37 | const convertTime = (dateString: string) => {
38 | return new Date(dateString).toLocaleString();
39 | };
40 |
41 | const makeHistoryElements = () => {
42 | const elements: any = history.map((object, index) => {
43 | const localTime = convertTime(object.created_at);
44 | return (
45 |
46 |
47 |
52 | {object.query}
53 |
54 |
58 | {localTime}
59 |
60 |
61 |
62 | );
63 | });
64 | setHistoryElements(elements);
65 | };
66 |
67 | useEffect(() => {
68 | getHistory();
69 | }, []);
70 |
71 | useEffect(() => {
72 | makeHistoryElements();
73 | }, [history]);
74 |
75 | const setHistoricalQuery = (e: any) => {
76 | setTab('Query');
77 | setQueryString(e.target.innerText);
78 | // TODO: this set submit is not triggering the Diagram useEffect to update
79 | setSubmit(!submit);
80 | };
81 |
82 | return (
83 |
84 |
85 |
86 | {historyElements}
87 |
88 |
89 |
90 | Query
91 |
92 |
93 | Created At
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | };
102 |
103 | export default History;
104 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/InputContainerComponents/QueryInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { HomepageContext } from '../../../Context';
3 | import { debounce } from 'lodash';
4 | import { check } from 'express-validator';
5 |
6 | const QueryInput: React.FC<{}> = () => {
7 | const {
8 | queryString,
9 | setQueryString,
10 | history,
11 | setHistory,
12 | errorMessages,
13 | setErrorMessages,
14 | submit,
15 | setSubmit,
16 | queryResponse,
17 | setQueryResponse,
18 | reset,
19 | setReset,
20 | } = useContext(HomepageContext)!;
21 |
22 | const errorList = () => {
23 | // if the query is not valid, errorMessage will be returned
24 | if (errorMessages.length > 0) {
25 | const ErrorMessage: any = errorMessages.map((err, i) => {
26 | return (
27 |
31 | {`⚠ ${err}`}
32 |
33 | );
34 | });
35 | return ErrorMessage;
36 | }
37 | };
38 |
39 | const err = errorList();
40 | // Handle submit of queryString
41 | const handleSubmit = async (e: any) => {
42 | e.preventDefault();
43 | //setSubmit to trigger useEffect for re-rendering Diagram.tsx and getting query results
44 | setSubmit(!submit);
45 | // POST request to database with queryString
46 | try {
47 | const created_at = String(Date.now());
48 | const data = await fetch('/api/postHistory', {
49 | method: 'POST',
50 | headers: { 'Content-Type': 'application/json' },
51 | body: JSON.stringify({ queryString }),
52 | });
53 | if (data.status === 200) {
54 | const parsedData = await data.json();
55 | setHistory([
56 | ...history,
57 | { created_at: parsedData, query: queryString },
58 | ]);
59 | } else {
60 | // errorList();
61 | }
62 | } catch (error) {
63 | console.log(`Error in QueryInput.tsx ${error}`);
64 | return `Error in QueryInput.tsx ${error}`;
65 | }
66 | };
67 |
68 | let checkPause: boolean;
69 | const handleTyping = (e: any) => {
70 | setQueryString(e.target.value);
71 | const lastChar = e.target.value[e.target.value.length - 1];
72 | const keys = new Set([' ', ',', ';', 'Tab', 'Return']);
73 | const lowerCaseQuery = e.target.value.toLowerCase();
74 | if (
75 | keys.has(lastChar) &&
76 | lowerCaseQuery.includes('select') &&
77 | lowerCaseQuery.includes('from')
78 | ) {
79 | setSubmit(!submit);
80 | errorList();
81 | // do not check for pause if the last character entered was in the list of keys
82 | checkPause = false;
83 | } else {
84 | checkPause = true;
85 | }
86 | };
87 |
88 | const handlePause = debounce(() => {
89 | // only run if handleTyping functionality did not just run
90 | if (checkPause) {
91 | const lowerCaseQuery = queryString.toLowerCase();
92 | if (
93 | lowerCaseQuery.includes('select') &&
94 | lowerCaseQuery.includes('from')
95 | ) {
96 | setSubmit(!submit);
97 | }
98 | }
99 | }, 500);
100 |
101 | // handling tab key
102 | const handleKeys = (e: any) => {
103 | if (e.key === 'Tab') {
104 | e.preventDefault();
105 | const start = e.target.selectionStart;
106 | const end = e.target.selectionEnd;
107 |
108 | e.target.value = `${e.target.value.substring(
109 | 0,
110 | start
111 | )}\t${e.target.value.substring(end)}`;
112 | e.target.selectionStart = e.target.selectionEnd = start + 1;
113 | }
114 | };
115 |
116 | return (
117 |
118 |
119 |
128 | {
131 | setQueryResponse([]);
132 | setQueryString('');
133 | setReset(!reset);
134 | }}
135 | >
136 | clear
137 |
138 |
143 | save
144 |
145 |
146 | {errorMessages[0] &&
{err}
}
147 |
148 | );
149 | };
150 |
151 | export default QueryInput;
152 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/InputContainerComponents/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from 'react';
2 | import { HomepageContext, dbCredentialsType } from '../../../Context';
3 |
4 | const CONNECTION: any = {
5 | success: 'Successfully connected!',
6 | fail: 'Failed to connect. Please try again.',
7 | };
8 |
9 | interface setTab {
10 | setTab: React.Dispatch>;
11 | }
12 |
13 | const Settings: React.FC = ({ setTab }) => {
14 | const {
15 | uri,
16 | setUri,
17 | dbCredentials,
18 | setDBCredentials,
19 | masterData,
20 | setMasterData,
21 | } = useContext(HomepageContext)!;
22 |
23 | const [connectionStatus, setConnectionStatus] = useState(null);
24 |
25 | useEffect(() => {
26 | let timer = setTimeout(() => {
27 | setConnectionStatus(null);
28 | }, 3000);
29 | return () => {
30 | clearTimeout(timer);
31 | };
32 | }, [connectionStatus]);
33 |
34 | //Handle submission of new URI
35 | const handleSubmit = async (e: any) => {
36 | e.preventDefault();
37 | let encodedURI: string;
38 | if (uri) {
39 | encodedURI = encodeURIComponent(uri);
40 | } else {
41 | const { host, port, dbUsername, dbPassword, database } = dbCredentials;
42 | const hostspec = port ? `${host}:${port}` : host;
43 | encodedURI = encodeURIComponent(
44 | `postgres://${dbUsername}:${dbPassword}@${hostspec}/${database}`
45 | );
46 | }
47 | try {
48 | const data = await fetch('/api/addURI', {
49 | method: 'POST',
50 | headers: { 'Content-Type': 'application/json' },
51 | body: JSON.stringify({ encodedURI }),
52 | });
53 | if (data.status === 200) {
54 | setConnectionStatus('success');
55 | setTimeout(() => {
56 | setTab('Query');
57 | }, 1500);
58 | setUri('');
59 | setDBCredentials({
60 | host: '',
61 | port: 0,
62 | dbUsername: '',
63 | dbPassword: '',
64 | database: '',
65 | });
66 | const parsedData = await data.json();
67 | setMasterData(parsedData);
68 | return;
69 | } else {
70 | setConnectionStatus('fail');
71 | }
72 | } catch (error) {
73 | setConnectionStatus('fail');
74 | return `Error in Settings.tsx ${error}`;
75 | }
76 | };
77 |
78 | return (
79 |
181 | );
182 | };
183 |
184 | export default Settings;
185 |
--------------------------------------------------------------------------------
/client/components/HomeComponents/QueryResults.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { HomepageContext } from '../../Context';
3 |
4 | const QueryResults: React.FC<{}> = () => {
5 | const { queryResponse, setQueryResponse } = useContext(HomepageContext)!;
6 | const { errorMessages, setErrorMessages } = useContext(HomepageContext)!;
7 |
8 | // if data response from backend is 200 then set queryResponse to the data
9 | /* columnNames: [['id','name',...] */
10 | let columnNames: string[] = [];
11 |
12 | // table headers
13 | for (const row of queryResponse) {
14 | columnNames = Object.keys(row);
15 | }
16 |
17 | // table rows
18 | const columnsValue: string | number[][] = [];
19 | let columnsValuesInner: any = [];
20 | // get an array of just the values from each object in the queryResponse array
21 | for (let i = 0; i < queryResponse.length; i++) {
22 | const resultsArr = Object.entries(queryResponse[i]);
23 | for (const result of resultsArr) {
24 | columnsValuesInner.push(result[1]);
25 | }
26 | columnsValue.push(columnsValuesInner);
27 | columnsValuesInner = [];
28 | }
29 |
30 | if (!errorMessages[0])
31 | return (
32 | <>
33 |
34 |
Query Results
35 |
36 |
37 |
38 |
39 | {columnNames.map((column) => {
40 | return (
41 |
42 | {column}
43 |
44 | );
45 | })}
46 |
47 |
48 |
49 | {columnsValue.map((column, index) => {
50 | const columnsArray: JSX.Element[] = [];
51 | column.map((data, i) => {
52 | return columnsArray.push(
53 |
54 | {data}
55 |
56 | );
57 | });
58 | return (
59 |
60 | {columnsArray}
61 |
62 | );
63 | })}
64 |
65 |
66 |
67 |
68 | >
69 | );
70 | else
71 | return (
72 | <>
73 |
74 |
Query Results
75 |
76 |
77 |
78 |
79 |
80 | See query error above
81 |
82 |
83 |
84 | >
85 | );
86 | };
87 |
88 | export default QueryResults;
89 |
90 | // {
91 | // columnsValue.map((values: any) => {
92 | //
93 | // {columnNames.map((column: any) => {
94 | // return {values[column]} ;
95 | // })}
96 | // ;
97 | // });
98 | // }
99 |
100 | // {columnsValue.map((value) => {
101 | //
102 | // {columnNames.map((column) => (
103 | // (
104 | // {value[column]}
105 | //
106 | // ))}
107 | //
108 | // })}
109 |
--------------------------------------------------------------------------------
/client/components/Homepage.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useState, useContext, useEffect } from 'react';
2 | import Diagram from './HomeComponents/Diagram';
3 | import Header from './HomeComponents/Header';
4 | import InputContainer from './HomeComponents/InputContainer';
5 | import QueryResults from './HomeComponents/QueryResults';
6 | import Split from 'react-split';
7 | import { useNavigate } from 'react-router-dom';
8 |
9 | const TopComp = () => {
10 | const navigate = useNavigate();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | const Homepage: React.FC<{}> = () => {
21 | return (
22 | <>
23 |
24 |
25 |
32 |
33 |
34 |
35 |
36 | >
37 | );
38 | };
39 |
40 | export default Homepage;
41 |
--------------------------------------------------------------------------------
/client/components/Login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { LoginContext } from '../Context';
4 |
5 | const Login: React.FC<{}> = () => {
6 | const { email, setEmail, password, setPassword } = useContext(LoginContext)!;
7 | const [validCredentials, setValidCredentials] = useState(true);
8 | const navigate = useNavigate();
9 |
10 | const handleSubmit = async (e: any) => {
11 | try {
12 | e.preventDefault();
13 | const data = await fetch('/user/login', {
14 | method: 'POST',
15 | headers: { 'Content-Type': 'application/json' },
16 | body: JSON.stringify({ email, password }),
17 | });
18 |
19 | // if the user is authenticated 200
20 | if (data.status === 200) {
21 | setEmail('');
22 | setPassword('');
23 | // JOYCE: unsure if i need to keep this both in state and in localStorage
24 | localStorage.setItem('userIn', 'true');
25 |
26 | navigate('/homepage');
27 | }
28 | // 401 incorrect pw , 400 middleware error
29 | else if (data.status === 401) {
30 | setPassword('');
31 | setValidCredentials(false);
32 | } else if (data.status === 400) {
33 | setEmail('');
34 | setPassword('');
35 | setValidCredentials(false);
36 | }
37 | } catch (error) {
38 | console.log(`Error in useEffect login ${error}`);
39 | return `Error in useEffect login ${error}`;
40 | }
41 | };
42 |
43 | //Sign-up Route
44 | const routeToSignUp = (e: any) => {
45 | e.preventDefault();
46 | navigate('/signup');
47 | };
48 |
49 | return (
50 |
51 |
54 |
91 |
92 | Don't have an Account?{' '}
93 |
94 | Sign up here!
95 |
96 |
97 |
98 | );
99 | };
100 |
101 | export default Login;
102 |
--------------------------------------------------------------------------------
/client/components/Signup.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useState, useContext, useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { LoginContext } from '../Context';
4 |
5 | const Signup: React.FC<{}> = () => {
6 | const { email, setEmail, password, setPassword } = useContext(LoginContext)!;
7 | const [secondPw, setSecondPW] = useState('');
8 | const [doPwMatch, setPwMatch] = useState(true);
9 |
10 | // TODO: Set up an email already exists function
11 | const [emailExistsError, setEmailExistsError] = useState(false);
12 |
13 | const navigate = useNavigate();
14 |
15 | //EMAIL CHECK
16 | useEffect(() => {
17 | (async () => {
18 | try {
19 | //if email has a '@' and '.'
20 | const validEmail = new RegExp(/^\S+@\S+\.\S\S+$/);
21 |
22 | if (email.match(validEmail)) {
23 | //Make API req to backend since match has been found to be valid email
24 | setEmailExistsError(false);
25 | const data = await fetch('/user/emailCheck', {
26 | method: 'POST',
27 | headers: { 'Content-Type': 'application/json' },
28 | body: JSON.stringify({ email }),
29 | });
30 | const parsedData = await data.json();
31 | if (parsedData === 'user exists') {
32 | setEmailExistsError(true);
33 | }
34 | }
35 | } catch (error) {
36 | console.log(`Error in useEffect signup.tsx ${error}`);
37 | return `Error in useEffect signup.tsx ${error}`;
38 | }
39 | })();
40 | }, [email]);
41 |
42 | const handleSubmit = async (e: any) => {
43 | try {
44 | e.preventDefault();
45 |
46 | if (secondPw === password) {
47 | const data = await fetch('/user/signup', {
48 | method: 'POST',
49 | headers: { 'Content-Type': 'application/json' },
50 | body: JSON.stringify({ email, password }),
51 | });
52 |
53 | if (data.status === 200) {
54 | localStorage.setItem('userIn', 'true');
55 | navigate('/homepage');
56 | }
57 | // 400 General Error | 409 User already exists | 200 Success
58 | else if (data.status === 404 || data.status === 400) {
59 | setEmail('');
60 | setPassword('');
61 | setSecondPW('');
62 | setPwMatch(true);
63 | } else if (data.status === 409) {
64 | setEmail('');
65 | setPassword('');
66 | navigate('/');
67 | }
68 | } else {
69 | setPassword('');
70 | setSecondPW('');
71 | setPwMatch(false);
72 | }
73 | } catch (error) {
74 | console.log(`Error in signup.tsx handleSubmit ${error}`);
75 | return `Error in signup.tsx handleSubmit ${error}`;
76 | }
77 | };
78 |
79 | // login route
80 | const routeToLogin = (e: React.MouseEvent) => {
81 | e.preventDefault();
82 | navigate('/');
83 | };
84 |
85 | return (
86 |
87 |
90 |
141 |
142 | Already have an account?{' '}
143 |
144 | Sign in here!
145 |
146 |
147 |
148 | );
149 | };
150 |
151 | export default Signup;
152 |
--------------------------------------------------------------------------------
/client/index.tsx:
--------------------------------------------------------------------------------
1 | import {createRoot} from 'react-dom/client';
2 | import React from 'react';
3 | import App from './App';
4 | import {BrowserRouter} from 'react-router-dom';
5 | import './styles/login.scss';
6 | import './styles/InputContainer.scss';
7 | import './styles/header.scss';
8 | import './styles/resultbar.scss';
9 | import './styles/split.scss';
10 | import './styles/diagram.scss';
11 |
12 | const rootElement = document.getElementById('root');
13 | if (!rootElement) throw new Error('Failed to find the root element');
14 | const root = createRoot(rootElement);
15 | root.render(
16 |
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/client/styles/InputContainer.scss:
--------------------------------------------------------------------------------
1 | //Tab Styling
2 | //https://www.youtube.com/watch?v=WkREeDy2WQ4
3 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
4 | $color1: #020024;
5 | $color2: #2e2c57;
6 | $color3: #004f77;
7 | $color4: #00a3b5;
8 | $color5: #6bfbce;
9 | // input box color
10 | $color6: #d6d9e6;
11 |
12 | body {
13 | font-family: 'Lato', sans-serif;
14 | }
15 |
16 | .input-container {
17 | border: none;
18 | display: flex;
19 | flex-direction: column;
20 | margin-left: 0.8%;
21 | margin-bottom: 0px;
22 | width: 40%;
23 | height: 100%;
24 | background-color: transparent;
25 | overflow-y: scroll;
26 | border-radius: 0 0 5px 5px;
27 | filter: drop-shadow(-2px 1px 20px rgba(0, 163, 181, 0.25));
28 | }
29 |
30 | // holds the three buttons
31 | .tab-container {
32 | display: flex;
33 | flex-direction: row;
34 | border: none;
35 | justify-content: center;
36 | background-color: transparent;
37 | }
38 |
39 | .tab-content {
40 | background: rgb(0, 79, 119);
41 | background: linear-gradient(
42 | 0deg,
43 | rgba(0, 79, 119, 1) 0%,
44 | rgba(46, 44, 87, 1) 100%
45 | );
46 | height: 100%;
47 | overflow: auto;
48 | }
49 |
50 | .tabs {
51 | margin: 0;
52 | padding: 0;
53 | text-align: center;
54 | width: 100%;
55 | height: 5vh;
56 | max-height: 40px;
57 | min-height: 15px;
58 | background: rgb(171, 165, 165);
59 | color: white;
60 | border: 1px solid lighten($color2, 10%);
61 | cursor: pointer;
62 | border-radius: 8px 8px 0 0;
63 | box-sizing: content-box;
64 | position: relative;
65 | outline: none;
66 | }
67 |
68 | .active-tabs {
69 | background: $color2;
70 | border-bottom: 1px solid transparent;
71 | }
72 |
73 | .active-tabs::before {
74 | content: '';
75 | display: block;
76 | position: absolute;
77 | top: -5px;
78 | left: 50%;
79 | transform: translateX(-50%);
80 | }
81 |
82 | button {
83 | border: none;
84 | }
85 | .content-tabs {
86 | flex-grow: 1;
87 | }
88 |
89 | .content {
90 | display: none;
91 | }
92 |
93 | .active-content {
94 | display: block;
95 | height: 100%;
96 | overflow: hidden;
97 | }
98 |
99 | ::-webkit-scrollbar {
100 | width: 10px;
101 | }
102 |
103 | ::-webkit-scrollbar-thumb {
104 | border-radius: 5px;
105 | background-color: #004f77;
106 | }
107 |
108 | ::-webkit-scrollbar-track {
109 | background-color: transparent;
110 | border-radius: 5px;
111 | }
112 |
113 | //QUERY STYLING
114 | .query-main {
115 | display: flex;
116 | flex-direction: column;
117 | align-items: center;
118 | padding-bottom: 40px;
119 | height: calc(100% - 40px);
120 |
121 | .query-main-inner {
122 | flex-grow: 3;
123 | position: relative;
124 | margin: 40px 0 5px;
125 | width: 80%;
126 | // height: 90%;
127 |
128 | .query-input {
129 | resize: none;
130 | background-color: $color6;
131 | outline: none;
132 | box-sizing: border-box;
133 | border: none;
134 | border-radius: 5px;
135 | padding: 12px 20px;
136 | font-family: 'Lato', sans-serif;
137 | // resize: vertical;
138 | overflow: auto;
139 | }
140 |
141 | .query-input:focus {
142 | outline: solid 1px lighten($color1, 15%);
143 | }
144 | }
145 | .error-message-container {
146 | width: 80%;
147 |
148 | .error-message {
149 | background-color: lighten(red, 20%);
150 | border-radius: 5px;
151 | color: white;
152 | font-size: 10px;
153 | font-family: 'Lato', sans-serif;
154 | padding: 5px 5px 5px 5px;
155 | margin-bottom: 3px;
156 | @keyframes fadeInDown {
157 | 0% {
158 | opacity: 0;
159 | transform: translateY(-20px);
160 | }
161 | 100% {
162 | opacity: 1;
163 | transform: translateY(0);
164 | }
165 | }
166 | }
167 | }
168 | }
169 |
170 | .submit-query-button {
171 | position: absolute;
172 | bottom: 0;
173 | right: 5px;
174 | background-color: $color6;
175 | border-radius: 0 0 5px 0;
176 | width: 20%;
177 | height: 30px;
178 | font-size: 12px;
179 | text-align: right;
180 | }
181 |
182 | .clear-button {
183 | position: absolute;
184 | bottom: 0;
185 | left: 5px;
186 | background-color: $color6;
187 | border-radius: 0 0 0 5px;
188 | width: 20%;
189 | height: 30px;
190 | font-size: 12px;
191 | text-align: left;
192 | }
193 | .submit-query-button:hover,
194 | .clear-button:hover {
195 | cursor: pointer;
196 | color: lighten(black, 52%);
197 | }
198 |
199 | textarea {
200 | height: 100%;
201 | width: 100%;
202 | }
203 |
204 | //SETTINGS STYLING
205 | .settings-main {
206 | height: 100%;
207 | overflow: auto;
208 | font-family: 'Lato', sans-serif;
209 | font-size: 11px;
210 |
211 | .uri-main {
212 | display: flex;
213 | flex-direction: column;
214 | align-items: center;
215 | margin: 0 20%;
216 |
217 | form {
218 | width: 100%;
219 | max-width: 250px;
220 |
221 | label {
222 | display: flex;
223 | flex-wrap: nowrap;
224 | width: 100%;
225 |
226 | // .db-submit-settings {
227 | // background-color: $color6;
228 | // // width: 30%;
229 | // height: 36px;
230 | // border-radius: 0 5px 5px 0;
231 | // width: 20%;
232 | // min-width: 60px;
233 | // }
234 |
235 | // .db-submit-settings:hover {
236 | // cursor: pointer;
237 | // color: lighten(black, 52%);
238 | // }
239 | }
240 | }
241 |
242 | .settings-title {
243 | text-align: center;
244 | color: white;
245 | margin: 40px 0 0 0;
246 | }
247 |
248 | form {
249 | display: flex;
250 | flex-direction: column;
251 | align-items: center;
252 | width: 100%;
253 | // max-width: 250px;
254 |
255 | label {
256 | margin-top: 5px;
257 | }
258 |
259 | .settings-input {
260 | width: 100%;
261 | max-width: 234px;
262 | height: 20px;
263 | border: 1px $color1;
264 | background-color: $color6;
265 | border-radius: 5px;
266 | color: black;
267 | padding: 8px;
268 | }
269 |
270 | .settings-input:focus {
271 | outline: solid 1px lighten(rgba(128, 128, 128, 0.443), 15%);
272 | }
273 |
274 | .submit-settings {
275 | margin-top: 10px;
276 | background-color: $color5;
277 | border-radius: 5px;
278 | border-width: 0;
279 | height: 30px;
280 | width: 100px;
281 | font-size: 11px;
282 | color: lighten(black, 5%);
283 | cursor: pointer;
284 | }
285 |
286 | .submit-settings:hover {
287 | background-color: darken($color5, 15%);
288 | cursor: pointer;
289 | }
290 | }
291 | }
292 |
293 | .connection-content {
294 | position: absolute;
295 | z-index: 100;
296 | bottom: 10px;
297 | }
298 | .failed {
299 | background-color: $color2;
300 | border-radius: 5px;
301 | padding: 5px;
302 | }
303 | .success {
304 | background-color: $color5;
305 | color: $color2;
306 | border-radius: 5px;
307 | padding: 5px;
308 | }
309 | }
310 |
311 | // HISTORY
312 | .history-table-container {
313 | height: 100%;
314 |
315 | .history-table {
316 | margin: 40px 10%;
317 | width: 80%;
318 | background-color: rgba(255, 255, 255, 0.45);
319 | border-radius: 5px;
320 | height: calc(100% - 80px);
321 | overflow-x: hidden;
322 | overflow-y: auto;
323 | display: flex;
324 | justify-content: center;
325 | position: relative;
326 |
327 | .query-table {
328 | list-style: none;
329 | display: flex;
330 | flex-direction: column-reverse;
331 | justify-content: flex-end;
332 | position: absolute;
333 | font-size: 10pt;
334 | padding: 3px 10px;
335 | color: black;
336 | width: 100%;
337 | height: calc(100% - 20px);
338 | margin: 0 20px;
339 |
340 | li {
341 | .header-row,
342 | .history-rows {
343 | list-style: none;
344 | display: grid;
345 | grid-template-columns: 80% 20%;
346 |
347 | margin: 0;
348 | padding: 10px;
349 | }
350 | }
351 |
352 | .header-row {
353 | padding: 5px 5px 2px 5px;
354 | border-bottom: 2px solid $color3;
355 | vertical-align: bottom;
356 | }
357 |
358 | .history-rows {
359 | border-bottom: 1px solid $color3;
360 | vertical-align: top;
361 | padding: 0 5px;
362 | }
363 |
364 | .history-query {
365 | text-align: left;
366 | flex-grow: 2;
367 | }
368 |
369 | .history-time {
370 | text-align: right;
371 | }
372 | }
373 | }
374 |
375 | .history-query:hover {
376 | cursor: pointer;
377 | color: #6bfbce;
378 | }
379 | }
380 |
--------------------------------------------------------------------------------
/client/styles/diagram.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
2 | $color1: #020024;
3 | $color2: #004f77;
4 | $color3: #00a3b5;
5 | $color4: #6bfbce;
6 |
7 | .diagram-box {
8 | border: 1px solid $color1;
9 | border: 1px solid lighten($color2, 10%);
10 | display: flex;
11 | width: 100%;
12 | }
13 |
14 | .column-node {
15 | border: 1px solid #1e3d59;
16 | border-radius: 5px;
17 | text-align: center;
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | height: 25px;
22 | width: 180px;
23 | animation: fade 0.9s ease-in;
24 | }
25 |
26 | .column-container {
27 | width: 180px;
28 | display: grid;
29 | grid-template-columns: 12px auto;
30 |
31 | .column-icon {
32 | padding-left: 3px;
33 | }
34 |
35 | #fkey {
36 | font-size: 12px;
37 | }
38 |
39 | .column-data {
40 | max-width: 155px;
41 | display: flex;
42 | width: 100%;
43 | height: 100%;
44 | align-items: center;
45 | justify-content: space-between;
46 | margin-left: 5px;
47 | margin-right: 5px;
48 | // text-overflow: wrap;
49 |
50 | .column-name {
51 | color: white;
52 | text-align: left;
53 | // width: 100%;
54 | overflow: hidden;
55 | text-overflow: ellipsis;
56 | padding: 0 0 3px 2px;
57 | // white-space: wrap;
58 | }
59 |
60 | .column-name:hover {
61 | overflow: visible;
62 | }
63 |
64 | .column-type {
65 | text-align: right;
66 | font-size: 60%;
67 | white-space: nowrap;
68 | overflow: hidden;
69 | text-overflow: ellipsis;
70 | max-width: 30%;
71 | }
72 |
73 | .column-type:hover {
74 | overflow: visible;
75 | }
76 | }
77 | }
78 |
79 | @keyframes fade {
80 | 0% {
81 | opacity: 0;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/client/styles/header.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
2 |
3 | $color1: #020024;
4 | $color2: #004f77;
5 | $color3: #00a3b5;
6 | $color4: #6bfbce;
7 |
8 | body {
9 | margin: 0;
10 | padding: 0;
11 | overflow: auto;
12 | }
13 |
14 | .header {
15 | position: fixed;
16 | width: 100%;
17 | height: 50px;
18 | top: 10px;
19 | left: 3%;
20 | background-color: transparent;
21 |
22 | .logo {
23 | width: 250px;
24 | height: 75px;
25 | position: absolute;
26 | margin: -5px 0 0 -1%;
27 | background-image: url(../../assets/logo-main.png);
28 | background-size: contain;
29 | background-repeat: no-repeat;
30 | }
31 | }
32 |
33 | .logout-button {
34 | position: fixed;
35 | top: 2%;
36 | right: 2%;
37 | outline: none;
38 | background-color: transparent;
39 | color: white;
40 | border: none;
41 | cursor: pointer;
42 | }
43 |
44 | .logout-button:hover {
45 | color: darken(white, 25%);
46 | }
47 |
48 | .main-container {
49 | // border: 1px solid lighten($color2, 10%);
50 | border-radius: 5px;
51 | background-color: transparent;
52 | margin: 75px auto 0;
53 | width: 95%;
54 | height: 95%;
55 | }
56 |
57 | .main-container-split {
58 | height: 85vh;
59 | }
60 |
--------------------------------------------------------------------------------
/client/styles/login.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Avestan&family=Oswald&family=Roboto&display=swap');
2 | $color1: #020024;
3 | $color2: #004f77;
4 | $color3: #00a3b5;
5 | $color4: #6bfbce;
6 | body {
7 | background: rgb(2, 0, 36);
8 | background: linear-gradient(
9 | 90deg,
10 | rgba(2, 0, 36, 1) 0%,
11 | rgba(46, 46, 58, 1) 65%,
12 | rgba(69, 69, 121, 1) 96%
13 | );
14 | color: white;
15 | }
16 |
17 | .login-container {
18 | margin-top: 200px;
19 | margin-bottom: 20px;
20 |
21 | .logo-container {
22 | display: flex;
23 | flex-direction: column;
24 | justify-content: center;
25 | align-items: center;
26 | margin-bottom: 10px;
27 |
28 | .logo {
29 | width: 250px;
30 | height: 75px;
31 | // position: absolute;
32 | background-image: url(../../assets/logo-main.png);
33 | background-size: contain;
34 | background-repeat: no-repeat;
35 | }
36 | }
37 |
38 | .loginForm {
39 | display: flex;
40 | flex-direction: column;
41 | gap: 20px;
42 | justify-content: center;
43 | align-items: center;
44 | }
45 |
46 | .login-text {
47 | display: flex;
48 | flex-direction: column;
49 | font-size: 12px;
50 | margin-bottom: 5px;
51 | }
52 |
53 | .formLine {
54 | display: flex;
55 | flex-direction: column;
56 | align-items: center;
57 | }
58 |
59 | .user-input {
60 | width: 150px;
61 | height: 30px;
62 | border: 1px $color1;
63 | background-color: rgba(128, 128, 128, 0.443);
64 | border-radius: 5px;
65 | color: white;
66 | padding: 8px;
67 | }
68 |
69 | .user-input:focus {
70 | outline: solid 1px lighten(rgba(128, 128, 128, 0.443), 15%);
71 | }
72 | .login-footer {
73 | display: flex;
74 | flex-direction: column;
75 | margin-top: 10px;
76 | margin-bottom: 0;
77 | font-size: 10px;
78 | gap: 10px;
79 | align-items: center;
80 | font-family: 'Noto Sans Avestan', sans-serif;
81 | }
82 |
83 | .submit {
84 | background-color: lighten(white, 10%);
85 | border-radius: 5px;
86 | border-width: 0;
87 | height: 30px;
88 | width: 100px;
89 | font-size: 11px;
90 | color: lighten(black, 5%);
91 | cursor: pointer;
92 | }
93 |
94 | .submit:hover {
95 | background-color: darken(white, 5%);
96 | }
97 |
98 | .small-submit {
99 | background-color: transparent;
100 | color: white;
101 | font-size: 11px;
102 | border: none;
103 | cursor: pointer;
104 | text-decoration: underline;
105 | margin-top: 0;
106 | }
107 |
108 | .small-submit:hover {
109 | color: darken(white, 25%);
110 | }
111 |
112 | .small-text {
113 | font-size: 11px;
114 | color: red;
115 | font-family: 'Noto Sans Avestan', sans-serif;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/client/styles/resultbar.scss:
--------------------------------------------------------------------------------
1 | $color1: #020024;
2 | $color2: #004f77;
3 | $color3: #00a3b5;
4 | $color4: #6bfbce;
5 |
6 | .query-table-outer-container {
7 | margin: 0.8%;
8 | padding: 10px;
9 | // background: rgb(0, 79, 119);
10 | background: linear-gradient(
11 | 0deg,
12 | rgba(0, 79, 119, 1) 0%,
13 | rgba(46, 44, 87, 1) 100%
14 | );
15 | border-radius: 5px;
16 | width: 98%;
17 | // table-layout: fixed;
18 | display: flex;
19 | flex-direction: column;
20 | filter: drop-shadow(-2px 1px 20px rgba(0, 163, 181, 0.25));
21 |
22 | h2 {
23 | margin: 0;
24 | font-size: 18px;
25 | }
26 |
27 | .query-table-container {
28 | margin: 0.8%;
29 | background: rgba(255, 255, 255, 0.375);
30 | border-radius: 5px;
31 | // width: 98%;
32 | overflow: auto;
33 | height: 98%;
34 | }
35 |
36 | // .query-table-container:hover {
37 | // overflow: auto;
38 | // }
39 |
40 | .query-table {
41 | font-size: 10pt;
42 | padding: 3px 10px;
43 | color: black;
44 | width: auto;
45 | // max-width: 100%;
46 | height: 100%;
47 | table-layout: auto;
48 |
49 | th {
50 | // background-color: $color2;
51 | padding: 5px 5px 2px 5px;
52 | border-bottom: 2px solid $color2;
53 | vertical-align: bottom;
54 | }
55 |
56 | td {
57 | border-bottom: 1px solid $color2;
58 | vertical-align: top;
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/client/styles/split.scss:
--------------------------------------------------------------------------------
1 | .flex {
2 | display: flex;
3 | }
4 |
5 | .gutter {
6 | // background-color: #eee;
7 | background-repeat: no-repeat;
8 | background-position: 50%;
9 | }
10 |
11 | .gutter.gutter-horizontal {
12 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
13 | cursor: col-resize;
14 | }
15 |
16 | .gutter.gutter-vertical {
17 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
18 | cursor: row-resize;
19 | }
20 |
21 | .flex2 {
22 | display: flex;
23 | flex-direction: column;
24 | }
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | smartER
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'jsdom',
3 | };
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "query-visualizer",
3 | "version": "1.0.0",
4 | "description": "visualize your sql queries as you type",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "concurrently \"NODE_ENV=development webpack serve --hot --open\" \"npx nodemon ./server/server.ts & redis-server\"",
8 | "front-end": "NODE_ENV=development webpack serve --hot --open",
9 | "server": "npx nodemon ./server/server.ts & redis-server",
10 | "build": "NODE_ENV=production webpack",
11 | "test": "NODE_ENV=test jest"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/oslabs-beta/smartER.git"
18 | },
19 | "contributors": [
20 | "Joyce Kwak",
21 | "Melissa McLaughlin",
22 | "Nathan Ngo",
23 | "Brian Vu"
24 | ],
25 | "devDependencies": {
26 | "@babel/core": "^7.21.3",
27 | "@babel/preset-env": "^7.21.4",
28 | "@babel/preset-react": "^7.18.6",
29 | "@babel/preset-typescript": "^7.21.0",
30 | "@types/bcrypt": "^5.0.0",
31 | "@types/express": "^4.17.17",
32 | "@types/jest": "^29.5.0",
33 | "@types/jsonwebtoken": "^9.0.1",
34 | "@types/lodash": "^4.14.192",
35 | "@types/morgan": "^1.9.4",
36 | "@types/node": "^18.15.5",
37 | "@types/pg": "^8.6.6",
38 | "@types/react": "^18.0.28",
39 | "@types/react-dom": "^18.0.11",
40 | "babel-jest": "^29.5.0",
41 | "babel-loader": "^9.1.2",
42 | "concurrently": "^7.6.0",
43 | "cookie-parser": "^1.4.6",
44 | "cross-env": "^7.0.3",
45 | "css-loader": "^6.7.3",
46 | "express": "^4.18.2",
47 | "html-webpack-plugin": "^5.5.0",
48 | "jest": "^29.5.0",
49 | "jsonwebtoken": "^9.0.0",
50 | "nodemon": "^2.0.21",
51 | "path": "^0.12.7",
52 | "react-test-renderer": "^18.2.0",
53 | "sass-loader": "^13.2.1",
54 | "style-loader": "^3.3.2",
55 | "ts-loader": "^9.4.2",
56 | "ts-node": "^10.9.1",
57 | "typescript": "^5.0.2",
58 | "webpack": "^5.76.2",
59 | "webpack-cli": "^5.0.1",
60 | "webpack-dev-server": "^4.13.1"
61 | },
62 | "dependencies": {
63 | "@fortawesome/free-regular-svg-icons": "^6.4.0",
64 | "@fortawesome/free-solid-svg-icons": "^6.4.0",
65 | "@fortawesome/react-fontawesome": "^0.2.0",
66 | "@reactflow/controls": "^11.1.10",
67 | "@reacticons/ionicons": "^7.1.0",
68 | "@testing-library/jest-dom": "^5.16.5",
69 | "@testing-library/react": "^14.0.0",
70 | "@testing-library/user-event": "^14.4.3",
71 | "@types/cookie-parser": "^1.4.3",
72 | "@types/cryptr": "^4.0.1",
73 | "@types/jest": "^29.5.0",
74 | "@types/lodash": "^4.14.192",
75 | "@types/react-router": "^5.1.20",
76 | "@types/supertest": "^2.0.12",
77 | "@typescript-eslint/eslint-plugin": "^5.56.0",
78 | "bcrypt": "^5.1.0",
79 | "cryptr": "^6.2.0",
80 | "dotenv": "^16.0.3",
81 | "elkjs": "^0.8.2",
82 | "express-validator": "^6.15.0",
83 | "jest": "^29.5.0",
84 | "jest-environment-jsdom": "^29.5.0",
85 | "lodash": "^4.17.21",
86 | "lodash.debounce": "^4.0.8",
87 | "morgan": "^1.10.0",
88 | "pg": "^8.10.0",
89 | "pgsql-ast-parser": "^11.0.1",
90 | "react": "^18.2.0",
91 | "react-dom": "^18.2.0",
92 | "react-router": "^6.9.0",
93 | "react-router-dom": "^6.9.0",
94 | "react-split": "^2.0.14",
95 | "react-syntax-highlighter": "^15.5.0",
96 | "reactflow": "^11.6.1",
97 | "redis": "^4.6.5",
98 | "redis-server": "^1.2.2",
99 | "sass": "^1.59.3",
100 | "supertest": "^6.3.3",
101 | "ts-jest": "^29.0.5",
102 | "web-worker": "^1.2.0"
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/server/app.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response, NextFunction } from 'express';
2 | import morgan from 'morgan';
3 | import cookieParser from 'cookie-parser';
4 | import apiRouter from './routes/router';
5 | import userController from './controllers/userController';
6 | import { body, validationResult } from 'express-validator';
7 | import cookieController from './controllers/cookieController';
8 |
9 | const app = express();
10 |
11 | //General middleware
12 | app.use(morgan('dev'));
13 | app.use(cookieParser());
14 | app.use(express.json());
15 | app.use(express.urlencoded({ extended: true }));
16 |
17 | // post request to check if user input email is unique
18 | app.post(
19 | '/user/emailCheck',
20 | body('email').isEmail().normalizeEmail(),
21 | (req, res, next) => {
22 | const errors = validationResult(req);
23 | if (!errors.isEmpty())
24 | return next({
25 | log: 'error: invalid email address',
26 | status: 400,
27 | message: { err: 'invalid email address' },
28 | });
29 | else return next();
30 | },
31 | userController.checkForEmail,
32 | (req, res, next) => {
33 | if (res.locals.userExists) {
34 | res.status(200).json('user exists');
35 | } else res.status(200).json('unique email');
36 | }
37 | );
38 |
39 | // post request to add new user to db
40 | app.post(
41 | '/user/signup',
42 | body('email').isEmail().normalizeEmail(),
43 | body('password').not().isEmpty(),
44 | (req, res, next) => {
45 | const errors = validationResult(req);
46 | if (errors && !errors.isEmpty())
47 | return res.status(400).json({ error: errors.array() });
48 | else return next();
49 | },
50 | userController.checkForEmail,
51 | userController.createUser,
52 | cookieController.setJwtCookie,
53 | (req, res) => {
54 | return res.status(200).send();
55 | }
56 | );
57 |
58 | app.post(
59 | '/user/login',
60 | body('email').isEmail().normalizeEmail(),
61 | body('password').not().isEmpty(),
62 | userController.verifyUser,
63 | cookieController.setJwtCookie,
64 | cookieController.setDbCookie,
65 | (req, res) => {
66 | return res.status(200).send();
67 | }
68 | );
69 |
70 | app.post(
71 | '/user/changePassword',
72 | body('email').isEmail().normalizeEmail(),
73 | body('password').not().isEmpty(),
74 | body('newPassword').not().isEmpty(),
75 | userController.verifyUser,
76 | userController.changePassword,
77 | (req, res) => {
78 | return res.status(200).send();
79 | }
80 | );
81 |
82 | app.post(
83 | '/user/logout',
84 | userController.authenticateToken,
85 | userController.blacklistToken,
86 | (req, res) => {
87 | return res.status(200).send();
88 | }
89 | );
90 |
91 | app.get('/user/authenticate', userController.authenticateToken, (req, res) => {
92 | return res.status(200).send();
93 | });
94 |
95 | // API Route
96 | app.use('/api', userController.authenticateToken, apiRouter);
97 |
98 | // Catch all 4048
99 | app.use('/', (req: Request, res: Response) => {
100 | res.status(404).json(`This is not the page you are looking for ¯\\_(ツ)_/¯`);
101 | });
102 | // Encrypt 2way, read only access, credentials stored on VS Code local Storage
103 |
104 | // Global error handler
105 | app.use((err: any, req: Request, res: Response, next: NextFunction) => {
106 | const defaultErr = {
107 | log: 'Express error handler caught unknown middleware error',
108 | status: 500,
109 | message: { err: 'An error occurred' },
110 | };
111 | console.log(err.log);
112 | console.log(err.message);
113 | const errorObj = Object.assign({}, defaultErr, err);
114 | return res.status(errorObj.status).json(errorObj.message);
115 | });
116 |
117 | export default app;
118 |
--------------------------------------------------------------------------------
/server/controllers/cookieController.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import { RequestHandler } from 'express';
3 | import db from '../models/userModel';
4 | import dotenv from 'dotenv';
5 | dotenv.config();
6 |
7 | interface cookieControllers {
8 | setJwtCookie: RequestHandler;
9 | setDbCookie: RequestHandler;
10 | }
11 |
12 | const cookieController: cookieControllers = {
13 | setJwtCookie: async (req, res, next) => {
14 | try {
15 | const createJwt = (email: string) => {
16 | const secret = process.env.JWT_SECRET_KEY;
17 | if (secret) {
18 | const token = jwt.sign(
19 | {
20 | email: email,
21 | },
22 | secret,
23 | {
24 | expiresIn: '7d',
25 | }
26 | );
27 | return token;
28 | }
29 | };
30 |
31 | const { email } = req.body;
32 |
33 | const jwtToken = createJwt(email);
34 |
35 | res.locals.JWT = jwtToken;
36 | res.cookie('JWT', jwtToken, {
37 | httpOnly: true,
38 | secure: true,
39 | });
40 | // res.header('Authorization', jwtToken);
41 | return next();
42 | } catch (error) {
43 | return next({
44 | log: 'error running cookieController.setJwtCookie middleware',
45 | status: 400,
46 | message: { err: error },
47 | });
48 | }
49 | },
50 |
51 | setDbCookie: async (req, res, next) => {
52 | try {
53 | if (res.locals.dbId) {
54 | // if uri was just saved, pull dbId from res.locals
55 | res.cookie('dbId', res.locals.dbId, {
56 | httpOnly: true,
57 | secure: true,
58 | });
59 | return next();
60 | } else if (res.locals.user_id) {
61 | // if user just signed up or logged in, get user id and use query to find most recent URI for that user
62 | // STRETCH: allow user to select from list of saved URIs instead of always pulling the last one
63 | const sql = await db.query(`
64 | SELECT _id FROM databases
65 | WHERE user_id = ${res.locals.user_id}
66 | ORDER BY _id desc
67 | ;`);
68 |
69 | let dbId = 0;
70 | if (sql.rowCount) dbId = sql.rows[0]._id;
71 | res.cookie('dbId', dbId, {
72 | httpOnly: true,
73 | secure: true,
74 | });
75 |
76 | res.locals.dbId = dbId;
77 | return next();
78 | } else throw new Error('user not set');
79 | } catch (error) {
80 | return next({
81 | log: 'error running cookieController.setDbCookie middleware',
82 | status: 400,
83 | message: { err: error },
84 | });
85 | }
86 | },
87 | };
88 |
89 | export default cookieController;
90 |
--------------------------------------------------------------------------------
/server/controllers/dbController.ts:
--------------------------------------------------------------------------------
1 | import { RequestHandler } from 'express';
2 | import db from '../models/userModel';
3 | import dotenv from 'dotenv';
4 | import {} from 'pg';
5 | import Cryptr from 'cryptr';
6 | dotenv.config();
7 |
8 | interface dbControllers {
9 | saveURI: RequestHandler;
10 | getHistory: RequestHandler;
11 | postHistory: RequestHandler;
12 | }
13 |
14 | const dbController: dbControllers = {
15 | saveURI: async (req, res, next) => {
16 | try {
17 | if (req.user) {
18 | const { id } = req.user;
19 | const { encodedURI } = req.body;
20 |
21 | const cryptr = new Cryptr(process.env.URI_SECRET_KEY || 'test', {
22 | pbkdf2Iterations: 10000,
23 | saltLength: 10,
24 | });
25 |
26 | const encryptedUri = cryptr.encrypt(encodedURI);
27 | const postUri = await db.query(`
28 | INSERT INTO databases (user_id, uri)
29 | VALUES (${id}, '${encryptedUri}')
30 | RETURNING _id
31 | ;`);
32 |
33 | res.locals.dbId = postUri.rows[0]._id;
34 | return next();
35 | } else throw new Error('user not set');
36 | } catch (error) {
37 | return next({
38 | log: `Error in dbController.saveURI ${error}`,
39 | status: 400,
40 | message: { error },
41 | });
42 | }
43 | },
44 |
45 | getHistory: async (req, res, next) => {
46 | try {
47 | const { dbId } = req.cookies;
48 | if (dbId) {
49 | const history = await db.query(`
50 | SELECT
51 | h.created_at, h.query
52 | FROM history h
53 | JOIN databases d on d._id = h.database_id
54 | WHERE d._id = ${dbId}
55 | ;`);
56 | console.log('history', history);
57 | res.locals.queryHistory = history.rows;
58 | return next();
59 | } else throw new Error('db not found');
60 | } catch (error) {
61 | return next({
62 | log: `Error in dbController.getHistory ${error}`,
63 | status: 400,
64 | message: { error },
65 | });
66 | }
67 | },
68 |
69 | postHistory: async (req, res, next) => {
70 | try {
71 | const { dbId } = req.cookies;
72 | if (req.cookies.dbId) {
73 | const { created_at, queryString } = req.body;
74 | const dateInt = Math.floor(parseInt(created_at) / 1000);
75 | const saveHistory = await db.query(
76 | `INSERT INTO history (database_id, created_at, query)
77 | VALUES (${dbId}, current_timestamp, $1)
78 | RETURNING created_at
79 | ;`,
80 | [queryString]
81 | );
82 | res.locals.timestamp = saveHistory.rows[0].created_at;
83 | return next();
84 | } else throw new Error('db not found');
85 | } catch (error) {
86 | return next({
87 | log: `Error in dbController.postHistory ${error}`,
88 | status: 400,
89 | message: { error },
90 | });
91 | }
92 | },
93 | };
94 |
95 | export default dbController;
96 |
--------------------------------------------------------------------------------
/server/controllers/schemaController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction, RequestHandler } from 'express';
2 | import { Pool } from 'pg';
3 | import dotenv from 'dotenv';
4 | import db from '../models/userModel';
5 | import { table } from 'console';
6 | import Cryptr from 'cryptr';
7 | dotenv.config();
8 |
9 | interface schemaControllers {
10 | connectDb: RequestHandler;
11 | getSchemaPostgreSQL: RequestHandler;
12 | getQueryResults: RequestHandler;
13 | }
14 | //
15 | const schemaController: schemaControllers = {
16 | connectDb: async (req, res, next) => {
17 | try {
18 | console.log('running connectDb');
19 | let dbId;
20 | if (res.locals.dbId) dbId = res.locals.dbId;
21 | if (!dbId) dbId = req.cookies.dbId;
22 |
23 | if (!dbId) throw new Error('no db cookie');
24 |
25 | const dbResult = await db.query(`
26 | SELECT uri FROM databases
27 | WHERE _id = ${dbId}
28 | ;`);
29 |
30 | const cryptr = new Cryptr(process.env.URI_SECRET_KEY || 'test', {
31 | pbkdf2Iterations: 10000,
32 | saltLength: 10,
33 | });
34 |
35 | const decryptedUri = cryptr.decrypt(dbResult.rows[0].uri);
36 | const pg_uri = decodeURIComponent(decryptedUri);
37 |
38 | var envCredentials: any = { connectionString: pg_uri };
39 | res.locals.pg = new Pool(envCredentials);
40 | return next();
41 | } catch (error) {
42 | return next({
43 | log: `Error in schemaController.connectDb ${error}`,
44 | status: 400,
45 | message: { error },
46 | });
47 | }
48 | },
49 | getSchemaPostgreSQL: async (req, res, next) => {
50 | try {
51 | const pg = res.locals.pg;
52 | // Get all relationships between all tables
53 | // Identify the current schema name for use in full schema query
54 | const currentSchemaSQL = await pg.query(
55 | `SELECT current_schema FROM current_schema`
56 | );
57 |
58 | const currentSchema = currentSchemaSQL.rows[0].current_schema;
59 |
60 | const constraintArr: Record[] = [];
61 | // Get Relationships, Tables names, Column names, Data types
62 | const query = `SELECT * FROM (
63 | SELECT DISTINCT ON (c.table_name, c.column_name)
64 | c.table_name,
65 | c.column_name,
66 | c.data_type,
67 | c. ordinal_position,
68 | max(case when tc.constraint_type = 'PRIMARY KEY' then 1 else 0 end) OVER(PARTITION BY c.table_name, c.column_name) AS is_primary_key,
69 | cc.table_name as table_origin,
70 | cc.column_name as table_column
71 |
72 | FROM information_schema.key_column_usage kc
73 |
74 | INNER JOIN information_schema.table_constraints tc
75 | ON kc.table_name = tc.table_name AND kc.table_schema = tc.table_schema AND kc.constraint_name = tc.constraint_name
76 |
77 | LEFT JOIN information_schema.constraint_column_usage cc
78 | ON cc.constraint_name = kc.constraint_name AND tc.constraint_type = 'FOREIGN KEY'
79 |
80 | RIGHT JOIN information_schema.columns c
81 | ON c.table_name = kc.table_name AND c.column_name = kc.column_name
82 |
83 | WHERE c.table_schema = '${currentSchema}' AND is_updatable = 'YES'
84 |
85 | ORDER BY c.table_name, c.column_name, is_primary_key desc, table_origin) subquery
86 |
87 | ORDER BY table_name, ordinal_position;`;
88 | const schema = await pg.query(query);
89 |
90 | // Initialize object to hold returned data
91 | let erDiagram: Record = {};
92 | let tableObj: Record = {};
93 | // Make custom type for any on tableObj
94 |
95 | // Iterate through array of all table names, columns, and data types
96 | for (let i = 0; i < schema.rows.length; i++) {
97 | let nextTableName;
98 | if (schema.rows[i + 1]) nextTableName = schema.rows[i + 1].table_name;
99 | // current represents each object in the array
100 | const current = schema.rows[i];
101 | //column object type and declaration
102 |
103 | // Assign table name and column name
104 | tableObj[current.column_name] = {};
105 | tableObj[current.column_name].table_name = current.table_name;
106 | tableObj[current.column_name].column_name = current.column_name;
107 |
108 | // Assign data type
109 | if (current.data_type === 'integer')
110 | tableObj[current.column_name].data_type = 'int';
111 | else if (current.data_type === 'character varying')
112 | tableObj[current.column_name].data_type = 'varchar';
113 | else tableObj[current.column_name].data_type = current.data_type;
114 |
115 | // Add relationships and constraints if there are any
116 | if (current.is_primary_key) {
117 | tableObj[current.column_name].primary_key = true;
118 | tableObj[current.column_name].foreign_tables = [];
119 | }
120 |
121 | // table_origin is only given when column is a foreign key
122 | if (current.table_origin) {
123 | const constraintObj: Record = {};
124 | constraintObj[`${[current.table_origin]}.${current.table_column}`] =
125 | current.table_name;
126 | tableObj[current.column_name].foreign_key = true;
127 | tableObj[current.column_name].linkedTable = current.table_origin;
128 | tableObj[current.column_name].linkedTableColumn =
129 | current.table_column;
130 |
131 | constraintArr.push({ ...constraintObj });
132 | }
133 |
134 | // if table name at next row is a different table,
135 | // push a deep copy of the tableObj to final ER diagram and reset tableObj
136 | if (!nextTableName || nextTableName !== current.table_name) {
137 | erDiagram[current.table_name] = { ...tableObj };
138 | tableObj = {};
139 | }
140 | }
141 |
142 | for (const constraint of constraintArr) {
143 | for (const relationship in constraint) {
144 | const string = relationship.split('.'); // [species, _id]
145 | const tableName = string[0]; // species
146 | const columnName = string[1]; // _id
147 | const tableOrigin = constraint[relationship]; // people
148 | erDiagram[tableName][columnName].foreign_tables.push(tableOrigin);
149 | }
150 | }
151 |
152 | res.locals.erDiagram = erDiagram;
153 | return next();
154 | } catch (error) {
155 | return next({
156 | log: `Error in schemaController.getSchemaPostgreSQL ${error}`,
157 | status: 400,
158 | message: { error },
159 | });
160 | }
161 | },
162 | getQueryResults: async (req, res, next) => {
163 | try {
164 | const { queryString } = req.body;
165 | const pg = res.locals.pg;
166 | // Make a query based on the passed in queryString
167 | const getQuery = await pg.query(queryString);
168 |
169 | // Return query to FE
170 | const results = getQuery.rows;
171 | res.locals.queryResults = results;
172 | return next();
173 | } catch (error) {
174 | return next({
175 | log: `Error in schemaController.getQueryResults ${error}`,
176 | status: 400,
177 | message: { error },
178 | });
179 | }
180 | },
181 | };
182 |
183 | export default schemaController;
184 |
--------------------------------------------------------------------------------
/server/controllers/userController.ts:
--------------------------------------------------------------------------------
1 | import db from '../models/userModel';
2 | import { RequestHandler } from 'express';
3 | import jwt from 'jsonwebtoken';
4 | import { redisClient } from '../server';
5 | import bcrypt from 'bcrypt';
6 | const SALTROUNDS = 5;
7 | import dotenv from 'dotenv';
8 | dotenv.config();
9 |
10 | interface userControllers {
11 | checkForEmail: RequestHandler;
12 | createUser: RequestHandler;
13 | verifyUser: RequestHandler;
14 | changePassword: RequestHandler;
15 | authenticateToken: RequestHandler;
16 | blacklistToken: RequestHandler;
17 | }
18 |
19 | const comparePassword = async (password: string, hashedPassword: string) => {
20 | return await bcrypt.compare(password, hashedPassword);
21 | };
22 |
23 | const userController: userControllers = {
24 | // confirm whether user exists based on email passed in
25 | checkForEmail: async (req, res, next) => {
26 | try {
27 | const emailLookup = await db.query(
28 | `SELECT _id FROM users WHERE email = '${req.body.email}'`
29 | );
30 | res.locals.userExists = Boolean(emailLookup.rowCount);
31 | return next();
32 | } catch (error) {
33 | return next({
34 | log: 'error running userController.checkForEmail middleware',
35 | status: 400,
36 | message: { err: error },
37 | });
38 | }
39 | },
40 |
41 | // create user based on email and password passed in
42 | createUser: async (req, res, next) => {
43 | try {
44 | if (res.locals.userExists) {
45 | return next({
46 | log: 'error: email already exists',
47 | status: 409,
48 | message: { err: 'account with this email already exists' },
49 | });
50 | }
51 |
52 | const { email, password } = req.body;
53 | const hashedPassword = await bcrypt.hash(password, SALTROUNDS);
54 | const newUser = await db.query(`
55 | INSERT into users (email, password)
56 | VALUES ('${email}', '${hashedPassword}')
57 | RETURNING _id
58 | ;`);
59 |
60 | res.locals.user_id = newUser.rows[0]._id;
61 | return next();
62 | } catch (error) {
63 | return next({
64 | log: 'error running userController.createUser middleware',
65 | status: 400,
66 | message: { err: error },
67 | });
68 | }
69 | },
70 |
71 | // verify user by email/password combination
72 | verifyUser: async (req, res, next) => {
73 | try {
74 | const { email, password } = req.body;
75 | const pwLookup = await db.query(
76 | `SELECT _id, password FROM users WHERE email = '${email}'`
77 | );
78 |
79 | res.locals.user_id = pwLookup.rows[0]._id;
80 |
81 | const hashedPassword = pwLookup.rows[0].password;
82 | const isValidPw = await comparePassword(password, hashedPassword);
83 | if (!isValidPw) {
84 | return next({
85 | log: 'error: incorrect password',
86 | status: 401,
87 | message: { err: 'email or password is incorrect' },
88 | });
89 | } else return next();
90 | } catch (error) {
91 | return next({
92 | log: 'error running userController.verifyUser middleware',
93 | status: 400,
94 | message: { err: error },
95 | });
96 | }
97 | },
98 |
99 | // verify user by email/password combination
100 | changePassword: async (req, res, next) => {
101 | try {
102 | const { email, newPassword } = req.body;
103 | const hashedPassword = await bcrypt.hash(newPassword, SALTROUNDS);
104 | await db.query(
105 | `UPDATE users SET password = '${hashedPassword}' WHERE email = '${email}'`
106 | );
107 | return next();
108 | } catch (error) {
109 | return next({
110 | log: 'error running userController.verifyUser middleware',
111 | status: 400,
112 | message: { err: error },
113 | });
114 | }
115 | },
116 |
117 | // protect API routes by validating JWT
118 | authenticateToken: async (req, res, next) => {
119 | try {
120 | // console.log('running authenticateToken');
121 | const token: string | undefined = req.cookies.JWT;
122 | if (token) console.log('validating token');
123 |
124 | // reject request if no token provided
125 | if (!token) {
126 | return next({
127 | log: 'no token provided',
128 | status: 401,
129 | message: { err: 'no token provided' },
130 | });
131 | }
132 |
133 | // reject request if token is in deny list (user logged out)
134 | const inDenyList = await redisClient.get(`bl_${token}`);
135 | if (inDenyList) {
136 | return next({
137 | log: 'JWT rejected',
138 | status: 401,
139 | message: { err: 'JWT rejected' },
140 | });
141 | }
142 |
143 | // reject request if token is invalid
144 | // console.log('confirming token is valid');
145 | const secret = process.env.JWT_SECRET_KEY;
146 | if (token && secret) {
147 | jwt.verify(token, secret, (error, payload) => {
148 | if (error) {
149 | return next({
150 | log: 'JWT invalid',
151 | status: 401,
152 | message: { err: error },
153 | });
154 | }
155 |
156 | // declare types of properties on payload
157 | const decodedToken = payload as { email: string; exp: number };
158 | const { email, exp } = decodedToken;
159 |
160 | // confirm payload contains correct properties and they are expected type before adding to request
161 | if (
162 | email &&
163 | typeof email === 'string' &&
164 | exp &&
165 | typeof exp === 'number'
166 | ) {
167 | db.query(`SELECT _id FROM users WHERE email = '${email}'`).then(
168 | (result: any) => {
169 | const userId = result.rows[0]._id;
170 |
171 | req.user = {
172 | email: email,
173 | id: userId,
174 | token: token,
175 | exp: exp,
176 | };
177 |
178 | return next();
179 | }
180 | );
181 | } else
182 | return next({
183 | log: 'JWT invalid',
184 | status: 401,
185 | message: { err: error },
186 | });
187 | });
188 | }
189 | } catch (error) {
190 | return next({
191 | log: 'error running userController.authenticateToken middleware',
192 | status: 400,
193 | message: { err: error },
194 | });
195 | }
196 | },
197 |
198 | blacklistToken: async (req, res, next) => {
199 | try {
200 | const { user } = req;
201 |
202 | if (user && user.token && user.exp) {
203 | const { token, exp } = user;
204 | console.log('blacklisting token');
205 | const token_key = `bl_${token}`;
206 | await redisClient.set(token_key, token);
207 | redisClient.expireAt(token_key, exp);
208 | }
209 |
210 | return next();
211 | } catch (error) {
212 | return next({
213 | log: 'error running userController.blacklistToken middleware',
214 | status: 400,
215 | message: { err: error },
216 | });
217 | }
218 | },
219 | };
220 |
221 | export default userController;
222 |
--------------------------------------------------------------------------------
/server/models/userModel.ts:
--------------------------------------------------------------------------------
1 | const { Pool } = require('pg');
2 | import dotenv from 'dotenv';
3 | dotenv.config();
4 | const PG_URI = process.env.DATABASE_API;
5 |
6 | // create a new pool here using the connection string above
7 | const pool = new Pool({
8 | connectionString: PG_URI,
9 | });
10 |
11 | // export an object that contains a property called query,
12 | // which is a function that returns the invocation of pool.query() after logging the query
13 | // This will be required in the controllers to be the access point to the database
14 |
15 | export default {
16 | query: (text: string, arr?: string[]) => {
17 | console.log('executed query', text);
18 | return pool.query(text, arr);
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/server/routes/router.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import schemaController from '../controllers/schemaController';
3 | import userController from '../controllers/userController';
4 | import dbController from '../controllers/dbController';
5 | import cookieController from '../controllers/cookieController';
6 | const router = express.Router();
7 |
8 | // Possibly add route for storing users previous login credentials or URIs?
9 | router.post(
10 | '/getQueryResults',
11 | schemaController.connectDb,
12 | schemaController.getQueryResults,
13 | (req, res) => {
14 | res.status(200).json(res.locals.queryResults);
15 | }
16 | );
17 |
18 | router.post('/postHistory', dbController.postHistory, (req, res) => {
19 | res.status(200).json(res.locals.timestamp);
20 | });
21 |
22 | router.get(
23 | '/getSchema',
24 | schemaController.connectDb,
25 | schemaController.getSchemaPostgreSQL,
26 | (req, res) => {
27 | res.status(200).json(res.locals.erDiagram);
28 | }
29 | );
30 |
31 | router.get('/getHistory', dbController.getHistory, (req, res) => {
32 | res.status(200).json(res.locals.queryHistory);
33 | });
34 |
35 | router.post(
36 | '/addURI',
37 | dbController.saveURI,
38 | cookieController.setDbCookie,
39 | schemaController.connectDb,
40 | schemaController.getSchemaPostgreSQL,
41 | (req, res) => {
42 | res.status(200).json(res.locals.erDiagram);
43 | }
44 | );
45 |
46 | router.delete('/', (req, res, next) => {});
47 |
48 | export default router;
49 |
--------------------------------------------------------------------------------
/server/server.ts:
--------------------------------------------------------------------------------
1 | import app from './app';
2 | import dotenv from 'dotenv';
3 | import { createClient, RedisClientType } from 'redis';
4 | dotenv.config();
5 |
6 | const PORT = process.env.PORT || 9001;
7 |
8 | app.listen(PORT, () => {
9 | console.log(`⚡️Express:${PORT} ⚡️`);
10 | });
11 |
12 | // connect redis for use in logout functionality
13 | let redisClient: RedisClientType;
14 | (async () => {
15 | redisClient = createClient();
16 |
17 | redisClient.on('error', (error) => {
18 | // ECONNREFUSED error will be thrown if redis is not installed. brew install redis to resolve
19 | console.log(`Error connecting redis: ${error}`);
20 | });
21 |
22 | await redisClient.connect();
23 | })();
24 |
25 | export { redisClient };
26 |
--------------------------------------------------------------------------------
/src/static/diagram-rearranged.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/diagram-rearranged.png
--------------------------------------------------------------------------------
/src/static/express.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/express.png
--------------------------------------------------------------------------------
/src/static/history-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/history-2.png
--------------------------------------------------------------------------------
/src/static/history-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/history-3.png
--------------------------------------------------------------------------------
/src/static/history.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/history.png
--------------------------------------------------------------------------------
/src/static/icons8-react-native-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/icons8-react-native-48.png
--------------------------------------------------------------------------------
/src/static/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/login.png
--------------------------------------------------------------------------------
/src/static/postgresql.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/postgresql.png
--------------------------------------------------------------------------------
/src/static/query-people-species.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/query-people-species.png
--------------------------------------------------------------------------------
/src/static/react-flow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/static/redis.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/redis.png
--------------------------------------------------------------------------------
/src/static/results.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/results.png
--------------------------------------------------------------------------------
/src/static/sass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/sass.png
--------------------------------------------------------------------------------
/src/static/settings-credentials.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/settings-credentials.png
--------------------------------------------------------------------------------
/src/static/settings-uri.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/settings-uri.png
--------------------------------------------------------------------------------
/src/static/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/settings.png
--------------------------------------------------------------------------------
/src/static/smarter-logo-padded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/smarter-logo-padded.png
--------------------------------------------------------------------------------
/src/static/typescript.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/smartER/f25708190ffbf068e24ab1df60936ce417f5d03b/src/static/typescript.png
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "noImplicitAny": true,
5 | "lib": ["DOM", "ESNEXT"],
6 | "target": "ES6",
7 | "jsx": "react-jsx",
8 | "allowJs": true,
9 | "moduleResolution": "node",
10 | "strict": true,
11 | "allowSyntheticDefaultImports": true,
12 | "esModuleInterop": true,
13 | "typeRoots": ["./types", "node_modules/@types"]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/types/custom.ts:
--------------------------------------------------------------------------------
1 | export type User = {
2 | email: string;
3 | id: number;
4 | token: string | null;
5 | exp: number;
6 | };
7 |
--------------------------------------------------------------------------------
/types/express/index.d.ts:
--------------------------------------------------------------------------------
1 | /*
2 | TypeScript uses the .d.ts declaration files to load type information about a library written in JavaScript.
3 | Here, the index.d.ts global module will be used by TypeScript to extend the Express Request type globally through
4 | declaration merging. According to the Express source code, this is the officially endorsed way to extend the Request type.
5 | */
6 |
7 | import { User } from '../custom';
8 |
9 | export {};
10 |
11 | declare global {
12 | namespace Express {
13 | export interface Request {
14 | user?: User;
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import HtmlWebpackPlugin from 'html-webpack-plugin';
3 |
4 | export default {
5 | entry: ['./client/index.tsx'],
6 | output: {
7 | path: path.resolve(__dirname, 'dist'),
8 | publicPath: '/',
9 | filename: 'bundle.js',
10 | },
11 | mode: process.env.NODE_ENV ? process.env.NODE_ENV : 'development',
12 | devServer: {
13 | static: {
14 | directory: path.resolve(__dirname, 'dist'),
15 | publicPath: '/',
16 | },
17 | port: 8080,
18 | historyApiFallback: true,
19 | headers: { 'Access-Control-Allow-Origin': '*' },
20 | proxy: {
21 | // '/api/**': 'http://localhost:9001/',
22 | '/api': 'http://localhost:9001/',
23 | '/user': 'http://localhost:9001/',
24 | },
25 | },
26 | module: {
27 | rules: [
28 | {
29 | test: /\.(ts|js)x?$/,
30 | exclude: /node_modules/,
31 | include: [path.resolve(__dirname, 'client')],
32 | use: 'babel-loader',
33 | },
34 | {
35 | test: /.(scss|sass|css)$/,
36 | exclude: /node_modules\/(?!@?reactflow).*/,
37 | use: ['style-loader', 'css-loader', 'sass-loader'],
38 | },
39 | ],
40 | },
41 | plugins: [new HtmlWebpackPlugin({ template: './index.html' })],
42 | resolve: {
43 | extensions: ['.ts', '.tsx', '.js', '.jsx'],
44 | },
45 | };
46 |
--------------------------------------------------------------------------------