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 component features. 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 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 by the controller and view.

Let's create a new Volunteers component that "imports" its contacts data from these extra options, beginning with the controller:

// src/components/Volunteers.js
window.Volunteers = {}

Volunteers.controller = function (attrs) {  
  var ctrl = this

  ctrl.add = function () {
    attrs.volunteers.push( Entry.volunteerVM() )
  }
  ctrl.remove = function (idx) {
    attrs.volunteers.splice(idx, 1)
  }
}

This code has been taken from EntryForm and slightly modified. The main point to understand is that now we expect a new parameter attrs, which is the object the parent component (EntryForm) will send down (you will see what this looks like in the next section).

Second, instead of creating our array of contacts, we receive it from the parent via attrs.volunteers. This is what we are manipulating in our add and remove controller actions.

Notice how this component is mutating options.volunteers directly. Because of this, we can say this is a mutative component; it mutates the data of its parent. This is by no means the only way to design a component; you will see other types later in the post.

Extracting the View

Now for the Volunteers view. This is also extracted from the EntryForm component, slightly modified to work with attrs.volunteers instead of ctrl.volunteers:

Volunteers.view = function (ctrl, attrs) {  
  return m('.volunteers', [

    attrs.volunteers.map(function(volunteer, idx) {
      // Same as before
    }),

    m('button', { onclick: ctrl.add }, 'Add another volunteer'),
  ])
}

function removeAnchor (ctrl, attrs, idx) {  
  if (attrs.volunteers.length >= 2) {
    return m('button', { onclick: ctrl.remove.papp(idx) }, 'remove')
  }
}

You should end up with a new component that does almost everything the EntryForm component, minus the logic to submit and create a new Entry. More accurately, this component has the sole responsibility of managing the volunteers for a new Entry.

Another 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 (ctrl) {  
  return m('.entry-form', [
    m('h1', "Entry Form"),
    m('h3', "Please enter each volunteer's contact information:"),

    m.component(Volunteers, { volunteers: ctrl.entry.volunteers }),

    m('button', { onclick: ctrl.submit }, 'Submit')
  ])
}
// Don't forget to delete removeAnchor!

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

Notice also how we're passing ctrl.entry.volunteers to the child Volunteers component. Any time you use a top-down component architecture like this, I strongly recommend storing / initializing all model data in the top-level component, and passing it down to nested child components as needed.

For completeness, here's the controller:

EntryForm.controller = function () {  
  var ctrl = this
  ctrl.entry = Entry.vm()

  ctrl.submit = function () {
    Entry.create( ctrl.entry )
    m.route('/')
  }
}

The only change is we've removed the logic for adding/removing volunteers. As a result, we have a much slimmer controller. 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
Total.view = function (ctrl, attrs) {  
  return m('.total', [
    m('label', "Total: "),
    m('b', "$" + Total.calcPrice(attrs.discount, attrs.count))
  ])
}

Here Total.calcPrice (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).

Also note that we don't use the ctrl variable. That's because, just like EntryList, there is no controller:

// src/components/Total.js
window.Total = {}

/* Model-level logic */
Total.pricePerCount = 10  
Total.calcPrice = function (discount, count) {  
  var total = count * Total.pricePerCount
  return roundCents(total - total * discount)
}

/* View */
Total.view = function (ctrl, attrs) {  
  // [clipped; same as before]
}

/* Helpers */
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 (Total.calcPrice). Of course, if this file starts getting 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. To do so, there are two tasks to complete:

  1. Add a discount property to the EntryForm controller
  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
EntryForm.controller = function () {  
  var ctrl = this
  ctrl.entry = Entry.vm()
  ctrl.discount = 0 /*1*/

  ctrl.submit = function () {
    Entry.create( ctrl.entry )
    m.route('/')
  }
}

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

    m.component(Volunteers, { volunteers: ctrl.entry.volunteers }),

    m.component(Total, { /*2*/
      count: ctrl.entry.volunteers.length,
      discount: ctrl.discount
    }),

    m('button', { onclick: ctrl.submit }, 'Submit')
  ])
}
  1. Here we attach a new property discount to the controller. As previously mentioned, it's 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 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 note of what would normally go 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 controller
 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 Controller

Let's first take a look at the Coupon controller:

// src/components/Coupon.js
Coupon.controller = function (attrs) {  
  var ctrl = this
  ctrl.code = "" /* 1 */
  ctrl.submit = function (e) { /* 2 */
    e.preventDefault()

    validateCoupon(ctrl.code) /* 3 */
      .then(function(discount) { /* 4 */
        alert('Coupon applied!')
        ctrl.code = ""
        attrs.onSuccess(discount)
      })
  }
}
  1. ctrl.code is our view-state; it's where we will store the coupon string typed in by the user.
  2. This 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 attrs.onSuccess with the new discount.

