Created
March 24, 2009 19:56
-
-
Save TMorgan99/84339 to your computer and use it in GitHub Desktop.
Build Agility, as per the tutorial
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
## ======================================================== | |
## Build Agility, as per the tutorial | |
## ======================================================== | |
## run this script from a fresh directory. | |
## It will unload the heplers here, and build the app in the agility | |
## It is meant to be run in stages, where it runs into an 'exit' | |
## just comment over, and start again for the next step | |
rails --version # Rails 2.2.2 | |
# hobo --version ## !! bug! not an option! | |
gem list hobo | |
## part 0. Setup the helper tools. | |
# sed_model stream-edit the model code. | |
cat >sed_add_action <<"RUBY" && chmod +x sed_add_action | |
#!/usr/bin/env ruby | |
## Insert input (as from heredoc) to the controller listed. | |
## Append an action method to a controller | |
require 'rubygems' | |
require 'activesupport' | |
include ActiveSupport::Inflector | |
model = ARGV.shift | |
code = $stdin.read | |
code.gsub! /^/, ' ' # indent | |
file = "./app/controllers/#{model.underscore.pluralize}_controller.rb" | |
tag = /(^end$)/ | |
inp = File.read file | |
File.open( file, 'w' ) { |f| | |
f.puts inp.sub( tag, code + $/ + '\1' ) | |
} | |
RUBY | |
cat >sed_model <<"RUBY" && chmod +x sed_model | |
#!/usr/bin/env ruby | |
## Read input (as from heredoc) | |
## input gives a general description of the changes to be applied to the various models. | |
## First line names the model, and optional verb | |
## verb | |
## (blank) -- ensure the model exists, and add any remaining lines to the association section. | |
## model: -- run the hobo_model generator, supplying the remaining arguments. | |
## model_resource: -- hobo_model_resource generator | |
## add_(fields) | |
## route: replace auto_actions code as supplied ( in the controller) | |
## create/update/destroy/view: replace permission code as supplied. | |
require 'rubygems' | |
require 'activesupport' | |
include ActiveSupport::Inflector | |
migration_name = ARGV.shift | |
migration_option = ARGV.shift | |
model = String.new | |
verb = String.new | |
h = Hash.new # model information | |
v = Hash.new # scoped vars | |
gen = Array.new # generators | |
log = String.new # collect command output, but ignore it. | |
puts ">>> Applying [#{migration_name}] migration ..." | |
## ======================================================== | |
# == Parse the input heredoc | |
$stdin.read.each do |line| | |
case line.chomp! | |
when /^\s*$/ # then empty line | |
when /^\s*#/ # then comment line | |
when /^(\w+)=(.*)$/ # set runtime option | |
var, val = $1, $2 | |
v[ var.to_sym ] = val | |
when /^(\w+):(\w+:?)?\s*(.*)?$/ | |
model, verb, eol = $1, $2, $3 | |
h[ model ] ||= Hash.new # create sub-hash for model, if needed. | |
case verb | |
when nil then verb = 'assoc' | |
when /model|model_resource/ # hobo generators | |
verb.chomp! ':' | |
gen << "script/generate hobo_#{verb} #{model.underscore} #{eol}" | |
verb = 'assoc' | |
else verb.chomp! ':' | |
end | |
h[ model ][ verb ] ||= [] # append (reset) verb value | |
else | |
h[ model ][ verb ] << line # append to verb value | |
end | |
end | |
## ======================================================== | |
# === Apply generators first | |
gen.each do |spec| | |
puts "--> ruby #{spec}" | |
%x[#{ spec } |tee -a log/hobo.log] | |
end | |
## ======================================================== | |
# === Apply actions to models | |
h.each do |model, sub_h| | |
# -- controller | |
file = "app/controllers/#{model.underscore.pluralize}_controller.rb" | |
if route = sub_h.delete( 'route' ) | |
code = route.join $/ | |
tag = /^\s*auto_actions.*$/ | |
inp = File.read file | |
# remove previous code | |
inp.gsub!( /^\s*auto_actions_for.*$/, '' ) | |
# replace | |
inp.sub!( tag, $/ + code ) | |
File.open( file, 'w' ) { |f| f.puts inp } | |
end | |
# -- model | |
file = "app/models/#{model.underscore}.rb" | |
inp = File.read file | |
# - associations ( append to association section ) | |
# -- TODO: remove dups? | |
if assoc = sub_h.delete( 'assoc' ) | |
code = assoc.join $/ | |
tag = /^(\s*fields do)$/ | |
inp.sub!( tag, $/ + code + $/ + '\1' ) | |
end | |
# - fields | |
if field = sub_h.delete( 'field' ) | |
code = field.join $/ | |
tag = /^(\s*fields do)$/ | |
inp.sub!( tag, '\1' + $/ + ' ' + code ) | |
end | |
if field = sub_h.delete( 'field_drop' ) | |
tag = /^((\ *)fields do(.*?)\2end$)/m | |
inp =~ tag | |
old_fields, indent = $1, $2 | |
new_fields = old_fields | |
field.each { |f| new_fields.sub!( /^(\s*#{f}\b)/, '##' + '\1' ) } | |
inp.sub!( tag, new_fields ) | |
end | |
# - permissions | |
%w[ create update destroy view ].each do |perm| | |
if code = sub_h.delete( perm ) | |
tag = /^((\ *)def #{perm}_permitted\?(.*?)\2end$)/m | |
inp =~ tag | |
old_method, indent = $1, $2 | |
lines = old_method.split( $/ ) | |
# replace internal line with the new code. | |
lines[ 1 .. -2 ] = code.map { |line| indent + line } | |
new_method = lines.join $/ | |
inp.sub! old_method, new_method | |
end | |
end | |
File.open( file, 'w' ) { |f| f.puts inp } | |
end | |
# run hobo's migration | |
opt = migration_option + "\n" if migration_option | |
puts IO.popen( "echo '#{opt}m\n\n' |script/generate hobo_migration", "w+" ).read | |
RUBY | |
# ----------------------------------------------------------------------------- | |
# | |
# ----------------------------------------------------------------------------- | |
# gem install hobo # hobo 0.8.5 | |
hobo Agility && cd Agility | |
../sed_model 'Hobo User Model' <<MODEL | |
# migrate the db to get it started | |
MODEL | |
# ------------ setup cucumber for the first feature | |
# --- create a feature story, step definitions, and a blueprint sheet | |
./script/generate cucumber | |
cat > features/part-one.feature <<FEATURE | |
Feature: Part One | |
* Track multiple projects | |
* Each projects has a collection of stories | |
* Stories are just a brief chunk of text | |
* A story can be assigned a current status and a set of outstanding tasks | |
* Tasks can be assigned to users | |
* Users can get an easy heads up of the tasks they are assigned to | |
Scenario: * Outline of project scope | |
Given a Project has a name | |
And a Project has a collection of stories | |
And a Story has a title | |
And a Story has a body | |
And a Story has a status | |
And a Story has a collection of tasks | |
And a Task has a description | |
And a Task has a collection of users | |
Then all is well | |
FEATURE | |
cat > features/step_definitions/my_steps.rb <<STEPS | |
Given /^an? (\w+)\s+has an? (\w+)$/ do | model, attribute_name | | |
o = model.constantize.make | |
o.should be_a_kind_of( Hobo::Model ) | |
o.should respond_to( attribute_name ) | |
end | |
Given /^an? (\w+)\s+has a collection of (\w+)$/ do | model, collection_name | | |
o = model.constantize.make( collection_name.to_s => (1..3).map { | |
collection_name.classify.constantize.make | |
} ) | |
o.should be_a_kind_of( Hobo::Model ) | |
end | |
Then /^all is well$/ do | |
end | |
STEPS | |
cat >features/support/blueprints.rb <<BLUEPRINTS | |
require 'faker' | |
require 'machinist' | |
# --- Shams | |
Sham.define do | |
title { Faker::Lorem.words(5).join(' ') } | |
name { Faker::Name.name } | |
body { Faker::Lorem.paragraphs(3).join("\n\n") } | |
status { Faker::Lorem.words(1).first } | |
description { Faker::Lorem.sentence } | |
project_name {|index| "Project #{index}" } | |
email_address { Faker::Internet.email } | |
end | |
Project.blueprint { | |
name { Sham.project_name } | |
} | |
Story.blueprint { | |
project | |
title; body; status | |
} | |
Task.blueprint { | |
story | |
description | |
} | |
User.blueprint { | |
name; email_address | |
} | |
BLUEPRINTS | |
## ---- We need to generate our models. | |
../sed_model 'First models' <<MODEL | |
Project:model_resource name:string | |
Story:model_resource title:string body:text status:string | |
Task:model_resource description:string | |
MODEL | |
## -- check out our features | |
rake -s features | |
## ----- We need to associate these models | |
../sed_model 'First model -- associations' <<MODEL | |
Project: | |
has_many :stories, :dependent => :destroy | |
Story: | |
belongs_to :project | |
has_many :tasks, :dependent => :destroy | |
Task: | |
belongs_to :story | |
has_many :task_assignments, :dependent => :destroy | |
has_many :users, :through => :task_assignments, :accessible => true | |
TaskAssignment:model | |
belongs_to :user | |
belongs_to :task | |
User: | |
has_many :task_assignments, :dependent => :destroy | |
has_many :tasks, :through => :task_assignments | |
MODEL | |
## --- now, our features pass! | |
rake -s features | |
echo 'Part 1-3 completed' #&& exit | |
# ===== Part 4 ===== | |
# -- Don't have a nice way to edit the dryml ... I just overwrite the files. | |
cat >>app/views/taglibs/application.dryml <<DRYML | |
<extend tag="card" for="Task"> | |
<old-card merge> | |
<append-body:> | |
<div class="users"> | |
Assigned users: <repeat:users join=", "><a/></repeat><else>None</else> | |
</div> | |
</append-body:> | |
</old-card> | |
</extend> | |
DRYML | |
cat >app/views/users/show.dryml <<DRYML | |
<show-page> | |
<content-body:> | |
<h3><Your/> Assigned Tasks</h3> | |
<repeat with="&@user.tasks.group_by(&:story)"> | |
<h4>Story: <a with="&this_key"/></h4> | |
<collection/> | |
</repeat> | |
</content-body:> | |
</show-page> | |
DRYML | |
cat >app/views/projects/show.dryml <<DRYML | |
<show-page> | |
<collection: replace> | |
<table-plus with="&@stories" fields="this, tasks.count, status"> | |
<empty-message:>No stories match your criteria</empty-message:> | |
</table-plus> | |
</collection:> | |
</show-page> | |
DRYML | |
## this was not mentioned in the tutorial at this point, | |
## but it should have been. It is not mentioned until later | |
## But with BDD, these things would have been caught? | |
cat > app/views/tasks/edit.dryml <<DRYML | |
<edit-page> | |
<form:> | |
<cancel: with="&this.story"/> | |
</form:> | |
</edit-page> | |
DRYML | |
../sed_add_action Project <<RUBY | |
def show | |
@project = find_instance | |
@stories = @project.stories.apply_scopes( | |
:search => [ params[:search], :title ], | |
:order_by => parse_sort_param( :title, :status ) | |
) | |
end | |
RUBY | |
echo 'Part 4 completed' #&& exit | |
### --- Add rake task to load testing data into db | |
cat >lib/tasks/test_data.rake <<RAKE | |
desc 'Run Machinist to load up data' | |
task :load_records => [ 'environment' ] do | |
require 'features/support/blueprints' | |
User.make :name => 'Admin' # Admin is the first user | |
6.times { User.make } | |
5.times { Project.make } | |
users = User.all; users.shift # all but the Admin | |
projects = Project.all | |
projects.each do |prj| | |
4.times { Story.make :project => prj } # ensure 4 stories per project | |
end | |
6.times { Story.make :project => projects.rand } # more stories on any project | |
stories = Story.all | |
( stories.size * 4 ).times do # make tasks; average 4 per story | |
n = ( 0.25 < rand ) ? 1 : 3 # assign 3 users 25% of the time | |
Task.make :story => stories.rand, | |
:users => users.shuffle[ 0, n ] # users will be distinct | |
end | |
end | |
RAKE | |
rake load_records | |
echo 'Part 4 data loader completed' #&& exit | |
## ====== Part 5 ====== | |
../sed_model 'Story status menu' 'drop status' <<MODEL | |
StoryStatus:model_resource name:string | |
StoryStatus:route: | |
auto_actions :write_only | |
Story: | |
belongs_to :status, :class_name => "StoryStatus" | |
Story:field_drop | |
status | |
MODEL | |
cat >> 'features/support/blueprints.rb' <<BLUEPRINT | |
StoryStatus.blueprint do | |
name # status-names are specified in the rake task; no sham needed here. | |
end | |
# revised model # ruby will toss the previous declaration | |
Story.blueprint { | |
project | |
status | |
title; body | |
} | |
BLUEPRINT | |
# The menu is working in the edit-story page now. | |
# It would be nice though if we had a ajaxified editor right on the story page. | |
cat >app/views/stories/show.dryml <<DRYML | |
<show-page> | |
<field-list: tag="editor"/> | |
</show-page> | |
DRYML | |
cat >>lib/tasks/test_data.rake <<RAKE | |
# distribute a model, (using it's belongs_to field) over the list of possibilities | |
# generates SQL like this. | |
# UPDATE "stories" SET "status_id" = 5 WHERE ("stories"."id" IN (9,10,13,15,16)) | |
# UPDATE "stories" SET "status_id" = 6 WHERE ("stories"."id" IN (NULL)) | |
# UPDATE "stories" SET "status_id" = 1 WHERE ("stories"."id" IN (2,6,8,14,18)) | |
def distribute( model, belongs_to, list ) | |
belongs_to = "#{belongs_to}_id".to_s # for some annoying reason, rails can't handle foriegn_keys in update_all | |
list = list.map &:id | |
ids = model.all.map &:id | |
k = Hash.new | |
k[list.shift], ids = ids.partition { rand( list.size) < 1 } until list.empty? | |
k.each_pair do | key, members | | |
model.update_all( { belongs_to => key }, { :id => members } ) unless members.empty? | |
end | |
end | |
desc 'Run Machinist to load up data' | |
task :load_story_status => [ 'environment' ] do | |
require 'features/support/blueprints' | |
%w(new accepted discussion implementation user_testing deployed rejected). | |
each { |status| StoryStatus.make :name => status } | |
distribute( Story, 'status', StoryStatus.all ) | |
end | |
RAKE | |
rake load_story_status | |
echo 'Part 5a - Story Status menu completed' #&& exit | |
## ============================== | |
## Filtering stories by status | |
cat >app/views/projects/show.dryml <<DRYML | |
<show-page> | |
<collection: replace> | |
<table-plus with="&@stories" fields="this, tasks.count, status"> | |
<prepend-header:> | |
<div class="filter"> | |
Display by status: | |
<filter-menu param-name="status" options="&StoryStatus.all"/> | |
</div> | |
</prepend-header:> | |
<empty-message:>No stories match your criteria</empty-message:> | |
</table-plus> | |
</collection:> | |
</show-page> | |
DRYML | |
## To make the filter look right, add this: | |
cat >public/stylesheets/application.css <<CSS | |
.show-page.project .filter {float: left;} | |
.show-page.project .filter form, .show-page.project .filter form div {display: inline;} | |
CSS | |
### Controller fixes? | |
## I wanted to add 'task counter' to the list of 'order-by' | |
## -- but how to specify this? | |
## -- needs to be a virtual attribute somehow | |
### DRYML fixes? | |
## Also, the StoryStatus menu filter refreshes to the 'All' choice when something is checked | |
## and reselecting 'All' does not clear off the filter. | |
../sed_add_action Project <<RUBY | |
def show | |
@project = find_instance | |
@stories = @project.stories.apply_scopes( | |
:search => [params[:search], :title], | |
:status_is => params[:status], | |
:order_by => parse_sort_param(:title, :status) | |
) | |
end | |
RUBY | |
echo 'Part 5b - Filtering stories by status completed' #&& exit | |
## ================= | |
# Task re-ordering | |
./script/plugin install acts_as_list | |
../sed_model 'acts_as_list' <<MODEL | |
Task: | |
# set the protected flag on this attribute, to avoid UI generation. | |
# ( should have really happened in the plugin .... ) | |
attr_protected :postion | |
acts_as_list :scope => :story | |
Story: | |
has_many :tasks, :dependent => :destroy, :order => :position | |
MODEL | |
## AAL will automagically take care of initializion of the position field. | |
## Although the Guest is not permitted to adjust position, the JavaSctipt is enabled for her | |
# # this has been fixed by the attr_protected flag, so is no longer needed. | |
## well. I thought the attr_protected flag would do the trick | |
## I thought it was before ... | |
cat >>app/views/taglibs/application.dryml <<DRYML | |
<extend tag="form" for="Task"> | |
<old-form merge> | |
<field-list: fields="description, users"/> | |
</old-form> | |
</extend> | |
DRYML | |
# Markdown / Textile formatting of stories | |
## I skipped this part -- but don't you have an HTML tag for this that uses javascript rich-text editor? | |
## nicEdit? some vestage is found in the git clone | |
echo 'Part 5c - Task re-ordering completed' #&& exit | |
## ============================ | |
# Part 6 -- Project Ownership | |
../sed_model 'Project Ownership' <<MODEL | |
Project: | |
belongs_to :owner, :class_name => "User", :creator => true | |
Project:create: | |
owner_is? acting_user | |
Project:update: | |
acting_user.administrator? || ( owner_is?(acting_user) && !owner_changed?) | |
Project:destroy: | |
acting_user.administrator? || owner_is?(acting_user) | |
User: | |
has_many :projects, :class_name => "Project", :foreign_key => "owner_id" | |
MODEL | |
cat > app/views/front/index.dryml <<DRYML | |
<page title="Home"> | |
<body: class="front-page"/> | |
<content:> | |
<header class="content-header"> | |
<h1>Welcome to <app-name/></h1> | |
<section class="welcome-message"> | |
<h3>[[ The Agile Hobo --- up and running ]]</h3> | |
</section> | |
</header> | |
<section with="¤t_user" class="content-body" if="&logged_in?"> | |
<h3>Your Projects</h3> | |
<collection:projects><card without-creator-link/></collection> | |
<a:projects action="new">New Project</a> | |
<h3>Projects you have joined</h3> | |
<collection:joined-projects><card without-creator-link/></collection> | |
</section> | |
<section class="content-body" if="&!logged_in?"> | |
You will certainly be able to see more once you have logged in. | |
</section> | |
</content:> | |
</page> | |
DRYML | |
## now, lets distribute our existing Projects amoung the Users | |
cat >>lib/tasks/test_data.rake <<RAKE | |
desc 'Run Machinist to load up data' | |
task :load_owner => [ 'environment' ] do | |
owner_list = User.all | |
owner_list.shift # don't overwork the long suffering Admin | |
distribute( Project, 'owner', owner_list ) | |
end | |
RAKE | |
rake load_owner | |
echo 'Part 6 -- Project Ownership completed' #&& exit | |
# ======================================== | |
# Part 7 -- Granting read access to others | |
../sed_model 'Grant read access' <<MODEL | |
ProjectMembership:model_resource | |
ProjectMembership:route | |
auto_actions :write_only | |
ProjectMembership: | |
belongs_to :project | |
belongs_to :user | |
# --- Permissions -- only the project owner (and admins) can manipulate these: | |
ProjectMembership:create: | |
acting_user.administrator? || acting_user == project.owner | |
ProjectMembership:update: | |
acting_user.administrator? || acting_user == project.owner | |
ProjectMembership:destroy: | |
acting_user.administrator? || acting_user == project.owner | |
ProjectMembership:view: | |
true | |
# === The other ends of those two belongs-to associations: | |
Project: | |
has_many :memberships, :class_name => "ProjectMembership", :dependent => :destroy | |
has_many :members, :through => :memberships, :source => :user | |
User: | |
has_many :project_memberships, :dependent => :destroy | |
has_many :joined_projects, :through => :project_memberships, :source => :project | |
# --- View permission on projects, stories and tasks according to project membership. | |
Project:view: | |
acting_user.administrator? || acting_user == owner || acting_user.in?(members) | |
Story:view: | |
project.viewable_by? acting_user | |
Task:view: | |
story.viewable_by? acting_user | |
# Modify the actions provided by the projects controller to: | |
Project:route | |
auto_actions :show, :edit, :update, :destroy | |
auto_actions_for :owner, [:new, :create] | |
MODEL | |
cat >>lib/tasks/test_data.rake <<RAKE | |
# the usual suspects have joined all projects | |
desc 'Run Machinist to load up data' | |
task :load_member => [ 'environment' ] do | |
[ 2, 3, 4 ].each do | member_id | | |
member = User.find member_id | |
member.joined_projects << Project.all | |
end | |
end | |
RAKE | |
rake load_member | |
## The view layer | |
cat >app/viewhints/project_hints.rb <<RUBY | |
class ProjectHints < Hobo::ViewHints | |
children :stories, :memberships | |
end | |
RUBY | |
cat > app/views/projects/show.dryml <<DRYML | |
<show-page> | |
<collection: replace> | |
<table-plus with="&@stories" fields="this, tasks.count, status"> | |
<prepend-header:> | |
<div class="filter"> | |
Display by status: | |
<filter-menu param-name="status" options="&StoryStatus.all"/> | |
</div> | |
</prepend-header:> | |
<empty-message:>No stories match your criteria</empty-message:> | |
</table-plus> | |
</collection:> | |
<aside:> | |
<h2>Project Members</h2> | |
<collection:memberships part="members"> | |
<card><heading:><a:user/></heading:></card> | |
</collection> | |
<form:memberships.new update="members" reset-form refocus-form> | |
<div> | |
Add a member: <name-one:user complete-target="&@project" completer="new_member_name"/> | |
</div> | |
</form> | |
</aside:> | |
</show-page> | |
DRYML | |
## name-one:user completer working, but what happens when I press enter? | |
## seems to need a User. find_by_name somewhere in the mix | |
../sed_add_action Project <<RUBY | |
autocomplete :new_member_name do | |
project = find_instance | |
hobo_completions :name, User.without_joined_project(project).is_not(project.owner) | |
end | |
RUBY | |
echo 'Part 7 -- Granting read access completed' #&& exit |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment