Page specific JavaScript in Phoenix framework (pt.1)

This post belongs to the Page specific JavaScript in Phoenix framework series.

  1. Brunch and ES6 approach
  2. Webpack approach

Thanks to our personal experience working on legacy Rails applications, we've been able to see several different approaches on how other developers usually organize JavaScript assets. Choosing the wrong approach, or not even having one, will give you some headaches in the future – in other words, just requiring everything in your manifest file (application.js) and relying on multiple document load handlers across serveral files with the hope that everything will just work fine is not a good idea. For our last projects (both client and internal ones) we've been replacing the Asset Pipeline with Webpack to handle asset files requirement and build process, loving the result so far. That's why the first time I took a look at Phoenix's documentation and discovered that there was not such a thing as the Asset Pipeline I got instantly hooked by it.

Simple approach using Brunch and ES6

In order to build assets, Phoenix initially comes with Brunch out of the box, and not only that, it also comes configured to support ES6 by default, including its modules syntax. Taking advantage of this, let's generate a very basic project and see how we can create a better way of organizing the JavaScript instead of putting everything we might need in app.js as if it was a Rails project.

Generating the project

Let's begin by installing the lates version on Phoenix:

$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez && mix local.phoenix

Found existing archive(s): phoenix_new.ez.
Are you sure you want to replace them? [Yn]
* creating /Users/myuser/.mix/archives/phoenix_new.ez
Found existing archive(s): phoenix_new.ez.
Are you sure you want to replace them? [Yn]
* creating /Users/myuser/.mix/archives/phoenix_new.ez

Now that we are up to date, let's generate a new Phoenix project:

$ mix phoenix.new phoenix_template

* creating phoenix_template/config/config.exs
* creating phoenix_template/config/dev.exs
* creating phoenix_template/config/prod.exs
* creating phoenix_template/config/prod.secret.exs
* ...
* ...

We are all set! Run your Phoenix application:

    $ cd phoenix_template
    $ mix phoenix.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:

    $ mix ecto.create

After following the instructions and running the server we can see that the new application is ready.

Shared common JavaScript between views

Before continuing let's first think about what our good old web application is going to need. There's probably going to be some common js functionality shared across the whole application, like handling pulldown menus in the header, initialazing third party plugins, etc. Having this in mind we can create our first js module that will be in charge of all this common functionality that needs to be executed on every page:

// web/static/js/views/main.js

export default class MainView {
  mount() {
    // This will be executed when the document loads...
    console.log('MainView mounted');
  }

  unmount() {
    // This will be executed when the document unloads...
    console.log('MainView unmounted');
  }
}

The MainView module will basically have to main functions:

  • mount which will be called every time the page loads and will contain all the initializing of common functionality needed.
  • umount which can be used to add any functionality needed to be executed when the document unloads. This might be useful in some situations like showing a confirm alert to the user when he tries to leave an edit view with unsaved changes, for example.

Now let's update the main app.js file so it uses the new MainView module:

// web/static/js/app.js

import 'phoenix_html';
import MainView from './views/main';

function handleDOMContentLoaded() {
  const view = new MainView();
  view.mount();

  window.currentView = view;
}

function handleDocumentUnload() {
  window.currentView.unmount();
}

window.addEventListener('DOMContentLoaded', handleDOMContentLoaded, false);
window.addEventListener('unload', handleDocumentUnload, false);

We add an event listener so when the DOM is completely loaded it initialzes the MainView, mounts it and assigns it globally. We are also doing the same for the unload event of the document. If we now open the web inspector, we can see the log message and verify everything that is working fine:

We can see the log message, yay! Let's add one more route so we can navigate through both the root and the new one and check what happens:

# web/router.ex

defmodule PhoenixTemplate.Router do
  use PhoenixTemplate.Web, :router

  # ...
  # ...

  scope "/", PhoenixTemplate do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    get "/new", PageController, :new
  end
end

We need to add the new action to the existing controller:

# web/controllers/page_controller.ex

defmodule PhoenixTemplate.PageController do
  use PhoenixTemplate.Web, :controller

  # ...

  def new(conn, _params) do
    render conn, "new.html"
  end
end

Next step is to create the new template and update the existing index one, so they display the current view module and template:

<!-- web/templates/page/index.html.eex -->
<!-- web/templates/page/new.html.eex -->

<div class="jumbotron">
  <h2><%= @view_module  %></h2>
  <p class="lead"><%= @view_template %></p></p>
</div>

Finally we have to update the main layout template with the navigation links:

<!-- web/templates/layout/app.html.eex -->

...
...

<body>
    <div class="container">
      <header class="header">
        <nav role="navigation">
          <ul class="nav nav-pills pull-right">
            <li><%= link("Index", to: page_path(@conn, :index)) %></li>
            <li><%= link("New", to: page_path(@conn, :new)) %></li>
          </ul>
...
...

Now we can click in the new link and check that it works just the same as before:

From now on, every time we create a new route and it's view/template is shown to the user, all the common JavaScript functionality will be executed exactly the same as in any other route. But what happens I if we want to add some unique behaviour to a specific view/template?

View/tempate specific JavaScript

The main idea is to specify somehow in the app.js the JavaScript view we want to mount instead of the MainView, but executing all the common stuff as well. To do so we are going to create a helper function to generate the current view/template name which we'll use later to mount it. Let's add it to the LayoutView module:

# web/views/layout_view.ex

defmodule PhoenixTemplate.LayoutView do
  use PhoenixTemplate.Web, :view

  @doc """
  Generates name for the JavaScript view we want to use
  in this combination of view/template.
  """
  def js_view_name(conn, view_template) do
    [view_name(conn), template_name(view_template)]
    |> Enum.reverse
    |> List.insert_at(0, "view")
    |> Enum.map(&String.capitalize/1)
    |> Enum.reverse
    |> Enum.join("")
  end

  # Takes the resource name of the view module and removes the
  # the ending *_view* string.
  defp view_name(conn) do
    conn
    |> view_module
    |> Phoenix.Naming.resource_name
    |> String.replace("_view", "")
  end

  # Removes the extion from the template and reutrns
  # just the name.
  defp template_name(template) when is_binary(template) do
    template
    |> String.split(".")
    |> Enum.at(0)
  end
end

The result for the Elixir.PhoenixTemplate.PageView view and index.html template will be PageIndexView, and on the other hand, for the new.html template will be PageNewView. Now we need a place to set the returning name so that it's accessible to the JavaScript, so let's add it as a data attribute to the body tag:

<!-- web/templates/layout/app.html.eex -->

...
...

<body data-js-view-name="<%= js_view_name(@conn, @view_template) %>">

...

After refreshing the browser and inspecting the source code it should look something like this:

<body data-js-view-name="PageIndexView">

All right! So let's create a specific JavaScript view for the new.html template:

// web/static/js/views/page/new.js

import MainView from '../main';

export default class View extends MainView {
  mount() {
    super.mount();

    // Specific logic here
    console.log('PageNewView mounted');
  }

  unmount() {
    super.unmount();

    // Specific logic here
    console.log('PageNewView unmounted');
  }
}

Note how View extends MainView and how we are calling its super functions in both mount() and mount(). This way it will run all the common functionality like in any other view/template before running any specifc logic. Now we need a mechanism to load the current view name module so we can call its mount and unmount methods, so let's write a simple loader module:

// web/static/js/views/loader.js

import MainView    from './main';
import PageNewView from './page/new';

// Collection of specific view modules
const views = {
  PageNewView,
};

export default function loadView(viewName) {
  return views[viewName] || MainView;
}

We need to import any specific page view we need to have and add it to the views object. The loadView function will try to return it by its viewName argument, returning the MainView if not found. Finally we need to change the code in the app.js file in order to use the loadView function:

// web/static/js/app.js

import 'phoenix_html';
import loadView from './views/loader';

function handleDOMContentLoaded() {
  // Get the current view name
  const viewName = document.getElementsByTagName('body')[0].dataset.jsViewName;

  // Load view class and mount it
  const ViewClass = loadView(viewName);
  const view = new ViewClass();
  view.mount();

  window.currentView = view;
}

function handleDocumentUnload() {
  window.currentView.unmount();
}

window.addEventListener('DOMContentLoaded', handleDOMContentLoaded, false);
window.addEventListener('unload', handleDocumentUnload, false);

Note the subtle change in the handleDOMContentLoaded. It gets the viewName from the body's data attribute previously set by the js_view_name/2 function. Then it uses the loadView function to get the view class by viewName and creates a new view object with it and calling its mount method. And with this, we are done! If we inspect the browser's console it should look somehting like this:

We can now see the 2 log messages resulting from calling the mount function of the view object which is a PageNewView. One message for the super.mount() call and the second one of its own.

Conclusion

Thanks to this small changes we can organize the JavaScript of a fresh new generated Phoenix project in a more tiddy and efficient way, without the need of any other third party libraries, appart from those which are initially added. Of course there can be several different ways of achieving something similar, like removing Brunch and using Webpack and it's dynamic module loading, which we will comment on the next post. Meanwhile, don't forget to check out the source code:

Happy coding!

comments powered by Disqus