At the job, we're constantly trying to improve the UX of eLocal.com by making the site behave harder, better, faster, stronger. For some reason, recently we've had a surge in these kind of feature requests from the "peanut gallery" of sales and operations staff who are the primary source of feedback for the application. Most of what I do every day is solving bugs or adding new features to this big monolithic app, which powers the vast majority of our business. It is used by the staff on a daily basis to accomplish all sorts of tasks, from an in-house CRM to an advanced billing system (complete with recurring charges and invoicing), as well as basic control of our paying customers' ZIP code ads and lead dissemination.
All of our components are instantiated with the [jQuery][jq] framework. If you're dealing with the DOM, there's just no substitute. jQuery handles a lot of boilerplate we'd have to build ourselves, and is cross-browser to boot! The major reason we're talking about jQuery here, however, is its minimal plugin architecture.
A simple jQuery plugin written in CoffeeScript might look like:
jQuery.fn.viewAllButton = -> @each (i, element) -> $(element).on 'click', (event) -> container = $(this) button = container.find('button') form = container.closest('form') per_page = button.attr('data-per-page') form.find('input[name="per_page"]').val per_page form.find('input[type="submit"]').click()
Pretty easy to understand. For every element selected by this plugin,
bind a click action that submits the form without its
sent over, effectively returning all pages of all content. Most of the
details are just for the specific implementation on eLocal's admin
Thankfully, [CoffeeScript][cs] is here to save the day. CoffeeScript
build objects. If you can imagine trying to stuff OOP into the concept
relatively common keyword,
class, CoffeeScript gets the job done by
Here's an example of a CoffeeScript plugin that was built to solve a
simple UX problem when dealing with Rails
fields_for associated forms:
jQuery.fn.undoFieldsFor = (pluginOptions) -> @each (i, element) -> new UndoFieldsFor($(element), pluginOptions) class UndoFieldsFor defaults: form: 'form' item: '.line-item' # Assign the `<form>` and `<input>` buttons that we're working with # to properly "undo" changes made to the line items in a form. constructor: (@element, options) -> @options = _.extend @defaults, options @form = $(@options.form) @item = @element.closest(@options.item) @initialize() # Bind a removal event onClick initialize: -> @item.find('input[type=checkbox]').hide() @element.on 'click', @removeItem removeItem: (event) => event.stopPropagation() and event.preventDefault() @item.remove() @_disableSubmission() if @form.find(@options.item).length == 0 _disableSubmission: -> submit = @form.find 'input[type="submit"]' submit.removeAttr "value" submit.val "Disabled" submit.addClass "inactive" submit.attr "disabled", true
Basically, it replaces the
_destroy checkbox on every additional item
with an "UNDO" button. When this button is clicked, the surrounding
line item element is destroyed and Rails will not submit that data as
part of the form, because it's no longer on the page. The plugin takes
advantage of the way Rails associations work, as long as you have an
index number higher than that of its previous index, you're good to go.
_destroy checkbox is really just there for compatibility, as
You can see just in this short example how easy it is to understand a
slightly more complex plugin like
undoFieldsFor. For each selected
jQuery.undoFieldsFor() is applied to, the
checkbox is hidden and replaced with an "Undo" button. The click event
on this button (actually an
<a> link) is bound to a JS action that
destroys the current
.line-item but also disables form submission in
the event that no line items are in the DOM at the time the event is
sumatra tastes exactly how it smells...delicious
You can already see a lot of boilerplate going on in the above
CoffeeScript plugin example. For starters, I always have to do that
little code on line 1 which sets up the jQuery plugin and tells it to
instantiate a helper class every time. Also, because
an object, I need to construct it with an
$(element) jQuery object and
options hash passed into the plugin at time of instantiation.
Wouldn't it be sweet if I didn't have to do this every time? Wouldn't it
be nice if I could use an interface that allowed practically every part
of the object building process to be customized, but in most
conventional setups, would default to some sane settings that could be
used for quickly writing jQuery plugins in a beautiful way.
sumatra 'undoFieldsFor', -> class UndoFieldsFor extends SumatraPlugin action: 'click' defaults: form: 'form' item: '.line-item' # Set up the <form>, .line-item and submit button we'll # be working with, and hide the checkbox from view. initialize: -> @form = $(@options.form) @submit = @form.find 'input[type="submit"]' @item = @element.closest(@options.item) @item.find('input[type=checkbox]').hide() # Remove the surrounding .line-item perform: (event) => event.stopPropagation() and event.preventDefault() @item.remove() @_disableSubmission() unless @_lineItemsExist() # If no more items are left, disable the submit button on this form. _disableSubmission: -> @submit.removeAttr "value" @submit.val "Disabled" @submit.addClass "inactive" @submit.attr "disabled", true # Check if any line items still exist in the form. _lineItemsExist: -> @form.find(@options.item).length
sumatra() is a globally-available function that performs that first line of jQuery from the control example. Since it's written in CoffeeScript, this function also returns the object defined on its last line, which is typically the implementation of the plugin itself. You can name the plugin here, defining how you will call it via jQuery, and that code you pass in as a function is called as the body of the jQuery plugin.
sumatra 'undoFieldsFor', ->
Now we're defining a new class which extends from SumatraPlugin. The object it extends from has some [really awesome time-saving features][sp], but it is purely optional and has no bearing on the plugin you actually make.
class UndoFieldsFor extends SumatraPlugin action: 'click' defaults: form: 'form' item: '.line-item'
In the class itself, we define an action which is the event that
we're listening for, and when fired will cause
perform() to execute.
perform() method is another thing we have to define in the
class itself, as it is the hard-set event handler for the plugin.
Additionally, Sumatra gives us a place to define default option values
options hash that can be passed into any Sumatra plugin. You
can set up this hash in
defaults, which is merged with the passed in
options to build the public
initialize: -> @form = $(@options.form) @submit = @form.find 'input[type="submit"]' @item = @element.closest(@options.item) @item.find('input[type=checkbox]').hide()
While the constructor sets us up with some sane, minimal defaults, the
initialize() method is for code that would normally go in the
constructor. It is bad practice to override SumatraPlugin's
initialize() provides everything you need to
get the class support up and running. Here, we're just caching some
"instance" variables to some other elements that UndoFieldsFor needs to
know about, like the
<form> surrounding all of this, and the
<input type="submit"> button that is used to submit the form. Additionally, we
have the ability to set the item class in case of a conflict, so we also
cache the item property here and delete its corresponding checkbox.
perform: (event) => event.stopPropagation() and event.preventDefault() @item.remove() @_disableSubmission() unless @_lineItemsExist()
Now it comes time to actually define what this plugin is doing. Notice that here, unlike the other plugin, it's much less "baroque", and basically defines the control flow of th event handler, instead of its logic. Using Sumatra directly influenced the design of this plugin, because now that we have a trusted framework set up to handle all of the boilerplate crap (that I make EVERY TIME), we can go ahead and abstract more of, if not all of, the actual logic into methods.
Since we've already defined
this.item, we can just
remove() it as
it's just a jQuery DOM object. And the line above that, preventing the
event from bubbling up and firing the default browser action, is also
standard for plugin/event authoring. That leaves us with the two custom
this._lineItemsExist(). As you can see, we denote private methods with
_ in the method name. There are no private methods in
distinction between public and private is purely semantic, and has no
bearing on the logic of the code. They're to show you that the
methods were not written to be called externally on this object.
Let's take a look at _disableSubmission():
_disableSubmission: -> @submit.removeAttr "value" @submit.val "Disabled" @submit.addClass "inactive" @submit.attr "disabled", true
We already know about
this.submit because it was saved up in the
initialize method. So basically, all this method does is disable
submission until a new line item is added. But this method is only
called when another method returns, true:
_lineItemsExist: -> @form.find(@options.item).length
Here's a method that offers no real code line reduction, but increases
readability and visibility in the
perform() method. This is the kind
of thing that Sumatra makes it easy for you to do, add methods that
increase readability and should enable a higher understanding of the
code out of its readers.
doing it yourself because you're hardcore
Sumatra is 121 lines of
and its concepts aren't very hard to implement from scratch in the first
place. Realistically, the most complex part is the
which uses a bit of meta-programming to get the job done and allow you
to choose any class name as your service object. Other than that, I'm
just giving you an "interface" prototype object to build your plugin off
of, with very limited resources. This keeps the codebase light and
easy to read, and lets you take care of the important things.
when you SHOULDN'T use sumatra
check it out!
I'm interested to hear what others think of this, and I always at least read over any contribution made by pull request to http://github.com/tubbo/sumatra. That's where the codebase for Sumatra is stored. To install it, you can use [bower][bwr]:
bower install sumatra
Or, you can use the handy-dandy [sumatra-rails][smr] gem that I made to use Sumatra within Rails' "asset pipeline". For efficiency and consistency, each Sumatra package pushed to Bower is correspondingly pushed to sumatra-rails, so simply by updating your Gemfile you can start using the latest edition of Sumatra:
- http://github.com/tubbo/sumatra is where the basic JS/CS is located
- http://github.com/tubbo/sumatra-rails is where you can find the Rails helper engine.
I hope this was at the very least entertaining, and at best helped you
learn a little more about the way we do things here at eLocal. A
short disclaimer: we do not use
sumatra in production (yet!), but I
thought it'd be a great way to show you guys our conventions as
sumatra is somewhat of a codification of those techniques.