Note that attrs.onSuccess will come from the parent component. 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') /* 1 */
  var discount = 0.20
  // Mock AJAX promise
  var deferred = m.deferred() /* 2 */
  if (isValid) { deferred.resolve(discount) }
  else         { deferred.reject('invalid_code') }
  return deferred.promise /* 3 */
}
  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 are mocking an AJAX request in promise form by using m.deferred. 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 Coupon controller) can call .then() as necessary.

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

As it turns out, the view is quite straightforward:

// src/components/Coupon.js
Coupon.view = function (ctrl) {  
  return m('form', { onsubmit: ctrl.submit }, [
    m('label', "Enter coupon (if you have one):"),
    m('input[type=text]', {
      value: ctrl.code,
      onchange: function(e) {
        ctrl.code = e.currentTarget.value
      }
    }),
    m('button[type=submit]', "Validate coupon")
  ])
}

The one new thing we haven't seen before is binding a controller action to a form submit event. Assigning ctrl.submit to onsubmit: will run ctrl.submit when the user submits this form.

By the time the user submits, ctrl.code will contain the user's 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
EntryForm.view = function (ctrl) {  
  return m('.entry-form', [
    m('h1', "Entry Form"),
    m('h3', "Please enter each volunteer's contact information:"),

    m.component(Volunteers, { volunteers: ctrl.entry.volunteers }),

    m.component(Total, { /*2*/
      count: ctrl.entry.volunteers.length,
      discount: ctrl.discount
    }),

    m.component(Coupon, {
      onSuccess: function(newDiscount) {
        ctrl.discount = newDiscount
      }
    }),

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

More excitement! The onSuccess callback we pass to Coupon updates the value of ctrl.discount, gracefully and indirectly updating the discount passed to the Total component when Mithril redraws!

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 changed! Try adding and removing attendees. The discount still applies! :D

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's controller+view combo makes this easy.

Let's go back to the Coupon controller and add a dash of code:

Coupon.controller = function (options) {  
  var ctrl = this
  ctrl.code = ''
  ctrl.error = null /* 1 */

  ctrl.submit = function (e) {
    e.preventDefault()
    ctrl.error = null /* 2 */

    validateCoupon(ctrl.code())
      .then(/* [clipped; same as before] */)
      .then(null, function(err) {
        ctrl.error = err /*3*/
      })
  }
}
  1. An error message is also considered view-state; here is where we store it. The view will use this ctrl.error to determine whether or not to display error information. Note how we initialize with null, indicating that we begin with no error.

  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 ctrl.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.

That wasn't so bad. Now let's revisit the Coupon view:

Coupon.view = function (ctrl) {  
  return m('form.coupon', { onsubmit: ctrl.submit }, [
    ctrl.error ? [
      m('.error', "Invalid coupon.")
    ] : null,
    m('label', "Enter coupon (if you have one):"),
    /* [clipped; rest is same as before] */
  ])
}

Do you see the ternary? The pattern condition ? [content] : null, is a good practice for showing/hiding content based on state. There are several advantages to doing it this way: the conditional comes first, the content array can contain multiple elements, and moving the pattern block up/down won't cause any comma-related syntax errors.

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 view:

// src/components/Total.js
/* Model-level logic */
Total.calcDiscount = function (discount, count) {  
  var total = count * Total.pricePerCount
  return roundCents(total * discount)
}

/* View */
Total.view = function (ctrl, attrs) {  
  return m('.total', [
    m('label', "Total: "),
    discountView(ctrl, attrs), /*2*/
    m('b', "$" + Total.calcPrice(attrs.discount, attrs.count))
  ])
}

/* Helpers */
function discountView (ctrl, attrs) {  
  if (attrs.discount > 0) {
    var discountedAmount =
      Total.calcDiscount(attrs.discount, attrs.count)
    return m('span', "(Coupon discount: -$" + discountedAmount + ")")
  }
}
  1. First, we create another model-level method to avoid hardcoding any business logic in our view.

  2. Second, we include a helper view to keep our main view clean. It's common practice to pass both the controller and attributes to helper views.

  3. Within the helper view, we return a span containing the discounted amount, but only if a discount is present.

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:

  • Contacts, a mutative component. It receives an m.prop containing an array of contacts from its parent.
  • Total, an almost-stateless component. It displays the total price information and has no controller.
  • Coupon, a callback component. It handles validation and runs a parent-provided callback when validation passes.

Because EntryForm is the top-level component, it holds all the important model data and passes it down to its children as needed.

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