DEV Community

Cover image for Trailblazer tutorial: collections, forms, testing Cells - part 6
Krzysztof for 2n IT

Posted on • Originally published at 2n.pl

Trailblazer tutorial: collections, forms, testing Cells - part 6

In this post, we will finish up refactoring views that we saw, including a view that is heavily based on callbacks. We won't be able to delete them yet since for now we only render the cell in one view, all the others will still use ActionView and those callbacks, but we will be able to get rid of them for this one place at least. We will also get to know how to use collections for cells and how to write some tests for them.

Let's start by finishing the TODO's we left last time.


- if display_staff_event_subnav?
  = cell(Navigation::Staff::Cell::Event)
- elsif schedule_mode?
  = cell(Navigation::Staff::Cell::Schedule)

As you can see we nest another concept into Navigation, called Staff. Then we create another folder cell and another folder view. This keeps our code structured logically. Keeping to this convention you might have a lot of files called event.rb or event.haml but they will be in their right scope so it will be even easier to find them, depending on the context you want the view for even for. You might have events displayed differently in many places in the app like the navbar event will have a different display that event-specific show view.

This will be represented accordingly in your Trailblazer files structure. In those cells, we will also use inheritance, cause we need methods that are in the Main cell. It is worth mentioning that cells allow us to inherit entire views, not just class methods. You can also inherit ONLY the view, without the class through inherit_views Something::Cell. But by default, you will also get the view inheritance as a "backup" for your view. This means that our Event cell will look for a view in its parent folder scope if the view for its cell is not found.


Navigation::Staff::Cell::Event.prefixes => ["app/concepts/navigation/staff/view", "app/concepts/navigation/view"]

The cells and views are nothing new by themselves for you.

