Of Sound Mind and Love

Mithril.js: A Tutorial Introduction (Part 2)

Welcome to part 2 of this tutorial series! In part 1 we implemented the EntryList and EntryForm components. In part 2 we will first refactor the EntryForm component to take advantage of Mithril's stellar support for nested components. Then we will implement the Total component and the Coupon component, all of which will be managed by Mithril Virtual DOM algorithm under one parent component.

You can see the demo here!

Project User Stories

Before we dive back in, let's review our user stories:

  • As a volunteer, I want to sign up for an event. (Covered by part 1)
  • As a volunteer, I want to sign up multiple people at once. (Covered by part 1)
  • As an organizer, I need at least one attendee email address to send event details to.
  • As an organizer, I want to charge $10 per attendee.
  • As an organizer, I want to provide coupons to offer a percent discount on total price.
  • As an organizer, I want to see all entered volunteer entries. (Covered by part 1)

New Components

Once we are done, the EntryForm component will be the parent of three child components:

  • The Volunteers component will manage the volunteers' information entered
  • The Total component will calculate the final price
  • The Coupon component will handle checking the validity of an entered coupon via AJAX

Getting Started

If you want to code along, clone down this repo and git checkout the mithril-part-1-solution branch. This is the point we will start from.

Keep in mind that the purpose of this project is to teach Mithril concepts, so it uses some non-best practices. In a real project you would minimize the use of DOM ids, concatenate and minify your JavaScript files, wrap your code in anonymous function scopes, and so on.

Strategy

First we will factor out the volunteer contact management out of the EntryForm and into a new component. Afterwards we will use Mithril's stellar support for nesting components to build and nest this and two more components. In the end, you will get to see how everything fits in one application.

This is the approach we will take:

  1. Extract some logic out of EntryForm into a new Volunteers component
  2. Refactor the EntryForm component
  3. Create the Total component
  4. Create the Coupon component

Enough talk, let's get started!

1. The Moderate Refactor

When a Mithril component is nested, its parent can specify component attributes. These attributes are received in a parameter called vnode.

Let's create a new Volunteers component that "imports" its data from these attributes. This code is pulled straight from EntryForm with a tweak:

window.Volunteers = {  
  view(vnode) {
    var {volunteers, add, remove} = vnode.attrs
    var showRemove = (volunteers.length >= 2)

    return m('.volunteers', [
      volunteers.map(function (volunteer, idx) {
        // [This is the same as before]
      }),
      // This button is the same too
      m('button', { onclick: add }, 'Add another volunteer'),
    ])
  }
}

New concept: See the vnode? This little guy represents an instance of the component. Specifically, vnode.attrs contains data from the component's parent (we'll see how this works in the next section). For now, recognize how we're receiving our data – volunteers – and also our actions – add and remove.

With this, the Volunteers component is complete. It's a new component that is focused on displaying a list of volunteers. Put differently, this component has the sole responsibility of rendering the volunteer management user interface (UI).

One more point: we did not bring over the h3 element. Because we intend this component to be reusable, we leave that responsibility to the parent (you'll see this in the next section).

2. Refactoring the EntryForm Component

Now that we have a Volunteers component, let's refactor EntryForm to make use of it, starting with the view:

// src/components/EntryForm.js
EntryForm.view = function () {  
  // ...[truncated]...
  return {
    view() {
      return m('.entry-form', [
        m('h1', "New Entry"),
        m('h3', "Please enter each volunteer's contact information:"),
        // New!
        m(Volunteers, {
          add: add,
          remove: remove,
          volunteers: entry.volunteers
        }),
        m('br'),
        m('button', { onclick: submit }, 'Submit'),
      ])
    }
  }
}
// Don't forget to delete removeAnchor!

This is where it gets exciting! We've refactored EntryFormto nest the Volunteers component using m(). Given this code, when it comes time to render the page, Mithril will automatically initialize the nested component and run its view function, all without the need for us to manage it at all.

Notice also how we're passing add, remove, and entry.volunteers to the child Volunteers component. This object is the vnode.attrs object we saw in the previous section.

Note how our component actions did not have to change. Our refactor is complete!

If you're coding along, refresh the page and try creating a new entry. Everything should still work the same.

3. The Total Component

It's time to write the Total component. This component will display the total amount the user needs to donate for the signup application. Let's begin with the view:

// src/components/Total.js
window.Total = {  
  view(vnode) {
    var {count, discount} = vnode.attrs
    return m('.total', [
      m('label', "Total: "),
      m('b', "$" + calculatePrice(discount, count))
    ])
  }
}

Here calculatePrice (implementation not shown yet) is a model function that calculates the final price based on the component attributes. This is important to not only making the Total component reusable, but making the price calculation reusable as well (we will see the definition of this function shortly).

Here is the model logic. You can put this in the same file:

// src/components/Total.js

/* Model logic */
var PRICE_PER_COUNT = 10  
function calculatePrice (discount, count) {  
  var total = count * PRICE_PER_COUNT
  return roundCents(total - total * discount)
}
function roundCents (num) {  
  return Math.round(num * 100) / 100
}

As an aside, the nice thing about writing Total as its own component is we now have one place to go to if we need to modify anything total-related – whether it's changing how it looks on the page (Total.view) or changing the business logic (calculatePrice). Of course, if this file start to get too big, you can always split it up into multiple files in proper folders.

Nesting Total in VolunteerForm

Now we need to put the Total component on the page. This will require two updates:

  1. Add a discount property to the EntryForm component
  2. Nest the Total component within the EntryForm view and send down discount and number of volunteers as attributes.

Here is EntryForm with the above updates:

// src/components/EntryForm.js
window.EntryForm = function () {  
  var entry = Entry.vm()
  var discount = 0  // <-- New! (1.)

  // Component actions ...[trucated]...

  return {
    view() {
      return m('.entry-form', [
        m('h1', "New Entry"),
        m('h3', "Please enter each volunteer's contact information:"),

        m(Volunteers, {
          add: add,
          remove: remove,
          volunteers: entry.volunteers
        }),
        m(Total, { // <-- New! (2.)
          count: entry.volunteers.length,
          discount: discount
        }),
        m('br'),
        m('button', { onclick: submit }, 'Submit'),
      ])
    }
  }
}
  1. Here we create new view state variable discount. It's generally a good idea to attach all your model data to the top-level component, and then pass down data to child components as necessary.
  2. Here is another example of nesting a component. This time we pass two attributes. Note how the Total component's view does not need to know where vnode.attrs.count originates from.

If you're coding along, refresh the page and visit the new entry form. You should see the total update as you add and remove attendees.

4. The Coupon Component

Lastly, let's implement the Coupon component. As mentioned in part 1, this tutorial will not cover server-side code. Instead, we will hard code some logic and take comment on what would normally happen on the server side.

This component will be the most complicated one we've implemented so far. It needs to do the following:

- Allow the user to type in the coupon code
- Validate the user's coupon code on coupon form submit
  - If valid, then discount the total amount
  - If invalid, then display an error

To accomplish this, we will take the following approach:

  I. Implement the Coupon state & actions
 II. Implement the Coupon view
III. Include Coupon in the VolunteerForm component  
 IV. Add error handling for invalid coupons
  V. Show coupon discount in Total component view

I. The Coupon State

Let's first take a look at the state for the Coupon component:

// src/components/Coupon.js
window.Coupon = function () {  
  var code = '' // 1. //
  function submit (onSuccess) { // 2. //
    e.preventDefault()
    validateCoupon(code) // 3. //
      .then(function (discount) { // 4. //
        alert('Coupon applied!')
        code = ''
        onSuccess(discount)
      })
  }
  // return {...[truncated]...}
}
  1. code is our view-state; it's where we will store the coupon string typed in by the user.
  2. submit is the function that will run when the user submits the coupon code form (you'll see the form in the next subsection).
  3. validateCoupon is the function that validates the user's coupon code against the server via AJAX. However, in our case this function will mock the AJAX request instead of hitting a real server. We will see the implementation of this function soon.
  4. Another thing we expect validateCoupon to do is return a promise. When this promise resolves, we inform the user the coupon is valid, reset the input field, and call onSuccess with the new discount.

Note that onSuccess will come from the parent component (we will see how shortly). Because of this, we can say Coupon is a callback-style component.

Now let's look at the implementation of validateCoupon:

// src/components/Coupon.js
function validateCoupon (code) {  
  var isValid = (code === 'happy')
  var discount = 0.20
  // Mock AJAX promise
  if (isValid) return Promise.resolve(discount)
  else         return Promise.reject('invalid_code')
}
  1. As you can see, we hardcode the validation in the first line of this function. In a real application, this logic (and the discount amount) would be hidden on the server.

  2. Here we mock an AJAX request by using the built-in JS promise helpers. If the coupon is valid, we resolve with the discount amount. If not, we reject with an error message. The key point to mocking is to resolve and reject with values that would normally come from a server.

  3. Finally, we return the promise so that other code (such as the submit action) can call .then(), just as if it were a real async AJAX request.

In general, mocking allows us to simulate an AJAX request without actually touching a server. This allows us to continue developing as if we did have a server, postponing the work of writing one until later (excellent for prototyping).

II. The Coupon View

The view is more of the same, but with a slight twist:

// src/components/Coupon.js
window.Coupon = function () {  
  // ...[truncated] The stuff we just wrote
  return {
    view(vnode) {
      var {onSuccess} = vnode.attrs
      return m('form', {
        onsubmit(e) {
          e.preventDefault()
          submit(onSuccess) // ~-~Twist!~-~
        }
      }, [
        m('label', "Enter coupon (if you have one):"),
        m('input[type=text]', {
          value: code,
          onchange(e) {
            code = e.target.value
          }
        }),
        m('button[type=submit]', "Validate coupon")
      ])
    }
  }
}

Here we handle the submit form event. First we run e.preventDefault() to prevent the page from refreshing. Then we run submit(onSuccess) to start the coupon submit process. The twist is how we pass onSuccess from vnode.attrs to the submit action!

By the time the user submits, code will contain the user's text input due to the two-way data binding between it and the input field.

III. Nesting Coupon into EntryForm

Revisiting the EntryForm view, we can now nest the Coupon component:

// src/components/EntryForm.js
// ...
view() {  
  return m('.entry-form', [
    m('h1', "New Entry"),
    m('h3', "Please enter each volunteer's contact information:"),

    m(Volunteers, {
      add: add,
      remove: remove,
      volunteers: entry.volunteers
    }),

    m(Total, {
      count: entry.volunteers.length,
      discount: discount
    }),

    // New!
    m(Coupon, {
      onSuccess(newDiscount) {
        discount = newDiscount
      }
    }),

    m('br'),
    m('button', { onclick: submit }, 'Submit'),
  ])
}

Clean! The onSuccess callback we pass to Coupon updates the value of discount. Afterwards, Mithril will redraw, passing the new value of discount to the the Total component to the correct state!

If you're coding along, refresh the page and take note of the total amount. Enter happy into the coupon code form and click the button. The total changes! Try adding and removing attendees. The discount still applies! :)

IV. Error handling for invalid coupons

The happy path works perfectly. However, we still need to give visual feedback when the user types in an invalid code. Fortunately, Mithril will make this easy.

Let's go back to the Coupon component and add a bit of view state:

window.Coupon = function () {  
  var code = ''
  var error = null // 1. //

  function submit = function () {
    error = null // 2. //

    validateCoupon(code)
      .then(/* ...[truncated]... */)
      .catch(function(err) { // 3. //
        ctrl.error = err
      })
  }
}
  1. An error message is view state. Here we initialize it with null, indicating there is currently no error. The view will use this error variable to determine whether or not to display error information.

  2. On submit, we always clear the error. This will hide any previous error message that might be on the page.

  3. Here we added error as a parameter to .then(). As it turns out, .then() can take two parameters: a success callback, and a failure callback. In this case, any reject error value from validateCoupon will feed into our failure callback, updating the value of ctrl.error.

Ok, now let's use our new error variable within our view:

// ...
view(vnode) {  
  var {onSuccess} = vnode.attrs
  return m('form', {
    onsubmit(e) {
      e.preventDefault()
      submit(onSuccess)
    }
  }, [
    error && m('.error', error),
    // ...[truncated]...
  ])
}

If you're coding along, refresh the page, type in an invalid coupon code, and submit. See the error message? Now try submitting happy. Cool! Not only does the discount apply, but the error message also disappears :)

V. Showing the discounted amount

The last thing we will do (in part 2) is show the user how much money they're "saving" with their coupon. Intuitively, we will do this in the Total component's view. In fact, we won't have to touch the Coupon component at all :)

Let's revisit the Total component. First, a minor refactor to calculatePrice:

function calculatePrice (discount, count) {  
  var total = calculateTotal(count)
  return roundCents(total - total * discount)
}
function calculateTotal (count) {  
  return count * PRICE_PER_COUNT
}

Here we pull out the logic for calculating the total into its own function. It's not bad to repeat yourself, but business logic should almost always be DRY.

Next, let's write a helper function for our discount view:

function discountView (count, discount) {  
  var total = calculateTotal(count)
  var discountedAmount = total * discount
  return m('span', "(Coupon discount: -$" + discountedAmount + ")")
}

Lastly, use this helper in the main view:

window.Total = {  
  view(vnode) {
    var {count, discount} = vnode.attrs
    return m('.total', [
      m('label', "Total: "),
      discount > 0 &&
        discountView(count, discount),
      m('b', "$" + calculatePrice(discount, count))
    ])
  }
}
// ...

Notice how I indent discountView. This small trick makes && conditionals a bit more readable.

If you're coding along, refresh the page, enter happy as a coupon code, and try adding/removing volunteers. You should now see the discounted amount appear and update automatically :)

Conclusion

We have written four components in a top-down component architecture. EntryForm is the top-level parent component that has three child components:

  • Volunteers, a stateless, callback-style component. It receives callbacks to manipulate data from its parent.
  • Total, a plain stateless component. It displays the total price information and has no state of its own.
  • Coupon, another stateless, callback-style component. It handles validation and runs a parent-provided callback when validation passes.

Because EntryForm is the top-level component, it holds all the state and model data, passing them down to its children as necessary.

At the end of the day, Mithril's component features gives us a lot of flexibility for organizing and gluing together our code. Used properly, we can achieve succinct code that is easy to reason about, yet still accomplishes a great deal of functionality.

In part 3 (not yet released), we will handle submission and validation of the entire form, as well as handle some new user stories that involve changing the UX. Until next time!

Further Reading

Gilbert

Gilbert

https://github.com/mindeavor

Instructor, developer, writer.

View Comments
Navigation