+2

Let Build Single Page App - Part III

In Part II we have built API end point that needed for implementing authentication system. In this part we'll be focusing on building user interface using that end point with ReactJS. As usual you can find source code here. Now without any further ado lets get started.

Routing Configuration

Before we could get start with configuring our application routes, you'll need to add this to your package.json.

"dependencies": {
  "flux": "^2.1.1",
  "jquery": "^2.2.2",
  "lodash": "^4.8.0",
  "react-cookie": "^0.4.5",
  "react-router": "^2.0.1"
}

Lets look at the following code snippet. First we import nescessary dependecies that we need. AppComponent and HomeComponent are the ones that we will implement later.

import ReactDOM from 'react-dom';
import AppComponent from './components/app';
import HomeComponent from './components/home';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';

history={browerHistory} instructed react-router to use html5 mode url style meaning there is no hash bang after each url. Next we tell react-router to map / to AppComponent. Anything that goes between <Route></Route> will become children of parent route. IndexRoute make component that associated with it become default component that will render along with its parent (AppComponent). ReactDOM.render responsible for mouting component into DOM.

ReactDOM.render((
  <Router history={browserHistory}>
    <Route path="/" component={ AppComponent }>
      <IndexRoute component={ HomeComponent } />
    </Route>
  </Router>
), document.getElementById('app'));

Entering Flux Architecture

Flux application have three major parts. Dispatcher an application's central hub. When there is an action originate from user interaction with views dispatcher then invoke the callbacks that stores have registered with it. Store respond to this by pulling data that flow through dispatcher and save it. The stores then emit change event to notify view that there is a change to data. View listen for change event and retrieve the new data from stores and pass that data down to the entire tree of their child views or calling setState() which will causing a re-rendering of themselves and all of their descendants in the component tree.

User's interaction logics and data retrieval usually live within view(component), but as an application growth these logics will started to get messy. I would like to separate these data retrieval logics into something we would called Action. Take a look at the following diagram for a better overview of how data flow in flux.

flux-diagram.png

Dispatcher

This class extend from facebook's flux Dispatcher class which provide a minimal methods that could get us started.

import { Dispatcher } from 'flux';

export default class AppDispatcherClass extends Dispatcher {
  handleViewAction(action) {
    this.dispatch({
      source: 'VIEW_ACTION',
      action: action
    });
  }
}

export const AppDispatcher = new AppDispatcherClass();

Action

This class mainly for a wrap around to AJAX call to our API end point and passing data that we get to dispatcher. Since the code will be too long I will show only a method definition as an example. The rest will be quite similair. The code is really straightforward there isn't much to explain here.

export default class UserActionClass {
  login(credentials) {
    $.ajax({
      url: Config.LOGIN_URL,
      method: 'POST',
      dataType: 'json',
      data: credentials,
      success: (data) => {
        AppDispatcher.handleViewAction({
          data: data.user,
          type: UserConstant.USER_LOGIN_SUCCESS
        });
      },
      error: (xhr) => {
        AppDispatcher.handleViewAction({
          data: xhr.responseText,
          type: UserConstant.USER_LOGIN_ERROR
        });
      }
    });
  }
}

export const UserAction = new UserActionClass();

Store

The point to notice here is in constructor. As you can see we register a callback to AppDispatcher so that we can get a payload when there is data flow through dispatcher. A switch statement here is to ensure that we only store new data that concern to our store logic. Notice the emit at the end. This is the key to notify view that there is a data changed in the store.

export default class UserStoreClass extends EventEmitter {
  constructor() {
    super();
    this.user = null;
    this.loginError = '';
    this.validationErrors = [];

    this.dispatchIndex = AppDispatcher.register((payload) => {
      let action = payload.action;
      switch (action.type) {
        case UserConstant.CURRENT_USER:
        case UserConstant.USER_LOGIN_SUCCESS:
        case UserConstant.REGISTER_SUCCESS:
          this.saveUser(action.data);
          this.clearErrors();
          break;
        case UserConstant.USER_LOGIN_ERROR:
          this.saveUser(null);
          this.setLoginError('Invalid email or password');
        case UserConstant.USER_NOT_FOUND:
        case UserConstant.USER_LOGOUT_SUCCESS:
          this.saveUser(null);
          break;
        case UserConstant.REGISTER_ERROR:
          this.saveUser(null);
          this.setValidationErrors(action.data);
          break;
      }

      this.emit('USER_CHANGED');
      return true;
    });
  }

  addChangedListener(callback) {
    this.addListener('USER_CHANGED', callback);
  }

  removeChangedListener(callback) {
    this.removeListener('USER_CHANGED', callback);
  }

  getUser() {
    return this.user;
  }

  getToken() {
    return localStorage.getItem('token');
  }

  getLoginError() {
    return this.loginError;
  }

  getValidationErrors() {
    return this.validationErrors;
  }

  saveUser(user) {
    if (user) {
      this.user = user;
      localStorage.setItem('token', this.user.token);
    } else {
      this.user = null;
      localStorage.removeItem('token');
    }
    this.validationErrors = [];
  }

  setLoginError(message) {
    this.loginError = message;
  }

  setValidationErrors(messages) {
    this.validationErrors = messages.users;
  }

  clearErrors() {
    this.loginError = '';
    this.validationErrors = [];
  }

  isLogin() {
    return !!localStorage.getItem('token');
  }
}

export const UserStore = new UserStoreClass();

View

Application Component

