Skip to content
This repository has been archived by the owner on Oct 19, 2018. It is now read-only.

5 Squad CRUD refactor (before)

Barrie Hadfield edited this page Jan 5, 2017 · 1 revision

(incomplete wip)

This is a real life example taken from my Squaddie application.

The Squad::Admin component renders a table with one line per squad and there are buttons for Editing, Deleting or creating new Squads. Edit or New open a Dialog over the Table. In this code all reading is done via ReactiveRecord and all writing is done via POST or PUT API calls to the backend.

I have chosen this example as it perfectly illustrates the mess of UI, Models and business logic distributed throughout the front end and (post API) Trailblazer Operation code. Also it will hopefully illustrate the amount of code saving of a IWA (Isomorphic Web Application) architecture vs a Component/API/Controller/Concept/Model architecture.

Code before refactoring

Here is the big Admin component: (yes, I know its a huge component)

module Components
  module Squad

    class Admin < React::Component::Base

      # following added to remove warning messages
      param :route
      param :history
      param :location
      param :routeParams
      param :routes
      param :params

      define_state show_save: false
      define_state errors: []
      define_state show_modal: false
      define_state show_save: false

      before_mount do
        @mandate = ""
        @name = ""
        @leader_id = 0
        @tribe_id = 0
      end

      before_mount do
        state.squads! ReactiveRecord::Squad.all
        UIHelpers::UIHelper.clear_alert
      end

      def render
        div.container {
          if state.squads.nil? || state.squads.loading? # this is not working right yet
            br {}
            Shared::Spinner()
          else
            div.row {
              h2.text_info {
                i.fa.fa_th {}
                " Squads ".span
                small { Shared::Store.current_period[:name] } if Shared::Store.current_period
              }
              div.row {
                  div.panel.panel_default {
                    div.panel_body {
                      div.col_md_12 {
                        br
                        UIHelpers::AlertMessage()
                        modal_render
                        Bs.Button(bsStyle: :primary) { "New Squad" }.on(:click) do
                          reset_form
                          state.show_modal! true
                        end
                        table_render
                      }
                    }
                  }
              }
            }
          end
        }
      end

      def table_render
        div.row {
          div.col_md_12 {
            br {}
            table(class: "table table-hover") {
              thead {
                tr {
                  td.text_muted.small(width: '20%') { "SQUAD" }
                  td.text_muted.small(width: '20%') { "TRIBE" }
                  td.text_muted.small(width: '20%') { "LEADER" }
                  td.text_muted.small(width: '40%') { "MANDATE" }
                }
              }
              tbody {
                state.squads.each do |squad|
                  table_row squad
                end
              }
            }
          }
        }
      end

      def table_row squad
        tr {
            td(width: '20%') { span.link {
              squad.name }.on(:click) do
                set_form squad
                state.show_modal! true
              end
            }
            td(width: '20%') { squad.tribe.name }
            td(width: '20%') { squad.leader.full_name }
            td(width: '60%') { squad.mandate }
        }
      end

      def close
        state.show_modal! false
      end

      def reset_form
        @squad_id = 0
        @delete_disabled = true
        @name = ""
        @mandate = ""
        @leader_id = 0
        @tribe_id = 0
        state.show_save! false
        state.show_delete_confirm! false
        state.errors! []
        state.show_spinner! false
      end

      def set_form squad
        @squad_id = squad.id
        @delete_disabled = squad.can_delete? ? false : true
        @name = squad.name
        @mandate = squad.mandate
        @leader_id = squad.leader.id
        @tribe_id = squad.tribe.id
        state.show_delete_confirm! false
        state.errors! []
        state.show_spinner! false
      end

      def delete
        HTTP.delete("/api/v3/squad/#{@squad_id}.json") do |response|
          if response.ok?
            # Shared::Store.get_squads
            state.show_modal! false
            UIHelpers::UIHelper.set_alert @name, "has been deleted"
          else
            puts response
            alert "Unable to delete Squad"
          end
        end
      end

      def save
        data = {squad: {name: @name,
          mandate: @mandate, leader_id: @leader_id, tribe_id: @tribe_id}
        }
        if @squad_id == 0
          HTTP.post("/api/v3/squad.json", payload: data) do |response|
            if response.ok?
              state.show_modal! false
              UIHelpers::UIHelper.set_alert @name, "has been created"
            else
              alert "Unable to create Squad"
            end
          end
        else
          HTTP.patch("/api/v3/squad/#{@squad_id}.json", payload: data) do |response|
            if response.ok?
              # Shared::Store.get_squads
              state.show_modal! false
              UIHelpers::UIHelper.set_alert @name, "has been updated"
            else
              alert "Unable to update Squad"
            end
          end
        end
      end

      def save_modal
        state.errors! []
        name_validation
        mandate_validation
        leader_validation
        tribe_validation
        unless state.errors.any?
          state.show_spinner! true
          state.errors! []
          save
        end
      end

      def name_validation
        case
        when @name.length < 5
          state.errors! << "Name must be more than 5 chars"
        when @name.length > 30
          state.errors! << "Name must be less than 30 chars"
        end
      end

      def mandate_validation
        case
        when @mandate.length < 10
          state.errors! << "Mandate must be more than 10 chars"
        when @mandate.length > 100
          state.errors! << "Mandate must be less than 100 chars"
        end
      end

      def leader_validation
        case
        when @leader_id.to_i == 0 || !@leader_id
          state.errors! << "Please select a leader"
        end
      end

      def tribe_validation
        case
        when @tribe_id.to_i == 0 || !@tribe_id
          state.errors! << "Please select a Tribe"
        end
      end

      def modal_render
        Bs.Modal(show: state.show_modal, backdrop: 'static', onHide: lambda { close }) {
          Bs.ModalHeader {
            h4 { "Squad" }
          }
          Bs.ModalBody {
            Bs.FormGroup {
              Bs.ControlLabel { "* Tribe" }
              Bs.FormControl(componentClass: "select", placeholder: "select", defaultValue: @tribe_id) {
                option(value: 0) {"select"}
                ReactiveRecord::Tribe.all.each do |tribe|
                  option(value: tribe.id) { tribe.name }
                end
              }.on(:change) do |e|
                @tribe_id = e.target.value
              end
            }
            Bs.FormGroup {
              Bs.ControlLabel { "* Name" }
              Bs.FormControl.Feedback(defaultValue: @name ,type: :text, placeholder: "(5 - 30 chars)").on(:change) do |e|
                @name = e.target.value
              end
            }
            Bs.FormGroup {
              Bs.ControlLabel { "* Mandate" }
              Bs.FormControl(componentClass: :textarea, defaultValue: @mandate,
                placeholder: "What is the main purpose or mandate of this Squad? (5 - 200 chars)"
              ).on(:change) do |e|
                @mandate = e.target.value
              end
            }
            Bs.FormGroup {
              Bs.ControlLabel { "* Leader" }
              Bs.FormControl(componentClass: "select", placeholder: "select", defaultValue: @leader_id) {
                option(value: 0) {"select"}
                ReactiveRecord::Member.all.each do |member|
                  option(value: member.id) { member.full_name }
                end
              }.on(:change) do |e|
                @leader_id = e.target.value
              end
            }
            Bs.ModalFooter {
              span.pull_right {
                Bs.ButtonToolbar {
                  if !state.show_delete_confirm
                    if @squad_id != 0
                      Bs.Button(bsStyle: 'danger', class: 'danger-outline', disabled: @delete_disabled ? true : false) { 'Delete this Squad' }.on(:click) {
                        state.show_delete_confirm! true
                      }
                    end
                    Bs.Button(bsStyle: 'success') { 'Save' }.on(:click) { save_modal }
                  else
                    Bs.Button(bsStyle: :danger) { "Confirm Delete"}. on(:click) do
                      delete
                    end
                  end
                  Bs.Button { 'Cancel' }.on(:click) { close }
                }
              }
              if state.errors.any?
                br
                br
                div(class: "alert alert-danger") {
                  state.errors.each do |error|
                    para { "#{error}" }
                  end
                }
              end
              if state.show_spinner
                br
                br
                br
                Shared::SpinnerSpan()
                span { " working"}
              end
            }
          }
        }
      end
    end
  end
end

The comes the controller with the API calls

class API::V3::SquadController < ApplicationController

  respond_to :json

  def show
    respond API::V3::Squad::Show
  end

  def index
    respond API::V3::Squad::Index
  end

  def create
    res = run Squad::Create
    status = res.errors.any? ? 422 : 201
    respond_with(res.errors.any? ? res.errors : res.model, location: '/', status: status)
  end

  def update
    res = run Squad::Update
    status = res.errors.any? ? 422 : 201
    respond_with(res.errors.any? ? res.errors : res.model, location: '/', status: status)
  end

  def destroy
    res = run Squad::Delete
    status = res.errors.any? ? 422 : 201
    respond_with(res.errors.any? ? res.errors : res.model, location: '/', status: status)
  end
end

Then the representer (for JSON serialization and de-serialization)

module API::V3
  module Squad
    module Representer
      class Show < Roar::Decorator
        include Roar::JSON
        property :id
        property :name
        property :mandate
        property :leader, decorator: API::V3::Member::Representer::Show
        property :score_count
        property :objective_count
        property :key_result_count
        property :tribe, decorator: API::V3::Tribe::Representer::Show,
          if: ->(user_options:, **) { user_options["include_tribe"] }

        property :can_delete?

        property :last_score, decorator: API::V3::Score::Representer::Show,
            if: ->(user_options:, **) { user_options["include_last_score"] }

        collection :objectives, decorator: API::V3::Objective::Representer::Show,
            if: ->(user_options:, **) { user_options["include_objectives"] }

        collection :possible_parent_objectives, decorator: API::V3::Objective::Representer::Show,
            if: ->(user_options:, **) { user_options["include_objectives"] }
      end

      class Index < Roar::Decorator
        include Roar::JSON
        collection :to_a, as: :squads, embedded: true, decorator: API::V3::Squad::Representer::Show
      end
    end
  end
