Finished functionally complete client registration workflow

This commit is contained in:
Alexander Wong 2017-08-29 19:51:45 -06:00
parent aaad6ea3eb
commit 415aea74a3
25 changed files with 587 additions and 66 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_API_ENDPOINT="http://localhost:8000"

View File

@ -11,6 +11,15 @@ Generated using [create-react-app](https://github.com/facebookincubator/create-r
Now you can visit `localhost:3000` from your browser. Now you can visit `localhost:3000` from your browser.
## Environment Variables
The environment variables are embedded during the build time. For more information, please refer to the [docs](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-custom-environment-variables.
| Environment Variable | Default Value | Description |
| ------------------------- |:-------------------------:| -------------------:|
| `REACT_APP_API_ENDPOINT` | `"http://localhost:8000"` | Server API endpoint |
| `REACT_APP_REDUX_LOGGING` | `` | Set for Redux Log |
## Testing ## Testing
To test the react app, call `yarn test`. To test the react app, call `yarn test`.

View File

@ -3,6 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"axios": "^0.16.2",
"localStorage": "^1.0.3",
"react": "^15.6.1", "react": "^15.6.1",
"react-dom": "^15.6.1", "react-dom": "^15.6.1",
"react-redux": "^5.0.6", "react-redux": "^5.0.6",

View File

@ -0,0 +1,91 @@
import {
IS_SENDING_AUTH_REQUEST,
SET_AUTH_REQUEST_ERROR,
CLEAR_AUTH_REQUEST_ERROR,
SET_AUTH_REQUEST_SUCCESS,
CLEAR_AUTH_REQUEST_SUCCESS,
SET_SELF_USER,
SET_FORM_EMAIL,
SET_FORM_PASSWORD,
SET_FORM_PASSWORD_CONFIRMATION
} from "../../constants/auth.constants";
import { parseError } from "../common.actions";
export function isSendingAuthRequest(sendingRequest) {
return {
type: IS_SENDING_AUTH_REQUEST,
data: sendingRequest
};
}
export function setAuthRequestError(exception) {
let rawError = parseError(exception);
if (rawError.email) {
rawError["Email"] = rawError.email;
delete rawError["email"];
}
if (rawError.password1) {
rawError["Password"] = rawError.password1;
delete rawError["password1"];
}
if (rawError.password2) {
rawError["Password Confirmation"] = rawError.password2;
delete rawError["password2"];
}
if (rawError.non_field_errors) {
rawError["Non Field Errors"] = rawError.non_field_errors;
delete rawError["non_field_errors"];
}
return {
type: SET_AUTH_REQUEST_ERROR,
data: parseError(exception)
};
}
export function clearAuthRequestError() {
return {
type: CLEAR_AUTH_REQUEST_ERROR
};
}
export function setAuthRequestSuccess(response) {
return {
type: SET_AUTH_REQUEST_SUCCESS,
data: response.detail || response
}
}
export function clearAuthRequestSuccess() {
return {
type: CLEAR_AUTH_REQUEST_SUCCESS
}
}
export function setSelfUser(selfUser) {
return {
type: SET_SELF_USER,
data: selfUser
};
}
export function setFormEmail(email) {
return {
type: SET_FORM_EMAIL,
data: email
};
}
export function setFormPassword(password) {
return {
type: SET_FORM_PASSWORD,
data: password
};
}
export function setFormPasswordConfirmation(passwordConfirmation) {
return {
type: SET_FORM_PASSWORD_CONFIRMATION,
data: passwordConfirmation
};
}

View File

@ -0,0 +1,10 @@
import {
SEND_REGISTER_REQUEST
} from "../../constants/auth.constants";
export function sendRegisterRequest(postbody) {
return {
type: SEND_REGISTER_REQUEST,
data: postbody
}
}

View File

@ -0,0 +1,13 @@
/**
* Given an exception return the list of errors, the singular error, or generic error
* @param {object|string} exception - axios returned exception
*/
export function parseError(exception) {
let response = exception.response || {};
let data = response.data || {};
let err = "" + exception;
if (response.status) {
err = `${response.status} ${response.statusText}`;
}
return data || err
}

17
src/api/auth.api.js Normal file
View File

@ -0,0 +1,17 @@
import { post } from "./baseApi";
/**
* Function wrapping POST request for user registration
* @param {string} email - email of user to register
* @param {string} password1 - password of user to register
* @param {string} password2 - server side password confirmation
*/
export function registerUser(email, password1, password2) {
return post("/rest-auth/registration/", {
email,
password1,
password2
}).then(response => {
return Promise.resolve(response);
});
}

52
src/api/baseApi.js Normal file
View File

@ -0,0 +1,52 @@
import axios from "axios";
const API_ENDPOINT = process.env.REACT_APP_API_ENDPOINT;
// If testing, use localStorage polyfill, else use browser localStorage
const localStorage = global.process && process.env.NODE_ENV === "test"
? // eslint-disable-next-line import/no-extraneous-dependencies
require("localStorage")
: global.window.localStorage;
function headers() {
const token = localStorage.getItem("token") || "";
return {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Token: ${token}`
};
}
const apiInstance = axios.create({
baseURL: API_ENDPOINT,
timeout: 3000,
});
export function get(url, params = {}) {
return apiInstance
.get(url, {params, headers: headers()})
.then(response => response.data)
.catch(error => Promise.reject(error));
}
export function post(url, data) {
return apiInstance
.post(url, data, {headers: headers()})
.then(response => response.data)
.catch(error => Promise.reject(error));
}
export function patch(url, data) {
return apiInstance
.patch(url, data, {headers: headers()})
.then(response => response.data)
.catch(error => Promise.reject(error));
}
export function del(url) {
return apiInstance
.delete(url, {headers: headers()})
.then(response => response.data)
.catch(error => Promise.reject(error));
}

View File

@ -1,23 +1,32 @@
import React, { Component } from "react"; import React, { Component } from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import Login from "./Auth/Login";
import Register from "./Auth/Register";
import About from "./Static/About";
import Footer from "./Static/Footer";
import Home from "./Static/Home";
import NoMatch from "./Static/NoMatch";
import Navbar from "./Navbar"; import Navbar from "./Navbar";
import Footer from "./Footer";
import Home from "./Home";
import About from "./About";
import Topics from "./Topics";
class App extends Component { class App extends Component {
render() { render() {
const footSmash = {
display: "flex",
minHeight: "calc(100vh - 1px)",
flexDirection: "column"
};
return ( return (
<div> <div>
<Navbar /> <Navbar />
<div style={{ display: "flex", minHeight: "calc(100vh - 1px)", flexDirection: "column" }}> <div style={footSmash}>
<div style={{ flex: "1" }}> <div style={{ flex: "1" }}>
<Switch> <Switch>
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route path="/about" component={About} /> <Route path="/about" component={About} />
<Route path="/topics" component={Topics} /> <Route path="/auth/login" component={Login} />
<Route path="/auth/register" component={Register} />
<Route component={NoMatch} />
</Switch> </Switch>
</div> </div>
<Footer /> <Footer />

View File

@ -0,0 +1,16 @@
import React, { Component } from "react";
import { Container } from "semantic-ui-react";
class Login extends Component {
render() {
return <LoginView />;
}
}
const LoginView = () => (
<Container>
<p>Login</p>
</Container>
);
export default Login;

View File

@ -0,0 +1,135 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Container, Form, Header, Message } from "semantic-ui-react";
import {
clearAuthRequestError,
setFormEmail,
setFormPassword,
setFormPasswordConfirmation
} from "../../actions/auth/reducer.actions";
import { sendRegisterRequest } from "../../actions/auth/saga.actions";
import Error from "../Shared/Error";
class Register extends Component {
constructor(props) {
super(props);
this.props.dispatch(clearAuthRequestError());
}
changeEmail = event => {
this.props.dispatch(setFormEmail(event.target.value));
};
changePassword = event => {
this.props.dispatch(setFormPassword(event.target.value));
};
changePasswordConfirmation = event => {
this.props.dispatch(setFormPasswordConfirmation(event.target.value));
};
onSubmitRegistration = event => {
event.preventDefault();
const { dispatch, email, password, passwordConfirmation } = this.props;
dispatch(
sendRegisterRequest({
email,
password1: password,
password2: passwordConfirmation
})
);
};
render() {
const {
isSendingAuthRequest,
authRequestError,
authRequestSuccess,
email,
password,
passwordConfirmation
} = this.props;
return (
<RegisterView
isSendingAuthRequest={isSendingAuthRequest}
authRequestError={authRequestError}
authRequestSuccess={authRequestSuccess}
email={email}
password={password}
passwordConfirmation={passwordConfirmation}
changeEmail={this.changeEmail}
changePassword={this.changePassword}
changePasswordConfirmation={this.changePasswordConfirmation}
onSubmitRegistration={this.onSubmitRegistration}
/>
);
}
}
function mapStateToProps(state) {
return { ...state.auth };
}
/**
* Functional view component for Register logic
*/
const RegisterView = ({
isSendingAuthRequest,
authRequestError,
authRequestSuccess,
email,
password,
passwordConfirmation,
changeEmail,
changePassword,
changePasswordConfirmation,
onSubmitRegistration
}) => (
<Container>
<Header>Register</Header>
<Form
loading={isSendingAuthRequest}
onSubmit={onSubmitRegistration}
error={!!authRequestError}
success={!!authRequestSuccess}
>
<Form.Field>
<label>Email</label>
<input
placeholder="bob@gmail.com"
type="email"
value={email}
onChange={changeEmail}
/>
</Form.Field>
<Form.Field>
<label>Password</label>
<input
placeholder="••••••••"
type="password"
value={password}
onChange={changePassword}
/>
</Form.Field>
<Form.Field>
<label>Password Confirmation</label>
<input
placeholder="••••••••"
type="password"
value={passwordConfirmation}
onChange={changePasswordConfirmation}
/>
</Form.Field>
<Error header="Register failed!" error={authRequestError} />
<Message
success
header="Registration Sent"
content="A confirmation email has been sent to confirm your registration."
/>
<Form.Button>Submit</Form.Button>
</Form>
</Container>
);
export default connect(mapStateToProps)(Register);

View File

@ -12,9 +12,14 @@ class Navbar extends Component {
<Menu.Item as={Link} to="/about"> <Menu.Item as={Link} to="/about">
About About
</Menu.Item> </Menu.Item>
<Menu.Item as={Link} to="/topics"> <Menu.Menu position="right">
Topics <Menu.Item as={Link} to="/auth/login">
Login
</Menu.Item> </Menu.Item>
<Menu.Item as={Link} to="/auth/register">
Register
</Menu.Item>
</Menu.Menu>
</Menu> </Menu>
); );
} }

View File

@ -0,0 +1,41 @@
import PropTypes from "prop-types";
import React from "react";
import { Message } from "semantic-ui-react";
const propTypes = {
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
header: PropTypes.string
};
const defaultProps = {
error: "",
header: ""
};
const Error = ({ error, header }) => {
if (typeof error === "string") {
const hasError = !!error;
if (hasError) {
return <Message error={hasError} header={header} content={error} />;
}
} else if (typeof error === "object" && Object.keys(error).length > 0) {
const hasError = !!Object.keys(error);
if (hasError) {
return (
<Message
error={hasError}
header={header}
list={Object.keys(error).map(p => (
<Message.Item key={p}> {p}: {error[p]}</Message.Item>
))}
/>
);
}
}
return null;
};
Error.propTypes = propTypes;
Error.defaultProps = defaultProps;
export default Error;

View File

@ -0,0 +1,13 @@
import React from "react";
import { Link } from "react-router-dom";
import { Container } from "semantic-ui-react";
const NoMatch = ({ location }) => (
<Container>
<h3>Page not found!</h3>
<p>No match found for <code>{location.pathname}</code></p>
<p><Link to="/">Go to the home page </Link></p>
</Container>
);
export default NoMatch;

View File

@ -1,41 +0,0 @@
import React from "react";
import { Route, Link } from "react-router-dom";
import { Container } from "semantic-ui-react";
const Topics = ({ match }) => (
<Container>
<h2>Topics</h2>
<ul>
<li>
<Link to={`${match.url}/rendering`}>
Rendering with React
</Link>
</li>
<li>
<Link to={`${match.url}/components`}>
Components
</Link>
</li>
<li>
<Link to={`${match.url}/props-v-state`}>
Props v. State
</Link>
</li>
</ul>
<Route path={`${match.url}/:topicId`} component={Topic} />
<Route
exact
path={match.url}
render={() => <h3>Please select a topic.</h3>}
/>
</Container>
);
const Topic = ({ match }) => (
<div>
<h3>{match.params.topicId}</h3>
</div>
);
export default Topics;

View File

@ -0,0 +1,13 @@
// Reducer Auth Action Constants
export const IS_SENDING_AUTH_REQUEST = "IS_SENDING_AUTH_REQUEST";
export const SET_AUTH_REQUEST_ERROR = "SET_AUTH_REQUEST_ERROR";
export const CLEAR_AUTH_REQUEST_ERROR = "CLEAR_AUTH_REQUEST_ERROR";
export const SET_AUTH_REQUEST_SUCCESS = "SET_AUTH_REQUEST_SUCCESS";
export const CLEAR_AUTH_REQUEST_SUCCESS = "CLEAR_AUTH_REQUEST_SUCCESS";
export const SET_SELF_USER = "SET_SELF_USER";
export const SET_FORM_EMAIL = "SET_FORM_EMAIL";
export const SET_FORM_PASSWORD = "SET_FORM_PASSWORD";
export const SET_FORM_PASSWORD_CONFIRMATION = "SET_FORM_PASSWORD_CONFIRMATION";
// Saga Auth Action Constants
export const SEND_REGISTER_REQUEST = "SEND_REGISTER_REQUEST";

View File

@ -1,26 +1,15 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { createStore, applyMiddleware } from "redux";
import createSagaMiddleware from "redux-saga";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { createLogger } from "redux-logger";
import rootReducer from "./reducers"; import configureStore from "./store";
import rootSaga from "./sagas";
import App from "./components/App"; import App from "./components/App";
import { unregister } from "./registerServiceWorker"; import { unregister } from "./registerServiceWorker";
const sagaMiddleware = createSagaMiddleware(); const store = configureStore();
const debugLogger = createLogger();
const store = createStore(
rootReducer,
applyMiddleware(debugLogger, sagaMiddleware)
);
const supportsHistory = "pushState" in window.history; const supportsHistory = "pushState" in window.history;
sagaMiddleware.run(rootSaga);
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
ReactDOM.render( ReactDOM.render(

View File

@ -1,7 +1,72 @@
const initialState = {}; import {
IS_SENDING_AUTH_REQUEST,
SET_AUTH_REQUEST_ERROR,
CLEAR_AUTH_REQUEST_ERROR,
SET_AUTH_REQUEST_SUCCESS,
CLEAR_AUTH_REQUEST_SUCCESS,
SET_SELF_USER,
SET_FORM_EMAIL,
SET_FORM_PASSWORD,
SET_FORM_PASSWORD_CONFIRMATION
} from "../constants/auth.constants";
const initialState = {
isSendingAuthRequest: false,
authRequestError: "",
authRequestSuccess: "",
currentUser: {},
email: "",
password: "",
passwordConfirmation: ""
};
function authReducer(state = initialState, action) { function authReducer(state = initialState, action) {
switch (action.type) { switch (action.type) {
case IS_SENDING_AUTH_REQUEST:
return {
...state,
isSendingAuthRequest: action.data
};
case SET_AUTH_REQUEST_ERROR:
return {
...state,
authRequestError: action.data
};
case CLEAR_AUTH_REQUEST_ERROR:
return {
...state,
authRequestError: ""
};
case SET_AUTH_REQUEST_SUCCESS:
return {
...state,
authRequestSuccess: action.data
};
case CLEAR_AUTH_REQUEST_SUCCESS:
return {
...state,
authRequestSuccess: ""
};
case SET_SELF_USER:
return {
...state,
currentUser: action.data
};
case SET_FORM_EMAIL:
return {
...state,
email: action.data
};
case SET_FORM_PASSWORD:
return {
...state,
password: action.data
};
case SET_FORM_PASSWORD_CONFIRMATION:
return {
...state,
passwordConfirmation: action.data
};
default: default:
return state; return state;
} }

39
src/sagas/auth.sagas.js Normal file
View File

@ -0,0 +1,39 @@
import { effects } from "redux-saga";
import {
isSendingAuthRequest,
setAuthRequestError,
setAuthRequestSuccess,
clearAuthRequestError,
setFormEmail,
setFormPassword,
setFormPasswordConfirmation
} from "../actions/auth/reducer.actions";
import { registerUser } from "../api/auth.api";
/**
* Saga for registering a new user.
* @param {*} postBody
*/
function* registerUserCall(postBody) {
yield effects.put(isSendingAuthRequest(true));
const { email, password1, password2 } = postBody;
try {
return yield effects.call(registerUser, email, password1, password2);
} catch (exception) {
yield effects.put(setAuthRequestError(exception));
return false;
} finally {
yield effects.put(isSendingAuthRequest(false));
}
}
export function* registerUserFlow(request) {
const wasSucessful = yield effects.call(registerUserCall, request.data);
if (wasSucessful) {
yield effects.put(setAuthRequestSuccess(wasSucessful));
yield effects.put(clearAuthRequestError());
yield effects.put(setFormEmail(""));
yield effects.put(setFormPassword(""));
yield effects.put(setFormPasswordConfirmation(""));
}
}

View File

@ -1,4 +1,7 @@
import { takeLatest } from "redux-saga/effects";
import { SEND_REGISTER_REQUEST } from "../constants/auth.constants";
import { registerUserFlow } from "./auth.sagas";
export default function* rootSaga() { export default function* rootSaga() {
yield takeLatest(SEND_REGISTER_REQUEST, registerUserFlow);
} }

22
src/store/index.js Normal file
View File

@ -0,0 +1,22 @@
import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import { createLogger } from "redux-logger";
import rootReducer from "../reducers";
import rootSaga from "../sagas";
export default function configureStore(initialState = {}) {
const sagaMiddleware = createSagaMiddleware();
const middlewares = [sagaMiddleware];
if (process.env.NODE_ENV === "development" && process.env.REACT_APP_REDUX_LOGGING) {
middlewares.push(createLogger());
}
const enhancers = [applyMiddleware(...middlewares)];
const store = createStore(rootReducer, initialState, compose(...enhancers));
// Extensions
store.asyncReducers = {}; // Async reducer registry
sagaMiddleware.run(rootSaga);
return store;
}

View File

@ -316,6 +316,13 @@ aws4@^1.2.1:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
axios@^0.16.2:
version "0.16.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d"
dependencies:
follow-redirects "^1.2.3"
is-buffer "^1.1.5"
axobject-query@^0.1.0: axobject-query@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
@ -1757,7 +1764,7 @@ date-now@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
debug@2.6.8, debug@^2.1.1, debug@^2.2.0, debug@^2.6.0, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8: debug@2.6.8, debug@^2.1.1, debug@^2.2.0, debug@^2.4.5, debug@^2.6.0, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8:
version "2.6.8" version "2.6.8"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
dependencies: dependencies:
@ -2592,6 +2599,12 @@ flatten@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
follow-redirects@^1.2.3:
version "1.2.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.4.tgz#355e8f4d16876b43f577b0d5ce2668b9723214ea"
dependencies:
debug "^2.4.5"
for-in@^1.0.1: for-in@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@ -3881,6 +3894,10 @@ loader-utils@^1.0.2, loader-utils@^1.1.0:
emojis-list "^2.0.0" emojis-list "^2.0.0"
json5 "^0.5.0" json5 "^0.5.0"
localStorage@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/localStorage/-/localStorage-1.0.3.tgz#e6b89a57bb760a156a38cc87e0f2550f6ed413d8"
locate-path@^2.0.0: locate-path@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"