Now lets take a look at AppComponent our soon to be top level component. We subscribe our component to UserStore change event. When there is a change to UserStore we'll retrieve new data and set our component state to this new data, we also pass that data down to child component. In componentWillMount lifecycle method a call to UserAction.getCurrentUser() was made, which will in turn request for current logged in user infomation form our API end point with AJAX. After the request complete that data will be broadcast to store via dispatcher. During rendering we checked if our component state is still loading meaning that AJAX called was not completed yet. If so we will render the loading gif image otherwise we will render HeaderComponent along with child components. Recall that we configured HomeComponent to be a child of AppComponent, now we can refer to it as a property like this this.props.children. To pass data from AppComponent to HomeComponent we need to wrap children in a call to React.cloneElement().

export default class AppComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isLoading: true };
    this.onUserChanged = this.onUserChanged.bind(this);
    UserStore.addChangedListener(this.onUserChanged);
  }

  componentWillMount() {
    UserAction.getCurrentUser(UserStore.getToken());
  }

  onUserChanged() {
    let data = this.state.data;
    data.user = UserStore.getUser();
    data.isLogin = UserStore.isLogin();
    this.setState({ data: data, isLoading: false });
  }

  render() {
    if (this.state.isLoading) {
      return (<div className="loading"></div>);
    } else {
      return (
        <div className="main" onClick={ this.resetChildrenState }>
          <HeaderComponent data={ this.state.data } />
          { React.cloneElement(this.props.children, { data: this.state.data }) }
        </div>
      );
    }
  }
}

Home Component

Aside from state, rendering logic and event listeners there isn't much of a difference from AppComponent. doLogin() make used of UserAction.login() to send login credential to API end point and login user in if success. Before rendering we checked to see if user has logged in or not. If user is not logged in we will render login and register form. If user provides invalid information an error message will be store in UserStore. onUserChanged() callback will pull this error message and set state to trigger component re-rendering and showing error message to user.

export default class HomeComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      boards: [],
      isLoading: true,
      validationErrors: []
    };

    this.doLogin = this.doLogin.bind(this);
    this.doRegister = this.doRegister.bind(this);
    this.onUserChanged = this.onUserChanged.bind(this);
    UserStore.addChangedListener(this.onUserChanged);
  }

  /** Begin lifecycle hooks **/

  componentDidMount() {
    if (this.refs.txtEmail) this.refs.txtEmail.focus();
    BoardAction.getBoards(UserStore.getToken());

  /** End lifecycle hooks **/

  doLogin(event) {
    event.preventDefault();

    let email = this.refs.txtEmail.value;
    let password = this.refs.txtPassword.value;

    if (email && password) {
      UserAction.login({ email: email, password: password });
    }
  }

  doRegister(event) {
    event.preventDefault();
    UserAction.register({
      email: this.refs.txtUserEmail.value,
      username: this.refs.txtUserUsername.value,
      password: this.refs.txtUserPassword.value,
      password_confirmation: this.refs.txtUserPasswordConfirm.value
    });
  }

  onUserChanged() {
    let loginError = UserStore.getLoginError();
    let validationErrors = UserStore.getValidationErrors();

    if (loginError) this.refs.txtPassword.value = '';
    if (validationErrors.length > 0) {
      this.refs.txtUserPassword.value = '';
      this.refs.txtUserPasswordConfirm.value = '';
    }

    this.setState({
      loginError: UserStore.getLoginError(),
      validationErrors: UserStore.getValidationErrors()
    });
  }

  render() {
    if (this.props.data.isLogin) {
      return this.loginedContent();
    } else {
      return this.notLoginedContent();
    }
  }

  /** Helper methods **/

  loginedContent() {
    return (<h1>Home Page</h1>);
  }

  notLoginedContent() {
    return (
      <div className="home">
        { this.getLoginErrorNode() }

        <form className="form" onSubmit={ this.doLogin }>
          <div className="form-group">
            <input type="email" name="email" ref="txtEmail" placeholder="Email" />
          </div>
          <div className="form-group password">
            <input type="password" name="password" ref="txtPassword" placeholder="Password" />
          </div>
          <div className="form-group submit">
            <button onClick={ this.doLogin }>Login</button>
          </div>
        </form>

        { this.getValidationErrorNodes() }

        <form className="form" onSubmit={ this.doRegister }>
          <div className="form-group">
            <input type="text" name="username" ref="txtUserUsername" placeholder="Username" />
          </div>
          <div className="form-group">
            <input type="email" name="email" ref="txtUserEmail" placeholder="Email" />
          </div>
          <div className="form-group">
            <input type="password" name="password" ref="txtUserPassword" placeholder="Password" />
          </div>
          <div className="form-group password">
            <input type="password" name="password_confirmation" ref="txtUserPasswordConfirm" placeholder="Password Confirmation" />
          </div>
          <div className="form-group submit">
            <button onClick={ this.doRegister }>Register</button>
          </div>
        </form>
      </div>
    );
  }

  getLoginErrorNode() {
    if (this.state.loginError) {
      return (
        <ul className="validation-errors">
          <li>{ this.state.loginError }</li>
        </ul>
      );
    } else {
      return null;
    }
  }

  getValidationErrorNodes() {
    if (this.state.validationErrors.length > 0) {
      let messageNodes = this.state.validationErrors.map((message, index) => {
        return (<li key={ index }>{ message }</li>);
      });
      return (<ul className="validation-errors">{ messageNodes }</ul>);
    } else {
      return null;
    }
  }
}

Conclusion

In this post we have covered the basic overview of how flux architecture works and put it into practice by implement user interface using ReactJS for our authentication system.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí