Trailblazer tutorial: refactoring legacy rails views with Trailblazer Cells - part 5

krzykamil

Krzysztof

Posted on November 7, 2019

Trailblazer tutorial: refactoring legacy rails views with Trailblazer Cells - part 5

Encapsulating backend business logic into Trailblazer operations is a very important part of building an app based on it, alongside contracts and moving logic from controllers. Another important step that ties everything from backend to frontend together, are cells1. Let's quickly go over what cells are, why we should use them and then we will find some view that could use refactoring and help him be a better, clearer view.

As explained by the cell gem webpage

[...] cells, are simple ruby classes that can render templates.

Sounds nice and simple, right? Well... that's actually cause it's nice and simple. Their base functionality is nothing complicated and that's what we will cover in this post, but going further we will keep in mind cells can be used in so many more ways, than a simple template renderer2. Let's pick a view, and to be consistent with this series, we will simply go to Proposals index view. Here's how it looks on the webpage:

Proposals Index View

And here is the code we are dealing with:

/* app/views/proposals/index.html.haml */
.row
  .col-md-12
    .page-header
      %h1 My Proposals

.row
  .col-md-12.proposals
    - if proposals.blank? && invitations.blank?
      .widget.widget-card.text-center
        %h2.control-label You don't have any proposals.

    - events.each do |event|
      - talks = proposals[event] || []
      - invites = invitations[event] || []
      - event = event.decorate
      .row
        .col-md-4
          .widget.widget-card.flush-top
            .event-info.event-info-block
              %strong.event-title
                = event.name
              %span{:class => "event-status-badge event-status-#{event.status}"}
                CFP
                = event.status
              %span.pull-right
                - if event.open?
                  = link_to 'Submit a proposal', new_event_proposal_path(event.slug), class: 'btn btn-primary btn-sm'
              .event-meta
                - if event.start_date? && event.end_date?
                  %span.event-meta-item
                    %i.fa.fa-fw.fa-calendar
                    = event.date_range
              .event-meta.margin-top
                %span.event-meta
                  = link_to 'View Guidelines', event_path(event.slug)

        .col-md-8
          - if invites.present?
            .proposal-section.invitations.callout
              %h2.callout-title Speaker Invitations
              %ul.list-unstyled
                - invites.each do |invitation|
                  %li.invitation.proposal.proposal-info-bar
                    .flex-container.flex-container-md
                      .flex-item.flex-item-padded
                        %h4.proposal-title= link_to invitation.proposal.title, invitation_path(invitation.slug)

                        .proposal-meta.proposal-description
                          .proposal-meta-item
                            %strong Track:
                            = invitation.proposal.track_name
                          .proposal-meta-item
                            %strong #{ 'Speaker'.pluralize(invitation.proposal.speakers.count) }:
                            = invitation.proposal.speakers.collect { |speaker| speaker.name }.join(', ')
                      .flex-item.flex-item-fixed.flex-item-padded.flex-item-right
                        .invitation-status
                          = invitation.state_label
                        - if invitation.pending?
                          .proposal-meta.invite-btns
                            = invitation.decline_button(small: true)

                            = invitation.accept_button(small: true)

          .proposal-section
            %ul.list-unstyled
              - talks.each do |proposal|
                %li.proposal.proposal-info-bar
                  .flex-container.flex-container-md
                    .flex-item.flex-item-fixed.flex-item-padded.proposal-icon
                      %i.fa.fa-fw.fa-file-text
                    .flex-item.flex-item-padded
                      %h4.proposal-title= link_to proposal.title, event_proposal_path(event_slug: proposal.event.slug, uuid: proposal)
                      .proposal-meta.proposal-description
                        .proposal-meta-item
                          %strong #{ 'Speaker'.pluralize(proposal.speakers.count) }:
                          %span= proposal.speakers.collect { |speaker| speaker.name }.join(', ')
                        .proposal-meta-item
                          %strong Format:
                          %span #{proposal.session_format_name}

                        .proposal-meta-item
                          %strong Track:
                          %span #{proposal.track_name}
                      .proposal-meta.margin-top
                        %strong Updated:
                        %span #{proposal.updated_in_words}
                    .flex-item.flex-item-fixed.flex-item-padded
                      .proposal-status
                        = proposal.speaker_state(small: true)
                      .proposal-meta
                        %i.fa.fa-fw.fa-comments
                        = pluralize(proposal.public_comments.count, 'comment')

A rather big view, isn't? Good thing it is using a decorator (with Draper gem in this case) which is a good practice and will make it easier for us to improve it even more than simply adding a decorator. First off we need to add some base cell and render it from the controller level. In our concepts we already have a concept for proposal, we just need to add two folders there: cells and views. This is an important concept in TB, to keep everything connected to our concept in its folder. Thanks to that we won't have a humongous views folder where we have to look for adequate view, we will know that if we want a view for proposals, we simply go into its concept, and into view folder there, where we will have all the relative views bundled up.

Rendering cell is rather simple but gives a lot of options. As per version 2.1 the syntax for invoking a cell is either this

=concept("your_concept/cell", your_model)

(which is a syntax for using cells as a solo gem, as you will see further down below, using it with full trailblazer offers, different, more complex helper with more options), or:

  YourConcept::Cell.new(your_model).show

Please note that the haml example is called from the view, and what it does is invoking the second, ruby syntax, it's just a helper. The helper will always invoke the show method which is a default method of returning the cell body. Also, keep in mind that I called the passed object your_model for two reasons.

  1. Whatever you pass into the helper in the place of your_model will be called model inside the cell body
  2. Your passed your_model does not have to be an AR object, you can use cell on other types of data

After adding gem 'trailblazer-cells' we also need to add gem 'cells-hamlit' gem 'cells-rails' to have cells working with Trailblazer, instead of as a solo abstraction for view rendering, where we need only "cells" gem. Now we can start by adding a new class, a new base view, and render it from the controller level. Then step by step we will move stuff from the current view to the new one, using cells.

# app/controllers/proposals_controller.rb
def index
  proposals = current_user.proposals.decorate.group_by {|p| p.event}
  invitations = current_user.pending_invitations.decorate.group_by {|inv| inv.proposal.event}
  events = (proposals.keys | invitations.keys).uniq

  render locals: {
      events: events,
      proposals: proposals,
      invitations: invitations
  }
end

This is a base class that we will render in the controller.

# app/concepts/proposal/cell/index.rb
module Proposal::Cell
  class Index < Trailblazer::Cell
  end
end

We will only invoke this in our index action:

# app/controllers/proposals_controller.rb
def index
  render html: cell(Proposal::Cell::Index, current_user, context: { current_user: current_user })
end

Using the cell helper we pass in a cell we want to render, our model (in this case its user), and options (in this case nothing yet)
We are passing in the current user as our model, cause it is our model in this case, but we also pass it in context. This will get useful later on cause what we pass in in context, also gets passed to our cell nested cells. By default, we have controller data passed in context, so that we can have access to stuff like our current path, or CRUD action name (this will be used later on to demonstrate where and how we can use context).

This alone with a base view made in app/concepts/proposal/view/index.haml like this:

# app/concepts/proposal/view/index.haml
.row
  .col-md-12
    .page-header
      %h1 My Proposals

...will give us a result


  "<div class='row'>\n<div class='col-md-12'>\n<div class='page-header'>\n<h1>My Proposals</h1>\n</div>\n</div>\n</div>\n"

Nothing pretty but gives us ground to work on. Developing cell further on we add what we previously had passed in as locals for the view.

