-
Notifications
You must be signed in to change notification settings - Fork 18
5 Squad CRUD refactor (before)
(incomplete wip)
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.
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