g33k57
9/16/2019 - 6:05 PM

Create-React-App + Redux-Saga + Connected-React-Router => Yield put push

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"]
}