We’ve recently finished migrating FormKeep’s administrative dashboard from using the ember-rails gem to ember-cli-rails.
We chose to migrate to ember-cli-rails
so that we could
separate our client and server-side codebases and workflows,
align our project’s conventions with the Ember.js core team and community,
and utilize the rich EmberCLI addon ecosystem,
while still running truly end-to-end JavaScript-enabled Capybara integration tests.
Our migration consisted of two main steps:
- Modify our files and directories to be
ember-cli
compliant, - Integrate our
ember-cli
app with our Rails app.
Migrating to ember-cli
Background
Rails applications serving Sprockets-based Ember applications don’t enforce any
strict conventions. The location of files is unimportant. Applications are wired
together by global assignments to window.App
, regardless of the source file’s
name or location.
Although it’s discouraged, Sprockets-based applications are capable of declaring
classes from any location, sometimes declaring multiple classes per file.
There is nothing preventing app/assets/javascripts/router.js
from defining App.Router
along with App.PostsRoute
and App.Post
.
EmberCLI
applications, on the other hand, use an ES6
module resolver to
enforce that files are named properly and reside in their conventional
location.
The examples in this post will follow the traditional file structure conventions. If you’d like the migrate to a pod-based project, the approach is similar but the directories are slightly different.
A set of rough guidelines
At a high level, migrations follow a general pattern:
- Move directories from
app/assets/javascripts/$TYPE
toapp/$TYPE
.app/assets/javascripts/templates
->app/templates
- Replace
_
with-
in all filenames.app/assets/javascripts/helpers/in_dollars.js
->app/helpers/in-dollars.js
- Remove
_$TYPE
suffixes.app/assets/javascripts/views/posts_view.js
->app/views/posts.js
- Nest children under their parents.
app/assets/javascripts/views/posts_new_view.js
->app/views/posts/new.js
For a more in-depth guide, check out this post and the ember-cli documentation.
While these guidelines will work for most files, there are some additional quirks to be aware of.
Ember-Data
Sprockets-based Ember applications typically include an
app/assets/javascripts/store.js
file responsible for configuring and
extending Adapters
, Serializers
, and Transforms.
This file commonly ends up becoming a junk drawer full of ember-data
configurations.
EmberCLI expects these declarations to be located in
app/adapters
,
app/serializers
,
and app/transforms
,
respectively.
Any calls to .reopenClass
must be moved to their own initializer.
Initializers
Sprockets-based applications require initializers to be explicitly registered:
// app/assets/javascripts/initializers/foo_initializer.js
Ember.Application.initializer({
name: "foo",
initialize: function(container, application) {
/* initialize foo */
}
});
In module based EmberCLI applications, the resolver will
automatically load initializers
based on the properties exported by files in app/initializers
:
// app/initializers/foo.js
export default {
name: "foo",
initialize: function(container, application) {
/* initialize foo */
}
};
Helpers
Sprockets-based applications require helper factories to be registered
by Ember.Handlebars.helper
:
// app/assets/javascripts/helpers/in_dollars_helper.js
Ember.Handlebars.helper("in-dollars", function(amount) {
/* show in dollars */
});
EmberCLI’s resolver requires helpers to be created by
Ember.Handlebars.makeBoundHelper
and exported from their own module:
// app/helpers/in-dollars.js
import Ember from "ember";
export default Ember.Handlebars.makeBoundHelper("in-dollars", function(amount) {
/* show in dollars */
});
Special attention must be paid to single-word helpers like pluralize
.
EmberCLI requires these helpers to be exported as functions, then imported and
registered manually.
// helpers/pluralize.js
export default function(word, count) {
/* pluralize word */
}
// app/app.js
import Ember from "ember";
import pluralizeHelper from "./helpers/pluralize";
Ember.Handlebars.registerBoundHelper("pluralize", pluralizeHelper);
When in doubt, refer to the documentation.
Design assets
FormKeep uses the majority of thoughtbot’s design tools, which require SCSS support.
Luckily, there is an Ember Addon for SCSS, Bourbon, and Neat:
ember install ember-cli-{bourbon,neat,sass}
When moving styles from app/assets/stylesheets
to app/styles
, keep in mind:
- the asset pipeline helpers (such as
image_url
) aresnake_cased
, while the EmberCLI tools expect helpers to bekebab-cased
(such asimage-url
). - Node’s SCSS compiler doesn’t support globbing, so calls to
import directory/*
, must be replaced with explicit calls toimport directory/foo
.
Tests
Before the migration, FormKeep tested its front end with a combination of end-to-end JavaScript-enabled Capybara-Webkit feature tests, and lower-level fine-grained QUnit BDD tests run with Teaspoon.
The feature test suite ensured that our front and back ends were wired together properly, while the JavaScript test suite ensured that our features behaved the way we intended.
Since our Teaspoon suite was already JavaScript, the migration was very straightforward:
- move tests from
spec/javascripts/integration
totests/acceptance
- replace
_
with-
in filenames
For example, spec/javascripts/integration/user_edits_a_form_spec.js
became tests/acceptance/user-edits-a-form-test.js
Teaspoon’s test suites rely on Sprockets’ require
directive to concatenate
auxiliary files onto the global namespace.
// spec/javascripts/support/date-helpers.js
window.yesterday = function() { /* get Yesterday's date */ };
window.today = function() { /* get Today's date */ };
// spec/javascript/spec_helper.js
//= require application
//= require app
//= require support/date-helpers
var App;
window.startApp = function() { /* start the App */ };
window.stopApp = function() { /* stop the App */};
before(function() {
startApp();
});
after(function() {
stopApp();
});
// spec/javascripts/integration/user_creates_form_spec.js
describe("The new form", function() {
it("is created today", function() {
// ...
expect(form.get("createdAt")).to.equal(this.today());
});
});
EmberCLI, on the other hand, avoids declaring values to the global namespace.
Instead, the resolver relies on ES6 imports to act as an explicit
declaration of a file’s dependencies. In order to invoke a helper method, we
must first import
it.
// tests/helpers/date.js
function yesterday() { /* get Yesterday's date */ }
function today() { /* get Today's date */ }
export {
yesterday,
today
};
// tests/helpers/acceptance.js
function startApp { /* start the App */ }
function stopApp { /* stop the App */}
export {
startApp,
stopApp
};
// tests/acceptance/user-creates-form-test.js
import { startApp, stopApp } from "../helpers/acceptance";
import { today } from "../helpers/date";
describe("The new form", function() {
before(function() {
startApp();
});
after(function() {
stopApp();
});
it("is created today", function() {
// ...
expect(form.get("createdAt")).to.equal(today());
});
});
Configuration
Before our migration, we had been declaring application-wide configuration
values (sometimes from Rails’ ENV
) into a <script>
tag in our document’s
<head>
.
According to EmberCLI, these configuration values should be declared in the environments file.
Integrating with Rails
Once the code that powers our administrative dashboard was separated into its
own EmberCLI app, we used ember-cli-rails
to integrate with our Rails app.
Our configuration is very similar to the ember-cli-rails
getting
started guide:
# config/initializers/ember-cli.rb
EmberCLI.configure do |c|
c.app(
:frontend,
build_timeout: 10,
path: Rails.root.join("frontend"),
enable: lambda do |path|
# disable asset compilation during request specs
!path.starts_with?("/api/")
end
)
end
Cross Site Request Forgery protection
Rails’
protect_from_forgery
requires a CSRF token for every XHR except for GET. The CSRF token is normally found inapp/views/layouts/application.html.*
inserted with the rails helper:csrf_meta_tags
.
We opted to inject a valid <meta name="csrf-token">
into our document’s
<head>
before our server rendered the HTML.
On the client side, we added an initializer that introduces a jQuery
ajaxPrefilter
to add the header:
// app/initializers/jquery-csrf.js
export default {
name: "csrf",
initialize: function() {
$.ajaxPrefilter(function(options, originalOptions, xhr) {
var token = $('meta[name="csrf-token"]').attr("content");
xhr.setRequestHeader("X-CSRF-Token", token);
});
}
};
Workflow
FormKeep’s workflow includes running the following in their own processes:
foreman start
from the project root runs the API server and serves the EmberCLI application withember-cli-rails
(through the asset pipeline)ember test --serve
fromapp/frontend
runs theQUnit
tests against the front end
Depending upon what is being developed, each of these commands could be run in isolation.
To run both the Rails and Ember test suite, we declare a rake
dependency
between spec
and ember:test
:
# Rakefile
task default: ["spec", "ember:test"]
# run the full test suite
$ rake spec
Our outcome
We’re satisfied with where the migration has taken us:
- Our project is aligned with the conventions of the core team and the community
- We’re utilizing some awesome Ember addons that wouldn’t have been available through Sprockets
- We’ve taken the first steps towards separating our API from our (one day static) client
- Maintained the ability to run truly end-to-end integration tests
Further readings
When we started our migration, EmberCLI Migrator was in its infancy. It’s a more viable tool now, and definitely worth considering.