Skip to content

Exemplary Control Flow

Josh Holland edited this page Aug 19, 2019 · 2 revisions

Example: Creating a new rotation

Relevant files:

Frontend

  • The user clicks the "Create Rotation" button on this page:

    screenshot of "Create Rotation" page

  • The button's onClick handler is executed:

    <button
      type="submit"
      className="btn btn-primary btn-lg"
      onClick={this.props.onSubmit} // <-- this line
      disabled={!validity.every(i=>i)}
    >
      {this.props.submitName}
    </button>

    which executes the GroupForm's onSubmit prop, passed down from RotationCreate:

    <GroupForm
      deadlines = {this.state.deadlines}
      rotationHeader = {this.renderRotationHeader()}
      submitName = "Create Rotation"
      updateDeadline = {(deadlineName, date) => {
        this.setState((state, props) => {
          const newDeadline = update(state.deadlines[deadlineName], {$merge: {value: date}});
          return update(state, {
            deadlines: {$merge: {[deadlineName]: newDeadline}}
          });
        });
      }}
      onSubmit = {() => {this.onSubmit()}} // <-- this line
      afterSubmit = {() => {}}
    />

    which executes RotationCreate.onSubmit:

    onSubmit() {
      const deadlines = Object.keys(this.state.deadlines).reduce((obj, x) => {
        obj[x] = this.state.deadlines[x].value.format("YYYY-MM-DD");
        return obj;
      }, {});
      deadlines.series = this.state.series;
      deadlines.part = this.state.part;
      this.props.createRotation(deadlines).then(() => {
        this.props.history.push("/");
      });
    }
  • RotationCreate.onSubmit calls the RotationCreate's createRotation prop, which was injected by React Redux (see the docs on mapDispatchToProps – this component uses the object shorthand form, but many components also use the function form):

    import { connect } from 'react-redux';
    import {createRotation} from '../actions/rotations';
    
    // ...
    
    const mapDispatchToProps = {
      createRotation, // <-- this line
    };
    
    export default connect(
      mapStateToProps,
      mapDispatchToProps
    )(RotationCreate);

    which resolves to createRotation from src/actions/rotations.js:

    export function createRotation(rotation) {
      return function (dispatch) {
        return axios.post(`${api_url}/api/series`, rotation).then(response => (
          Promise.all([
            dispatch(fetchLatestRotation()),
            dispatch(fetchRotationYears()),
          ])
        ));
      };
    }
  • createRotation returns a thunk, which is intercepted and executed immediately by Redux Thunk.

  • The thunk returned by createRotation makes a POST request to /api/series with the new rotation.

Backend

  • The /api/series route and POST method map to the cogs.routes.api.rotations.create handler:

    app.router.add_post('/api/series', api.rotations.create)

    so that function is invoked on the server.

  • The request handler retrieves the data describing the rotation from the request, checks that there is no existing rotation with this series and part, then:

    • creates the rotation
    • stores it in the database
    • sends out emails announcing the rotation
    • schedules the jobs necessary to change the rotation's state over time
    • returns HTTP 201 ("Created")

Frontend

  • The POST request finishes, so the promise returned by axios.post(...) resolves, and the chained function is executed:

    export function createRotation(rotation) {
      return function (dispatch) {
        return axios.post(`${api_url}/api/series`, rotation).then(response => ( // execution resumes here
          Promise.all([
            dispatch(fetchLatestRotation()),
            dispatch(fetchRotationYears()),
          ])
        ));
      };
    }
  • This function executes fetchLatestRotation and fetchRotationYears (both operate in a very similar fashion, so only fetchLatestRotation is explained here):

    export function fetchLatestRotation() {
      return function (dispatch) {
        dispatch(requestRotations(1));
        dispatch(requestLatestRotation());
        return axios.get(`${api_url}/api/series/latest`).then(response => {
          const rotation = response.data;
          dispatch(receiveRotation(rotation));
          dispatch(receiveLatestRotation(rotation.data.id));
        });
      }
    }
  • As explained previously, the Redux Thunk indirection takes place.

  • The requestRotations and requestLatestRotation actions are dispatched:

    export const REQUEST_ROTATIONS = 'REQUEST_ROTATIONS';
    export const REQUEST_LATEST_ROTATION = 'REQUEST_LATEST_ROTATION';
    
    export function requestRotations(noRotations) {
      return {
        type: REQUEST_ROTATIONS,
        noRotations
      }
    }
    
    function requestLatestRotation() {
      return {
        type: REQUEST_LATEST_ROTATION,
      }
    }

    These are ordinary Redux actions, so the reducer is invoked to deal with them:

    const rootReducer = combineReducers({
      projects,
      users,
      rotations,
      emails,
    });
  • combineReducers invokes all four reducers it is passed, but only the rotations reducer knows how to deal with the rotation-related actions (the other reducers just return the state passed to them):

    function rotations(state={
      fetching: 0,
      rotations: {},
      yearList: [],
      latestID: null
    }, action) {
      switch (action.type) {
        case REQUEST_ROTATIONS: // <-- this case first
          return update(state, {
            fetching: {$set: state.fetching + action.noRotations}
          });
        // ...
        default: // <-- and then this case on the second time around, for REQUEST_LATEST_ROTATION
          return state;
      }
    }
  • The global state is updated by Redux to the new state returned by the reducer (that is, state.rotations.fetching is incremented).

  • Any components which declare a dependency on state.rotations.fetching in their mapStateToProps are updated by React Redux.

  • axios.get(...) is executed, and a GET request is made to /api/series/latest.

