User Profile Page - Part 2

This video is available to view for members only.

Click here to Join!

Already a member?

Login


In this video we are continuing on the process of adding in a Profile page to our application. This profile page will be available via /profile, and should only be available to logged in users.

By the end of the previous video we had created the Profile page, we had restricted this page down to being visible only by logged in users, and we had connected the new Profile page to the existing Redux Store.

In connecting the component we have gained access to our application's state, parts of which will be required to make our real API call. The most obvious part will be that we need the logged in user's ID to ensure we make a call to the correct endpoint - e.g. /profile/3, should the logged in user's ID be #3.

Perhaps less obvious is that to make any API call we will also need the JWT we received as part of our successful login. At present we aren't saving this data, so we'll need to fix that as we go.

As we are using Redux Saga in our application, the most logical starting point is to create ourselves a profile.saga.js file, and add in two new generator functions:

  • the function that will watch for a specific action describing a profile request;
  • and another function that contains the implementation specific to this request

Keen eyed readers will likely have spotted by now that yes, this is exactly the same process we have used during our Login journey.

The good news is that this is the same process we will repeat time and time again for most everything we do in this application. This may seem somewhat verbose and redundant, but this is the core structure that ensures our application grows in an understandable and predictable fashion.

// /src/sagas/profile.saga.js

import {takeLatest} from 'redux-saga';
import * as types from '../constants/actionTypes';


export function *doRequestProfile(action) {

  try {

  } catch (e) {

  } finally {

  }

}

export function *watchRequestProfile() {
  yield* takeLatest(types.PROFILE__REQUESTED, doRequestProfile);
}

This is the standard pattern every one of our sagas will follow.

We will do the bulk of our work in the try block. This will typically include starting a request, then dispatching an action for the real API call, and attempting to process the results thereof.

Should everything go to plan, we will then dispatch another action to indicate the process completed successfully, along with an optional payload full of relevant data.

If things don't go to plan, we catch the exception, and dispatch a different action - and optional payload - to indicate what went wrong. Processes further upstream can then react to this situation accordingly.

Whatever the outcome we always (finally) tidy up after ourselves, ensuring we dispatch an action to indicate the request finished.

With this boilerplate in mind, let's add that all in now:

// /src/sagas/profile.saga.js

import {put} from 'redux-saga/effects';
import {takeLatest} from 'redux-saga';
import * as types from '../constants/actionTypes';


export const REQUESTS = {
  PROFILE__DOREQUESTPROFILE__SAGA: 'profile.doRequestProfile.saga',
};


export function *doRequestProfile(action) {

  try {

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: REQUESTS.PROFILE__DOREQUESTPROFILE__SAGA
      }
    });

  } catch (e) {

    yield put({
      type: types.REQUEST__FAILED,
      payload: {
        message: e.message,
        statusCode: e.statusCode
      }
    })

  } finally {

    yield put({
      type: types.REQUEST__FINISHED,
      payload: {
        requestFrom: REQUESTS.PROFILE__DOREQUESTPROFILE__SAGA
      }
    });

  }
}

export function *watchRequestProfile() {
  yield* takeLatest(types.PROFILE__REQUESTED, doRequestProfile);
}

As can be seen here, this process is initiated when an action with the type of PROFILE__REQUESTED is dispatched.

We would typically have two other constants defined - one for when our profile request is successful, and another for when a profile request failed:

// /src/constants/actionTypes.js

export const PROFILE__REQUESTED = 'PROFILE__REQUESTED';
export const PROFILE__REQUESTED__SUCCEEDED = 'PROFILE__REQUESTED__SUCCEEDED';
export const PROFILE__REQUESTED__FAILED = 'PROFILE__REQUESTED__FAILED';

You are completely free to name these however you see fit. Or even not to use constants at all. It's your decision.

Knowing that we are going to need to dispatch an action with the type of PROFILE__REQUESTED, we might as well now update our ProfilePage's componentDidMount function to do this:

// /src/containers/ProfilePage.js

import * as types from '../constants/actionTypes';

class ProfilePage extends React.Component {