# app/concepts/proposal/cell/index.rb
module Proposal::Cell
  class Index < Trailblazer::Cell
    alias user model

    private

    def proposals
      user.proposals.decorate.group_by(&:event)
    end

    def no_proposals?
      proposals.blank? && invitations.blank?
    end

    def invitations
      user.pending_invitations.decorate.group_by { |inv| inv.proposal.event }
    end

    def events
      (proposals.keys | invitations.keys).uniq
    end
  end
end


With this, we already have access to what we had while dealing with this logic in the controller. We can now move on with recreating the view TB way.

/* app/concepts/proposal/view/index.haml */
.row
  .col-md-12
    .page-header
      %h1 My Proposals
.row
  .col-md-12.proposals
    - if no_proposals?
      .widget.widget-card.text-center
        %h2.control-label You don't have any proposals.

    - events.each do |event|
      = cell(Proposal::Cell::EventRow, event, current_user: user, proposals: proposals, invitations: invitations)

We start off standard, we move the condition to a separate method, that we name according to what the condition checks. Then we go over proposal events, and iterate over them, rendering a cell for each of those events. This is a pattern you will see more in our guides. Instead of using partials, helpers, etc. we only used nested cells, that we render for each object, keeping their logic and code nicely separated. We also pass some additional stuff in as options to the event cell.

The controller should not deal with finding those objects if possible, this is something cells can do for us. As we mentioned before, a decorator gem is used in the system to help with the views logic, we will try to move away from it and transfer everything into correct cells. So we create some more "helper" methods and we end up with this new cell for each event we display in our proposals view.

# app/concepts/proposal/cell/event_row.rb
module Proposal::Cell
  class EventRow < Trailblazer::Cell
    alias event model

    private

    def talks
      options[:proposals][event] || []
    end

    def invites
      options[:invitations][event] || []
    end

    def has_date_range?
      event.start_date? && event.end_date?
    end

    def status_class
      "event-status-badge event-status-#{event.status}"
    end

    def new_event_link
      link_to(
        new_event_proposal_path(event.slug),
        'Submit a proposal',
        class: 'btn btn-primary btn-sm'
      )
    end

    def guidelines_link
      link_to(event_path(event.slug), 'View Guidelines')
    end

    def date_range
      if (event.start_date.month == event.end_date.month) && (event.start_date.day != event.end_date.day)
        event.start_date.strftime('%b %d') + event.end_date.strftime(" \- %d, %Y")
      elsif (event.start_date.month == event.end_date.month) && (event.start_date.day == event.end_date.day)
        event.start_date.strftime('%b %d, %Y')
      else
        event.start_date.strftime('%b %d') + object.end_date.strftime(" \- %b %d, %Y")
      end
    end
  end
end

This gives us this view:

# app/concepts/proposal/view/event_row.haml
.row
  .col-md-4
    .widget.widget-card.flush-top
      .event-info.event-info-block
        %strong.event-title
          = event.name
        %span{:class => status_class}
          CFP
          = event.status
        %span.pull-right
          - if event.open?
            = new_event_link
        .event-meta
          -if has_date_range?
            %span.event-meta-item
              %i.fa.fa-fw.fa-calendar
              = date_range
        .event-meta.margin-top
          %span.event-meta
            = guidelines_link

We moved logic and everything connected to it to the cell. When you take a look at the original view, you will see that we have more nested views. This is perfect for cells (and should have probably been done with partials in the first place, but luckily we are here to do it the right way). Let's make adequate cells for invites and talks (they were previously set in the view, this also has been changed to use cells to assign those in the classes rather than the view) and render them in each event cell iteration.

In invite row I would like to point out another useful thing:

/* app/concepts/proposal/view/invite_row.haml */
%li.invitation.proposal.proposal-info-bar
  .flex-container.flex-container-md
    .flex-item.flex-item-padded
      %h4.proposal-title
        = invitation_link

      .proposal-meta.proposal-description
        .proposal-meta-item
          %strong Track:
          = track_name
        .proposal-meta-item
          %strong
            = title
          = speakers
    .flex-item.flex-item-fixed.flex-item-padded.flex-item-right
      .invitation-status
        =  content_tag :span, state, class: label_class
      - if invite.pending?
        .proposal-meta.invite-btns
          = decline_button(small: true)

          = accept_button(small: true)


