Change Password - Part 4 - Converting Errors From Symfony to Redux Form


In this video we are continuing on with our Change Password form implementation. Specifically in this video we are covering the process of mapping the returned Symfony Form errors into a format that can be easily consumed by Redux Form.

As it stands, if we send in a request to change our password, we get back something similar to:

{
  "children": {
    "current_password": {},
    "plainPassword": {
      "children": {
        first": {
          "errors": [
            "The entered passwords don't match."
          ]
        },
        "second": {}
      }
    }
  }
}

Again, if unsure, you can see the way in which a change password request is expected to work by reviewing the Behat tests.

There are a number of problems going on here.

The more obvious are:

  • This doesn't map 1:1 with our Redux Form setup
  • There's some funky mix of snake case, and camel case
  • We are getting an array of errors
  • Sometimes, errors won't exist (e.g. under second)

If you switched out your back end API from Symfony to something else, maybe even in a completely different programming language, you would very likely find a totally different data shape. Chances are, it would still exhibit some, or all, of these issues.

Whatever the outcome, we must pass on this returned response further 'downstream', so that our code can react accordingly.

Working With The Error Response

As it stands currently, we aren't keeping hold of the response body when we encounter an error:

// /src/connectivity/async-fetch.js

import HttpApiCallError from '../errors/HttpApiCallError';

export default async function asyncFetch(url, requestConfig = {}) {

  const response = await fetch(url, requestConfig);

  const isSuccess = response.status >= 200 && response.status < 300;

  if (isSuccess) {
    return response;
  }

  // things went wrong
  throw new HttpApiCallError(
    response.statusText,
    response.status
  );
}

For whatever reason, if we don't get back a good status code (>= 200, < 300), then we throw.

Up until now, throwing our custom Error has been sufficient. But we have omitted a key piece of info. If the API call returned a response body, we are currently discarding it. This won't do.

Thankfully, fixing this is easy enough, we just need to extract the JSON output from the response body. This call returns a promise, so we need to await the outcome before moving on:

// /src/connectivity/async-fetch.js

import HttpApiCallError from '../errors/HttpApiCallError';

export default async function asyncFetch(url, requestConfig = {}) {

  const response = await fetch(url, requestConfig);

  const isSuccess = response.status >= 200 && response.status < 300;

  if (isSuccess) {
    return response;
  }

  // things still went wrong, but now we know why
  const error = new HttpApiCallError(
    response.statusText,
    response.status
  );
  error.response = await response.json();

  throw error;
}

And now that we have this information available we can pass this further 'downstream' (think: to other interested functions) from our doChangePassword function:

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

export function *doChangePassword(action) {

  try {

    const {userId, currentPassword, newPassword, newPasswordRepeated} = action.payload;

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

    const responseBody = yield call(api.changePassword, userId, currentPassword, newPassword, newPasswordRepeated);

    yield put({
      type: types.CHANGE_PASSWORD__SUCCEEDED,
      payload: {
        message: responseBody
      }
    });

  } catch (e) {

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

    // now we can use that JSON from the response
    yield put({
      type: types.CHANGE_PASSWORD__FAILED,
      payload: {
        response: e.response
      }
    });

  } finally {

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

  }
}

Excellent. Now we have a generic REQUEST__FAILED action being dispatched, and then a more specific CHANGE_PASSWORD__FAILED action.

This more specific CHANGE_PASSWORD__FAILED action will have a payload containing a JavaScript object literal (or just a normal JS object to you and me) that we can immediately start working with.

We already have a watching saga function for CHANGE_PASSWORD__FAILED:

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

export function *doChangePasswordFailed(action) {

  // console.log('doChangePasswordFailed', action);
  const errorData = action.payload.response;

  yield put(stopSubmit('change-password', {
    currentPassword: 'Nope!' // hardcoded message for now
  }));
}

All we have done here is assign this new piece of data (action.payload.response) to a constant called errorData. Aside from that, no interesting changes. Yet.

Processing Our Symfony Form Errors

