Trailblazer tutorial: collections, forms, testing Cells - part 6
Krzysztof
Posted on November 19, 2019
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!
-
GH: https://github.com/trailblazer/rspec-cells documentation from TB: http://trailblazer.to/gems/cells/testing.html ↩
-
Make sure to add this gem in the gemfile AFTER capybara (below it, in order). https://github.com/trailblazer/rspec-cells/issues/21 ↩
-
https://github.com/trailblazer/cells/wiki/Troubleshooting-Tests ↩
Posted on November 19, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 7, 2019