/* app/concepts/proposal/cell/invite_row.rb */

module Proposal::Cell
  class InviteRow < Trailblazer::Cell
    alias invite model
    property :state
    property :proposal
    property :slug

    STATE_LABEL_MAP = {
      Invitation::State::PENDING => 'label-default',
      Invitation::State::DECLINED => 'label-danger',
      Invitation::State::ACCEPTED => 'label-success'
    }.freeze

    private

    def invitation_link
      link_to(invite.proposal.title, invitation_path(invite.slug))
    end

    def speakers
      invite.proposal.speakers.collect(&:name).join(', ')
    end

    def title
      'Speaker'.pluralize(invite.proposal.speakers.count)
    end

    def track_name
      proposal.track.try(:name) || Track::NO_TRACK
    end

    def label_class
      "label #{STATE_LABEL_MAP[state]}"
    end

    def decline_button(small: false)
      classes = 'btn btn-danger'
      classes += ' btn-xs' if small

      link_to 'Decline', decline_invitation_path(slug), class: classes,
                                                        data: { confirm: 'Are you sure you want to decline this invitation?' }
    end

    def accept_button(small: false)
      classes = 'btn btn-success'
      classes += ' btn-xs' if small

      link_to 'Accept', accept_invitation_path(slug), class: classes
    end
  end
end

property allows us to more easily access our model fields, while also nicely specifying them in the cell. So in the view = invitation.slug becomes simply slug cause we already know we are in the invitation "scope" and that scope has a proposal property. Going back we can also use this in EventRow, see for yourself if you can spot the lines where we can delete the event.something and replace it by just something by adding adequate property.

As you can see on the decline_button and accept_button methods we can easily add html elements in the cell. Also knowing the rules by now, and having some examples and seeing the code, try building the TalkRow cell and view on your own. My version is just below, but it might be a good check of your so far understanding of the topic.

 # app/concepts/proposal/cell/talk_row.rb
 module Proposal::Cell
  class TalkRow < Trailblazer::Cell
    alias talk model
    property :title
    property :event
    property :speakers
    property :session_format_name
    property :track_name
    property :updated_in_words
    property :public_comments

    private

    def talk_link
      link_to(title, event_proposal_path(event_slug: options[:event].slug, uuid: talk))
    end

    def title
      'Speaker'.pluralize(speakers.size)
    end

    def speakers_collection
      speakers.collect(&:name).join(', ')
    end

    def comments
      pluralize(public_comments.size, 'comment')
    end
  end
 end

/* app/concepts/proposal/view/talk_row.haml */
%li.proposal.proposal-info-bar
  .flex-container.flex-container-md
    .flex-item.flex-item-fixed.flex-item-padded.proposal-icon
      %i.fa.fa-fw.fa-file-text
    .flex-item.flex-item-padded
      %h4.proposal-title
        = talk_link
      .proposal-meta.proposal-description
        .proposal-meta-item
          %strong
            = title
          %span
            = speakers_collection
        .proposal-meta-item
          %strong Format:
          %span
            = session_format_name
        .proposal-meta-item
          %strong Track:
          %span
            = track_name
      .proposal-meta.margin-top
        %strong Updated:
        %span
          = updated_in_words
    .flex-item.flex-item-fixed.flex-item-padded
      .proposal-status
        = talk.speaker_state(small: true)
      .proposal-meta
        %i.fa.fa-fw.fa-comments
        = comments

Okay, seems like we are done? We can delete the index.html.haml file and check out how our page looks again!

Proposals Index View after

What are we forgetting? Why is it so basic? Well, when using standard rails views we have an app/views/layouts/application.html.haml to give us a base to build around, with not only base html elements, but also including styles, javascript, nav bar, footer, etc... we also need a cell for that. So let's build it quickly. Even though this still be a cell we should call it a Layout.

# app/concepts/cfp/cell/base_layout.rb
module Cfp
  module Cell
    class BaseLayout < Trailblazer::Cell
    end
  end
end

Cfp, by the way, is the name of our app. Base layout view can look something like this:

/* app/concepts/cfp/view/base_layout.haml */
!!!5
%html(lang="en")
  %head
    %title= "Cfp"
    %meta(name="viewport" content="width=device-width, initial-scale=1.0")

    = stylesheet_link_tag 'application', media: 'all'
    = stylesheet_link_tag '//maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css', media: 'all'
    = stylesheet_link_tag "//fonts.googleapis.com/css?family=Open+Sans:400italic,600italic,400,600"

    = javascript_include_tag 'application'
    = javascript_include_tag "//www.google.com/jsapi", "chartkick"
    = javascript_include_tag { yield :custom_js }

    // html5 shim and respond.js ie8 support of html5 elements and media queries
    /[if lt ie 9]
      = javascript_include_tag "/js/html5shiv.js"
      = javascript_include_tag "/js/respond.min.js"

  -# %body{id: body_id}
  -#   = render partial: "layouts/navbar"

  -# #flash= show_flash
  %body
    .main
      .main-inner
        .container-fluid
          =yield

  -# = render partial: "layouts/event_footer"

As you can see we have some comments here. Well get to them but first, let us just talk about why the layout is important. We should edit our controller action to use the base layout:

# app/controllers/proposals_controller.rb
def index
  render html: cell(Proposal::Cell::Index, current_user, context: { current_user: current_user }, layout: Cfp::Cell::BaseLayout)
end

And we get the content of our index page back.

Without nav bar though. This is a job for another cell since the nav bar is something that will get repeated every time we render the base layout. So we simply create a NavBar cell in the cfp concept, and add the view there, we render it each time with our BaseLayout and we yield the content of the cell that we are using the layout for (so Proposal::Cell::Index in our case). While we are at it, we might as well create the same thing for event_footer partial (the last comment in the haml code above). The problem with nav_bar and event_footer is that they rely heavily on ApplicationHelper and ApplicationController. Ideally, we want to skim those of methods that can be dealt with by cells, but we cant delete those from there until we refactor the whole app. For now, we are only doing it for one place, so we will have to define them anew in cells. Let's take a look at nav_bar and its many calls to helper methods, that we will move to cells.

/* app/views/layouts/_navbar.html.haml */
.navbar.navbar-default.navbar-fixed-top
  .container-fluid
    .navbar-header
      %button.navbar-toggle{ type: "button", data: { toggle: "collapse", target: ".navbar-collapse" } }
        %span.icon-bar
        %span.icon-bar
        %span.icon-bar
      - if current_event
        = link_to "#{current_event.name} CFP", event_path(current_event), class: 'navbar-brand'
      - else
        = link_to "CFP App", events_path, class: 'navbar-brand'

    .collapse.navbar-collapse
      - if current_user
        %ul.nav.navbar-nav.navbar-right
          - if speaker_nav?
            %li{class: nav_item_class("my-proposals-link")}
              = link_to proposals_path do
                %i.fa.fa-file-text
                %span My Proposals

          - if review_nav?
            %li{class: nav_item_class("event-review-proposals-link")}
              = link_to event_staff_proposals_path(current_event) do
                %i.fa.fa-balance-scale
                %span Review

          - if program_nav?
            %li{class: nav_item_class("event-program-link")}
              = link_to event_staff_program_proposals_path(current_event) do
                %i.fa.fa-sitemap
                %span Program

          - if schedule_nav?
            %li{class: nav_item_class("event-schedule-link")}
              = link_to event_staff_schedule_grid_path(current_event) do
                %i.fa.fa-calendar
                %span Schedule

          - if staff_nav?
            %li{class: nav_item_class("event-dashboard-link")}
              = link_to event_staff_path(current_event) do
                %i.fa.fa-dashboard
                %span Dashboard

          - if admin_nav?
            = render partial: "layouts/nav/admin_nav"

          = render partial: "layouts/nav/notifications_list"

          = render partial: "layouts/nav/user_dropdown"

      - else
        %ul.nav.navbar-nav.navbar-right
          %li= link_to "Log in", new_user_session_path