end

Then the Squad CRUD concept (operation):

class Squad < ActiveRecord::Base

  def can_delete?
    (objective_count == 0 && key_result_count == 0 && score_count == 0) ? true : false
    # false
  end

  class Create < Trailblazer::Operation
    include Model
    model Squad, :create
    contract do
      property :name
      property :mandate
      property :tribe_id
      property :leader_id

      validates :tribe_id, presence: true
      validate :valid_tribe?
      validates :leader_id, presence: true
      validate :valid_leader?
      validates :name, presence: true
      validates :name, length: {in: 5..30}
      validates :mandate, presence: true
      validates :mandate, length: {in: 5..200}

      def valid_tribe?
        errors.add("tribe_id", "not valid") unless Tribe.find_by_id(tribe_id)
      end

      def valid_leader?
        errors.add("leader_id", "not valid") unless Member.find_by_id(leader_id)
      end

    end
    def process(params)
      validate(params[:squad]) do |f|
        f.save
        Rails.cache.delete_matched("count/squads")
      end
    end
  end

  class Update < Create
    action :update
  end

  class Show < Trailblazer::Operation
    include Model
    model ::Squad, :find

    def process(params)
    end
  end


  class Index < Trailblazer::Operation
    include Collection

    def process(params)
    end

    def model!(params)
      # Squad.includes(:leader, :objectives, :key_results).order(:name)
      Squad.order(:name)
    end
  end

  class Delete < Trailblazer::Operation
    include Model
    model Squad, :find
    def process(params)
      if model.can_delete?
        model.destroy
        Rails.cache.delete_matched("count/squads")
      else
        return invalid!
      end
    end
  end

end

Then the model:

class Squad < ActiveRecord::Base

  belongs_to :leader, class_name: :Member
  belongs_to :tribe
  belongs_to :home_page, class_name: :Page

  has_many :objectives, -> { where is_enabled: true }, as: :owner
  has_many :squad_members
  has_many :members, through: :squad_members
  has_many :key_results, through: :objectives
  has_many :scores, through: :key_results
  has_many :stars, through: :scores

  default_scope -> { where(period_id: Period.current.id) }

  # pass 0 for all tribes
  scope :for_tribe, (lambda do |id|
      where(tribe_id: id) if id != 0
    end)
  scope :for_period, (lambda do |period_id|
      unscoped.where(period_id: period_id)
    end)


  def last_score
    self.scores.first
  end

  def last_scored_at
    scores.first.created_at if scores.any?
  end

  def score_count
    # tested ok
    Rails.cache.fetch("count/scores/squad/#{self.id}") do
      self.scores.count
    end
  end

  def objective_count
    # tested ok
    Rails.cache.fetch("count/objectives/squad/#{self.id}") do
      objectives.count
    end
  end

  def key_result_count
    #  tested ok
    Rails.cache.fetch("count/key_results/squad/#{self.id}") do
      key_results.count
    end
  end

  def parent
    tribe
  end

  def children
    nil
  end

  def set_period_on_create
    self.period_id = Period.current.id
    return true
  end

  def possible_parent_objectives
    tribe.objectives
  end

  def name
    db_name = read_attribute(:name)
    if db_name && !db_name.downcase.include?("squad") && !db_name.downcase.include?("chapter")
      db_name += " Squad"
    end
    db_name
  end

