Create-React-App + Redux-Saga + Connected-React-Router => Yield put push
import { createMuiTheme, MuiThemeProvider, Theme } from '@material-ui/core/styles';
import { StylesProvider } from '@material-ui/styles';
import history from 'config/history';
import configureStore from 'config/store';
import { ConnectedRouter } from 'connected-react-router';
import ConnectedHeader from 'containers/Header';
import { SnackbarProvider } from 'notistack';
import React, { FC } from 'react';
import { Provider } from 'react-redux';
import { blue, red, yellow } from 'utils/constants';
// @ts-ignore
const createTheme: Theme = createMuiTheme({
palette: {
contrastThreshold: 3,
error: {main: red},
primary: {main: blue},
secondary: {main: yellow},
tonalOffset: 0.2,
type: 'light',
},
typography: {
fontFamily: [
'verdana',
'arial',
'sans-serif'
].join(','),
fontSize: 16,
},
});
const App: FC = () => {
const initialState = {} as any;
const store = configureStore(initialState);
return (
<Provider store={store}>
<ConnectedRouter history={history}>
<StylesProvider injectFirst>
<MuiThemeProvider theme={createTheme}>
<SnackbarProvider maxSnack={4}>
<ConnectedHeader/>
</SnackbarProvider>
</MuiThemeProvider>
</StylesProvider>
</ConnectedRouter>
</Provider>
);
};
export default App;
import HomePage from 'containers/HomePage';
import LoginPage from 'containers/LoginPage';
import RegisterPage from 'containers/RegisterPage';
import React, { FC } from 'react';
import { Redirect, Route, Switch } from 'react-router';
import routes from './routes';
const Router: FC = () => (
<Switch>
<Route path={routes.loginPath} component={LoginPage}/>
<Route path={routes.signupPath} component={RegisterPage}/>
<Route path={routes.homePath} component={HomePage}/>
<Route component={LoginPage}/>
<Redirect exact strict from={routes.rootPath} to='login'/>
</Switch>
);
export default Router;
import { createBrowserHistory } from 'history';
export default createBrowserHistory();
export const routes = {
rootPath: '/',
loginPath: '/login',
signupPath: '/signup',
homePath: '/home',
};
export default routes;
import { routerMiddleware } from 'connected-react-router';
import history from './history';
import { AnyAction, applyMiddleware, createStore, Middleware, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import { createLogger } from 'redux-logger';
import createSagaMiddleware, { SagaMiddleware } from 'redux-saga';
import createRootReducer, { ApplicationState, rootSaga } from 'store/rootStore';
import { isDevEnv } from 'utils/helpers';
interface HotNodeModule extends NodeModule {
hot: any;
}
export default (initialState: ApplicationState): Store<ApplicationState> => {
const sagaMiddleware = createSagaMiddleware();
const middlewares: (SagaMiddleware | Middleware)[] = [sagaMiddleware, routerMiddleware(history)];
const logger = createLogger({
collapsed: (getState: () => any, action: AnyAction, logEntry: any) => !logEntry.error,
});
if (isDevEnv()) {
middlewares.push(logger);
}
const store = createStore(
createRootReducer(history),
initialState,
composeWithDevTools(applyMiddleware(...middlewares))
);
// Hot reloading in development
if ((module as HotNodeModule).hot) {
(module as HotNodeModule).hot.accept('../App.tsx', () => {
store.replaceReducer(createRootReducer(history));
});
}
sagaMiddleware.run(rootSaga);
return store;
};
import Grid from '@material-ui/core/Grid';
import classnames from 'classnames';
import NavBar from 'components/NavBar';
import Bar from 'components/Presentational/Bar';
import Logo from 'components/Presentational/Logo';
import Navigation from 'components/Presentational/Navigation';
import React, { FC } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { ApplicationState } from 'store/rootStore';
import { handleLogout } from 'store/user/userActions';
import css from 'styles/modules/Navigation.module.scss';
import cssPolyfills from 'styles/modules/polyfills.module.scss';
import { CurrentUser } from '../store/user/userTypes';
export type HeaderProps = {
currentUser?: CurrentUser;
handleLogout: () => void;
};
export const Header: FC<HeaderProps> = props => {
return (
<Navigation>
<Grid container direction='column' justify='center' alignItems='center' alignContent='center'>
<Grid key={1} item xs={12} className={cssPolyfills['ie11-logoHeaderCorrection']}>
<Bar>
<Logo/>
</Bar>
</Grid>
<Grid key={2} item xs={12} className={classnames(cssPolyfills['ie11-blueBarCorrection'], css.tabBar)}>
<NavBar currentUser={props.currentUser} handleLogout={props.handleLogout}/>
</Grid>
</Grid>
</Navigation>
);
};
const mapStateToProps = (state: ApplicationState) => ({
currentUser: state.users.currentUser,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
handleLogout,
},
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(Header);
{
"name": "sample-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@favware/querystring": "^1.0.0",
"@material-ui/core": "^4.0.1",
"@material-ui/icons": "^4.0.1",
"@material-ui/styles": "^4.0.1",
"axios": "^0.18.0",
"classnames": "^2.2.6",
"connected-react-router": "6.4.0",
"core-js": "^3.1.3",
"formik": "^1.5.7",
"formik-material-ui": "^0.0.16",
"fuse.js": "^3.4.4",
"history": "^4.9.0",
"normalize-scss": "^7.0.1",
"notistack": "^0.8.5",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-jss": "^8.6.1",
"react-redux": "^6",
"react-router": "^5.0.0",
"react-router-dom": "^5.0.0",
"react-scripts": "^3.0.1",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.8",
"redux-saga": "^1.0.2",
"typesafe-actions": "^4.4.0",
"yup": "^0.27.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"test:ci": "CI=true react-scripts test",
"lint": "concurrently \"yarn tslint\" \"yarn stylelint\"",
"tslint": "tslint --fix -p . -c ./tslint.json --fix --format verbose",
"stylelint": "stylelint --fix ./src/styles/**/*.scss ./src/styles/*.scss",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"ie 11",
"not op_mini all"
],
"devDependencies": {
"@types/classnames": "^2.2.7",
"@types/enzyme": "^3.9.3",
"@types/enzyme-adapter-react-16": "^1.0.5",
"@types/history": "^4.7.2",
"@types/jest": "^24.0.13",
"@types/node": "^11",
"@types/react": "^16.8.18",
"@types/react-dom": "^16.8.4",
"@types/react-jss": "^8.6.3",
"@types/react-redux": "^6",
"@types/react-router": "^5.0.1",
"@types/react-router-dom": "^4.3.3",
"@types/redux-logger": "^3.0.7",
"@types/redux-mock-store": "^1.0.1",
"@types/uuid": "^3.4.4",
"@types/yup": "^0.26.13",
"concurrently": "^4.1.0",
"env-cmd": "^9.0.1",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.13.2",
"node-sass": "^4.12.0",
"redux-logger": "^3.0.6",
"redux-mock-store": "^1.5.3",
"redux-saga-test-plan": "^4.0.0-beta.3",
"serve": "^11.0.0",
"stylelint": "^10.0.1",
"tslint": "^5.16.0",
"typescript": "^3.4.5",
"uuid": "^3.3.2"
}
}
import { connectRouter, go, goBack, goForward, push, replace, RouterState } from 'connected-react-router';
import { History } from 'history';
import { combineReducers } from 'redux';
import { all, fork } from 'redux-saga/effects';
import SnackbarReducer from './snackbars/snackbarReducer';
import { SnackbarState } from './snackbars/snackbarTypes';
import * as snackbarActions from 'store/snackbars/snackbarActions';
import * as userActions from 'store/user/userActions';
import UserReducer from 'store/user/userReducer';
import usersSaga from 'store/user/userSagas';
import { UserState } from 'store/user/userTypes';
import { ActionType } from 'typesafe-actions';
const routerActions = {
push: typeof push,
replace: typeof replace,
go: typeof go,
goBack: typeof goBack,
goForward: typeof goForward,
};
export type ApplicationState = Readonly<{
router: RouterState;
users: UserState;
notifications: SnackbarState;
}>;
export function* rootSaga () {
yield all([fork(usersSaga)]);
}
export const rootActions = {
router: routerActions,
users: userActions,
notifications: snackbarActions,
};
export type RootAction = ActionType<typeof rootActions>;
export default (history: History) => combineReducers<ApplicationState>({
router: connectRouter(history),
users: UserReducer,
notifications: SnackbarReducer,
});
import { action } from 'typesafe-actions';
import { CurrentUser, User, UserLoginRequest, UsersActionTypes } from './userTypes';
export const signinFormSubmit = (payload: UserLoginRequest) => action(UsersActionTypes.SIGNIN_FORM_SUBMIT, payload);
export const signupFormSubmit = (payload: User) => action(UsersActionTypes.SIGNUP_FORM_SUBMIT, payload);
export const setCurrentUser = (currentUser: CurrentUser) => action(UsersActionTypes.SET_CURRENT_USER, currentUser);
export const removeCurrentUser = () => action(UsersActionTypes.REMOVE_CURRENT_USER);
export const setAccessToken = (token: string) => action(UsersActionTypes.SET_ACCESS_TOKEN, token);
export const handleLogout = () => action(UsersActionTypes.HANDLE_LOGOUT);
import { createReducer } from 'typesafe-actions';
import { UsersActionTypes, UserState } from './userTypes';
const initialState: UserState = {};
const reducer = createReducer(initialState)
.handleAction(
UsersActionTypes.SET_CURRENT_USER,
(state, action) => ({
...state,
currentUser: action.payload,
})
)
.handleAction(
UsersActionTypes.REMOVE_CURRENT_USER, state => ({
...state,
currentUser: undefined,
})
);
export default reducer;
import { push } from 'connected-react-router';
import { all, call, put, takeEvery } from 'redux-saga/effects';
import { enqueueSnackbar } from 'store/snackbars/snackbarActions';
import { removeCurrentUser, setAccessToken, setCurrentUser, signinFormSubmit, signupFormSubmit } from './userActions';
import { CurrentUser, UserLoginRequest, UsersActionTypes, UserSignupData } from './userTypes';
import apiClient, { AxiosResponse } from 'utils/apiClient';
import {
ACCESS_TOKEN_PATH,
API_METHOD,
CHECK_EMAIL_AVAILABLE_PATH,
CHECK_USERNAME_AVAILABLE_PATH,
GET_CURRENT_USER_PATH,
LOGIN_PATH,
SIGNUP_PATH
} from 'utils/apiPaths';
import { DataroomError, HTTP_STATUS_CODES, snackbarHideDuration } from 'utils/constants';
import { errorHandler, generateRandomSnackbarID } from 'utils/helpers';
export function* handleSignup (action: ReturnType<typeof signupFormSubmit>) {
try {
const signupData: UserSignupData = {
name: `${action.payload.firstname} ${action.payload.lastname}`,
email: action.payload.email,
username: action.payload.username,
password: action.payload.password,
};
const checkUsernameResponse: AxiosResponse<{ available: boolean }> = yield call([apiClient, 'request'], API_METHOD.GET, CHECK_USERNAME_AVAILABLE_PATH(signupData.username));
if (!checkUsernameResponse.data.available) {
throw new DataroomError(HTTP_STATUS_CODES.CONFLICT, 'Username already exists');
}
const checkEmailResponse: AxiosResponse<{ available: boolean }> = yield call([apiClient, 'request'], API_METHOD.GET, CHECK_EMAIL_AVAILABLE_PATH(signupData.email));
if (!checkEmailResponse.data.available) {
throw new DataroomError(HTTP_STATUS_CODES.CONFLICT, 'Email already exists');
}
const signupUserResponse: AxiosResponse<any> = yield call([apiClient, 'request'], API_METHOD.POST, SIGNUP_PATH, signupData);
if (signupUserResponse.status !== HTTP_STATUS_CODES.CREATED) {
throw new DataroomError(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, 'Er ging iets fout. Probeer opnieuw te registreren');
}
yield all([
put(enqueueSnackbar({
key: `user-created-${generateRandomSnackbarID()}`,
message: 'Registratie successvol! U kunt nu ingeloggen',
options: {variant: 'success', autoHideDuration: snackbarHideDuration},
})),
put(push('/login'))
]);
} catch (err) {
if (err.status === HTTP_STATUS_CODES.CONFLICT) {
yield call(errorHandler, err.status, generateRandomSnackbarID(), err.message);
}
yield call(errorHandler, err.status ? err.status : HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, generateRandomSnackbarID(), err.message);
}
}
export function* handleSignin (action: ReturnType<typeof signinFormSubmit>) {
try {
const loginRequest: UserLoginRequest = {
usernameOrEmail: action.payload.usernameOrEmail,
password: action.payload.password,
};
const loginResponse: AxiosResponse<{ accessToken: string }> = yield call([apiClient, 'request'], API_METHOD.POST, LOGIN_PATH, loginRequest);
yield put(setAccessToken(loginResponse.data.accessToken));
const currentUserResponse: AxiosResponse<CurrentUser> = yield call([apiClient, 'request'], API_METHOD.GET, GET_CURRENT_USER_PATH());
yield all([
put(setCurrentUser(currentUserResponse.data)),
put(enqueueSnackbar({
key: `user-logged-in-${generateRandomSnackbarID()}`,
message: 'Successvol ingelogd!',
options: {variant: 'success', autoHideDuration: snackbarHideDuration},
})),
put(push('/home'))
]);
} catch (err) {
if (err.status === HTTP_STATUS_CODES.UNAUTHORIZED) {
yield call(errorHandler, err.status, generateRandomSnackbarID(), 'Gebruikersnaam of wachtwoord is incorrect. Probeer opnieuw!');
} else {
yield call(errorHandler, err.status ? err.status : HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, generateRandomSnackbarID(), 'Sorry! Er ging iets fout. Probeer opnieuw!');
}
}
}
export function* handleSetAccessToken (action: ReturnType<typeof setAccessToken>) {
localStorage.setItem(ACCESS_TOKEN_PATH, action.payload);
yield apiClient.accessToken = action.payload;
}
export function* handleLogout () {
yield all([
put(removeCurrentUser()),
put(enqueueSnackbar({
key: `user-logged-out-${generateRandomSnackbarID()}`,
message: 'Successvol afgemeld!',
options: {variant: 'success', autoHideDuration: snackbarHideDuration},
})),
put(push('/login'))
]);
}
export function* UserSaga () {
yield takeEvery(UsersActionTypes.SIGNIN_FORM_SUBMIT, handleSignin);
yield takeEvery(UsersActionTypes.SIGNUP_FORM_SUBMIT, handleSignup);
yield takeEvery(UsersActionTypes.SET_ACCESS_TOKEN, handleSetAccessToken);
yield takeEvery(UsersActionTypes.HANDLE_LOGOUT, handleLogout);
}
export default UserSaga;
export enum UsersActionTypes {
SIGNUP_FORM_SUBMIT = '@@user/SIGNUP_FORM_SUBMIT',
SIGNIN_FORM_SUBMIT = '@@user/SIGNIN_FORM_SUBMIT',
SET_CURRENT_USER = '@@user/SET_CURRENT_USER',
SET_ACCESS_TOKEN = '@@user/SET_ACCESS_TOKEN',
HANDLE_LOGOUT = '@@user/LOGOUT',
REMOVE_CURRENT_USER = '@@user/REMOVE_CURRENT_USER',
}
export type User = Readonly<{
firstname: string;
lastname: string;
email: string;
username: string;
password: string;
}>;
export type UserState = Readonly<{
currentUser?: CurrentUser,
}>;
export type UserSignupData = Readonly<{
name: string;
email: string;
username: string;
password: string;
}>;
export type UserLoginRequest = Readonly<{
usernameOrEmail: string;
password: string;
}>;
export type CurrentUser = Readonly<{
id: number;
username: string;
name: string;
}>;
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"downlevelIteration": true,
"baseUrl": "src",
"typeRoots": [ "typings", "./node_modules/@types"],
"noErrorTruncation": true
},
"include": [
"src"
],
"exclude": ["node_modules", "typings"]
}