While I’m not a big fan of form-based interfaces, it’s sometimes the best way to get basic object information into a system. Once there, manipulating objects via a more intuitive UX is the target. But forms live on in systems as a convenient, time-tested way to capture user input.
Meteor puts a different twist on the way interfaces are built - reactivity is what’s at the heart of Meteor interfaces. Instead of getting the data and building the interface from it, the interface is built from reactive templates that re-render when the data they depend on changes. This allows a developer to simply set things up and let them work rather than writing code to check for changes and update the interface. Quite a significant difference to the art of building web applications!
What’s in a Form?
A form is pretty simple. It’s a set of editable controls with a save or cancel button, usually at the bottom. The controls can be fairly sophisticated, but generally they’re not. The sophisticated ones have dependencies between the values being collected so that changing one might somehow change another. There are also validations that can be set up to make sure only acceptable values can be entered. Between validation and dependency, I can make sure forms collect ‘legal’ and ‘consistent’ values.
But these are bigger fish - I’m going to focus on simply getting information and saving it. I’m going to use Bootstrap3 and Coffeescript in the examples to follow.
I’ll create form that takes the logged in user’s identity as their first and last name and store it into the database.
client/identity.html
a1 <template name='identity'>
a2 <div id='identityForm'>
a3 {{> identityForm}}
a4 </div>
a5 </template>
a6
a7 <template name='identityForm'>
a8 {{#with person}}
a9 <form class='form-horizontal' role='identity-form'>
a10 <div class="form-group">
a11 <label for="name" class="col-sm-2 control-label">Name</label>
a12 <div class="col-sm-2">
a13 <input type="text" class="form-control formitem" id="firstName"
a14 placeholder="first" value="{{firstName}}">
a15 </div>
a16 <div class="col-sm-4">
a17 <input type="text" class="form-control formitem" id="lastName"
a18 placeholder="last" value="{{lastName}}">
a19 </div>
a20 </div>
a21 {{> updateButtons}}
a22 </form>
a23 {{/with}}
a24 </template>
The form page is simple enough. It starts with a template wrapper (a1) that is accessed by the router, and calls out (a3) to the identity form template (a7). The form pulls uses the person (a8) pulled from the coffeescript (a44) to display the person’s first name (a14) and last name (a18). At the end of the form, the form calls out (a21) to the update buttons template (a25).
client/common.html
a25 <template name="updateButtons">
a26 {{#if formitemChanged}}
a27 <div class="form-group">
a28 <div class="col-sm-offset-2 col-sm-4">
a29 <button type="submit" class="btn btn-default form-done">Save</button>
a30 <button class="btn form-done cancel">Cancel</button>
a31 </div>
a32 </div>
a33 {{/if}}
a34 </template>
The update buttons template can potentially be used by many forms, so it’s separated out. Basically, if the any form item has changed (a26) a Save button (a29) and a Cancel button (a30) are shown. If no form item has changed, they stay hidden.
client/common.coffee
a35 window.Events = {}
a36
a37 Events.handleNaturally = (e) ->
a38 e.preventDefault()
a39 e.stopPropagation()
a40
a41 Template.updateButtons.created = -> Session.set 'formitemChanged', false
a42
a43 Template.updateButtons.formitemChanged = -> Session.get 'formitemChanged'
The common code is separated out like the common interface. Here there is some event management code (a37) that is typically used by event handlers, set up in a namespace (a35) to keep everything neat and tidy.
The update button management is found here, too. When the update buttons are created (a41) whenever the form renders, the change state is cleared and stored in the Session. This value is read from the Session (a43) and used during rendering the buttons (a26).
client/identity.coffee
a44 Template.identityForm.person = ->
a45 person = Meteor.People.findOne { personId: Meteor.userId() }
a46 person ? {}
a47
a48 Template.identityForm.events
a49
a50 'submit form': (e) ->
a51 Events.handleNaturally e
a52 firstName = $('#firstName').val()
a53 lastName = $('#lastName').val()
a54 Meteor.call('setPersonIdentity',firstName,lastName)
a55 Session.set 'formitemChanged', false
a56
a57 'click .cancel': (e) ->
a58 Events.handleNaturally e
a59 form = $('#identityForm')
a60 form.children().remove()
a61 UI.insert(UI.render(Template.identityForm),form[0])
a62 Session.set 'formitemChanged', false
a63
a64 'keypress, change .formitem': (e) ->
a65 Session.set 'formitemChanged', true
The code that handles the identity form is simple. The person that the form is targeting (a8) is determined by a reactive database search (a45), or given by empty values (a46) the first time around.
The rest of the code is event handling. A click on the Save button (a29) triggers the submit event on the form (a50) which pulls the first name (a52) and last name (a53) from the form and calls the server (a54) to set the value in the database. Changes are then reset (a55) to hide the update buttons (a55).
Clicking the Cancel button (a30) means all changed values should be discarded and reset to the original values. This is done by capturing the form (a59) removing its child elements (a60) and re-rendering it (a61). Changes are also reset (a62) to hide the update buttons (a55).
Finally, when a key is pressed on a form item, or the value of the form item is changed, the event handler (a64) is triggered and marks the form as changed (a65) to display the update buttons (a55).
lib/collections.coffee
a66 Meteor.People = new Meteor.Collection('people')
None of this could be done without the people collection in the database. I declare it (a66) in a shared file used by the client and the server.
server/identity.coffee
a67 Meteor.methods
a68
a69 setPersonIdentity: (firstName, lastName) ->
a70 userId = Meteor.userId()
a71 selector =
a72 userId: userId
a73 modifier =
a74 userId: userId
a75 firstName: firstName
a76 lastName: lastName
a77 Meteor.People.upsert selector, modifier
When I want to save the person into the database (a54) it’s the method on the server (a69) that gets called. It’s Mongo underneath the covers here, and I form a selector (a71) and modifier (a73) to pass to the upsert command (a77) which will create or update the specified record.
router/config.coffee
a78 Router.map ->
a79 @route 'home', path: '/'
a80 @route 'identity, path: 'identity'
Finally, IronRouter is used to declare routes for the application. The identity route is set up to display the identity page (a80).
Of course, I’ve left out a few parts of the app, but this demonstrates the meat of the code fairly accurately. The big idea is that everything is driven by reactivity and user interaction.
-
When the person is pushed to the client from the server, the values are rendered into the form.
-
When items on the form have changed, the client-local reactivity affect the visibility of the update buttons.
Relieving the developer of these issues makes programming much simpler. Submitting changes to the server on commital or rerendering the form with original values on cancellation is an easy way to think about what’s going on. It’s all very close to the page, I’m thinking in terms of rendering templates and making simple responses to events, instead of being concerned with managing elements through a higher level interface.
(Note that there are high level form abstractions for Meteor like Dobbertin’s Autoform or just form validations like Copley’s Mesosphere. However, these require some level of detailing the mongo collection schema which has less to do with reactivity and more to do with building manageable systems, so they’ll just get a mention here.)
Dropping Autopublish
When developing a project in Meteor, the autopublish package is part of the app by default. This synchronizes the database between the client and the server. The benefit of this is that developers can concentrate on the logic of event handling and display rather than considering the transmission of which database entries are accessible.
At some point, a switch to the publish/subscribe model should be made and the autopublish package should be dropped. So when that bold step is taken:
$ meteor remove autopublish
all the data in the app disappears. To get it back, the client must subscribe to the database values it cares about, and the server must publish those values to the client.
Fortunately, this is easy.
router/config.coffee
b1 Router.map ->
b2 @route 'home', path: '/'
b3 @route 'identity, path: 'identity'
b4 waitOn: -> [ Meteor.subscribe 'identity' ]
In the router, I create a subscription (b4) to ‘identity’ that effectively states the the identity route needs the data pushed by the identity publisher on the server (b5).
server/identity.coffee
b5 Meteor.publish 'identity', ->
b6 Meteor.People.find { userId: @userId }
Now, instead of pushing all the entities in the people database to the client, only the one with the matching user id will be pushed.
And since I’m about to deal with a little more data, this will be important.
Adding Location
Now I’ll do something that get’s a little tricky to do reactively.
I want to add a Country/State/City selector that changes reactively - that is, one that’s a little bit sophisticated. The state depends on the country and the city depends on the state. Note also that it’s not really called Country/State/City in the interface - it might be Country/Province/City if Canada is selected, for instance. But I’m talking about three levels of cascaded location.
Additionally, I want to use Moreto’s Bootstrap-Select package so I get nice looking pulldowns that match the rest of the bootstrap interface. This may seem innocent, but it forces a whole extra level of interface control that strain’s the reactive mechanisms considerably.
client/identity.html
c1 <template name='identity'>
c2 <div id='identityForm'>
c3 {{> identityForm}}
c4 </div>
c5 </template>
c6
c7 <template name='identityForm'>
c8 {{#with person}}
c9 <form class='form-horizontal' role='identity-form'>
c10 <div class="form-group">
c11 <label for="name" class="col-sm-2 control-label">Name</label>
c12 <div class="col-sm-2">
c13 <input type="text" class="form-control formitem" id="firstName"
c14 placeholder="first" value="{{firstName}}">
c15 </div>
c16 <div class="col-sm-4">
c17 <input type="text" class="form-control formitem" id="lastName"
c18 placeholder="last" value="{{lastName}}">
c19 </div>
c20 </div>
c21 <div class="form-group">
c22 <label for="location" class="col-sm-2 control-label">Location</label>
c23 <div class="row-fluid">
c24 <span id="countriesSelect" class="col-sm-3">
c25 {{> countriesSelect}}
c26 </span>
c27 <span id="statesSelect" class="col-sm-3">
c28 {{> statesSelect}}
c29 </span>
c30 <span id="citiesSelect" class="col-sm-3">
c31 {{> citiesSelect}}
c32 </span>
c33 </div>
c34 </div>
c35 {{> updateButtons}}
c36 </form>
c37 {{/with}}
c38 </template>
c39
c40 <template name="countriesSelect">
c41 <select id="countries" class="selectpicker changeitem">
c42 {{> countriesSelectOptions}}
c43 </select>
c44 </template>
c45
c46 <template name="countriesSelectOptions">
c47 {{#each options}}
c48 <option>{{this}}</option>
c49 {{/each}}
c50 </template>
c51
c52 <template name="statesSelect">
c53 <select id="states" class="selectpicker changeitem">
c54 {{> statesSelectOptions}}
c55 </select>
c56 </template>
c57
c58 <template name="statesSelectOptions">
c59 {{#each options}}
c60 <option>{{this}}</option>
c61 {{/each}}
c62 </template>
c63
c64 <template name="citiesSelect">
c65 <select id="cities" class="selectpicker changeitem">
c66 {{> citiesSelectOptions}}
c67 </select>
c68 </template>
c69
c70 <template name="citiesSelectOptions">
c71 {{#each options}}
c72 <option>{{this}}</option>
c73 {{/each}}
c74 </template>
I introduce the three new select elements - one for countries (c40), one for states(c52), and one for cities (c64). The options for these selects are in their own templates(c46, c58, c70) - so each set of options can be rendered separately from its corresponsing select.
client/identity.coffee
c75 Template.identityForm.person = ->
c76 person = Meteor.People.findOne { personId: Meteor.userId() }
c77 person ? {}
c78
c79 Template.identityForm.events
c80
c81 'submit form': (e) ->
c82 Events.handleNaturally e
c83 firstName = $('#firstName').val()
c84 lastName = $('#lastName').val()
c85 country = $('#countries').val()
c86 state = $('#states').val()
c87 city = $('#cities').val()
c88 Meteor.call('setPersonIdentity',firstName,lastName,country,state,city)
c89 Session.set 'formitemChanged', false
c90
c91 'click .cancel': (e) ->
c92 Events.handleNaturally e
c93 form = $('#identityForm')
c94 form.children().remove()
c95 UI.insert(UI.render(Template.identityForm),form[0])
c96 Session.set 'formitemChanged', false
c97
c98 'keypress, change .formitem': (e) ->
c99 Session.set 'formitemChanged', true
c100
c101 'change .changeitem': (e) ->
c102 Session.set 'formitemChanged', true
c103
c104 'change #countries': (e) ->
c105 Template.statesSelect.change()
c106
c107 'change #states': (e) ->
c108 Template.citiesSelect.change()
c109
c110 Deps.autorun ->
c111 Meteor.subscribe("locations", ->
c112 Template.countriesSelect.updateUI())
c113
c114 Template.countriesSelect.updateUI = ->
c115 ui = $('#countriesSelect select')
c116 if ui.length > 0
c117 options = $('option',ui)
c118 if options.length > 0
c119 options.remove()
c120 UI.insert(UI.render(Template.countriesSelectOptions),ui[0])
c121
c122 Template.countriesSelectOptions.helpers
c123 options: ->
c124 countries = Meteor.Lookups.findOne { name: 'location_countries' }
c125 if countries
c126 countries.values.split '|'
c127 else
c128 [ ]
c129
c130 Template.countriesSelectOptions.rendered = ->
c131 changed = Session.get 'formitemChanged'
c132 $('#countries.selectpicker').selectpicker()
c133 $('#countries.selectpicker').selectpicker("refresh")
c134 $('#countries.selectpicker').selectpicker('val',Template.identityForm.person().country)
c135 Session.set 'formitemChanged', changed
c136 Template.statesSelect.change()
c137
c138 Template.statesSelect.change = ->
c139 if country = $('#countries').val()
c140 Meteor.subscribe("locations",country, -> Template.statesSelect.updateUI())
c141
c142 Template.statesSelect.updateUI = ->
c143 ui = $('#statesSelect select')
c144 options = $('option',ui)
c145 if options.length > 0
c146 options.remove()
c147 UI.insert(UI.render(Template.statesSelectOptions),ui[0])
c148
c149 Template.statesSelectOptions.helpers
c150 options: ->
c151 country = $('#countries').val()
c152 states = Meteor.Lookups.findOne { name: "location_#{country}_states" }
c153 if country and states
c154 states.values.split '|'
c155 else
c156 [ ]
c157
c158 Template.statesSelectOptions.rendered = ->
c159 changed = Session.get 'formitemChanged'
c160 $('#states.selectpicker').selectpicker()
c161 $('#states.selectpicker').selectpicker("refresh")
c162 $('#states.selectpicker').selectpicker('val',Template.identityForm.person().state)
c163 Session.set 'formitemChanged', changed
c164 Template.citiesSelect.change()
c165
c166 Template.citiesSelect.change = ->
c167 country = $('#countries').val()
c168 state = $('#states').val()
c169 if country && state
c170 Meteor.subscribe("locations",country,state, -> Template.citiesSelect.updateUI())
c171
c172 Template.citiesSelect.updateUI = ->
c173 ui = $('#citiesSelect select')
c174 options = $('option',ui)
c175 if options.length > 0
c176 options.remove()
c177 UI.insert(UI.render(Template.citiesSelectOptions),ui[0])
c178
c179 Template.citiesSelectOptions.helpers
c180 options: ->
c181 country = $('#countries').val()
c182 state = $('#states').val()
c183 cities = Meteor.Lookups.findOne { name: "location_#{country}_#{state}_cities" }
c184 if country && state && cities
c185 cities.values.split '|'
c186 else
c187 [ ]
c188
c189 Template.citiesSelectOptions.rendered = ->
c190 changed = Session.get 'formitemChanged'
c191 $('#cities.selectpicker').selectpicker()
c192 $('#cities.selectpicker').selectpicker("refresh")
c193 $('#cities.selectpicker').selectpicker('val',Template.identityForm.person().city)
c194 Session.set 'formitemChanged', changed
When considering the code that drives the selects, things get more complicated.
First the triggers. Responding to events drives the cascade process. When a change item changes (c101) the form item change is set to turn on the update buttons. When a new country is chosen (c104), we make the state change. When a new state is chosen (c107), we make the city change. Finally, a subscription to locations is created to start the cascade when ithey are published.
Next the changers. Changing the level in the cascade above has to subscribe to a different location and trigger user interfaces updates for the selectors. To change the state (c138) I subscribe to the selected country and update the state selector when the states for that country are published (c140). To change the city (c166) I subscribe to the selected country and state and update the city selector when the cities for that country and state are published (c169).
Now the user interface updaters. When the user interface for the selectors is updated, the options in a particular select are replaced by rerendering option templates. To change the country select (c114) a check is made to see if it’s in the DOM yet (c116) and if so, any existing options are removed (c119) and the country select options are rendered and inserted into the select (c120). Getting the country options is done in a helper (c123) that finds the countries that are available (c124) and returns them in an array (c126). To change the state select (c142) any existing options are removed (c146) and the state select options are rendered and inserted into the select (c147). Getting the state options is done in a helper (c123) that finds the states that are available for the chosen country (c152) and returns them in an array (c154). To change the city select (c172) any existing options are removed (c176) and the city select options are rendered and inserted into the select (c177). Getting the city options is done in a helper (c180) that finds the cities that are avialable for the chosen country and state (c183) and returns them in an array (c185).
Finally, the resonders to rendering. This last bit is what sycronizes the bootstrap-selects to the selects through the manipulation of selectpickers. for countries, states and cities, selectpickers are initialized (c132, c160, c191), refreshed (c133, c161, c192) and the person’s country, state or city is selected (c134, c162, c193). Then for the country and state, we fired the cascade for the next level down (c136, c164). Care is taken to preserve change due to initialization versus change due to user interaction.
Admitedly, this code is more than a bit repetitive and should be refactored, but I’m trying to make sure it’s clear what’s going on. Note that the code is reactive - the response to changes is updated subscriptions that cause new rendering, not direct manipulation of the user interface.
lib/collections.coffee
c195 Meteor.Lookups = new Meteor.Collection("lookups")
The data itself for the country, states and cities is stored in what I’ve traditionally called lookup tables. The idea is that I don’t want to hard code it because it might rarely change, but I’m not going to change the values within the course of running this system. They’re values that can be reliably looked up.
server/identity.coffee
c196 Meteor.publish 'locations', (country, state) ->
c197 Meteor.Lookups.find { name: ($in: [ "location_countries",
c198 "location_#{country}_states",
c199 "location_#{country}_#{state}_cities" ] ) }
When I publish locations, I’m really just publishing a few lookup table entries. Note that if country or state isn’t specified, then the corresponding keys won’t be found (c111, c140, c170). This is all designed to pull the correct values when required.
server/db_seed.coffee
c200 Meteor.startup ->
c201 assertLookups()
c202
c203 assertLookups = ->
c204 if Meteor.Lookups.find().count() == 0
c205 Meteor.Lookups.insert name: 'location_countries', values: 'United States'
c206 Meteor.Lookups.insert name: 'location_United States_states',
c207 values: 'Alabama|Alaska|Arizona| . . . |Wisconsin|Wyoming'
c208 Meteor.Lookups.insert name: 'location_United States_Alabama_cities',
c209 values: 'Abernant|Alabaster| . . . |Wetumpka'
c210 . . .
Finally, like all good Meteor programs, the existence of all needed data on the server is asserted when you start. In this case, the lookup table needs to include a list of countries, a list of states for each country, and a list of each city for each country-state combination. In this case I’m just including the United States, its fifty states, and cities with populations over 8000 people. I left off a lot of data, but you get the idea.
Thoughts
Cascading data turns out to be a non-trivial bit of code when it needs to work with specialized components in a chosen interface framework (in this case Bootstrap-select in Bootstrap). It’s clearly doable, but it feels like more work than it should be. In fact, the chain of “triggered change of subscriptions to updates and interface cleanup” has pretty much become a reactive pattern for me when using components that rely on unobtrusive javascript and setup calls to implement sophisticated interactions.
Doing forms reactively is certainly a different approach than what I did before using Meteor, but it does keep methods short and simple despite the number of moving parts. But once you’re done with them and the data has been entered into the system, you can use the kind of social reactivity that Meteor was made to shine for.