Connect app with redux and saga. Add basic login page

This commit is contained in:
= 2020-12-02 21:29:26 +01:00
parent cd19824b62
commit 6e00397e60
23 changed files with 406 additions and 48 deletions

View File

@ -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",

View File

View File

@ -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';

View File

@ -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 (
<ThemeProvider theme={theme}>
<CssBaseline />
<Navbar />
{isLogged && <Navbar /> }
<Switch>
{ROUTES.map(({ key, component, path, exact, routes }) => (
<RouteWithSubRoutes
key={key}
component={component}
path={path}
exact={exact}
routes={routes}
/>
))}
<Route exact path={routes.dashboard.path} component={HomePage} />
<Route exact path={routes.login.path} component={LoginPage} />
</Switch>
</ThemeProvider>
);

View File

@ -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;

View File

@ -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,
};

View File

@ -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 = [] }) => (
<Route
path={path}
exact={exact}
render={props => <Component routes={routes} {...props} />}
/>
)
RouteWithSubRoutes.propTypes = {
routes: PropTypes.array,
component: PropTypes.elementType.isRequired,
path: PropTypes.string.isRequired,
exact: PropTypes.bool.isRequired,
}
export default RouteWithSubRoutes;

View File

@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);

View File

@ -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,
})

View File

@ -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';

68
src/pages/Login/index.js Normal file
View File

@ -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 (
<Container>
<Box marginTop={2}>
<Paper component="form" noValidate autoComplete="off" onSubmit={handleSubmit}>
<Grid
container
direction="column"
alignItems="center"
justify="center"
>
<Typography>Login</Typography>
<TextField
type="text"
label="E-mail"
name="email"
variant="outlined"
value={email}
onChange={onChangeInput}
/>
<TextField
type="password"
label="password"
name="password"
variant="outlined"
value={password}
onChange={onChangeInput}
/>
<Button type="submit" disabled={loading} color="primary">Sign in</Button>
</Grid>
</Paper>
</Box>
</Container>
);
};
export default Login;

View File

@ -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;

30
src/pages/Login/saga.js Normal file
View File

@ -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);
}

View File

@ -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,
};

11
src/utils/api.js Normal file
View File

@ -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

View File

@ -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;
}

3
src/utils/history.js Normal file
View File

@ -0,0 +1,3 @@
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
export default history;

5
src/utils/index.js Normal file
View File

@ -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';

15
src/utils/reducers.js Normal file
View File

@ -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;
}

24
src/utils/request.js Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"