jsnumw
Last Updated: February 25, 2016
·
890
· zinkkrysty

How I made a MultiView toggle class in Coffeescript with TDD

I was in misery earlier today when I went into the code for a dynamic view I made some while ago.

Some background info:

The behavior of the view is that it toggles different elements depending on which "step" it is, something like a wizard, but with no consistent actions/buttons.

Its flow can be described like this:

  1. The first "view" consists of a form that users can submit to calculate a monthly price
  2. After they click submit, the view changes to display the result and to show a new call to action, that is to give their contact info if they want further information
  3. Upon agreeing to that, the view keeps the calculation result, but the call to action dissapears and it is replaced by a form to enter name, email and telephone.
  4. Finally when the user has submitted that, the whole view changes to show one line of thank you text.

As you can see, some elements are kept from the previous views, but others are replaced. Managing this in a class that does all other kinds of stuff (like submitting and getting the values, etc) is a pain, not to mention managing the mark-up in parallel to the js.

A process and a solution

I knew I could do better, so I started thinking about a solution. I got a notebook and started brainstorming and imagining possible scenarios.

The first idea was to make something like a "wizard", each step comes after the previous one and there was the idea of "triggers", elements that trigger the next step. Also, I had to find a way to deal with callbacks and before/after sorts.

That sounded a bit complicated so I said to myself, this is a perfect time for my first test-driven developed class.

I dived right into jasmine specs, and tried to imagine tests for a non-existent class. My first question popped: "What to test first?". The answer came shortly, that I should start with high-level, use-case type tests. So I went on writing a beforeEach to initialize my object.

beforeEach ->
  window.$container = $('.some-wizard')
  window.myMultiView = new MultiView($container)
  myMultiView.addState('start', true) # The initial state

I realized, to have a better picture of what I'm testing, I should make a fixture, so I came up with a simple one, but covering some of the cases I needed:

%section.some-wizard
  %h1 My page
  %article.start.state-start
    %h2 Please tell your name
    %input{:type => "text"}/
    %p We'll use your name to make some magic
    %a{:href => "#"} Go to next step >
  %article.details
    %h2.state-details Now we're going to give you some info
    %p Based on your name, here's a cool nickname:
    .generated-nickname
    .state-details
      %p Next, you can fight zombies:
      %a{:href => "#"} Yeah, I'm ready! >
  %article.zombie-game.state-zombie-game
    .game-window
    %a{:href => "#"} Game over
  %article.finish.state-finish
    %p You're done!

Then I continued my beforeEach:

myMultiView.addStates(['details', 'zombie-game', 'finish'])

As I was writing the tests, I realized I didn't need all those features I brainstormed about earlier, and I can do without triggers and callbacks, and only use mark-up to connect the views with the "states".

Then the progression would simply be done with a method on my class that accepts the name of the new state.

The solution that I came up with allows for marking elements with multiple states, so I can make my scenario above possible. It is also very flexible and doesn't constrain the progressing in one direction, such as in a wizard.

When implementing the class, I started by outlining the interface (the public methods) and working my way from there.

It was also clearer for me what kind of exceptions could occur so I wrote "pending" tests for them until I completed the basic stuff.

Here's how it looks now, working perfectly for my case, but far from finished:

window.MultiView = class
  constructor: (@$container) ->
    @states = []

  initialize: ->
    @change(@initialState)

  addState: (stateName, isInitial=false) ->
    @states.push stateName
    @makeInitialState(stateName) if isInitial

  addStates: (states) ->
    @states = @states.concat(states)

  change: (newState) ->
    @currentState = newState
    @$container.find(@selectorForAllStates()).hide()
    @elementForState(newState).show()

  # Private methods

  makeInitialState: (state) ->
    @initialState = state

  elementForState: (state) ->
    @$container.find(@elementSelectorForState(state))

  elementSelectorForState: (state) ->
    ".state-#{state}"

  selectorForAllStates: ->
    selectors = []
    for state in @states
      selectors.push @elementSelectorForState(state)
    selectors.join ', '

And here are the tests if you're curious

describe 'MultiView', ->
  beforeEach ->
    fixture.load 'multi_view.html'
    window.$container = $('.some-wizard')
    window.myMultiView = new MultiView($container)
    myMultiView.addState('start', true) # The initial state
    myMultiView.addStates(['details', 'zombie-game', 'finish'])
    myMultiView.initialize()

  describe 'when it is initialized', ->
    it 'shows the elements in initial state', ->
      expect($container.find('.state-start').is(':visible')).toBe(true)
    it "shows the elements in initial state even if it's hidden by CSS", ->
      $el = $container.find('.state-start').css('display', 'none')
      expect($el.is(':visible')).toBe(false)
      myMultiView.initialize()
      expect($el.is(':visible')).toBe(true)
    it 'hides the elements in other states', ->
      expect($container.find('[class^="state-"]:not(.state-start)').is(':visible')).toBe(false)

  describe 'when state changes', ->
    beforeEach ->
      myMultiView.change('details')
    it 'shows new state elements and hides all other states', ->
      expect($container.find('.state-details').is(':visible')).toBe(true)
      expect($container.find('[class^="state-"]:not(.state-details)').is(':visible')).toBe(false)

  describe 'when multiple initial states are created', ->
    it 'keeps the last introduced one'

  # elements of states that exist in the view but not in the config shouldn't be touched

  # If an element belongs to 2 states, and one of them is active, it should be displayed

It worked as a charm the first time I used it in real-code and it felt so good. There's lessons here to be learned, both by me but maybe by others, so that's why I'm posting this.

If you think this code is helpful and you're thinking of using it, let me know so I can make a GitHub project for it, allowing the open-source magic to turn it into a great library.