@jondavidjohn https://jondavidjohn.com Jonathan Ⓓ Johnson
DISCLAIMER All code covered in this talk will be provided as part of a working rails application in the form of a GitHub repository 😅
Modern JavaScript without giving up on Rails
Modern JavaScript without giving up on Rails
What do many common resources and tutorials assume?
• No constraints! • A Single Page Application is a good fit! • Your API is built and ready to consume!
🥴
🥴
🥴
What was our reality?
• Many constraints! • Established MVC server-side application • Lots of valuable legacy JavaScript • You do not have a fully featured API to work with (yet)
Where did CodeShip start? ( ~ 2 years ago ) • Standard mature Rails application • Lots of JavaScript sprinkles • But also CoffeeScript sprinkles • Lot’s of direct DOM manipulation and jQuery • Global everything (via Asset Pipeline) • JavaScript was only exercised within acceptance tests
Where do we want to end up? • Leverage the progress in the JavaScript ecosystem • JavaScript components as upgraded partials • Let Rails continue to be good at what Rails is good at • Our Vue usage should reflect the Vue community • Testability!
webpack(er)
Once your JavaScript files express their dependencies explicitly using ESModules or CommonJS, webpack can leverage that information to build your assets in more intelligent ways
app/javascript app/javascript/packs
app/javascript/packs/thing.js public/packs/thing-abc123.js
// app/javascript/packs/thing.js console.log('hello world')
// app/javascript/packs/thing.js //= require capitalize console.log(capitalize('hello world'))
yarn add capitalize
{ "name": "...", "version": "...", "dependencies": { "capitalize": "^2.0.0" } }
// app/javascript/packs/thing.js import capitalize from 'capitalize' console.log(capitalize('hello world'))
We have assets, now what?
<%= javascript_include_tag :thing %> Asset Pipeline Webpacker <%= javascript_pack_tag :thing %>
single page application
single page application
single page application multi application page
Apps vs. Components? (A distinction that has worked well for us)
Apps • “Smart” • Aware of their surroundings • Handles AJAX • Utilizes a vuex store if needed • Composed of other components
Components • Less Smart? • Ignorant of the world around them • Easily reused • Presentation focused • Track only local state
<!-- app/javascript/apps/hello-world.vue --> <template> <p>Hello World!</p> </template> <script> export default { name: 'HelloWorld' } </script>
<!—- Somewhere in an ERB template —-> <div id="hello-world"></div>
<!—- Somewhere in an ERB template —-> <div id="hello-world"></div> // app/javascript/packs/hello-world.js import Vue from 'vue' import HelloWorld from '@/apps/hello-world' const target = document.getElementById(‘hello-world') const App = Vue.extend(HelloWorld) const component = new App() component.$mount(target)
<!—- Somewhere in an ERB template —-> <div id="hello-world"></div> <!—- In your layout —-> <%= javascript_pack_tag :hello_world %> // app/javascript/packs/hello-world.js import Vue from 'vue' import HelloWorld from '@/apps/hello-world' const target = document.getElementById(‘hello-world') const App = Vue.extend(HelloWorld) const component = new App() component.$mount(target)
Components as Upgraded Partials
<%= render 'hello-world', user: @user %> <p>Hello <%= user.name %></p>
<%= vue_app 'hello-world', user: @user %> <template> <p>Hello {{ user.name }}!</p> </template> <script> export default { name: 'HelloWorld', props: { user: Object } } </script>
def vue_app(app) app_name = app.to_s.dasherize content_tag :div, nil, { 'vue-app': app_name } end <%= vue_app :hello_world %> <div vue-app=“hello-world"></div>
def vue_app(app) app_name = app.to_s.dasherize content_tag :div, nil, { 'vue-app': app_name } end
def add_javascript_pack(*packs) @custom_packs ||= Set.new @custom_packs += packs end def custom_packs @custom_packs || [] end def vue_app(app) app_name = app.to_s.dasherize content_tag :div, nil, { 'vue-app': app_name } end
def add_javascript_pack(*packs) @custom_packs ||= Set.new @custom_packs += packs end def custom_packs @custom_packs || [] end def vue_app(app) app_name = app.to_s.dasherize add_javascript_pack(app_name) content_tag :div, nil, { 'vue-app': app_name } end
<body> <%= yield %> <% custom_packs.each do |pack| %> <%= javascript_pack_tag pack %> <% end %> </body>
<body> <%= yield %> <% if custom_packs.empty? %> <%= javascript_include_tag :application %> <% end %> <% custom_packs.each do |pack| %> <%= javascript_pack_tag pack %> <% end %> </body>
Passing locals into our apps (Vue calls these “props”) <%= vue_app :hello_world, user: @user %>
def vue_app(app) app_name = app.to_s.dasherize add_javascript_pack(app_name) content_tag :div, nil, { 'vue-app': app_name } end
def vue_app(app, props = {}) app_name = app.to_s.dasherize add_javascript_pack(app_name) props = props.stringify_keys.map do |key, val| ["data-#{key.dasherize}", val.to_json] end content_tag :div, nil, Hash[props].merge({ 'vue-app': app_name }) end
// app/javascript/packs/hello-world.js import Vue from 'vue' import HelloWorld from '@/apps/hello-world' const App = Vue.extend(HelloWorld) const target = document.getElementById('hello-world') const component = new App() component.$mount(target)
// app/javascript/packs/hello-world.js import Vue from 'vue' import HelloWorld from '@/apps/hello-world' const target = document.getElementById('hello-world') const propsData = {} Object.entries(target.dataset) .forEach(([key, value]) => { try { propsData[key] = JSON.parse(value) } catch (e) { propsData[key] = value } }) const App = Vue.extend(HelloWorld) const component = new App({ propsData }) component.$mount(target)
<!-- app/javascript/apps/hello-world.vue --> <template> <p>Hello World!</p> </template> <script> export default { name: 'HelloWorld' } </script>
<!-- app/javascript/apps/hello-world.vue --> <template> <p>Hello {{ user.name }}!</p> </template> <script> export default { name: 'HelloWorld', props: { user: Object } } </script>
Pass in your URLs <%= vue_app :hello_world, users_url: users_url %>
Extracting our mounting logic
// app/javascript/lib/boot.js import Vue from 'vue' export default function (name, app) { }
// app/javascript/lib/boot.js import Vue from 'vue' export default function (name, app) { const nodes = document.querySelectorAll(`[vue-app=${name}]`) if (!nodes.length) return const App = Vue.extend(app) return Array.prototype.map.call(nodes, (node) => { const propsData = {} Object.entries(node.dataset).forEach(([key, value]) => { try { propsData[key] = JSON.parse(value) } catch (e) { propsData[key] = value } }) return new App({ propsData }).$mount(node) }) }
import boot from '@/lib/boot' import HelloWorld from '@/apps/hello-world' boot(‘hello-world', HelloWorld)
Testing yarn add —-dev jest yarn add —-dev @vue/test-utils
app/javascript/apps/hello-world.vue spec/javascript/apps/hello-world.spec.js
Generators!
rails generate vue_app hello_world create app/javascript/packs/hello-world.js create app/javascript/apps/hello-world/index.vue create spec/javascript/apps/hello-world/index.spec.js Generators!
// app/javascript/packs/hello-world.js import boot from '@/lib/boot' import HelloWorld from '@/apps/hello-world' boot('hello-world', HelloWorld)
<!-- app/javascript/apps/hello-world/index.vue --> <template> <p>Hello World!</p> </template> <script> export default { name: 'HelloWorld' } </script>
// spec/javascript/apps/hello-world.spec.js import { mount } from '@vue/test-utils' import HelloWorld from '@/apps/hello-world' describe(‘HelloWorld', () => { it('should render', () => { const app = mount(HelloWorld) expect(app).toMatchSnapshot() }) })
rails generate vue_app hello_world
rails generate vue_app hello_world <%= vue_app :hello_world, user: @user %>
rails generate vue_app hello_world <%= vue_app :hello_world, user: @user %> <template> <p>Hello {{ user.name }}!</p> </template> <script> export default { name: 'HelloWorld', props: { user: Object } } </script>
@jondavidjohn https://jondavidjohn.com bit.ly/rails-vue-example

Modern JavaScript, without giving up on Rails