- if display_staff_event_subnav?
  = render partial: "layouts/nav/staff/event_subnav"

- elsif program_mode?
  = render partial: "layouts/nav/staff/program_subnav"

- elsif schedule_mode?
  = render partial: "layouts/nav/staff/schedule_subnav"

We can start moving what we can to appropriate places. By now you might be able to visualize the code we will end up with or even DIY if you so wish. If you try you will see that refactoring some legacy rails apps to TB is not that hard, and the flow of it is very fluent and logical. We will also see how we can get rid of some rails callbacks using logic in cells. Navbar in our legacy project is big enough to deserve its concept too, so we are getting more and more of code division and grouping. I will post the result of our base layout and navbar view. We will go over the most interesting and noteworthy stuff and add some to do's for later.

First of all, this is our current stack of files.

As you can see we hold only the base stuff in Cfp namespace, when we use layout: Cfp::Cell::BaseLayout this layout in our controller we get all the stuff that this layout gives us, and when we pass the context: { current_user: current_user } we will get access to context[:current_user] in EVERY cell that follows rendering of base layout. We won't have to pass it every time. So, for example, this is our BaseLayout view and cell right now:

# app/concepts/cfp/cell/base_layout.rb
module Cfp
  module Cell
    class BaseLayout < Trailblazer::Cell
      private

      def current_user
        context[:current_user]
      end

      def body_id
        "#{context[:controller].request.env['REQUEST_PATH'].tr('/', '_')}_#{context[:controller].action_name}"
      end

      def show_flash
        context[:controller].flash.map do |key, value|
          key += ' alert-info' if key == 'notice'
          key = 'danger' if key == 'alert'
          content_tag(:div, class: "container alert alert-dismissable alert-#{key}") do
            content_tag(:button, content_tag(:span, '', class: 'glyphicon glyphicon-remove'),
                        class: 'close', data: { dismiss: 'alert' }) +
              simple_format(value)
          end
        end.join.html_safe
      end

      def current_event
        @current_event ||= set_current_event(session[:current_event_id]) if session[:current_event_id]
      end

      def set_current_event(event_id)
        @current_event = Event.find_by(id: event_id).try(:decorate)
        session[:current_event_id] = @current_event.try(:id)
        @current_event
      end
    end
  end
end

It has some helper methods, it sets current event to be passed further down to EventFooter cell, it uses context[:controller] - set by default by Trailblazer - to deal with flash messages display and classes, and it uses context[:current_user] to set an easier access to current_user via a method rather than accessing context each time. It also uses the controller from context to set some css id.

/* app/concepts/cfp/view/base_layout.haml */
!!!5
%html(lang="en")
  %head
    %title= title
    %meta(name="viewport" content="width=device-width, initial-scale=1.0")

    = stylesheet_link_tag 'application', media: 'all'
    = stylesheet_link_tag '//maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css', media: 'all'
    = stylesheet_link_tag "//fonts.googleapis.com/css?family=Open+Sans:400italic,600italic,400,600"

    = javascript_include_tag 'application'
    = javascript_include_tag "//www.google.com/jsapi", "chartkick"
    = javascript_include_tag { yield :custom_js }

    // html5 shim and respond.js ie8 support of html5 elements and media queries
    /[if lt ie 9]
      = javascript_include_tag "/js/html5shiv.js"
      = javascript_include_tag "/js/respond.min.js"


  #flash= show_flash

  %body{id: body_id}
    = cell(Navigation::Cell::Main, nil, context: {current_event: current_event})
    .main
      .main-inner
        .container-fluid
          =yield

  = cell(Cfp::Cell::EventFooter, nil, context: {current_event: current_event})