Remember, this would very likely apply even if you're not using Symfony. You may get very lucky and have a 1:1 mapping, and if so, cool beans, you can skip all of this entirely.

However, for the rest of us, we need to do a little processing.

Now, this is not overly complex, but there are some fundamentals here that if we can guarantee to work as expected, are likely going to make our lives a whole bunch easier. This sounds like the perfect opportunity to do a little TDD.

We already have Jest setup and working for this project, so that's half the battle.

Next, we need to create a new file, and describe what we are testing:

// /__tests__/helpers/formErrorHelpers.react-test.js

import errorHelper from '../../src/helpers/formErrorHelper';

describe('Symfony Form Error Helper', () => {
});

From this we can infer that we will be creating an implementation under /src/helpers/formErrorHelper.js. We haven't yet created that file, so whatever we do here will fail.

Next, we need to start describing what it should do:

// /__tests__/helpers/formErrorHelpers.react-test.js

import errorHelper from '../../src/helpers/formErrorHelper';

describe('Symfony Form Error Helper', () => {

  it('returns errors if found', () => {
    let errors = {
      children: {
        current_password: {
          errors: [
            "This value is invalid"
          ]
        }
      }
    };
    expect(
      errorHelper(errors, 'children.current_password.errors')
    ).toEqual('This value is invalid');
  });

});

We know that our API is going to send us errors in this shape, and that they will be available to us inside const errorData. This data will look like the above - let errors = ....

In order to display any / all of the returned errors, I would like to be able to pass in a dot-notation path representing the location of the errors, then convert the array to a single string.

As ever with TDD, we want to do the absolute minimum to make this test pass.

In order to make this pass, we must first create the file where this new helper function will live:

// /src/helpers/formErrorHelper.js

const errorHelper = () => {

};

export default errorHelper;

This is the raw function 'shell', enough to get us started and ensuring the function itself is export'ed.

We do know that we will have both the object containing the errors, and also a dot-notation path of the specific part of the errors object from which we'd like to extract the given error messages. We can update the errorHelper function with this information:

// /src/helpers/formErrorHelper.js

const errorHelper = (errors, errorPath) => {

};

export default errorHelper;

And as we have the path to the errors, we can make use of lodash, and more specifically lodash's get function. We do already have lodash in our project, so nothing else to do here on that front.

With all this info, we can now update our function accordingly:

// /src/helpers/formErrorHelper.js

import _ from 'lodash';

const errorHelper = (errors, errorPath) => {

  let errorInfo;

  try {

    errorInfo = _.get(errors, errorPath, undefined);

  } catch (e) {

    console.error('errorHelper e', e);

  }

  return errorInfo;
};

export default errorHelper;

With this in place we should now have a different error message:

Expected value to equal:
  "This value is invalid"
Received:
  ["This value is invalid"]

Close, but no cigar.

As already discussed, errors is an array. All we have done so far is extract whatever lives on the path children.current_password.errors on the errors object, and return it. This just means we return the array:

    let errors = {
      children: {
        current_password: {
          errors: [
            "This value is invalid"
          ]
        }
      }
    };

As mentioned, one of the nicest things about TDD is the pursuit of the easiest path to our goal.

Our goal here is simply to show the string "This value is invalid".

Our array only contains one entry. Let's 'cheat':

// /src/helpers/formErrorHelper.js

import _ from 'lodash';

const errorHelper = (errors, errorPath) => {

  let errorInfo;

  try {

    errorInfo = _.get(errors, errorPath, undefined);

    if (Array.isArray(errorInfo)) {
      errorInfo = errorInfo[0]; // heh
    }

  } catch (e) {

    console.error('errorHelper e', e);

  }

  return errorInfo;
};

export default errorHelper;

Simple enough. We check if errorInfo is an array. If it's not, we're likely already either dealing with a string, or undefined.

If it is an array, we simply use the first item in that array.

It's cheating, and it's not going to work forever. But it satisfies our first test which at this point is good enough.

Code For This Course

Get the code for this course.

Episodes