/* app/concepts/navigation/staff/view/schedule.haml */
.navbar.navbar-fixed-top.schedule-subnav
  .container-fluid
    %ul.nav.navbar-nav.navbar-right
    %li{class: subnav_item_class('event-schedule-grid-link')}
        %a{:href => event_staff_schedule_grid_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-calendar-times-o
        %span Grid
    %li{class: subnav_item_class('event-schedule-time-slots-link')}
        %a{:href => event_staff_schedule_time_slots_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-clock-o
        %span Time Slots
    %li{class: subnav_item_class('event-schedule-rooms-link')}
        %a{:href => event_staff_schedule_rooms_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-tag
        %span Rooms

# app/concepts/navigation/staff/cell/schedule.rb
module Navigation
  module Staff
    module Cell
      class Schedule < Navigation::Cell::Main
        alias event model
        property :slug
      end
    end
  end
end

We only change the inheritance, the rest is known. However, we have a problem to deal with - the mentioned callbacks that are going to be called when entering this view:


/* app/concepts/navigation/view/main.haml */
- elsif program_mode?
  = render partial: "layouts/nav/staff/program_subnav"

Let's create the cell and the view, paste the partial content into it and start dealing with callbacks one by one. program_mode? comes from ApplicationController itself. There it bases the result of the method on an instance variable set by a callback from a concern called ProgramSupport. This concern is included in controllers that will want to render this partial.

So, if we will aim to refactor the whole app using cells, we will be able to clean up ApplicationController and get rid of some concern that includes callbacks. A lot of benefits on the horizon. But right now we are limited to fixing one place. We need to define the condition anew. The ProgramSupport also has some helper methods that we will need to maintain the same state of our page.

So this is the concern:


# app/controllers/concerns/program_support.rb
require 'active_support/concern'

module ProgramSupport
  extend ActiveSupport::Concern

  included do
    before_action :require_program_team
    before_action :enable_staff_program_subnav
    before_action :set_proposal_counts

    helper_method :sticky_selected_track
  end

  private

  def sticky_selected_track
    session["event/#{current_event.id}/program/track"] if current_event
  end

  def sticky_selected_track=(id)
    session["event/#{current_event.id}/program/track"] = id if current_event
  end

  def set_proposal_counts
    @all_accepted_count ||= current_event.stats.all_accepted_proposals
    @all_waitlisted_count ||= current_event.stats.all_waitlisted_proposals
    unless sticky_selected_track == 'all'
    @all_accepted_track_count ||= current_event.stats.all_accepted_proposals(sticky_selected_track)
    @all_waitlisted_track_count ||= current_event.stats.all_waitlisted_proposals(sticky_selected_track)
    end
  end
end

And this is how we will remake this into a cell:


# app/concepts/navigation/staff/cell/program.rb
module Navigation
  module Staff
    module Cell
      class Program < Navigation::Cell::Main
        alias event model
        property :slug
        property :stats
        property :tracks

        private

        def program_tracks
          tracks&.any? ? tracks : []
        end

        def sticky_selected_track
          session["event/#{event.id}/program/track"]
        end

        def sticky_selected_track=(id)
          session["event/#{event.id}/program/track"] = id
        end

        def all_accepted_count
          stats.all_accepted_proposals
        end

        def all_waitlisted_count
          stats.all_waitlisted_proposals
        end

        def all_accepted_track_count
          stats.all_accepted_proposals(sticky_selected_track) if sticky_selected_track != 'all'
        end

        def all_waitlisted_track_count
          stats.all_waitlisted_proposals(sticky_selected_track) if sticky_selected_track != 'all'
        end
      end
    end
  end
end

Now we also need to deal with resolving the program_mode? method, which was based on callbacks, being run in certain controllers. The simplest way is that we still have access to the controller being used from the context. So we can simply check from what controller did the cell was rendered from, and if it fits our needs, we will return true in the condition.

No callbacks, no including, no setting variables used in one method. Just a check if the request came from the right place to render the cell. We are still dealing with the navigation bar at the top of the site, so its something displayed with base layouts, so controllers coming into this place will vary. This ends up being out of the view.


/* app/concepts/navigation/staff/view/program.haml */
.navbar.navbar-fixed-top.program-subnav
  .container-fluid
    %ul.nav.navbar-nav.navbar-right
    %li{class: subnav_item_class("event-program-proposals-link")}
        %a{:href => event_staff_program_proposals_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-balance-scale
        %span Proposals
    %li{class: subnav_item_class("event-program-proposals-selection-link")}
        %a{:href => selection_event_staff_program_proposals_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-gavel
        %span Selection
    %li{class: subnav_item_class("event-program-sessions-link")}
        %a{:href => event_staff_program_sessions_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-check
        %span Sessions
    %li{class: subnav_item_class("event-program-speakers-link")}
        %a{:href => event_staff_program_speakers_path(slug), :class => "text-primary", :method => :post}
        %i.fa.fa-users
        %span Speakers

    %ul.nav.navbar-nav.session-counts
    %li.static
        %span.title Sessions:
        %div.counts-container
        .total.all-accepted
            %span Accepted
            %span.badge= all_accepted_count
        .total.all-waitlisted
            %span Waitlisted
            %span.badge= all_waitlisted_count

    - if program_tracks.any?
        %li.static
        %span.title By Track:
        %form.track-select{data: {event: slug}}
            %select{id: 'track-select', name: 'track'}
            %option{value: 'all'} All
            %option{value: ' ', selected: selected?} General
            - program_tracks.each do |track|
                = cell(Navigation::Staff::Cell::TrackRow, track, sticky_selected_track: sticky_selected_track)
        %div.counts-container
            .by-track.all-accepted
            %span Accepted
            %span.badge= all_accepted_track_count
            .by-track.all-waitlisted
            %span Waitlisted
            %span.badge= all_waitlisted_track_count

Notice the nested cell, we pass options there. This is how the sticky_selected_track is later accessible.


# app/concepts/navigation/staff/cell/track_row.rb
module Navigation
  module Staff
    module Cell
      class TrackRow < Trailblazer::Cell
        alias track model

        property :id
        property :name

        private

        def selected?
          id.to_s == options[:sticky_selected_track]
        end
      end
    end
  end
end

/* app/concepts/navigation/staff/view/track_row.haml */
%option{value: id, selected: selected?}= name

And our navigation bar looks complete now, no callbacks in this view. It is also free to be used in the future, so from now on, we would only have to refactor content views (like proposal index that we did), while nav bar stays the same, rendered by our BaseLayout.

Now we can move on to some other options cells give us. Let's start from the collection option and its base usage.


- program_tracks.each do |track|
  = cell(Navigation::Staff::Cell::TrackRow, track, sticky_selected_track: sticky_selected_track)

Consider this code and how many times we've already used each to iterate over the collection and render a cell for each. We can simplify this to:


= cell(Navigation::Staff::Cell::TrackRow, collection: program_tracks, sticky_selected_track: sticky_selected_track)

Now we don't need to pass in the track we simply pass the collection. Each item from it will render a cell with itself as the model of that cell. Going back we can do this for every |each| do we created so far.

- notifications_unread.each do |notification|
  = cell(Navigation::Cell::NotificationRow, notification)

= cell(Navigation::Cell::NotificationRow, collection: notifications_unread)


- invites.each do |invitation|
  = cell(Proposal::Cell::InviteRow, invitation)

= cell(Proposal::Cell::InviteRow, collection: invites)

- talks.each do |talk|
  = cell(Proposal::Cell::TalkRow, talk, event: event)

= cell(Proposal::Cell::TalkRow, collection: talks, event: event)

You can see the comparison and as you can see, we can still pass in options (like the event in the TalkRow). While we are at it, let's check out other options we have with the collection, while not useful for us now, it is worth going over them, to know what we will have access to.

  • If we ever the cell to use something else than the show method to display its content, we can also call it on the collection by simply changing to:

= cell(Proposal::Cell::TalkRow, collection: talks, event: event).(:method)

  • Cells from the collection can be invoked differently, and joining them can also have additional options. Each item from collection will have their show method invoked, and then joined using the additional options passes to join like so:

= cell(:something, collection: Something.all).join("<hr/>") do |cell, i|
  i.odd? ? cell.(:show) : cell(:alternate_method)
end
# => "rendered with show\n<hr/>alternate render\n<hr/>rendered with show\n<hr/>alternate render"

join with an argument to be inserted between renders can be also called without a block:


= cell(Proposal::Cell::InviteRow, collection: invites).join("br /")

We should now talk about testing. I know in normal TDD we should have started with that, but this tutorial was focused on building and using cells to refactor the view of an application. So after doing that we should check if our tests are still good, and we can add cell tests to that. Our goal is to get rid of the controller test, and only have unit and integration tests (approach suggested by TB on its main page).

The controller's test still passes.

bundle exec rspec spec/controllers/proposals_controller_spec.rb
...................

Finished in 1.67 seconds (files took 3.29 seconds to load)
19 examples, 0 failures

We need to deal with integration tests though. This will be a lot more work, cause we also have to account for changes made before when creating operations and contracts. We will, however, aim to have still the same integration tests and add tests for cells on top of that. In the long term, this would eventually let us get rid of controllers' tests, along with adding more and more unit tests to operations. For now, we are modifying smaller parts of the app so we only add new stuff and change existing, without deleting things that eventually will become obsolete and unused.

feature 'Proposals' do
  let!(:user) { create(:user) }
  let!(:event) { create(:event, state: 'open') }
  let!(:closed_event) { create(:event, state: 'closed') }
  let!(:session_format) { create(:session_format, name: 'Only format') }
  let(:session_format2) { create(:session_format, name: '2nd format') }

  let(:go_to_new_proposal) { visit new_event_proposal_path(event_slug: event.slug) }

  let(:create_proposal) do
    fill_in 'Title', with: 'General Principles Derived by Magic from My Personal Experience'
    fill_in 'Abstract', with: 'Because certain things happened to me, they will happen in just the same manner to everyone.'
    fill_in 'proposal_speakers_attributes_0_bio', with: 'I am awesome.'
    fill_in 'Pitch', with: 'You live but once; you might as well be amusing. - Coco Chanel'
    fill_in 'Details', with: 'Plans are nothing; planning is everything. - Dwight D. Eisenhower'
    select 'Only format', from: 'Session format'
    click_button 'Submit'
  end

  let(:create_invalid_proposal) do
    fill_in 'proposal_speakers_attributes_0_bio', with: 'I am a great speaker!.'
    fill_in 'Pitch', with: 'You live but once; you might as well be amusing. - Coco Chanel'
    fill_in 'Details', with: 'Plans are nothing; planning is everything. - Dwight D. Eisenhower'
    click_button 'Submit'
  end

  before { login_as(user) }
  after { ActionMailer::Base.deliveries.clear }

  context 'when navigating to new proposal page' do
    context 'after closing time' do
      it 'redirects and displays flash' do
        visit new_event_proposal_path(event_slug: closed_event.slug)

        expect(current_path).to eq event_path(closed_event)
        expect(page).to have_text('The CFP is closed for proposal submissions.')
      end
    end
  end

  context 'when submitting' do
    context 'with invalid proposal' do
      before :each do
        go_to_new_proposal
        create_invalid_proposal
      end

      it 'submits unsuccessfully' do
        expect(page).to have_text('There was a problem saving your proposal.')
      end

      it 'shows Title validation if blank on submit' do
        expect(page).to have_text("Title *can't be blank")
      end

      it 'shows Abstract validation if blank on submit' do
        expect(page).to have_text("Abstract *can't be blank")
      end
    end

    context 'less than one hour after CFP closes' do
      before :each do
        go_to_new_proposal
        event.update(state: 'closed')
        event.update(closes_at: 55.minutes.ago)
        create_proposal
      end

      it 'submits successfully' do
        expect(page).to have_text('Thank you! Your proposal has been submitted and may be reviewed at any time while the CFP is open.')
      end
    end

    context 'more than one hour after CFP closes' do
      before :each do
        go_to_new_proposal
        event.update(state: 'closed')
        event.update(closes_at: 65.minutes.ago)
        create_proposal
      end

      it 'does not submit' do
        expect(page).to have_text('The CFP is closed for proposal submissions.')
      end
    end

    context 'with Session Formats' do
      # Default if one Session Format that it is auto-selected
      it "doesn't show session format validation if one session format" do
        go_to_new_proposal
        create_invalid_proposal
        expect(page).to_not have_text("Session format *None selected Only format 2nd formatcan't be blank")
      end

      it 'shows Session Format validation if two session formats' do
        skip 'Address after session format change'
        session_format2.save!
        go_to_new_proposal
        create_invalid_proposal
        expect(page).to have_text("Session format *None selected Only format 2nd formatcan't be blank")
      end
    end

    context 'with an existing bio' do
      before { user.update_attribute(:name, 'new speaker') }
      before { user.update_attribute(:bio, 'new bio') }

      it "shows the user's bio in the bio field" do
        go_to_new_proposal
        expect(page).to have_field('Bio', with: 'new bio')
      end

      it "shows the user's name in the name field" do
        go_to_new_proposal
        expect(page).to have_field('Name', disabled: true, with: 'new speaker')
      end
    end

    context 'With Pitch and Details' do
      before :each do
        go_to_new_proposal
        create_proposal
      end

      it 'submits successfully' do
        expect(Proposal.last.abstract).to_not match('<p>')
        expect(Proposal.last.abstract).to_not match('</p>')
        expect(page).to have_text('Thank you! Your proposal has been submitted and may be reviewed at any time while the CFP is open.')
      end

      it 'does not create an empty comment' do
        expect(Proposal.last.public_comments).to be_empty
      end

      it "shows the proposal on the user's proposals list" do
        visit proposals_path
        within(:css, 'div.proposals') do
          expect(page).to have_text('General Principles')
        end
      end
    end
  end

  context 'when editing' do
    scenario 'User edits their proposal' do
      go_to_new_proposal
      create_proposal
      click_link 'Edit'

      expect(page).to_not have_text('A new title')
      fill_in 'Title', with: 'A new title'
      click_button 'Submit'
      expect(page).to have_text('A new title')
    end
  end

  context 'when commenting' do
    before :each do
      go_to_new_proposal
      create_proposal
      fill_in 'public_comment_body', with: "Here's a comment for you!"
      click_button 'Comment'
    end

    scenario 'User comments on their proposal' do
      expect(page).to have_text("Here's a comment for you!")
    end

    it "it does not show the speaker's name" do
      within(:css, '.speaker-comment') do
        expect(page).to have_text('speaker')
      end
    end
  end

  context 'when confirming' do
    let(:proposal) { create(:proposal) }

    before { proposal.update(state: Proposal::State::ACCEPTED) }

    context 'when the proposal has not yet been confirmed' do
      let!(:speaker) { create(:speaker, proposal: proposal, user: user) }

      before do
        visit event_proposal_path(event_slug: proposal.event.slug, uuid: proposal)
        click_link 'Confirm'
      end

      it 'marks the proposal as confirmed' do
        expect(proposal.reload.confirmed?).to be_truthy
      end

      it 'redirects the user to the proposal page' do
        expect(current_path).to eq(event_proposal_path(event_slug: proposal.event.slug, uuid: proposal))
        expect(page).to have_text(proposal.title)
        expect(page).to have_text("You have confirmed your participation in #{proposal.event.name}.")
      end
    end

    context 'when the proposal has already been confirmed' do
      let!(:speaker) { create(:speaker, proposal: proposal, user: user) }

      before do
        proposal.update(confirmed_at: DateTime.now)
        visit event_proposal_path(event_slug: proposal.event.slug, uuid: proposal)
      end

      it 'does not show the confirmation link' do
        expect(page).not_to have_link('Confirm my participation')
      end
    end
  end

  context 'when deleted' do
    let(:proposal) { create(:proposal, event: event, state: Proposal::State::SUBMITTED) }
    let!(:speaker) { create(:speaker, proposal: proposal, user: user) }

    before do
      visit event_proposal_path(event_slug: event.slug, uuid: proposal)
      click_link 'delete'
    end

    it 'redirects to the proposal show page' do
      expect(page).not_to have_text(proposal.title)
    end
  end

  context 'when withdrawn' do
    let(:proposal) { create(:proposal, :with_reviewer_public_comment, event: event, state: Proposal::State::SUBMITTED) }
    let!(:speaker) { create(:speaker, proposal: proposal, user: user) }

    before do
      visit event_proposal_path(event_slug: event.slug, uuid: proposal)
      click_link 'Withdraw'
      expect(page).to have_content('As requested, your talk has been removed for consideration.')
    end

    it 'sends a notification to reviewers' do
      expect(Notification.count).to eq(1)
    end

    it 'redirects to the proposal show page' do
      expect(page).to have_text(proposal.title)
    end
  end
end

This is what we are dealing with. The functionalities of the page stayed the same, but now we have operations, contracts, and cells. So we need to maintain those functionalities.

Finished in 9.71 seconds (files took 3.26 seconds to load)
22 examples, 6 failures, 1 pending

The first failure is from Proposals when submitting with invalid proposal submits unsuccessfully context.

Failure/Error: expect(page).to have_text('There was a problem saving your proposal.')
expected to find text "There was a problem saving your proposal." in "Fine Event3 CFP View all notifications User Name 2 My Profile Sign Out Proposal creation failure Fine Event3 Dec 02 - 07, 2019 http://fineevent.com/ Fine Event3 CFP CFP open CFP closes: Nov 28, 2019 at 12:46pm UTC 21 days left to submit your proposal Submit a proposal We want all the good talks! Fine Event3 Contact: Powered by CFP App"

Since not all validations were yet moved from model to contract, and our test provides data required by contract, the test fails on persist step, cause it validates Proposal as ApplicationRecord instance, which has additional validations. This is easily found out, using trace and wtf?.

result = Proposal::Operation::Create.trace(params: params, current_user: current_user)

result.wtf?
`-- Proposal::Operation::Create
   |-- Start.default
   |-- event
   |-- model.build
   |-- assign_event
   |-- contract.build
   |-- event_open?
   |-- contract.default.validate
   |   |-- Start.default
   |   |-- contract.default.params_extract
   |   |-- contract.default.call
   |   `-- End.success
   |-- persist.save
   `-- End.failure

Wee need move those

validates :title, :abstract, :session_format, presence: true

Here:


# app/concepts/proposal/contract/create.rb
module Proposal::Contract
  class Create < Reform::Form
    property :title
    property :abstract
    property :details
    property :pitch
    property :session_format_id
    property :event_id
    property :current_user, virtual: true
    validates :current_user, presence: true
    validates :title, presence: true
    validates :abstract, presence: true
    validates :session_format_id, presence: true
    [...]

We can add parsing default validation errors like this:

# app/controllers/proposals_controller.rb
def create
  result = Proposal::Operation::Create.call(params: params, current_user: current_user)
  if result.success?
    flash[:info] = setup_flash_message(result[:model].event)
    redirect_to event_proposal_url(event_slug: result[:model].event.slug, uuid: result[:model])
  elsif result[:errors] == 'Event not found'
    flash[:danger] = 'Your event could not be found, please check the url.'
    redirect_to events_path
  elsif result[:errors] == 'Event is closed'
    flash[:danger] = 'The CFP is closed for proposal submissions.'
    redirect_to event_path(slug: result[:model].event.slug)
  elsif result["contract.default"].errors.size.positive?
    errors = result["contract.default"].errors.messages
    flash[:danger] = errors.map{ |field, msg| "#{field.to_s.capitalize} => #{msg.first}" }.join(', ')
    redirect_to event_path(slug: result[:model].event.slug)
  end
end

And it will now pass:


bundle exec rspec spec/features/proposal_spec.rb:52
Run options: include {:locations=>{"./spec/features/proposal_spec.rb"=>[52]}}
.

Finished in 2.05 seconds (files took 3.28 seconds to load)
1 example, 0 failures

BUT! We added operations to not deal with those stuff in the controller, so let us parse those errors in operation, and use the same way of displaying errors in the operation as we used to.

One more try. Let's move back to operation


# app/concepts/proposal/operation/create.rb
[...]
step Contract::Validate(key: :proposal)
fail :validation_failed, fail_fast: true

[...]
def validation_failed(ctx, **)
  errors = ctx["contract.default"].errors.messages
  ctx[:errors] = errors.map{ |field, msg| "#{field.to_s.capitalize} => #{msg.first}" }.join(', ')
end
[...]

And then in the controller:

[...]
else
  flash[:danger] = result[:errors]
  redirect_to event_path(slug: result[:model].event.slug)
end
[...]

No nasty mapping in the controller, operation deals with everything, controllers stay a happy place. The test stays green. We change other tests that validate some fields the same way, just to reflect new message display style, validations stay the same, they just happen on contract, not model level.

A plugin that allows rspec for cells is available separately, but if you are following this tutorial you will have it included as a dependency already 1. We will also rely on capybara since it is the way it has been established in this app so far for making integration/feature tests.

So if you use rspec, be sure to add the gem for it to work with cells
gem "rspec-cells"2

└──● rails g rspec:cell proposal index

Running this will create:

# spec/cells/proposal_cell_spec.rb
require 'rails_helper'

RSpec.describe Proposal::Cell::Index, type: :cell do
  context 'cell rendering' do
    context 'rendering index' do
      subject { cell(described_class, proposal).call(:show) }

      it { is_expected.to have_selector('h1', text: 'Proposal#index') }
      it { is_expected.to have_selector('p', text: 'Find me in app/concepts/proposal/cell/index.haml') }
    end
  end
end

This is quite convenient, although it generates the path as not for the full trailblazer stack, but rather for just using cells as a view object template. We just have to change the class name and set the right subject. Let's add some stuff to the test. Please note that we have access to both cell and concept helpers from the test level. We can recreate the real environment easily thanks to that. Using options we discussed earlier is also available thanks to that (like collection).

We can start taking controller tests and feature tests that touched the index page of proposals and start making them into feature/integration tests from the level of our cell test. In the end, we will be able to get rid of the controller test and one huge feature test in favor of grouped by cell tests. We are also gonna add cells to create action in our controller to complete refactoring that flow to TB. This will also allow us to move some scenarios from the test above.

# app/controllers/proposals_controller.rb
if @event.closed?
  redirect_to event_path(@event)
  flash[:danger] = 'The CFP is closed for proposal submissions.'
  return
end

render html: cell(Proposal::Cell::New, @event,
  context: { current_user: current_user },
  layout: Cfp::Cell::BaseLayout
)
flash.now[:warning] = incomplete_profile_msg unless current_user.complete?
end
if @event.closed?
  redirect_to event_path(@event)
  flash[:danger] = 'The CFP is closed for proposal submissions.'
  return
end
@proposal = @event.proposals.new
@proposal.speakers.build(user: current_user)
flash.now[:warning] = incomplete_profile_msg unless current_user.complete?

We move to set the proposal and building speakers into a cell, but this whole method should just render a form, right? And as you can see in the old controller code, the proposal is being initialized, along with speakers before the render. We also want to initialize and build those stuff, but we don't want to do it in the cell. Of course, we could, but it wouldn't be ideal. Look at the code below, where everything is working and overall seems okay, bu we simply move initialization of the proposal into a cell.

app/concepts/proposal/view/new.haml
.event-info-bar
  .row
    .col-md-8
      .event-info.event-info-dense
        %strong.event-title= event.name
        - if has_date_range?
          %span.event-meta
            %i.fa.fa-fw.fa-calendar
            = date_range
    .col-md-4.text-right.text-right-responsive
      .event-info.event-info-dense
        %span{:class => "event-meta event-status-badge event-status-#{status}"}
          CFP
          = status
        - if event.open?
          %span.event-meta
            CFP closes:
            %strong= closes_at(:month_day_year)

.page-header.page-header-slim
  .row
    .col-md-12
      %h1 Submit a Proposal

.row
  .col-md-12
    .tabbable
      %ul.nav.nav-tabs
        %li.active
          %a{"data-toggle" => "tab", :href => "#create-proposal"} Proposal Form
        %li
          %a{"data-toggle" => "tab", :href => "#preview"} Preview
      .tab-content
        #create-proposal.tab-pane.active
          .row
            .col-md-8
              %p
                Read the <strong>#{link_to 'guidelines', event_path(event.slug)}</strong> to maximize
                your chance of approval. Refrain from including any information that
                would allow a reviewer to identify you.
              %p
                All fields support
                %a{href: markdown_link}
                  %strong GitHub Flavored Markdown.

              = simple_form_for proposal, url: event_proposals_path(event) do |f|
                = render partial: 'form', locals: {f: f}
        #preview.tab-pane
          = render partial: 'preview', locals: { proposal: proposal }
# app/concepts/proposal/cell/new.rb

module Proposal::Cell
  class New < Trailblazer::Cell
    alias event model

    property :start_date
    property :end_date
    property :status
    property :closes_at
    property :date_range

    private

    def has_date_range?
      date_range && end_date
    end

    def markdown_link
      'https://help.github.com/articles/github-flavored-markdown'
    end

    def proposal
      proposal = event.proposals.new
      # This sort of thing should happen in Present scope of operation, we will get to that soon
      proposal.speakers.build(user: context[:current_user])
      proposal
    end
  end
end

We move to set the proposal and building speakers into a cell, but this whole method should just render a form, right? And as you can see in the old controller code, the proposal is being initialized, along with speakers before the render. We also want to initialize and build those stuff, but we don't want to do it in the cell. Of course, we could, but it wouldn't be ideal. Look at the code below, where everything is working and overall seems okay, but we simply move the initialization of the proposal into a cell.

# app/concepts/proposal/operation/create.rb
module Proposal::Operation
  class Create < Trailblazer::Operation
    class Present < Trailblazer::Operation
      step :event
      fail :event_not_found, fail_fast: true
      step Model(Proposal, :new)
      step :event_open?
      fail :event_not_open_error, fail_fast: true
      step :assign_event
      step Contract::Build(constant: Proposal::Contract::Create, builder: -> (ctx, constant:, model:,  **){
        constant.new(model, current_user: ctx[:current_user])
        })
      # [...we will need to move the methods used here...]
    end
    step Contract::Validate(key: :proposal)
    fail :validation_failed, fail_fast: true
    step Contract::Persist(method: :save)
    step :update_user_bio
    [...]

Structure of our operations changes a bit, we encapsulate staff needed before actual creation to prepare a form with a view in the Present class that we then use in new, like this:


.event-info-bar
  .row
    .col-md-8
      .event-info.event-info-dense
        %strong.event-title= event.name
        - if has_date_range?
          %span.event-meta
            %i.fa.fa-fw.fa-calendar
            = event_date_range
    .col-md-4.text-right.text-right-responsive
      .event-info.event-info-dense
        %span{:class => "event-meta event-status-badge event-status-#{event_status}"}
          CFP
          = event_status
        - if event.open?
          %span.event-meta
            CFP closes:
            %strong= event_closes_at

.page-header.page-header-slim
  .row
    .col-md-12
      %h1 Submit a Proposal

.row
  .col-md-12
    .tabbable
      %ul.nav.nav-tabs
        %li.active
          %a{"data-toggle" => "tab", :href => "#create-proposal"} Proposal Form
        %li
          %a{"data-toggle" => "tab", :href => "#preview"} Preview
      .tab-content
        #create-proposal.tab-pane.active
          .row
            .col-md-8
              %p
                Read the <strong>#{link_to 'guidelines', event_path(event.slug)}</strong> to maximize
                your chance of approval. Refrain from including any information that
                would allow a reviewer to identify you.
              %p
                All fields support
                %a{href: markdown_link}
                  %strong GitHub Flavored Markdown.

                =cell(Proposal::Cell::Form, proposal.model, event: event)
        #preview.tab-pane
          =cell(Proposal::Cell::Preview, proposal, event: event)

module Proposal::Cell
  class New < Trailblazer::Cell
    alias proposal model

    private

    def has_date_range?
      event_start_date && event_end_date
    end

    def markdown_link
      'https://help.github.com/articles/github-flavored-markdown'
    end

    def event
      options[:event]
    end

    def event_start_date
      event.start_date
    end
    def event_end_date
      event.end_date
    end

    def event_status
      event.status
    end


    def event_closes_at
      event.closes_at(:month_day_year)
    end

    def event_date_range
      event.date_range
    end
  end
end

Both work, but the second simply makes more sense. Remember not to settle for the first thing that works, but for what works the best and is the most logical or easy to understand. Now that we ended up with this view and this cell, we have to go deeper and remake the partials that were in the new.htaml.haml into cells, creating out the first form in cells. To make the form work in cells, you need to add some include's' to your form cell.

include ActionView::RecordIdentifier
include ActionView::Helpers::FormOptionsHelper
include SimpleForm::ActionViewExtensions::FormHelper

They allow you to use rails form and SimpleForm helpers so be sure to add this. The view will break if you don't add those and use form helpers so it shouldn't be a problem. In the form cell, we can use our form from the view, no problem. We can define form fields in the cells, we can pass the form as an argument to a cell method. As in the examples below.

We can, however, go for more universal form, rather than just doing both new and update with basically the same views.


# app/concepts/proposal/cell/form.rb
module Proposal::Cell
  class Form < Trailblazer::Cell
    include ActionView::RecordIdentifier
    include ActionView::Helpers::FormOptionsHelper
    include SimpleForm::ActionViewExtensions::FormHelper

    property :session_format_name
    property :tags

    def show
      render :form
    end

    private

    def title_input(form)
      form.input :title,
                 autofocus: true,
                 maxlength: :lookup, input_html: { class: 'watched js-maxlength-alert' },
                 hint: 'Publicly viewable title. Ideally catchy, interesting, essence of the talk. Limited to 60 characters.'
    end

    def markdown_link
      'https://help.github.com/articles/github-flavored-markdown'
    end

    def no_track_name_for_speakers
      "#{Track::NO_TRACK} - No Suggested Track"
    end

    def session_format_more_than_one?
      opts_session_formats.length > 1
    end

    def track_name_for_speakers
      proposal.track.try(:name) || no_track_name_for_speakers
    end

    def has_reviewer_activity?
      proposal.ratings.present? || proposal.has_reviewer_comments?
    end

    def multiple_tracks_without_activity?
      event.multiple_tracks? && !has_reviewer_activity?
    end

    def opts_session_formats
      event.session_formats.publicly_viewable.map { |st| [st.name, st.id] }
    end

    def opts_tracks
      event.tracks.sort_by_name.map { |t| [t.name, t.id] }
    end

    def abstract_tooltip
      'A concise, engaging description for the public program. Limited to 600 characters.'
    end

    def abstract_input(form, _abstract_tooltip = 'Proposal Abstract')
      form.input :abstract,
                 maxlength: 1000, input_html: { class: 'watched js-maxlength-alert', rows: 5 },
                 hint: 'A concise, engaging description for the public program. Limited to 1000 characters.' # , popover_icon: { content: tooltip }
    end

    def event
      options[:event]
    end
  end
end

# app/concepts/proposal/cell/new.rb
module Proposal::Cell
  class New < Form
    alias proposal model
    private

    def has_date_range?
      event_start_date && event_end_date
    end

    def event
      options[:event]
    end

    def event_start_date
      event.start_date
    end

    def event_end_date
      event.end_date
    end

    def event_status
      event.status
    end

    def event_closes_at
      event.closes_at.to_s(:month_day_year)
    end

    def event_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') + event.end_date.strftime(" \- %b %d, %Y")
      end
    end
  end
end

We just inherit the cell and define view specific methods on the child cell. The same will happen to update, and we are free to use one Form cell as a base for both views. Simple. We do not create a view for both create and update we just do one form.haml view (this, of course, depends on how different your edit and new views would be, here we assume it's the same form, but in the edit, the fields are prefilled).


/* app/concepts/proposal/view/form.haml */
.event-info-bar
  .row
    .col-md-8
      .event-info.event-info-dense
        %strong.event-title= event.name
        - if has_date_range?
          %span.event-meta
            %i.fa.fa-fw.fa-calendar
            = event_date_range
    .col-md-4.text-right.text-right-responsive
      .event-info.event-info-dense
        %span{:class => "event-meta event-status-badge event-status-# {event_status}"}
          CFP
          = event_status
        - if event.open?
          %span.event-meta
            CFP closes:
            %strong= event_closes_at

.page-header.page-header-slim
  .row
    .col-md-12
      %h1 Submit a Proposal

.row
  .col-md-12
    .tabbable
      %ul.nav.nav-tabs
        %li.active
          %a{"data-toggle" => "tab", :href => "#create-proposal"} Proposal Form
        %li
          %a{"data-toggle" => "tab", :href => "#preview"} Preview
      .tab-content
        #create-proposal.tab-pane.active
          .row
            .col-md-8
              %p
                Read the <strong>#{link_to 'guidelines', event_path(event.slug)}</strong> to maximize
                your chance of approval. Refrain from including any information that
                would allow a reviewer to identify you.
              %p
                All fields support
                %a{href: markdown_link}
                  %strong GitHub Flavored Markdown.
                = simple_form_for proposal, url: event_proposals_path(event) do |f|
                  %fieldset.margin-top
                    = title_input(f)

                    - if !has_reviewer_activity?
                      - if session_format_more_than_one?
                        = f.association :session_format, collection: opts_session_formats, include_blank: 'None selected', required: true, input_html: {class: 'dropdown'},
                          hint: "The format your proposal will follow."#, popover_icon: { content: session_format_tooltip }
                      - else
                        = f.association :session_format, collection: opts_session_formats, include_blank: false, input_html: {readonly: "readonly"},
                          hint: "The format your proposal will follow."#, popover_icon: { content: "Only One Session Format for #{event.name}" }
                    - else
                      .form-group
                        = f.label :session_format, 'Session format'
                        %div #{session_format_name}

                    - if multiple_tracks_without_activity?
                      = f.association :track, collection: opts_tracks, include_blank: no_track_name_for_speakers, input_html: {class: 'dropdown'},
                        hint: "Optional: suggest a specific track to be considered for."#, popover_icon: { content: track_tooltip }
                    - else
                      .form-group
                        = f.label :track, 'Track'
                        %div #{track_name_for_speakers}

                    = abstract_input(f, abstract_tooltip)

                    - if event.public_tags?
                      .form-group
                        %h3.control-label
                          Tags
                        = f.select :tags,
                          options_for_select(event.proposal_tags, tags),
                          {}, {class: 'multiselect proposal-tags', multiple: true }

                  %fieldset
                    %legend.fieldset-legend For Review Committee
                    %p
                      This content will <strong> only</strong> be visible to the review committee.

                    = f.input :details, input_html: { class: 'watched', rows: 5 },
                      hint: 'Include any pertinent details such as outlines, outcomes or intended audience.'#, popover_icon: { content: details_tooltip }

                    = f.input :pitch, input_html: { class: 'watched', rows: 5 },
                      hint: 'Explain why this talk should be considered and what makes you qualified to speak on the topic.'#, popover_icon: { content: pitch_tooltip }

                  - if event.custom_fields.any?
                    - event.custom_fields.each do |custom_field|1
                      .form-group
                        = f.label custom_field
                        = text_field_tag "proposal[custom_fields][#{custom_field}]", proposal.custom_fields[custom_field], class: "form-control"

                  -# TODO
                  -# = render partial: 'speakers/fields', locals: { f: f, event: event }

                  .form-submit.clearfix.text-right
                    - if proposal.persisted?
                      = link_to "Cancel", event_proposal_path(event_slug: event.slug, uuid: proposal), {class: "btn btn-default btn-lg"}
                    - else
                      = link_to "Cancel", event_path(event.slug), {class: "btn btn-default btn-lg"}
                    %button.btn.btn-primary.btn-lg{type: "submit"} Submit
        #preview.tab-pane
          =cell(Proposal::Cell::Preview, proposal, event: event)

This will render our form view:

We defined some fields and cells, and put a lot of helper methods in it, we could move more and more to cells, but I think you get the gist by now of what you can do with it.

Let's finish our cell test.

require 'rails_helper'

RSpec.describe Proposal::Cell::Index, type: :cell do
  subject { cell(described_class, current_user, context: { current_user: current_user }).call(:show) }
  controller ProposalsController
  context 'cell rendering' do
    context 'without proposals' do
      let(:current_user) { FactoryGirl.create(:user) }
      it { expect(subject).to have_content("You don't have any proposals.") }
    end

    context 'with proposals' do
      let(:current_user) { FactoryGirl.create(:user) }
      let!(:proposal) { create :proposal }
      let!(:speaker) { create :speaker, user: current_user, proposal: proposal, event: proposal.event }

      it { expect(subject).to have_content proposal.title }
    end
  end
end

This is our test in the base, simplistic form (just to show the syntax basically, test content with expects, etc. is not relevant here). Please note, that we do have a controller dependency in this cell test, without controller ProposalsController we would get an error in this particular case (the first test, passes without the dependency, second does not cause one of the nested cells needs the controller)3.

Join us next time, when we cover more and more aspects of Trailblazer!


  1. GH: https://github.com/trailblazer/rspec-cells documentation from TB: http://trailblazer.to/gems/cells/testing.html 

  2. Make sure to add this gem in the gemfile AFTER capybara (below it, in order). https://github.com/trailblazer/rspec-cells/issues/21 

  3. https://github.com/trailblazer/cells/wiki/Troubleshooting-Tests 

Top comments (0)