In the view, as you can see we do some stuff we usually do in application.html, but doing so in cells gives us more flexibility and makes it easier to create a separate layout for different parts of the system. We will get to that when we create a layout for unlogged users. We also added another Navigation scope in concepts where we deal with nav bar for our view.

/* app/concepts/navigation/view/main.haml */
.navbar.navbar-default.navbar-fixed-top
  .container-fluid
    .navbar-header
      %button.navbar-toggle{ type: "button", data: { toggle: "collapse", target: ".navbar-collapse" } }
        %span.icon-bar
        %span.icon-bar
        %span.icon-bar
      - if current_event
        = link_to "#{current_event.name} CFP", event_path(current_event), class: 'navbar-brand'
      - else
        = link_to "CFP App", events_path, class: 'navbar-brand'

    .collapse.navbar-collapse
      - if current_user
        %ul.nav.navbar-nav.navbar-right
          - if speaker_nav?
            %li{class: nav_item_class("my-proposals-link")}
              %a{:href => proposals_path}
                %i.fa.fa-file-text
                %span My Proposals
          - if review_nav?
            %li{class: nav_item_class("event-review-proposals-link")}
              %a{:href => event_staff_proposals_path(current_event)}
                %i.fa.fa-balance-scale
                %span Review
          - if program_nav?
            %li{class: nav_item_class("event-program-link")}
              %a{:href => event_staff_program_proposals_path(current_event)}
                %i.fa.fa-sitemap
                %span Program
          - if schedule_nav?
            %li{class: nav_item_class("event-schedule-link")}
              %a{:href => event_staff_schedule_grid_path(current_event)}
                %i.fa.fa-calendar
                %span Schedule
          - if staff_nav?
            %li{class: nav_item_class("event-dashboard-link")}
              %a{:href => event_staff_path(current_event)}
                %i.fa.fa-dashboard
                %span Dashboard

          - if admin_nav?
            = cell(Navigation::Cell::Admin)
          = cell(Navigation::Cell::NotificationsList)
          = cell(Navigation::Cell::UserDropdown)

      - else
        %ul.nav.navbar-nav.navbar-right
          %li= link_to "Log in", new_user_session_path

This is the view of our nav bar, it encapsulates the logic for all the ifs in the cell, and it renders more cells. Like NotificationsList.

# app/concepts/navigation/cell/notifications_list.rb
module Navigation
  module Cell
    class NotificationsList < Trailblazer::Cell
      def notifications_count
        context[:current_user].notifications.unread.length
      end

      def notifications_more_unread_count
        context[:current_user].notifications.more_unread_count
      end

      def notifications_unread
        context[:current_user].notifications.recent_unread
      end

      def unread_notications?
        context[:current_user].notifications.unread.any?
      end

      def more_unread_notifications?
        context[:current_user].notifications.more_unread?
      end
    end
  end
end

See? It still uses the context that was passed way back in the controller, we only passed the current user once into the context, into a completely different cell, but we still have easy access to it. Pasting all the small cells and views would be unnecessary, and at this point, you can probably figure them out by yourself. Now lets checkout out our view.

This pretty much covers the base of refactoring legacy views, there are still more options to cells of course, but this is enough to get you started. Next up we will cover testing cells, passing collections into cells, using a different layout for different views and other cells options that will come along the way.


  1. The cells gem is completely stand-alone and can be used without Trailblazer. 

  2. As said in the gem page, cells can be used to build a proper OOP, polymorphic builders, nesting, view inheritance, using Rails helpers, asset packaging to bundle JS, CSS or images, simple distribution via gems or Rails engines, encapsulated testing, etc. 

💖 💪 🙅 🚩
krzykamil
Krzysztof

Posted on November 7, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related