diff --git a/package.json b/package.json index c79622c..bd60e24 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "@testing-library/user-event": "^12.1.10", "@zxing/library": "^0.18.3", "add": "^2.0.6", + "connected-react-router": "^6.8.0", + "history": "4.10.1", + "immer": "^8.0.0", "lodash": "^4.17.20", "prop-types": "^15.7.2", "react": "^17.0.1", @@ -21,6 +24,7 @@ "react-webcam": "^5.2.2", "recharts": "^1.8.5", "redux": "^4.0.5", + "redux-injectors": "^1.3.0", "redux-saga": "^1.1.3", "reselect": "^4.0.0", "web-vitals": "^0.2.4", diff --git a/src/containers/App/actions.js b/src/containers/App/actions.js new file mode 100644 index 0000000..e69de29 diff --git a/src/containers/App/constants.js b/src/containers/App/constants.js new file mode 100644 index 0000000..755d49d --- /dev/null +++ b/src/containers/App/constants.js @@ -0,0 +1,4 @@ +export const LOGOUT_REQUEST = 'app/App/LOGOUT_REQUEST'; +export const LOGOUT_ERROR = 'app/App/LOGOUT_ERROR'; +export const LOGOUT_SUCCESS = 'app/App/LOGOUT_SUCCESS'; +export const LOGIN_SUCCESS = 'app/App/LOGIN_SUCCESS'; diff --git a/src/containers/App/index.js b/src/containers/App/index.js index c29af36..dfc6eda 100644 --- a/src/containers/App/index.js +++ b/src/containers/App/index.js @@ -1,28 +1,30 @@ import React from 'react'; -import { Switch } from 'react-router-dom'; -import RouteWithSubRoutes from 'containers/RouteWithSubRoutes' +import {Route, Switch} from 'react-router-dom'; import { ThemeProvider } from '@material-ui/core/styles' +import {createStructuredSelector} from "reselect"; import CssBaseline from '@material-ui/core/CssBaseline' -import { ROUTES } from 'utils/routes'; -import { theme } from 'utils/theme' - +import { theme, routes } from 'utils' +import {makeSelectIsLogged} from "./selectors"; +import { useSelector } from 'react-redux'; import Navbar from 'components/Navbar' +import HomePage from 'pages/Home' +import LoginPage from 'pages/Login' + +const stateSelector = createStructuredSelector({ + isLogged: makeSelectIsLogged(), +}); + const App = () => { + const { isLogged } = useSelector(stateSelector) + return ( - + {isLogged && } - {ROUTES.map(({ key, component, path, exact, routes }) => ( - - ))} + + ); diff --git a/src/containers/App/reducer.js b/src/containers/App/reducer.js new file mode 100644 index 0000000..72e87fc --- /dev/null +++ b/src/containers/App/reducer.js @@ -0,0 +1,30 @@ +import produce from 'immer'; +import { + LOGOUT_ERROR, + LOGOUT_SUCCESS, +} from './constants'; + +import { LOGIN_SUCCESS } from 'pages/Login/constants' + +export const initialState = { + isLogged: false, + notifications: [], + tokens: {}, + user: {}, +}; +const appReducer = produce((draft, action) => { + switch(action.type) { + case LOGIN_SUCCESS: + draft.isLogged = true; + draft.tokens = action.tokens; + draft.user = action.user; + break; + case LOGOUT_ERROR: + case LOGOUT_SUCCESS: + return initialState; + default: + return initialState; + } +}, initialState); + +export default appReducer; diff --git a/src/containers/App/selectors.js b/src/containers/App/selectors.js new file mode 100644 index 0000000..81ef201 --- /dev/null +++ b/src/containers/App/selectors.js @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; + +const selectGlobalDomain = (state) => state.global || initialState; + +const makeSelectIsLogged = () => + createSelector(selectGlobalDomain, (substate) => substate.isLogged); + +const makeSelectNotifications = () => + createSelector(selectGlobalDomain, (substate) => substate.notifications); + +const makeSelectTokens = () => + createSelector(selectGlobalDomain, (substate) => substate.tokens); + +const makeSelectUser = () => + createSelector(selectGlobalDomain, (substate) => substate.user); + +export { + makeSelectIsLogged, + makeSelectNotifications, + makeSelectTokens, + makeSelectUser, +}; diff --git a/src/containers/RouteWithSubRoutes/index.js b/src/containers/RouteWithSubRoutes/index.js deleted file mode 100644 index 9df255f..0000000 --- a/src/containers/RouteWithSubRoutes/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Route } from 'react-router-dom' -import PropTypes from 'prop-types'; - -const RouteWithSubRoutes = ({ component: Component, path, exact, routes = [] }) => ( - } - /> -) - -RouteWithSubRoutes.propTypes = { - routes: PropTypes.array, - component: PropTypes.elementType.isRequired, - path: PropTypes.string.isRequired, - exact: PropTypes.bool.isRequired, -} - -export default RouteWithSubRoutes; diff --git a/src/index.js b/src/index.js index 967621a..ecfe17f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,22 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { BrowserRouter } from 'react-router-dom' +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'connected-react-router'; import App from 'containers/App'; import reportWebVitals from './reportWebVitals'; +import configureStore from 'utils/configureStore' +import {history} from 'utils'; + +const initialState = {}; +const store = configureStore(initialState, history); ReactDOM.render( - - - + + + + + , document.getElementById('root') ); diff --git a/src/pages/Login/actions.js b/src/pages/Login/actions.js new file mode 100644 index 0000000..1e8db07 --- /dev/null +++ b/src/pages/Login/actions.js @@ -0,0 +1,22 @@ +import { LOGIN_INPUT_CHANGE, LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_ERROR } from './constants'; + +export const loginInputChange = (name, value) => ({ + type: LOGIN_INPUT_CHANGE, + name, + value, +}) + +export const loginAction = () => ({ + type: LOGIN_REQUEST, +}) + +export const loginSuccessAction = (user, tokens) => ({ + type: LOGIN_SUCCESS, + user, + tokens, +}) + +export const loginErrorAction = (error) => ({ + type: LOGIN_ERROR, + error, +}) diff --git a/src/pages/Login/constants.js b/src/pages/Login/constants.js new file mode 100644 index 0000000..e7f2f4c --- /dev/null +++ b/src/pages/Login/constants.js @@ -0,0 +1,5 @@ +export const LOGIN_REQUEST = 'app/LoginPage/LOGIN_REQUEST'; +export const LOGIN_SUCCESS = 'app/LoginPage/LOGIN_SUCCESS'; +export const LOGIN_ERROR = 'app/LoginPage/LOGIN_ERROR'; + +export const LOGIN_INPUT_CHANGE = 'app/LoginPage/LOGIN_INPUT_CHANGE'; diff --git a/src/pages/Login/index.js b/src/pages/Login/index.js new file mode 100644 index 0000000..2705052 --- /dev/null +++ b/src/pages/Login/index.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { useInjectReducer, useInjectSaga } from 'redux-injectors'; +import { createStructuredSelector } from 'reselect'; +import { useSelector, useDispatch } from 'react-redux'; +import { Container, Grid, Box, Paper, Button, Typography, TextField } from '@material-ui/core' +import reducer from './reducer'; +import saga from './saga'; +import { makeSelectEmail, makeSelectPassword, makeSelectLoading } from './selectors' +import { loginInputChange, loginAction } from './actions' + +const stateSelector = createStructuredSelector({ + email: makeSelectEmail(), + password: makeSelectPassword(), + loading: makeSelectLoading(), +}); + +const key = 'loginPage' +const Login = () => { + useInjectReducer({ key, reducer }); + useInjectSaga({ key, saga }); + const { email, password, loading } = useSelector(stateSelector) + const dispatch = useDispatch(); + + const onChangeInput = ({target: { name, value }}) => { + dispatch(loginInputChange(name, value)) + } + + const handleSubmit = (event) => { + event.preventDefault() + dispatch(loginAction()) + } + + return ( + + + + + Login + + + + + + + + ); +}; + +export default Login; diff --git a/src/pages/Login/reducer.js b/src/pages/Login/reducer.js new file mode 100644 index 0000000..760add0 --- /dev/null +++ b/src/pages/Login/reducer.js @@ -0,0 +1,31 @@ +import produce from 'immer'; +import {LOGIN_INPUT_CHANGE, LOGIN_ERROR, LOGIN_REQUEST, LOGIN_SUCCESS} from './constants'; + +export const initialState = { + loading: false, + error: {}, + email: 'admin@admin.com', + password: 'Kox32113@#$', +}; + +const loginPageReducer = produce((draft, action) => { + switch(action.type) { + case LOGIN_SUCCESS: + draft.loading = false; + break; + case LOGIN_REQUEST: + draft.loading = true; + break; + case LOGIN_ERROR: + draft.loading = false; + draft.error = action.error; + break; + case LOGIN_INPUT_CHANGE: + draft[action.name] = action.value; + break; + default: + return initialState; + } +}, initialState); + +export default loginPageReducer; diff --git a/src/pages/Login/saga.js b/src/pages/Login/saga.js new file mode 100644 index 0000000..1cfc3d6 --- /dev/null +++ b/src/pages/Login/saga.js @@ -0,0 +1,30 @@ +import { takeLatest, call, put, select } from 'redux-saga/effects'; +import { push } from 'connected-react-router'; +import { api, request, routes } from 'utils'; +import { LOGIN_REQUEST } from './constants'; +import { makeSelectPassword, makeSelectEmail } from './selectors'; +import { loginSuccessAction, loginErrorAction } from './actions'; + +export function* login() { + const email = yield select(makeSelectEmail()); + const password = yield select(makeSelectPassword()); + + const requestURL = api.auth.login; + const requestParameters = { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }; + + try { + const { tokens, user } = yield call(request, requestURL, requestParameters); + yield put(loginSuccessAction(user, tokens)); + yield put(push(routes.dashboard.path)); + } catch (error) { + yield put(loginErrorAction(error.message)); + } +} + +export default function* loginPageSaga() { + yield takeLatest(LOGIN_REQUEST, login); +} diff --git a/src/pages/Login/selectors.js b/src/pages/Login/selectors.js new file mode 100644 index 0000000..0d66bc1 --- /dev/null +++ b/src/pages/Login/selectors.js @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; + +const selectLoginPageDomain = (state) => state.loginPage || initialState; + +const makeSelectEmail = () => + createSelector(selectLoginPageDomain, (substate) => substate.email); + +const makeSelectPassword = () => + createSelector(selectLoginPageDomain, (substate) => substate.password); +const makeSelectLoading = () => + createSelector(selectLoginPageDomain, (substate) => substate.loading); + +export { + selectLoginPageDomain, + makeSelectEmail, + makeSelectPassword, + makeSelectLoading, +}; diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..dfaa36a --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,11 @@ +const API_BASE_URL = 'http://localhost:3001/v1' +const AUTH = 'auth'; + +const urls = { + auth: { + login: `${API_BASE_URL}/${AUTH}/login`, + register: `${API_BASE_URL}/${AUTH}/register`, + } +} + +export default urls diff --git a/src/utils/configureStore.js b/src/utils/configureStore.js new file mode 100644 index 0000000..5db2f3f --- /dev/null +++ b/src/utils/configureStore.js @@ -0,0 +1,41 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import { routerMiddleware } from 'connected-react-router'; +import { createInjectorsEnhancer } from 'redux-injectors'; +import createSagaMiddleware from 'redux-saga'; +import createReducer from './reducers'; + +export default function configureStore(initialState = {}, history) { + let composeEnhancers = compose; + const reduxSagaMonitorOptions = {}; + + if (typeof window === 'object') { + /* eslint-disable no-underscore-dangle */ + if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) + composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}); + + } + + const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions); + const { run: runSaga } = sagaMiddleware; + + // Create the store with two middlewares + // 1. sagaMiddleware: Makes redux-sagas work + // 2. routerMiddleware: Syncs the location/URL path to the state + const middlewares = [sagaMiddleware, routerMiddleware(history)]; + + const enhancers = [ + applyMiddleware(...middlewares), + createInjectorsEnhancer({ + createReducer, + runSaga, + }), + ]; + + const store = createStore( + createReducer(), + initialState, + composeEnhancers(...enhancers), + ); + + return store; +} diff --git a/src/utils/history.js b/src/utils/history.js new file mode 100644 index 0000000..ee3abb7 --- /dev/null +++ b/src/utils/history.js @@ -0,0 +1,3 @@ +import { createBrowserHistory } from 'history'; +const history = createBrowserHistory(); +export default history; diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..3708272 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,5 @@ +export { default as api } from './api'; +export { default as request } from './request'; +export { default as routes } from './routes'; +export { default as history } from './history'; +export { default as theme } from './theme'; diff --git a/src/utils/reducers.js b/src/utils/reducers.js new file mode 100644 index 0000000..dfcfdfd --- /dev/null +++ b/src/utils/reducers.js @@ -0,0 +1,15 @@ +import { combineReducers } from 'redux'; +import { connectRouter } from 'connected-react-router'; + +import {history} from 'utils'; +import globalReducer from 'containers/App/reducer'; + +export default function createReducer(injectedReducers = {}) { + const rootReducer = combineReducers({ + global: globalReducer, + router: connectRouter(history), + ...injectedReducers, + }); + + return rootReducer; +} diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000..8690992 --- /dev/null +++ b/src/utils/request.js @@ -0,0 +1,24 @@ +function parseJSON(response) { + if (response.status === 204 || response.status === 205) { + return null; + } + return response.json(); +} + +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + + const error = new Error(response.statusText); + error.statusCode = response.status; + throw error; +} + +const request = async (url, options) => { + const fetchResponse = await fetch(url, options); + const response = await checkStatus(fetchResponse); + return parseJSON(response); +} + +export default request diff --git a/src/utils/routes.js b/src/utils/routes.js index 44b52d8..53b4593 100644 --- a/src/utils/routes.js +++ b/src/utils/routes.js @@ -1,10 +1,13 @@ -import HomePage from 'pages/Home' -export const ROUTES = [ - { +const routes = { + dashboard: { path: '/', - key: 'HOME', exact: true, - component: HomePage }, -] + login: { + path: '/login', + exact: true, + }, +} + +export default routes diff --git a/src/utils/theme.js b/src/utils/theme.js index 606bd8c..72492e6 100644 --- a/src/utils/theme.js +++ b/src/utils/theme.js @@ -1,6 +1,6 @@ import { createMuiTheme } from '@material-ui/core/styles'; -export const theme = createMuiTheme({ +const theme = createMuiTheme({ palette: { type: 'dark', primary: { @@ -11,3 +11,4 @@ export const theme = createMuiTheme({ }, }, }) +export default theme diff --git a/yarn.lock b/yarn.lock index ca58d2e..9bcd675 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3466,6 +3466,13 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== +connected-react-router@^6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.8.0.tgz#ddc687b31d498322445d235d660798489fa56cae" + integrity sha512-E64/6krdJM3Ag3MMmh2nKPtMbH15s3JQDuaYJvOVXzu6MbHbDyIvuwLOyhQIuP4Om9zqEfZYiVyflROibSsONg== + dependencies: + prop-types "^15.7.2" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -5606,7 +5613,7 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -history@^4.9.0: +history@4.10.1, history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== @@ -5857,6 +5864,11 @@ immer@7.0.9: resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e" integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A== +immer@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.0.tgz#08763549ba9dd7d5e2eb4bec504a8315bd9440c2" + integrity sha512-jm87NNBAIG4fHwouilCHIecFXp5rMGkiFrAuhVO685UnMAlOneEAnOyzPt8OnP47TC11q/E7vpzZe0WvwepFTg== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5994,6 +6006,13 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -7301,7 +7320,7 @@ loglevel@^1.6.8: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0" integrity sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ== -loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -9719,6 +9738,16 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^1.0.0" +redux-injectors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/redux-injectors/-/redux-injectors-1.3.0.tgz#7d283633428ccbe22e165903b60f432e63da6947" + integrity sha512-ZoyKf8Y0bRqpmJImaVO3jnDKuMXTzMDp3j+b0bqtSuPAgWcHD/2P9gRr4mI1EjgCiheIyQ/JJI8yLG29ijqRaw== + dependencies: + hoist-non-react-statics "^3.3.2" + invariant "^2.2.4" + lodash "^4.17.15" + redux "^4.0.5" + redux-saga@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"