end

Then the Squad concept tests:

require 'test_helper'

class SquadCrudTest < MiniTest::Spec
  describe "Squad CRUD" do
    before do
      Rails.cache.delete_matched("current/company")
      Rails.cache.delete_matched("count")
      period = Period::Create.(period: {name: "Period valid 1", start_date: Date.today, end_date: Date.tomorrow}).model
      Company::Create.(company: {name: "Test company 2"})
      @member = Member::Create.(member: {first_name: "John", last_name: "Smith",
        email: "[email protected]", password: 'AComplicated88test'}).model
      Period::SetAsCurrentPeriod.run(id: period.id)
      Period.count.must_equal 1
      Company.count.must_equal 1
      Member.count.must_equal 1
      @tribe = Tribe::Create.(tribe: {name: "Test tribe",
        mandate: "A valid mandate",
        leader_id: @member.id
      }).model
      @tribe.persisted?.must_equal true
      @tribe.name.must_equal "Test tribe"
      @company_objective = Objective::Create.(objective: {name: "Test objective", note: "A valid note",
        owner_type: "Company", owner_id: Company.current.id
       }).model
      @company_objective.persisted?.must_equal true
    end

    describe "Create" do
      it "persists valid" do
        squad = Squad::Create.(squad: {name: "Test squad",
          mandate: "A valid mandate", tribe_id: @tribe.id,
          leader_id: @member.id
        }).model
        squad.persisted?.must_equal true
        squad.name.must_equal "Test squad"
      end
      it "invalid as too short" do
        res, op = Squad::Create.run(squad: {name: "T", mandate: "A valid mandate", tribe_id: @tribe.id})
        res.must_equal false
        op.model.persisted?.must_equal false
      end
    end

    describe "Update" do
      it "persists valid after update" do
        squad = Squad::Create.(squad: {name: "A name which is long enough",
          mandate: "and a mandate", tribe_id: @tribe.id,
          leader_id: @member.id
        }).model
        squad.persisted?.must_equal true
        Squad::Update.(
          id:     squad.id,
          squad: {name: "A brand new name"}).model
        squad.reload
        squad.name.must_equal "A brand new name Squad"
      end
    end

   describe "Delete" do
     it "ok to delete" do
       squad = Squad::Create.(squad: {name: "A name which is long enough",
         mandate: "and a mandate", tribe_id: @tribe.id,
         leader_id: @member.id
        }).model
       squad.persisted?.must_equal true
       res, op = Squad::Delete.run(squad)
       res.must_equal true
       op.model.persisted?.must_equal false
     end
      it "must fail to delete Tribe if it has Squads" do
        squad = Squad::Create.(squad: {name: "Test tribe",
          mandate: "A valid mandate", tribe_id: @tribe.id,
          leader_id: @member.id}
        ).model
        squad.persisted?.must_equal true
        res, op = Tribe::Delete.run(@tribe)
        res.must_equal false
        op.model.persisted?.must_equal true
      end
     end

     describe "Correct counts from cache" do
       it "has correct Objective count" do
         squad = Squad::Create.(squad: {name: "A name which is long enough",
           mandate: "and a mandate", tribe_id: @tribe.id,
           leader_id: @member.id
          }).model
         squad.persisted?.must_equal true
         squad.objective_count.must_equal 0
         squad_objective = Objective::Create.(objective: {name: "Test objective for squad", note: "A valid note",
           owner_type: "Squad", owner_id: squad.id, parent_id: @company_objective.id
          }).model
         squad_objective.persisted?.must_equal true
         squad.objective_count.must_equal 1
         squad_objective2 = Objective::Create.(objective: {name: "Test objective 2 for squad", note: "A valid note",
           owner_type: "Squad", owner_id: squad.id, parent_id: @company_objective.id
          }).model
         squad_objective2.persisted?.must_equal true
         squad.objective_count.must_equal 2
       end
       it "has correct KeyResult count" do
         squad = Squad::Create.(squad: {name: "A name which is long enough",
           mandate: "and a mandate", tribe_id: @tribe.id,
           leader_id: @member.id
          }).model
         squad.persisted?.must_equal true
         squad_objective = Objective::Create.(objective: {name: "Test objective for squad", note: "A valid note",
           owner_type: "Squad", owner_id: squad.id, parent_id: @company_objective.id
          }).model
         squad_objective.persisted?.must_equal true
         squad.objective_count.must_equal 1
         squad.key_result_count.must_equal 0
         key_result = KeyResult::Create.(key_result: {name: "Test result", note: "A valid note",
           objective_id: squad_objective.id
          }).model
         key_result.persisted?.must_equal true
         squad.key_result_count.must_equal 1
       end
       it "has correct Score count" do
         squad = Squad::Create.(squad: {name: "A name which is long enough",
           mandate: "and a mandate", tribe_id: @tribe.id,
           leader_id: @member.id
          }).model
         squad.persisted?.must_equal true
         squad_objective = Objective::Create.(objective: {name: "Test objective for squad", note: "A valid note",
           owner_type: "Squad", owner_id: squad.id, parent_id: @company_objective.id
          }).model
         squad_objective.persisted?.must_equal true
         squad.objective_count.must_equal 1
         squad.key_result_count.must_equal 0
         key_result = KeyResult::Create.(key_result: {name: "Test result", note: "A valid note",
           objective_id: squad_objective.id
          }).model
         key_result.persisted?.must_equal true
         squad.key_result_count.must_equal 1
         squad.score_count.must_equal 0
         score = Score::Create.(score: {note: "Test note", key_result_id: key_result.id,
           achievement: 1, confidence: 1, created_by_id: @member.id}).model
         score.persisted?.must_equal true
         squad.score_count.must_equal 1
         score2 = Score::Create.(score: {note: "Test note2", key_result_id: key_result.id,
           achievement: 1, confidence: 1, created_by_id: @member.id}).model
         score2.persisted?.must_equal true
         squad.score_count.must_equal 2
       end
     end
   end