  componentDidMount() {
    this.props.dispatch({
      type: types.PROFILE__REQUESTED,
      payload: {
        userId: this.props.pageState.auth.id
      }
    });
  }

And so we know that the action that *doRequestProfile receives must therefore contain a payload with a single key - userId.

We can therefore extract that information from the payload:

// /src/sagas/profile.saga.js

export function *doRequestProfile(action) {

  try {

    const {userId} = action.payload;

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: REQUESTS.PROFILE__DOREQUESTPROFILE__SAGA
      }
    });

  } catch (e) {

  // ...

However, unless we explicitly add in our new saga's watch function into our rootSaga array of active sagas, nothing will happen. Let's fix that:

// /src/sagas/index.js

import {fork} from 'redux-saga/effects';
import * as authSaga from './auth.saga';
import * as profileSaga from './profile.saga';

export default function *rootSaga() {
  yield [
    fork(authSaga.watchLogin),
    fork(authSaga.watchLoginSucceeded),
    fork(authSaga.watchLoginFailed),

    fork(authSaga.watchLogoutRequested),

    fork(profileSaga.watchRequestProfile), // our new line
  ];
}

At this point when a User visits the /profile page there should be an action dispatched containing their user ID. This action will be 'seen' by our watching saga, and our *doRequestProfile function will be invoked with our action as its argument.

However, our function doesn't actually do anything interesting.

We need this saga to handle the process of calling our API - specifically the /profile/{userId} endpoint.

Unfortunately, that function doesn't exist yet. Let's create it.

// /src/connectivity/api.profile.js

import asyncFetch from './async-fetch';
import {getBaseRequestConfig} from './baseRequestConfig';

export async function fetchProfile(userId) {

  /* global API_BASE_URL */
  const url = API_BASE_URL + '/profile/' + userId;

  const response = await asyncFetch(url, getBaseRequestConfig());

  return await response.json();
}

Whilst this should work in so much as it should make an API call when given a userId, that call will ultimately fail. More on that momentarily.

Back inside our profile.saga.js file we can now use this new code:

// /src/sagas/profile.saga.js

import * as api from '../connectivity/api.profile';

export function *doRequestProfile(action) {

  try {

    const {userId} = action.payload;

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: REQUESTS.PROFILE__DOREQUESTPROFILE__SAGA
      }
    });

    const responseBody = yield call(api.fetchProfile, userId);

  } catch (e) {

  // ...

As we control the API we can cheat a little here and take a look at the Behat feature spec for calls to /profile to understand what shape the response data will be in. Of course, in the real world you could do this with API docs - depending on the API, this can be quite hit and miss.

If interested, you can see the Behat feature in full. We are interested in the response from a GET request to our own profile:

# /src/AppBundle/Features/profile.feature

Feature: Manage User profile data via the RESTful API

  In order to allow a user to keep their profile information up to date
  As a client software developer
  I need to be able to let users read and update their profile


  Background:
    Given there are Users with the following details:
      | id | username | email          | password |
      | 1  | peter    | peter@test.com | testpass |
      | 2  | john     | john@test.org  | johnpass |
     And I am successfully logged in with username: "peter", and password: "testpass"
     And I set header "Content-Type" with value "application/json"


  Scenario: Can view own profile
    When I send a "GET" request to "/profile/1"
    Then the response code should be 200
     And the response should contain json:
      """
      {
        "id": "1",
        "username": "peter",
        "email": "peter@test.com"
      }
      """

So if everything goes to plan, responseBody should contain JSON similar to:

{
  "id": "1",
  "username": "peter",
  "email": "peter@test.com"
}

We can use ES6 destructuring to pull these values out of the responseBody, and then dispatch (put) a new action containing this information:

// /src/sagas/profile.saga.js

import * as api from '../connectivity/api.profile';

export function *doRequestProfile(action) {

  try {

    const {userId} = action.payload;

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: REQUESTS.PROFILE__DOREQUESTPROFILE__SAGA
      }
    });

    const responseBody = yield call(api.fetchProfile, userId);

    const {username, email} = responseBody;

    yield put({
      type: types.PROFILE__REQUESTED__SUCCEEDED,
      payload: {
        id: userId,
        username,
        email
      }
    });

  } catch (e) {

  // ...

At present we don't have anything listening for an action with type PROFILE__REQUESTED__SUCCEEDED, and so we will need to create a reducer to process this information.

The problem is, however, that if we try this, our API call will result in a 401 - unauthorised.

If you think about it, this is unsurprising as our fetchProfile call is missing one important piece of data: the user's JWT.

Thinking about it though, you likely don't want to have to explicitly add this in to every single API call you make. Well, maybe you do. I didn't.

My workaround to this is to include it in the getBaseRequestConfig function. You may dislike this approach. I'm open to a better one. Feel free to leave alternatives in the comments:

// /src/connectivity/baseRequestConfig.js

import {loadState} from './localStorage';
import _ from 'lodash';

export const getBaseRequestConfig = () => {

  const state = loadState();

  const config = {
    method: 'GET',
    mode: 'cors',
    headers: {
      'Content-Type': 'application/json'
    }
  };

  if (state && _.has(state, 'auth.token')) {
    config.headers.Authorization = `Bearer ${state.auth.token}`;
  }

  return config;
};

Firstly we are making use of loadState. This was covered in this video, so if unsure please do go back and watch that one.

Then I'm making use of the has function from lodash to check if the auth.token property path exists, and if so, add it as a header of type Authorization, with the value of Bearer {my-token-here}.

Ultimately this means that if we have a token in the format:

auth = {
    token: 'my token here'
};

Then lodash will help us pick that out, and set it. If we don't, this part is skipped.

Even if we run this now, this still won't work, as we aren't saving the user's token on login. Oops! Let's fix that.

Firstly we need to add this information into the payload that is dispatched upon successful login from auth.saga.js:

// /src/sagas/auth.saga.js#L30

    yield put({
      type: types.LOGIN__SUCCEEDED,
      payload: {
        idToken: responseBody.token
      }
    });

Then update the *doLoginSucceeded function inside auth.saga.js:

// /src/sagas/auth.saga.js#L78

export function *doLoginSucceeded(action) {

  const {idToken} = action.payload;

  const {id, username} = yield call(jwtDecode, idToken);

  yield put({
    type: types.LOGIN__COMPLETED,
    payload: {
      id,
      username,
      token: idToken
    }
  });
}

And finally ensure the token value is being stored (on login), and removed properly (on logout) in the authReducer:

// /src/reducers/authReducer.js

import * as types from '../constants/actionTypes';

export default function auth(state = {
  isAuthenticated: false,
  id: undefined,
  username: undefined,
  token: undefined
}, action) {

  switch (action.type) {

    case types.LOGIN__COMPLETED: {
      const { id, username, token } = action.payload;
      return Object.assign({}, state, {
        isAuthenticated: true,
        id,
        username,
        token
      });
    }


    case types.LOGOUT__COMPLETED: {
      return Object.assign({}, state, {
        isAuthenticated: false,
        id: undefined,
        username: undefined,
        token: undefined
      });
    }


    default: {
      // console.log('authReducer hit default', action.type);
      return state;
    }
  }
}

Now when we visit the profile page, we should see a request is successfully made to retrieve our profile.

Unfortunately this doesn't fully complete the process. We need a way to process this returned information - which we will do by way of a new reducer. We will get onto this in the very next video.

However, before finishing I want to cover an issue specific to the way we are exposing our profile information from our Symfony 3 API. If you view the response returned from our API from the /profile/{userId} endpoint, it will look something like this:

{"id":1,"username":"peter","username_canonical":"peter","email":"peter@test.com","email_canonical":"peter@test.com","enabled":true,"salt":"o4q0h5avtk0k4k080w8w00o0k0w0c4k","password":"$2y$13$VHmiJhyw.6zt8naR1aYYeuaxCMULOl7f64CZzKTJPXtIf4fHPs1fO","last_login":"2017-02-12T21:20:39+0000","groups":[],"locked":false,"expired":false,"roles":[],"credentials_expired":false}

It's highly unlikely you want all this information output. To fix this issue follow these instructions which will allow you to more granularly define exactly which of your User object's properties you wish to expose.


Code For This Course

Get the code for this course.

Code For This Episode

Get the code for this episode.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 App Walkthrough - User Experience 03:15
2 App Walkthrough - Developer Experience 07:41
3 Development Environment Setup 06:34
4 Login - Part 1 09:15
5 Login - Part 2 07:55
6 Login - Part 3 12:37
7 Login - Part 4 10:22
8 Login - Part 5 08:00
9 Saving Redux State to Local Storage 08:50
10 Logout 10:57
11 Adding an Auth-aware NavBar 14:43
12 Cleanup, Linting, and Login Form Styling 09:58
13 Showing Spinning Icons, Because Why Not? 08:11
14 More Robust Request Tracking 09:07
15 Getting Started Testing With Jest 06:43
16 Testing Request Reducer - Part 1 11:35
17 Testing Request Reducer - Part 2 05:25
18 Testing AuthSaga - Happy Path 09:19
19 Testing AuthSaga - Unhappy Paths 04:38
21 Testing JavaScript's Fetch with Jest - Happy Path 05:15
21 Testing JavaScript's Fetch with Jest - Unhappy Paths 04:35
22 Getting Started with Jest Mocks 08:52
23 Using Webpack Environment Variables in Jest Tests 09:37
24 User Profile Page - Part 1 07:31
25 User Profile Page - Part 2 10:25
26 User Profile Page - Part 3 07:23
27 Change Password - Part 1 10:01
28 Change Password - Part 2 07:59