When you spend your day writing decent Ruby code, maintaining JavaScript is really a pain. We have partially overcome this problem by isolating the calls. Let’s have a look on how we achieve this
I obviously like to write JavaScript (well, thanks to CoffeeScript) but god, I hate maintaining it!
I certainly hate maintaining those long list of onLoad()
wherever you can think of.
Some of our app use EmberJS or Backbone, which makes the maintenance part obviously easier, but most of the applications we create don’t need to rely on heavy frameworks.
To keep our sanity, we came up with 3 rules that makes our JavaScript maintenance much easier when coding a Rails app.
Have a master yield in your application’s layout
= yield(:javascript)
Then in your page use something like:
- content_for :javascript do
:javascript
You would be surprised by the number of legacy apps that don’t have this!
But by injecting your JavaScript always in the same place it’s easier to move around the block when you need to.
Note that if you’re using HAML you can also using CoffeeScript directly:
- content_for :javascript do
:coffeescript
Organise your code
We separate our JS folders per Rails namespace and we keep a shared folder for all the classes that can be shared between difference namespaces.
/javascripts
/main
base.coffee
menu_preview.coffee
[..]
/namespace
base.coffee
pageAction.coffee
[..]
/shared
base.coffee
client_lookup.coffee
[..]
main.js
namespace.js
shared.js
You can then only load the the library you need inside your specific js files.
For example main.js might look like:
//= require cocoon
//= require sortable
//= require select2
//= require services
// Make sure to always load your base file before the tree
//= require ./main/base
//= require_tree ./main
The base
files declare our main module and contains our needed on_load
/ready
event, if any.
Following this, you now have 3 files (main.js, namespace.js, shared.js) that you can add when needed in your layouts.
main/base.coffee
@Main = {}
$(document).on "ready page:load", ->
$('a[href^="/documents/"]').attr('target', '_blank')
Here we are creating the Main
module, that we will be able to extend later, but we also declare our “read page:load” trigger.
The other files
Now that our Main
module is created we can extend it in our other files. For example:
class @Main.MenuPreview
constructor: (checkboxesSelector, menuItemsSelector, specialRole) ->
@checkboxes = $(checkboxesSelector)
@menuItems = $(menuItemsSelector)
@specialRole = specialRole.toLowerCase()
@_triggerCheckbox()
@_triggerLinks()
@_highlightMenuItems()
_triggerCheckbox: ->
@checkboxes.on "change", =>
@_resetMenuItems()
@_highlightMenuItems()
_triggerLinks: ->
@menuItems.on "click", (event) ->
event.preventDefault()
_findMenuItems: (roleNames) ->
return @menuItems if @specialRole in roleNames
@menuItems.filter ->
roles = $(@).data("roles")
return true unless roles?
roles.some (role) ->
role.toLowerCase() in roleNames
_resetMenuItems: ->
@menuItems.removeClass("menu-visible")
_highlightMenuItems: ->
@_findMenuItems(@_getRoles()).addClass("menu-visible")
_getRoles: ->
@checkboxes.filter(":checked").map(->
$(@).data("role-name").toLowerCase()
).get()
Don’t worry about the Coffeescript code here, what’s important is the structure:
- We extend our
Main
object with aMenuPreview
class. - We keep all the related methods in the same file.
- We now have a simple, readable and self-contained file that has only one responsibility, but might also be reusable within different projects.
Calling context in the view
I know it sounds strange to recommend mixing your JavaScript and your views, but it will actually help you, or anyone else coming back to your code next time, to understand what’s going on.
Obviously I’m not talking about writing all your JavaScript in the view, but just make sure you call your relevant objects/methods from the relevant views.
In the menu example this is what the view looks like:
-# invitations/new.html.haml
%h2= t "devise.invitations.new.header"
= simple_form_for resource, as: resource_name, url: invitation_path(resource_name), html: { method: :post } do |f|
.panel.panel-primary.no-border
.panel-body
= devise_error_messages!
- resource.class.invite_key_fields.each do |field|
= f.input field, label: false, placeholder: field.to_s.humanize
.row
.col-md-4
= f.association :roles, as: :check_boxes, collection: Role.all.map {|r| [r.human_name, r.id, data: { role_name: r.human_name }] }
.col-md-8
%ul#menu-preview
= render "rfl/shared/menu"
.panel-footer
.pull-left
= link_to t('buttons.back'), authenticated_company_user_root_path, class: "btn btn-default"
.pull-right
= f.submit t("devise.invitations.new.submit_button"), class: "btn btn-success"
- content_for(:javascript) do
:coffeescript
$ -> new Main.MenuPreview(".company_user_roles input", "#menu-preview a", "Admin")
Any developer coming to this page will be able to understand that this page is also linked to the MenuPreview
class on the JavaScript side.
It doesn’t sounds much, but it will be a great improvement for your future self or your colleagues when a bug arises and it needs to be hunted down.