end

And finally some API tests:

require 'test_helper'

class SquadAPIOperationTest < MiniTest::Spec
  include TestHTTPHelpers
  include SquaddieTestSetup

  before Squad do
    api_test_setup
  end

  describe "GET squad" do
    it "gets" do
      tribe = Tribe::Create.(tribe: {name: "Test tribe", mandate: "A valid mandate"}).model
      tribe.persisted?.must_equal true
      squad = Squad::Create.(squad: {name: "Test squad", mandate: "A valid mandate", tribe_id: tribe.id}).model
      squad.persisted?.must_equal true
      get "/api/v3/squad/#{squad.id}"
      last_response.body.must_equal(
        {
          id: squad.id, name: "Test squad", mandate: "A valid mandate"
        }.to_json)
    end
    it "gets include objectives" do
      tribe = Tribe::Create.(tribe: {name: "Test tribe", mandate: "A valid mandate"}).model
      tribe.persisted?.must_equal true
      squad = Squad::Create.(squad: {name: "Test squad", mandate: "A valid mandate", tribe_id: tribe.id}).model
      squad.persisted?.must_equal true
      objective = Objective::Create.(objective: {name: "Test objective2", note: "A valid note2", owner_type: "Squad", owner_id: squad.id }).model
      objective.persisted?.must_equal true
      get "/api/v3/squad/#{squad.id}?include=objectives"
      last_response.body.must_equal(
        {
          id: squad.id, name: "Test squad", mandate: "A valid mandate",
          objectives: [{id: objective.id, name: objective.name, note: objective.note}]
        }.to_json)
    end
    it "renders a collection" do
      tribe = Tribe::Create.(tribe: {name: "Test tribe", mandate: "A valid mandate"}).model
      tribe.persisted?.must_equal true
      first = Squad::Create.(squad: {name: "Test squad 1", mandate: "A valid mandate", tribe_id: tribe.id}).model
      second = Squad::Create.(squad: {name: "Test squad 2", mandate: "A valid mandate", tribe_id: tribe.id}).model
      first.persisted?.must_equal true
      second.name.must_equal "Test squad 2"
      get "/api/v3/squad"
      last_response.body.must_equal(
        {
          squads: [
            {
              id: first.id, name: first.name, mandate: first.mandate
            },
            {
              id: second.id, name: second.name, mandate: second.mandate
            },
          ]
        }.to_json)
    end
  end
end
Clone this wiki locally