Backend

  • The /api/series/latest route and GET method map to the cogs.routes.api.rotations.latest handler:

    app.router.add_get('/api/series/latest', api.rotations.latest)

    so that function is invoked on the server.

  • latest just serves a (307 Temporary) redirect to the latest rotation:

    db = request.app["db"]
    latest = db.get_most_recent_group()
    return HTTPTemporaryRedirect(f"/api/series/{latest.series}/{latest.part}")

    which is handled by cogs.routes.api.rotations.get:

    app.router.add_get('/api/series/{group_series}/{group_part}', api.rotations.get)
  • get retrieves the specified (series/part) rotation from the database, and if the user is allowed to view the rotation (if they are staff or the rotation has been made visible to students), returns the serialised rotation.

Frontend

  • The GET request finishes, so the promise returned by axios.get(...) resolves, and the chained function is executed:

    export function fetchLatestRotation() {
      return function (dispatch) {
        dispatch(requestRotations(1));
        dispatch(requestLatestRotation());
        return axios.get(`${api_url}/api/series/latest`).then(response => { // execution resumes here
          const rotation = response.data;
          dispatch(receiveRotation(rotation));
          dispatch(receiveLatestRotation(rotation.data.id));
        });
      }
    }
  • The receiveRotation and receiveLatestRotation actions are dispatched:

    export const RECEIVE_ROTATION = 'RECEIVE_ROTATION';
    export const RECEIVE_LATEST_ROTATION = 'RECEIVE_LATEST_ROTATION';
    
    export function receiveRotation(rotation) {
      return {
        type: RECEIVE_ROTATION,
        rotation
      }
    }
    
    function receiveLatestRotation(rotationID) {
      return {
        type: RECEIVE_LATEST_ROTATION,
        rotationID
      }
    }
  • Again, the global state is updated by Redux to the new state returned by the reducer:

    function rotations(state={
      fetching: 0,
      rotations: {},
      yearList: [],
      latestID: null
    }, action) {
      switch (action.type) {
        // ...
        case RECEIVE_ROTATION:
          return update(state, {
            fetching: {$set: state.fetching-1},
            rotations: {$merge: {[action.rotation.data.id]: action.rotation}}
          });
        case RECEIVE_LATEST_ROTATION:
          return update(state, {
            latestID: {$set: action.rotationID}
          });
        // ...
      }
    }
  • Any components which need updating (see mapStateToProps) are updated by React Redux.

  • The promise returned by createRotation resolves, so the chained function in RotationCreate.onSubmit runs:

    onSubmit() {
      const deadlines = Object.keys(this.state.deadlines).reduce((obj, x) => {
        obj[x] = this.state.deadlines[x].value.format("YYYY-MM-DD");
        return obj;
      }, {});
      deadlines.series = this.state.series;
      deadlines.part = this.state.part;
      this.props.createRotation(deadlines).then(() => { // execution continues here
        this.props.history.push("/");
      });
    }
  • The history prop (provided by React Router) is used to redirect the user to / (at which point the React Router route-matching machinery takes over, and will unmount the RotationCreate and mount the MainPage in its place).

Clone this wiki locally