├── .gemtest ├── app ├── models │ └── .gitkeep ├── controllers │ ├── application.rb │ └── main.rb ├── views │ ├── main │ │ ├── _boolean.html.erb │ │ ├── _text.html.erb │ │ ├── _float.html.erb │ │ ├── _big_decimal.html.erb │ │ ├── _integer.html.erb │ │ ├── _date.html.erb │ │ ├── _time.html.erb │ │ ├── _datetime.html.erb │ │ ├── _timestamp.html.erb │ │ ├── _string.html.erb │ │ ├── new.html.erb │ │ ├── index.html.erb │ │ ├── _has_many.html.erb │ │ ├── _properties.html.erb │ │ ├── edit.html.erb │ │ ├── _belongs_to.html.erb │ │ ├── _has_one.html.erb │ │ ├── delete.html.erb │ │ └── list.html.erb │ └── layout │ │ ├── _message.html.erb │ │ ├── dashboard.html.erb │ │ ├── list.html.erb │ │ └── form.html.erb └── helpers │ ├── application_helper.rb │ └── main_helper.rb ├── public ├── javascripts │ ├── master.js │ ├── i18n.js │ ├── actions.js │ ├── timeparse.js │ ├── RelatedObjectLookups.js │ ├── CollapsedFieldsets.js │ ├── ordering.js │ ├── SelectBox.js │ ├── calendar.js │ ├── urlify.js │ ├── core.js │ ├── SelectFilter2.js │ ├── getElementsBySelector.js │ └── dateparse.js ├── images │ ├── nav-bg.gif │ ├── arrow-up.gif │ ├── icon-no.gif │ ├── icon-yes.gif │ ├── arrow-down.gif │ ├── chooser-bg.gif │ ├── default-bg.gif │ ├── icon_alert.gif │ ├── icon_clock.gif │ ├── icon_error.gif │ ├── tool-left.gif │ ├── tool-right.gif │ ├── tooltag-add.gif │ ├── changelist-bg.gif │ ├── icon-unknown.gif │ ├── icon_addlink.gif │ ├── icon_calendar.gif │ ├── icon_success.gif │ ├── inline-delete.png │ ├── selector-add.gif │ ├── deleted-overlay.gif │ ├── icon_changelink.gif │ ├── icon_deletelink.gif │ ├── icon_searchbox.png │ ├── inline-restore.png │ ├── nav-bg-grabber.gif │ ├── nav-bg-reverse.gif │ ├── selector-addall.gif │ ├── selector-remove.gif │ ├── selector-search.gif │ ├── tool-left_over.gif │ ├── tool-right_over.gif │ ├── tooltag-add_over.gif │ ├── changelist-bg_rtl.gif │ ├── chooser_stacked-bg.gif │ ├── default-bg-reverse.gif │ ├── inline-delete-8bit.png │ ├── inline-splitter-bg.gif │ ├── selector-removeall.gif │ ├── tooltag-arrowright.gif │ ├── inline-restore-8bit.png │ ├── selector_stacked-add.gif │ ├── selector_stacked-remove.gif │ └── tooltag-arrowright_over.gif └── stylesheets │ ├── master.css │ ├── null.css │ ├── dashboard.css │ ├── patch-iewin.css │ ├── login.css │ ├── ie.css │ ├── layout.css │ ├── rtl.css │ ├── changelists.css │ ├── forms.css │ └── global.css ├── lib ├── merb-admin │ ├── version.rb │ ├── spectasks.rb │ ├── merbtasks.rb │ └── slicetasks.rb ├── generic_support.rb ├── abstract_model.rb ├── merb-admin.rb ├── datamapper_support.rb ├── active_record_support.rb └── sequel_support.rb ├── screenshots ├── new.png ├── edit.png ├── index.png ├── list.png ├── create.png └── delete.png ├── config ├── router.rb └── init.rb ├── spec ├── models │ ├── activerecord │ │ ├── league.rb │ │ ├── division.rb │ │ ├── player.rb │ │ ├── draft.rb │ │ └── team.rb │ ├── datamapper │ │ ├── league.rb │ │ ├── division.rb │ │ ├── draft.rb │ │ ├── player.rb │ │ └── team.rb │ └── sequel │ │ ├── league.rb │ │ ├── division.rb │ │ ├── player.rb │ │ ├── draft.rb │ │ └── team.rb ├── migrations │ ├── activerecord │ │ ├── 003_create_leagues_migration.rb │ │ ├── 001_create_divisions_migration.rb │ │ ├── 002_create_drafts_migration.rb │ │ ├── 004_create_players_migration.rb │ │ └── 005_create_teams_migration.rb │ └── sequel │ │ ├── 003_create_leagues_migration.rb │ │ ├── 001_create_divisions_migration.rb │ │ ├── 002_create_drafts_migration.rb │ │ ├── 004_create_players_migration.rb │ │ └── 005_create_teams_migration.rb └── spec_helper.rb ├── Rakefile ├── .gitignore ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.md ├── merb-admin.gemspec └── README.md /.gemtest: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/javascripts/master.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/merb-admin/version.rb: -------------------------------------------------------------------------------- 1 | module MerbAdmin 2 | VERSION = "0.8.8" 3 | end 4 | -------------------------------------------------------------------------------- /screenshots/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/new.png -------------------------------------------------------------------------------- /screenshots/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/edit.png -------------------------------------------------------------------------------- /screenshots/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/index.png -------------------------------------------------------------------------------- /screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/list.png -------------------------------------------------------------------------------- /public/images/nav-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/nav-bg.gif -------------------------------------------------------------------------------- /screenshots/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/create.png -------------------------------------------------------------------------------- /screenshots/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/screenshots/delete.png -------------------------------------------------------------------------------- /public/images/arrow-up.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/arrow-up.gif -------------------------------------------------------------------------------- /public/images/icon-no.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon-no.gif -------------------------------------------------------------------------------- /public/images/icon-yes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon-yes.gif -------------------------------------------------------------------------------- /public/images/arrow-down.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/arrow-down.gif -------------------------------------------------------------------------------- /public/images/chooser-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/chooser-bg.gif -------------------------------------------------------------------------------- /public/images/default-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/default-bg.gif -------------------------------------------------------------------------------- /public/images/icon_alert.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_alert.gif -------------------------------------------------------------------------------- /public/images/icon_clock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_clock.gif -------------------------------------------------------------------------------- /public/images/icon_error.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_error.gif -------------------------------------------------------------------------------- /public/images/tool-left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tool-left.gif -------------------------------------------------------------------------------- /public/images/tool-right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tool-right.gif -------------------------------------------------------------------------------- /public/images/tooltag-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tooltag-add.gif -------------------------------------------------------------------------------- /public/images/changelist-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/changelist-bg.gif -------------------------------------------------------------------------------- /public/images/icon-unknown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon-unknown.gif -------------------------------------------------------------------------------- /public/images/icon_addlink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_addlink.gif -------------------------------------------------------------------------------- /public/images/icon_calendar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_calendar.gif -------------------------------------------------------------------------------- /public/images/icon_success.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_success.gif -------------------------------------------------------------------------------- /public/images/inline-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/inline-delete.png -------------------------------------------------------------------------------- /public/images/selector-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector-add.gif -------------------------------------------------------------------------------- /public/images/deleted-overlay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/deleted-overlay.gif -------------------------------------------------------------------------------- /public/images/icon_changelink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_changelink.gif -------------------------------------------------------------------------------- /public/images/icon_deletelink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_deletelink.gif -------------------------------------------------------------------------------- /public/images/icon_searchbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/icon_searchbox.png -------------------------------------------------------------------------------- /public/images/inline-restore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/inline-restore.png -------------------------------------------------------------------------------- /public/images/nav-bg-grabber.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/nav-bg-grabber.gif -------------------------------------------------------------------------------- /public/images/nav-bg-reverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/nav-bg-reverse.gif -------------------------------------------------------------------------------- /public/images/selector-addall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector-addall.gif -------------------------------------------------------------------------------- /public/images/selector-remove.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector-remove.gif -------------------------------------------------------------------------------- /public/images/selector-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector-search.gif -------------------------------------------------------------------------------- /public/images/tool-left_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tool-left_over.gif -------------------------------------------------------------------------------- /public/images/tool-right_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tool-right_over.gif -------------------------------------------------------------------------------- /public/images/tooltag-add_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tooltag-add_over.gif -------------------------------------------------------------------------------- /public/images/changelist-bg_rtl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/changelist-bg_rtl.gif -------------------------------------------------------------------------------- /public/images/chooser_stacked-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/chooser_stacked-bg.gif -------------------------------------------------------------------------------- /public/images/default-bg-reverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/default-bg-reverse.gif -------------------------------------------------------------------------------- /public/images/inline-delete-8bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/inline-delete-8bit.png -------------------------------------------------------------------------------- /public/images/inline-splitter-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/inline-splitter-bg.gif -------------------------------------------------------------------------------- /public/images/selector-removeall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector-removeall.gif -------------------------------------------------------------------------------- /public/images/tooltag-arrowright.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tooltag-arrowright.gif -------------------------------------------------------------------------------- /public/images/inline-restore-8bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/inline-restore-8bit.png -------------------------------------------------------------------------------- /public/images/selector_stacked-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector_stacked-add.gif -------------------------------------------------------------------------------- /config/router.rb: -------------------------------------------------------------------------------- 1 | # This file is here so slice can be testing as a stand alone application. 2 | 3 | Merb::Router.prepare do 4 | end 5 | -------------------------------------------------------------------------------- /public/images/selector_stacked-remove.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/selector_stacked-remove.gif -------------------------------------------------------------------------------- /public/images/tooltag-arrowright_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/merb-admin/master/public/images/tooltag-arrowright_over.gif -------------------------------------------------------------------------------- /public/stylesheets/master.css: -------------------------------------------------------------------------------- 1 | html, body { margin: 0; padding: 0; } 2 | #container { width: 800px; margin: 4em auto; padding: 4em 4em 6em 4em; background: #DDDDDD; } -------------------------------------------------------------------------------- /spec/models/activerecord/league.rb: -------------------------------------------------------------------------------- 1 | class League < ActiveRecord::Base 2 | validates_presence_of(:name) 3 | 4 | has_many(:divisions) 5 | has_many(:teams) 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/application.rb: -------------------------------------------------------------------------------- 1 | class MerbAdmin::Application < Merb::Controller 2 | include Merb::MerbAdmin::ApplicationHelper 3 | 4 | controller_for_slice 5 | 6 | end 7 | -------------------------------------------------------------------------------- /public/stylesheets/null.css: -------------------------------------------------------------------------------- 1 | /* Nothing to see here. Dummy file to feed to the high pass filter which hides CSS from IE5/win. Details: http://tantek.com/CSS/Examples/highpass.html */ -------------------------------------------------------------------------------- /spec/models/activerecord/division.rb: -------------------------------------------------------------------------------- 1 | class Division < ActiveRecord::Base 2 | validates_numericality_of(:league_id, :only_integer => true) 3 | validates_presence_of(:name) 4 | 5 | belongs_to(:league) 6 | has_many(:teams) 7 | end 8 | -------------------------------------------------------------------------------- /app/views/main/_boolean.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | label = property[:pretty_name] 4 | %> 5 |
6 | <%= check_box(property_name, :label => label) %> 7 |
8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'merb-core' 2 | require 'merb-core/tasks/merb' 3 | require 'spec/rake/spectask' 4 | require 'merb-core/test/tasks/spectasks' 5 | require 'bundler' 6 | Bundler::GemHelper.install_tasks 7 | 8 | desc 'Run RSpec code examples' 9 | task :default => :spec 10 | task :test => :spec 11 | -------------------------------------------------------------------------------- /lib/generic_support.rb: -------------------------------------------------------------------------------- 1 | module MerbAdmin 2 | class AbstractModel 3 | module GenericSupport 4 | def to_param 5 | model.to_s.snake_case 6 | end 7 | 8 | def pretty_name 9 | model.to_s.snake_case.gsub('_', ' ').capitalize 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/models/datamapper/league.rb: -------------------------------------------------------------------------------- 1 | class League 2 | include DataMapper::Resource 3 | 4 | property(:id, Serial) 5 | property(:created_at, DateTime) 6 | property(:updated_at, DateTime) 7 | property(:name, String, :required => true, :index => true) 8 | 9 | has(n, :divisions) 10 | has(n, :teams) 11 | end 12 | -------------------------------------------------------------------------------- /app/views/layout/_message.html.erb: -------------------------------------------------------------------------------- 1 | <% if message && message[:error] %> 2 |

3 | <%=h message[:error] %> 4 |

5 | <% end %> 6 | <% if message && message[:notice] %> 7 | 10 | <% end %> 11 | -------------------------------------------------------------------------------- /spec/migrations/activerecord/003_create_leagues_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateLeaguesMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table(:leagues) do |t| 4 | t.timestamps 5 | t.string(:name, :limit => 50, :null => false) 6 | end 7 | end 8 | 9 | def self.down 10 | drop_table(:leagues) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/activerecord/player.rb: -------------------------------------------------------------------------------- 1 | class Player < ActiveRecord::Base 2 | validates_presence_of(:name) 3 | validates_numericality_of(:number, :only_integer => true) 4 | validates_uniqueness_of(:number, :scope => :team_id, :message => "There is already a player with that number on this team") 5 | 6 | belongs_to(:team) 7 | has_one(:draft) 8 | end 9 | -------------------------------------------------------------------------------- /spec/migrations/sequel/003_create_leagues_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateLeagues < Sequel::Migration 2 | def up 3 | create_table(:leagues) do 4 | primary_key(:id) 5 | DateTime(:created_at) 6 | DateTime(:updated_at) 7 | String(:name, :limit => 50, :null => false) 8 | end 9 | end 10 | 11 | def down 12 | drop_table(:leagues) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/migrations/activerecord/001_create_divisions_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDivisionsMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table(:divisions) do |t| 4 | t.timestamps 5 | t.integer(:league_id) 6 | t.string(:name, :limit => 50, :null => false) 7 | end 8 | end 9 | 10 | def self.down 11 | drop_table(:divisions) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/models/datamapper/division.rb: -------------------------------------------------------------------------------- 1 | class Division 2 | include DataMapper::Resource 3 | 4 | property(:id, Serial) 5 | property(:created_at, DateTime) 6 | property(:updated_at, DateTime) 7 | property(:league_id, Integer, :required => true, :index => true) 8 | property(:name, String, :required => true, :index => true) 9 | 10 | belongs_to(:league) 11 | has(n, :teams) 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/sequel/league.rb: -------------------------------------------------------------------------------- 1 | class League < Sequel::Model 2 | set_primary_key(:id) 3 | plugin(:timestamps, :update_on_create => true) 4 | plugin(:validation_helpers) 5 | 6 | one_to_many(:divisions) 7 | one_to_many(:teams) 8 | 9 | self.raise_on_save_failure = false 10 | self.raise_on_typecast_failure = false 11 | def validate 12 | validates_presence(:name) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/main/_text.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | name = property[:name] 3 | label = property[:pretty_name] 4 | required = !property[:nullable?] 5 | %> 6 |
7 | <%= text_area(name, :cols => 80, :label => label) %> 8 |

9 | <%= required ? "Required." : "Optional." %> 10 |

11 |
12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !gems/cache/ 2 | *.db 3 | *.gem 4 | *.rbc 5 | *.sqlite 6 | *.sqlite3 7 | *.sw[p|o] 8 | *~ 9 | .#* 10 | .bundle 11 | .hg/* 12 | .hgignore 13 | .svn/* 14 | Gemfile.lock 15 | TAGS 16 | TODO 17 | bin/* 18 | coverage/* 19 | doc/* 20 | gems/ 21 | log/* 22 | merb_profile_results 23 | pkg/* 24 | schema/*.db 25 | schema/*.sqlite 26 | schema/*.sqlite3 27 | schema/*_structure.sql 28 | schema/schema.rb 29 | src/* 30 | tmp/* 31 | -------------------------------------------------------------------------------- /spec/migrations/sequel/001_create_divisions_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDivisions < Sequel::Migration 2 | def up 3 | create_table(:divisions) do 4 | primary_key(:id) 5 | DateTime(:created_at) 6 | DateTime(:updated_at) 7 | foreign_key(:league_id, :table => :leagues) 8 | String(:name, :limit => 50, :null => false) 9 | end 10 | end 11 | 12 | def down 13 | drop_table(:divisions) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/sequel/division.rb: -------------------------------------------------------------------------------- 1 | class Division < Sequel::Model 2 | set_primary_key(:id) 3 | plugin(:timestamps, :update_on_create => true) 4 | plugin(:validation_helpers) 5 | 6 | many_to_one(:league) 7 | one_to_many(:teams) 8 | 9 | self.raise_on_save_failure = false 10 | self.raise_on_typecast_failure = false 11 | def validate 12 | validates_numeric(:league_id, :only_integer => true) 13 | validates_presence(:name) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /public/stylesheets/dashboard.css: -------------------------------------------------------------------------------- 1 | /* DASHBOARD */ 2 | 3 | .dashboard .module table th { 4 | width: 100%; 5 | } 6 | 7 | .dashboard .module table td { 8 | white-space: nowrap; 9 | } 10 | 11 | .dashboard .module table td a { 12 | display: block; 13 | padding-right: .6em; 14 | } 15 | 16 | /* RECENT ACTIONS MODULE */ 17 | 18 | .module ul.actionlist { 19 | margin-left: 0; 20 | } 21 | 22 | ul.actionlist li { 23 | list-style-type: none; 24 | } 25 | -------------------------------------------------------------------------------- /spec/models/activerecord/draft.rb: -------------------------------------------------------------------------------- 1 | class Draft < ActiveRecord::Base 2 | validates_numericality_of(:player_id, :only_integer => true) 3 | validates_numericality_of(:team_id, :only_integer => true) 4 | validates_presence_of(:date) 5 | validates_numericality_of(:round, :only_integer => true) 6 | validates_numericality_of(:pick, :only_integer => true) 7 | validates_numericality_of(:overall, :only_integer => true) 8 | 9 | belongs_to(:team) 10 | belongs_to(:player) 11 | end 12 | -------------------------------------------------------------------------------- /app/views/main/_float.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | length = property[:length].to_i 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] || property[:serial?] 6 | %> 7 |
8 | <%= text_field(property_name, :maxlength => length, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /app/views/main/_big_decimal.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | length = property[:length].to_i 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] || property[:serial?] 6 | %> 7 |
8 | <%= text_field(property_name, :maxlength => length, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /app/views/main/_integer.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | length = property[:length].to_i 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] || property[:serial?] 6 | %> 7 |
8 | <%= text_field(property_name, :maxlength => length, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /spec/migrations/activerecord/002_create_drafts_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDraftsMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table(:drafts) do |t| 4 | t.timestamps 5 | t.integer(:player_id) 6 | t.integer(:team_id) 7 | t.date(:date) 8 | t.integer(:round) 9 | t.integer(:pick) 10 | t.integer(:overall) 11 | t.string(:college, :limit => 100) 12 | t.text(:notes) 13 | end 14 | end 15 | 16 | def self.down 17 | drop_table(:drafts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/views/main/_date.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | value = @object.send(property_name) 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] 6 | %> 7 |
8 | <%= text_field(property_name, :class => "vDateField", :value => value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : nil, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /app/views/main/_time.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | value = @object.send(property_name) 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] 6 | %> 7 |
8 | <%= text_field(property_name, :class => "vTimeField", :value => value.respond_to?(:strftime) ? value.strftime("%H:%M:%S") : nil, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /app/views/main/_datetime.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | value = @object.send(property_name) 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] 6 | %> 7 |
8 | <%= text_field(property_name, :class => "vDateField", :value => value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d %H:%M:%S") : nil, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /app/views/main/_timestamp.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | value = @object.send(property_name) 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] 6 | %> 7 |
8 | <%= text_field(property_name, :class => "vDateField", :value => value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d %H:%M:%S") : nil, :label => label) %> 9 |

10 | <%= required ? "Required." : "Optional." %> 11 |

12 |
13 | -------------------------------------------------------------------------------- /spec/models/activerecord/team.rb: -------------------------------------------------------------------------------- 1 | class Team < ActiveRecord::Base 2 | validates_numericality_of(:league_id, :only_integer => true) 3 | validates_numericality_of(:division_id, :only_integer => true) 4 | validates_presence_of(:manager) 5 | validates_numericality_of(:founded, :only_integer => true) 6 | validates_numericality_of(:wins, :only_integer => true) 7 | validates_numericality_of(:losses, :only_integer => true) 8 | validates_numericality_of(:win_percentage) 9 | 10 | belongs_to(:league) 11 | belongs_to(:division) 12 | has_many(:players) 13 | end 14 | -------------------------------------------------------------------------------- /spec/models/sequel/player.rb: -------------------------------------------------------------------------------- 1 | class Player < Sequel::Model 2 | set_primary_key(:id) 3 | plugin(:timestamps, :update_on_create => true) 4 | plugin(:validation_helpers) 5 | 6 | many_to_one(:team) 7 | one_to_one(:draft) 8 | 9 | self.raise_on_save_failure = false 10 | self.raise_on_typecast_failure = false 11 | def validate 12 | validates_numeric(:number, :only_integer => true) 13 | validates_unique(:number, :message => "There is already a player with that number on this team") do |dataset| 14 | dataset.where("team_id = ?", team_id) 15 | end 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /spec/migrations/sequel/002_create_drafts_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDrafts < Sequel::Migration 2 | def up 3 | create_table(:drafts) do 4 | primary_key(:id) 5 | DateTime(:created_at) 6 | DateTime(:updated_at) 7 | foreign_key(:player_id, :table => :players) 8 | foreign_key(:team_id, :table => :teams) 9 | Date(:date) 10 | Integer(:round) 11 | Integer(:pick) 12 | Integer(:overall) 13 | String(:college, :limit => 100) 14 | String(:notes, :text => true) 15 | end 16 | end 17 | 18 | def down 19 | drop_table(:drafts) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/migrations/activerecord/004_create_players_migration.rb: -------------------------------------------------------------------------------- 1 | class CreatePlayersMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table(:players) do |t| 4 | t.timestamps 5 | t.integer(:team_id) 6 | t.string(:name, :limit => 100, :null => false) 7 | t.string(:position, :limit => 50) 8 | t.integer(:number, :null => false) 9 | t.boolean(:retired, :default => false) 10 | t.boolean(:injured, :default => false) 11 | t.date(:born_on) 12 | t.text(:notes) 13 | end 14 | end 15 | 16 | def self.down 17 | drop_table(:players) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/migrations/sequel/004_create_players_migration.rb: -------------------------------------------------------------------------------- 1 | class CreatePlayers < Sequel::Migration 2 | def up 3 | create_table(:players) do 4 | primary_key(:id) 5 | DateTime(:created_at) 6 | DateTime(:updated_at) 7 | foreign_key(:team_id, :table => :teams) 8 | String(:name, :limit => 100, :null => false) 9 | String(:position, :limit => 50) 10 | Integer(:number, :null => false) 11 | TrueClass(:retired, :default => false) 12 | TrueClass(:injured, :default => false) 13 | Date(:born_on) 14 | String(:notes, :text => true) 15 | end 16 | end 17 | 18 | def down 19 | drop_table(:players) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/models/datamapper/draft.rb: -------------------------------------------------------------------------------- 1 | class Draft 2 | include DataMapper::Resource 3 | 4 | property(:id, Serial) 5 | property(:created_at, DateTime) 6 | property(:updated_at, DateTime) 7 | property(:player_id, Integer, :required => true, :index => true) 8 | property(:team_id, Integer, :required => true, :index => true) 9 | property(:date, Date, :required => true) 10 | property(:round, Integer, :required => true) 11 | property(:pick, Integer, :required => true) 12 | property(:overall, Integer, :required => true) 13 | property(:college, String, :length => 100, :index => true) 14 | property(:notes, Text) 15 | 16 | belongs_to(:team) 17 | belongs_to(:player) 18 | end 19 | -------------------------------------------------------------------------------- /spec/migrations/activerecord/005_create_teams_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateTeamsMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table(:teams) do |t| 4 | t.timestamps 5 | t.integer(:league_id) 6 | t.integer(:division_id) 7 | t.string(:name, :limit => 50) 8 | t.string(:logo_url, :limit => 255) 9 | t.string(:manager, :limit => 100, :null => false) 10 | t.string(:ballpark, :limit => 100) 11 | t.string(:mascot, :limit => 100) 12 | t.integer(:founded) 13 | t.integer(:wins) 14 | t.integer(:losses) 15 | t.float(:win_percentage) 16 | end 17 | end 18 | 19 | def self.down 20 | drop_table(:teams) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | In the spirit of [free software][free-sw], **everyone** is encouraged to help 3 | improve this project. 4 | 5 | [free-sw]: http://www.fsf.org/licensing/essays/free-sw.html 6 | 7 | Here are some ways *you* can contribute: 8 | 9 | * by using alpha, beta, and prerelease versions 10 | * by reporting bugs 11 | * by suggesting new features 12 | * by writing or editing documentation 13 | * by writing specifications 14 | * by writing code (**no patch is too small**: fix typos, add comments, clean up 15 | inconsistent whitespace) 16 | * by refactoring code 17 | * by fixing [issues][] 18 | * by reviewing patches 19 | 20 | [issues]: https://github.com/sferik/merb-admin/issues 21 | -------------------------------------------------------------------------------- /spec/models/sequel/draft.rb: -------------------------------------------------------------------------------- 1 | class Draft < Sequel::Model 2 | set_primary_key(:id) 3 | plugin(:timestamps, :update_on_create => true) 4 | plugin(:validation_helpers) 5 | 6 | many_to_one(:team) 7 | many_to_one(:player) 8 | 9 | self.raise_on_save_failure = false 10 | self.raise_on_typecast_failure = false 11 | def validate 12 | validates_numeric(:player_id, :only_integer => true, :allow_blank => true) 13 | validates_numeric(:team_id, :only_integer => true, :allow_blank => true) 14 | validates_presence(:date) 15 | validates_numeric(:round, :only_integer => true) 16 | validates_numeric(:pick, :only_integer => true) 17 | validates_numeric(:overall, :only_integer => true) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/models/sequel/team.rb: -------------------------------------------------------------------------------- 1 | class Team < Sequel::Model 2 | set_primary_key(:id) 3 | plugin(:timestamps, :update_on_create => true) 4 | plugin(:validation_helpers) 5 | 6 | many_to_one(:league) 7 | many_to_one(:division) 8 | one_to_many(:players) 9 | 10 | self.raise_on_save_failure = false 11 | self.raise_on_typecast_failure = false 12 | def validate 13 | validates_numeric(:league_id, :only_integer => true) 14 | validates_numeric(:division_id, :only_integer => true) 15 | validates_presence(:manager) 16 | validates_numeric(:founded, :only_integer => true) 17 | validates_numeric(:wins, :only_integer => true) 18 | validates_numeric(:losses, :only_integer => true) 19 | validates_numeric(:win_percentage) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/migrations/sequel/005_create_teams_migration.rb: -------------------------------------------------------------------------------- 1 | class CreateTeams < Sequel::Migration 2 | def up 3 | create_table(:teams) do 4 | primary_key(:id) 5 | DateTime(:created_at) 6 | DateTime(:updated_at) 7 | foreign_key(:league_id, :table => :leagues) 8 | foreign_key(:division_id, :table => :divisions) 9 | String(:name, :limit => 50) 10 | String(:logo_url, :limit => 255) 11 | String(:manager, :limit => 100, :null => false) 12 | String(:ballpark, :limit => 100) 13 | String(:mascot, :limit => 100) 14 | Integer(:founded) 15 | Integer(:wins) 16 | Integer(:losses) 17 | Float(:win_percentage) 18 | end 19 | end 20 | 21 | def down 22 | drop_table(:teams) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/models/datamapper/player.rb: -------------------------------------------------------------------------------- 1 | class Player 2 | include DataMapper::Resource 3 | 4 | property(:id, Serial) 5 | property(:created_at, DateTime) 6 | property(:updated_at, DateTime) 7 | property(:team_id, Integer, :index => true) 8 | property(:name, String, :length => 100, :required => true, :index => true) 9 | property(:position, String, :index => true) 10 | property(:number, Integer, :required => true) 11 | property(:retired, Boolean, :default => false) 12 | property(:injured, Boolean, :default => false) 13 | property(:born_on, Date) 14 | property(:notes, Text) 15 | 16 | validates_uniqueness_of(:number, :scope => :team_id, :message => "There is already a player with that number on this team") 17 | 18 | belongs_to(:team) 19 | has(1, :draft) 20 | end 21 | -------------------------------------------------------------------------------- /app/views/main/_string.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | property_name = property[:name] 3 | length = property[:length].to_i 4 | label = property[:pretty_name] 5 | required = !property[:nullable?] 6 | %> 7 |
8 | <%= text_field(property_name, :size => [50, length].min, :maxlength => length, :label => label) %> 9 | <% if property_name.to_s =~ /(image|logo|photo|photograph|picture|thumb|thumbnail)_ur(i|l)/i %> 10 | 11 | <% end %> 12 |

13 | <%= required ? "Required." : "Optional." %> <%= length %> <%= length == 1 ? "character." : "characters or fewer." %> 14 |

15 |
16 | -------------------------------------------------------------------------------- /public/stylesheets/patch-iewin.css: -------------------------------------------------------------------------------- 1 | * html #container { position:static; } /* keep header from flowing off the page */ 2 | * html .colMS #content-related { margin-right:0; margin-left:10px; position:static; } /* put the right sidebars back on the page */ 3 | * html .colSM #content-related { margin-right:10px; margin-left:-115px; position:static; } /* put the left sidebars back on the page */ 4 | * html .form-row { height:1%; } 5 | * html .dashboard #content { width:768px; } /* proper fixed width for dashboard in IE6 */ 6 | * html .dashboard #content-main { width:535px; } /* proper fixed width for dashboard in IE6 */ 7 | * html #changelist-filter ul { margin-right:-10px; } /* fix right margin for changelist filters in IE6 */ 8 | * html .change-list .filtered { height:400px; } /* IE ignores min-height, but treats height as if it were min-height */ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | # Bundle gems for the local environment. Make sure to 6 | # put test-only gems in this group so their generators 7 | # and rake tasks are available in test mode: 8 | group :test do 9 | gem 'activerecord', '~> 2.3', :require => 'active_record' 10 | gem 'dm-core', '~> 1.0' 11 | gem 'dm-aggregates', '~> 1.0' 12 | gem 'dm-types', '~> 1.0' 13 | gem 'dm-migrations', '~> 1.0' 14 | gem 'dm-sqlite-adapter', '~> 1.0' 15 | gem 'dm-validations', '~> 1.0' 16 | gem 'rspec', '~> 1.3' 17 | gem 'sequel', '~> 3.18' 18 | if 'java' == RUBY_PLATFORM 19 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.0', :platform => :jruby 20 | gem 'jdbc-sqlite3', '~> 3.6', :platform => :jruby 21 | else 22 | gem 'sqlite3', '~> 1.3' 23 | end 24 | gem 'webrat', '~> 0.7' 25 | end 26 | -------------------------------------------------------------------------------- /app/views/main/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for(@object, :action => url(:merb_admin_create, :model_name => @abstract_model.to_param)) do %> 3 |
4 | <%= partial('properties', :properties => @properties) -%> 5 | <%= partial('belongs_to', :with => @abstract_model.belongs_to_associations, :as => :association) -%> 6 | <%= partial('has_one', :with => @abstract_model.has_one_associations, :as => :association) -%> 7 | <%= partial('has_many', :with => @abstract_model.has_many_associations, :as => :association) -%> 8 |
9 | <%= submit "Save", :class => "default", :name => "_save" %> 10 | <%= submit "Save and add another", :name => "_add_another" %> 11 | <%= submit "Save and continue editing", :name => "_continue" %> 12 |
13 |
14 | <% end =%> 15 | 16 |
17 | -------------------------------------------------------------------------------- /public/javascripts/i18n.js: -------------------------------------------------------------------------------- 1 | /* gettext library */ 2 | 3 | var catalog = new Array(); 4 | 5 | function pluralidx(count) { return (count == 1) ? 0 : 1; } 6 | 7 | function gettext(msgid) { 8 | var value = catalog[msgid]; 9 | if (typeof(value) == 'undefined') { 10 | return msgid; 11 | } else { 12 | return (typeof(value) == 'string') ? value : value[0]; 13 | } 14 | } 15 | 16 | function ngettext(singular, plural, count) { 17 | value = catalog[singular]; 18 | if (typeof(value) == 'undefined') { 19 | return (count == 1) ? singular : plural; 20 | } else { 21 | return value[pluralidx(count)]; 22 | } 23 | } 24 | 25 | function gettext_noop(msgid) { return msgid; } 26 | 27 | function interpolate(fmt, obj, named) { 28 | if (named) { 29 | return fmt.replace(/%\(\w+\)s/g, function(match){return String(obj[match.slice(2,-2)])}); 30 | } else { 31 | return fmt.replace(/%s/g, function(match){return String(obj.shift())}); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spec/models/datamapper/team.rb: -------------------------------------------------------------------------------- 1 | class Team 2 | include DataMapper::Resource 3 | 4 | property(:id, Serial) 5 | property(:created_at, DateTime) 6 | property(:updated_at, DateTime) 7 | property(:league_id, Integer, :required => true, :index => true) 8 | property(:division_id, Integer, :required => true, :index => true) 9 | property(:name, String, :index => true) 10 | property(:logo_url, String, :length => 255) 11 | property(:manager, String, :length => 100, :required => true, :index => true) 12 | property(:ballpark, String, :length => 100, :index => true) 13 | property(:mascot, String, :length => 100, :index => true) 14 | property(:founded, Integer, :required => true) 15 | property(:wins, Integer, :required => true) 16 | property(:losses, Integer, :required => true) 17 | property(:win_percentage, Float, :required => true, :precision => 4, :scale => 3) 18 | 19 | belongs_to(:league) 20 | belongs_to(:division) 21 | has(n, :players) 22 | end 23 | -------------------------------------------------------------------------------- /public/stylesheets/login.css: -------------------------------------------------------------------------------- 1 | /* LOGIN FORM */ 2 | 3 | body.login { 4 | background: #eee; 5 | } 6 | 7 | .login #container { 8 | background: white; 9 | border: 1px solid #ccc; 10 | width: 28em; 11 | min-width: 300px; 12 | margin-left: auto; 13 | margin-right: auto; 14 | margin-top: 100px; 15 | } 16 | 17 | .login #content-main { 18 | width: 100%; 19 | } 20 | 21 | .login form { 22 | margin-top: 1em; 23 | } 24 | 25 | .login .form-row { 26 | padding: 4px 0; 27 | float: left; 28 | width: 100%; 29 | } 30 | 31 | .login .form-row label { 32 | float: left; 33 | width: 9em; 34 | padding-right: 0.5em; 35 | line-height: 2em; 36 | text-align: right; 37 | font-size: 1em; 38 | color: #333; 39 | } 40 | 41 | .login .form-row #id_username, .login .form-row #id_password { 42 | width: 14em; 43 | } 44 | 45 | .login span.help { 46 | font-size: 10px; 47 | display: block; 48 | } 49 | 50 | .login .submit-row { 51 | clear: both; 52 | padding: 1em 0 0 9.4em; 53 | } 54 | 55 | -------------------------------------------------------------------------------- /public/stylesheets/ie.css: -------------------------------------------------------------------------------- 1 | /* IE 6 & 7 */ 2 | 3 | /* Proper fixed width for dashboard in IE6 */ 4 | 5 | .dashboard #content { 6 | *width: 768px; 7 | } 8 | 9 | .dashboard #content-main { 10 | *width: 535px; 11 | } 12 | 13 | /* IE 6 ONLY */ 14 | 15 | /* Keep header from flowing off the page */ 16 | 17 | #container { 18 | _position: static; 19 | } 20 | 21 | /* Put the right sidebars back on the page */ 22 | 23 | .colMS #content-related { 24 | _margin-right: 0; 25 | _margin-left: 10px; 26 | _position: static; 27 | } 28 | 29 | /* Put the left sidebars back on the page */ 30 | 31 | .colSM #content-related { 32 | _margin-right: 10px; 33 | _margin-left: -115px; 34 | _position: static; 35 | } 36 | 37 | .form-row { 38 | _height: 1%; 39 | } 40 | 41 | /* Fix right margin for changelist filters in IE6 */ 42 | 43 | #changelist-filter ul { 44 | _margin-right: -10px; 45 | } 46 | 47 | /* IE ignores min-height, but treats height as if it were min-height */ 48 | 49 | .change-list .filtered { 50 | _height: 400px; 51 | } -------------------------------------------------------------------------------- /app/views/main/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 7 | <% @abstract_models.each do |abstract_model| %> 8 | 9 | 12 | 15 | 18 | 19 | <% end %> 20 |
5 | <%= link_to("Models", url(:merb_admin_dashboard), :class => "section") %> 6 |
10 | <%= link_to(abstract_model.pretty_name, url(:merb_admin_list, :model_name => abstract_model.to_param)) %> 11 | 13 | <%= link_to("Add", url(:merb_admin_new, :model_name => abstract_model.to_param), :class => "addlink") %> 14 | 16 | <%= link_to("Edit", url(:merb_admin_list, :model_name => abstract_model.to_param), :class => "changelink") %> 17 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /app/views/main/_has_many.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | association_name = association[:name] 3 | collection = MerbAdmin::AbstractModel.new(association[:child_model]).all.map{|object| [object.id, object_label(object)]}.sort_by{|object| object[1]} 4 | selected = @object.send(association_name) 5 | label = association[:pretty_name] 6 | %> 7 |
8 |

<%= label %>

9 |
10 |
11 | <%= select(:name => "associations[#{association_name}][]", :id => association_name, :collection => collection, :selected => selected.map{|o| o.id.to_s}, :label => label, :multiple => true) %> 12 | 13 |

Hold down "Control", or "Command" on a Mac, to select more than one.

14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /app/views/main/_properties.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% belongs_to_keys = @abstract_model.belongs_to_associations.map{|b| b[:child_key].first} %> 3 | <% properties.reject{|property| [:id, :created_at, :created_on, :deleted_at, :updated_at, :updated_on, :deleted_on].include?(property[:name]) || belongs_to_keys.include?(property[:name])}.each do |property| %> 4 | <% property_name = property[:name] %> 5 | <% property_type = property[:type] %> 6 | <% errors_exist = !(@object.errors[property_name].nil? || @object.errors[property_name].empty?) %> 7 |
"> 8 | <% if errors_exist %> 9 | 14 | <% end %> 15 | <%= partial(property_type.to_s, :property => property) -%> 16 |
17 | <% end %> 18 |
19 | -------------------------------------------------------------------------------- /app/views/main/edit.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for(@object, :action => url(:merb_admin_update, :model_name => @abstract_model.to_param, :id => @object.id)) do %> 3 |
4 | <%= partial('properties', :properties => @properties) -%> 5 | <%= partial('belongs_to', :with => @abstract_model.belongs_to_associations, :as => :association) -%> 6 | <%= partial('has_one', :with => @abstract_model.has_one_associations, :as => :association) -%> 7 | <%= partial('has_many', :with => @abstract_model.has_many_associations, :as => :association) -%> 8 |
9 | <%= submit "Save", :class => "default", :name => "_save" %> 10 | 13 | <%= submit "Save and add another", :name => "_add_another" %> 14 | <%= submit "Save and continue editing", :name => "_continue" %> 15 |
16 |
17 | <% end =%> 18 | 19 |
20 | -------------------------------------------------------------------------------- /config/init.rb: -------------------------------------------------------------------------------- 1 | # 2 | # ==== Standalone MerbAdmin configuration 3 | # 4 | # This configuration/environment file is only loaded by bin/slice, which can be 5 | # used during development of the slice. It has no effect on this slice being 6 | # loaded in a host application. To run your slice in standalone mode, just 7 | # run 'slice' from its directory. The 'slice' command is very similar to 8 | # the 'merb' command, and takes all the same options, including -i to drop 9 | # into an irb session for example. 10 | # 11 | # The usual Merb configuration directives and init.rb setup methods apply, 12 | # including use_orm and before_app_loads/after_app_loads. 13 | # 14 | # If you need need different configurations for different environments you can 15 | # even create the specific environment file in config/environments/ just like 16 | # in a regular Merb application. 17 | # 18 | # In fact, a slice is no different from a normal # Merb application - it only 19 | # differs by the fact that seamlessly integrates into a so called 'host' 20 | # application, which in turn can override or finetune the slice implementation 21 | # code and views. 22 | 23 | use_orm :activerecord 24 | use_test :rspec 25 | use_template_engine :erb 26 | 27 | Merb::Config.use do |c| 28 | c[:exception_details] = true 29 | c[:reload_templates] = true 30 | c[:reload_classes] = true 31 | end 32 | -------------------------------------------------------------------------------- /app/views/main/_belongs_to.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | child_key = association[:child_key].first 3 | collection = MerbAdmin::AbstractModel.new(association[:parent_model]).all.map{|object| [object.id, object_label(object)]}.sort_by{|object| object[1]} 4 | selected = @object.send(child_key) 5 | label = association[:pretty_name] 6 | required = false 7 | errors_exist = !(@object.errors[child_key].nil? || @object.errors[child_key].empty?) 8 | @properties.select{|property| property[:name] == child_key}.each do |property| 9 | required = true unless property[:nullable?] 10 | end 11 | %> 12 |
13 |

<%= label %>

14 |
"> 15 | <% if errors_exist %> 16 | 21 | <% end %> 22 |
23 | <%= select(child_key, :collection => collection, :include_blank => true, :selected => selected.to_s, :label => label) %> 24 |

25 | <%= required ? "Required." : "Optional." %> 26 |

27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /public/stylesheets/layout.css: -------------------------------------------------------------------------------- 1 | /* PAGE STRUCTURE */ 2 | #container { position:relative; width:100%; min-width:760px; padding:0; } 3 | #content { margin:10px 15px; } 4 | #header { width:100%; } 5 | #content-main { float:left; width:100%; } 6 | #content-related { float:right; width:18em; position:relative; margin-right:-19em; } 7 | #footer { clear:both; padding:10px; } 8 | 9 | /* COLUMN TYPES */ 10 | .colMS { margin-right:20em !important; } 11 | .colSM { margin-left:20em !important; } 12 | .colSM #content-related { float:left; margin-right:0; margin-left:-19em; } 13 | .colSM #content-main { float:right; } 14 | .popup .colM { width:95%; } 15 | .subcol { float:left; width:46%; margin-right:15px; } 16 | .dashboard #content { width:500px; } 17 | 18 | /* HEADER */ 19 | #header { background:#417690; color:#ffc; overflow:hidden; } 20 | #header a:link, #header a:visited { color:white; } 21 | #header a:hover { text-decoration:underline; } 22 | #branding h1 { padding:0 10px; font-size:18px; margin:8px 0; font-weight:normal; color:#f4f379; } 23 | #branding h2 { padding:0 10px; font-size:14px; margin:-8px 0 8px 0; font-weight:normal; color:#ffc; } 24 | #user-tools { position:absolute; top:0; right:0; padding:1.2em 10px; font-size:11px; text-align:right; } 25 | 26 | /* SIDEBAR */ 27 | #content-related h3 { font-size:12px; color:#666; margin-bottom:3px; } 28 | #content-related h4 { font-size:11px; } 29 | #content-related .module h2 { background:#eee url(../images/nav-bg.gif) bottom left repeat-x; color:#666; } 30 | -------------------------------------------------------------------------------- /app/views/main/_has_one.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | child_key = association[:child_key].first 3 | association_name = association[:name] 4 | collection = MerbAdmin::AbstractModel.new(association[:child_model]).all.map{|object| [object.id, object_label(object)]}.sort_by{|object| object[1]} 5 | selected = @object.send(association_name) 6 | label = association[:pretty_name] 7 | required = false 8 | errors_exist = !(@object.errors[child_key].nil? || @object.errors[child_key].empty?) 9 | @properties.select{|property| property[:name] == child_key}.each do |property| 10 | required = true unless property[:nullable?] 11 | end 12 | %> 13 |
14 |

<%= label %>

15 |
"> 16 | <% if errors_exist %> 17 | 22 | <% end %> 23 |
24 | <%= select(:name => "associations[#{association_name}][]", :id => association_name, :collection => collection, :include_blank => true, :selected => selected ? selected.id.to_s : nil, :label => label) %> 25 |

26 | <%= required ? "Required." : "Optional." %> 27 |

28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /app/views/layout/dashboard.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | slice_name = "MerbAdmin" + (MerbAdmin[:app_name].blank? ? "" : " for #{MerbAdmin[:app_name]}") 3 | page_name = "Site administration" 4 | %> 5 | 6 | 7 | 8 | 9 | 10 | <%= page_name %> | <%= slice_name %> 11 | 12 | 13 | 14 | 15 | 16 |
17 | 24 | <%= partial('layout/message', :message => message) unless message.blank? -%> 25 |
26 |

27 | <%= page_name %> 28 |

29 | <%= catch_content(:for_layout) -%> 30 |
31 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /public/javascripts/actions.js: -------------------------------------------------------------------------------- 1 | var Actions = { 2 | init: function() { 3 | var selectAll = document.getElementById('action-toggle'); 4 | if (selectAll) { 5 | selectAll.style.display = 'inline'; 6 | addEvent(selectAll, 'click', function() { 7 | Actions.checker(selectAll.checked); 8 | }); 9 | } 10 | var changelistTable = document.getElementsBySelector('#changelist table')[0]; 11 | if (changelistTable) { 12 | addEvent(changelistTable, 'click', function(e) { 13 | if (!e) { var e = window.event; } 14 | var target = e.target ? e.target : e.srcElement; 15 | if (target.nodeType == 3) { target = target.parentNode; } 16 | if (target.className == 'action-select') { 17 | var tr = target.parentNode.parentNode; 18 | Actions.toggleRow(tr, target.checked); 19 | } 20 | }); 21 | } 22 | }, 23 | toggleRow: function(tr, checked) { 24 | if (checked && tr.className.indexOf('selected') == -1) { 25 | tr.className += ' selected'; 26 | } else if (!checked) { 27 | tr.className = tr.className.replace(' selected', ''); 28 | } 29 | }, 30 | checker: function(checked) { 31 | var actionCheckboxes = document.getElementsBySelector('tr input.action-select'); 32 | for(var i = 0; i < actionCheckboxes.length; i++) { 33 | actionCheckboxes[i].checked = checked; 34 | Actions.toggleRow(actionCheckboxes[i].parentNode.parentNode, checked); 35 | } 36 | } 37 | }; 38 | 39 | addEvent(window, 'load', Actions.init); 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Erik Michaels-Ober 2 | Stylesheets and javascript files copyright (c) Django Software Foundation and individual contributors. 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of Django nor the names of its contributors may be used 16 | to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /app/views/main/delete.html.erb: -------------------------------------------------------------------------------- 1 |

Are you sure you want to delete the <%= @abstract_model.pretty_name.downcase %> “<%= object_label(@object) %>”? All of the following related items will be deleted:

2 | 23 | <%= form_for(@object, :action => url(:merb_admin_destroy, :model_name => @abstract_model.to_param, :id => @object.id), :method => :delete) do %> 24 |
25 | <%= submit "Yes, I'm sure" %> 26 |
27 | <% end =%> 28 | 29 | -------------------------------------------------------------------------------- /merb-admin.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.expand_path('../lib/merb-admin/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.add_dependency 'builder', '~> 2.1' 6 | gem.add_dependency 'merb-assets', '~> 1.1' 7 | gem.add_dependency 'merb-helpers', '~> 1.1' 8 | gem.add_dependency 'merb-slices', '~> 1.1' 9 | gem.add_development_dependency 'activerecord', '~> 2.3' 10 | gem.add_development_dependency 'dm-core', '~> 1.0' 11 | gem.add_development_dependency 'dm-aggregates', '~> 1.0' 12 | gem.add_development_dependency 'dm-types', '~> 1.0' 13 | gem.add_development_dependency 'dm-migrations', '~> 1.0' 14 | gem.add_development_dependency 'dm-sqlite-adapter', '~> 1.0' 15 | gem.add_development_dependency 'dm-validations', '~> 1.0' 16 | gem.add_development_dependency 'rspec', '~> 1.3' 17 | gem.add_development_dependency 'sequel', '~> 3.18' 18 | gem.add_development_dependency 'webrat', '~> 0.7' 19 | gem.author = "Erik Michaels-Ober" 20 | gem.description = %q{MerbAdmin is a Merb plugin that provides an easy-to-use interface for managing your data} 21 | gem.email = 'sferik@gmail.com' 22 | gem.files = `git ls-files`.split("\n") 23 | gem.homepage = 'https://github.com/sferik/merb-admin' 24 | gem.name = 'merb-admin' 25 | gem.post_install_message =<= 1.3.6') 33 | gem.summary = %q{MerbAdmin is a Merb plugin that provides an easy-to-use interface for managing your data} 34 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 35 | gem.version = MerbAdmin::VERSION 36 | end 37 | -------------------------------------------------------------------------------- /lib/merb-admin/spectasks.rb: -------------------------------------------------------------------------------- 1 | require 'spec/rake/spectask' 2 | 3 | namespace :slices do 4 | namespace :"merb-admin" do 5 | 6 | desc "Run slice specs within the host application context" 7 | task :spec => [ "spec:explain", "spec:default" ] 8 | 9 | namespace :spec do 10 | 11 | slice_root = File.expand_path(File.join(File.dirname(__FILE__), '..', '..')) 12 | 13 | task :explain do 14 | puts "\nNote: By running MerbAdmin specs inside the application context any\n" + 15 | "overrides could break existing specs. This isn't always a problem,\n" + 16 | "especially in the case of views. Use these spec tasks to check how\n" + 17 | "well your application conforms to the original slice implementation." 18 | end 19 | 20 | Spec::Rake::SpecTask.new('default') do |t| 21 | t.spec_opts = ["--format", "specdoc", "--colour"] 22 | t.spec_files = Dir["#{slice_root}/spec/**/*_spec.rb"].sort 23 | end 24 | 25 | desc "Run all model specs, run a spec for a specific Model with MODEL=MyModel" 26 | Spec::Rake::SpecTask.new('model') do |t| 27 | t.spec_opts = ["--format", "specdoc", "--colour"] 28 | if(ENV['MODEL']) 29 | t.spec_files = Dir["#{slice_root}/spec/models/**/#{ENV['MODEL']}_spec.rb"].sort 30 | else 31 | t.spec_files = Dir["#{slice_root}/spec/models/**/*_spec.rb"].sort 32 | end 33 | end 34 | 35 | desc "Run all request specs, run a spec for a specific request with REQUEST=MyRequest" 36 | Spec::Rake::SpecTask.new('request') do |t| 37 | t.spec_opts = ["--format", "specdoc", "--colour"] 38 | if(ENV['REQUEST']) 39 | t.spec_files = Dir["#{slice_root}/spec/requests/**/#{ENV['REQUEST']}_spec.rb"].sort 40 | else 41 | t.spec_files = Dir["#{slice_root}/spec/requests/**/*_spec.rb"].sort 42 | end 43 | end 44 | 45 | desc "Run all specs and output the result in html" 46 | Spec::Rake::SpecTask.new('html') do |t| 47 | t.spec_opts = ["--format", "html"] 48 | t.libs = ['lib', 'server/lib' ] 49 | t.spec_files = Dir["#{slice_root}/spec/**/*_spec.rb"].sort 50 | end 51 | 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Merb 2 | module MerbAdmin 3 | module ApplicationHelper 4 | 5 | # @param *segments Path segments to append. 6 | # 7 | # @return 8 | # A path relative to the public directory, with added segments. 9 | def image_path(*segments) 10 | public_path_for(:image, *segments) 11 | end 12 | 13 | # @param *segments Path segments to append. 14 | # 15 | # @return 16 | # A path relative to the public directory, with added segments. 17 | def javascript_path(*segments) 18 | public_path_for(:javascript, *segments) 19 | end 20 | 21 | # @param *segments Path segments to append. 22 | # 23 | # @return 24 | # A path relative to the public directory, with added segments. 25 | def stylesheet_path(*segments) 26 | public_path_for(:stylesheet, *segments) 27 | end 28 | 29 | # Construct a path relative to the public directory 30 | # 31 | # @param The type of component. 32 | # @param *segments Path segments to append. 33 | # 34 | # @return 35 | # A path relative to the public directory, with added segments. 36 | def public_path_for(type, *segments) 37 | ::MerbAdmin.public_path_for(type, *segments) 38 | end 39 | 40 | # Construct an app-level path. 41 | # 42 | # @param The type of component. 43 | # @param *segments Path segments to append. 44 | # 45 | # @return 46 | # A path within the host application, with added segments. 47 | def app_path_for(type, *segments) 48 | ::MerbAdmin.app_path_for(type, *segments) 49 | end 50 | 51 | # Construct a slice-level path. 52 | # 53 | # @param The type of component. 54 | # @param *segments Path segments to append. 55 | # 56 | # @return 57 | # A path within the slice source (Gem), with added segments. 58 | def slice_path_for(type, *segments) 59 | ::MerbAdmin.slice_path_for(type, *segments) 60 | end 61 | 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/views/layout/list.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | slice_name = "MerbAdmin" + (MerbAdmin[:app_name].blank? ? "" : " for #{MerbAdmin[:app_name]}") 3 | page_name = "Select " + @abstract_model.pretty_name.downcase + " to edit" 4 | %> 5 | 6 | 7 | 8 | 9 | 10 | <%= page_name %> | <%= slice_name %> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 28 | 32 | <%= partial('layout/message', :message => message) unless message.blank? -%> 33 |
34 |

35 | <%= page_name %> 36 |

37 | <%= catch_content(:for_layout) -%> 38 |
39 | 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /lib/abstract_model.rb: -------------------------------------------------------------------------------- 1 | require 'generic_support' 2 | 3 | module MerbAdmin 4 | class AbstractModel 5 | # Returns all models for a given Merb app 6 | def self.all 7 | @models = [] 8 | orm = Merb.orm 9 | case orm 10 | when :activerecord, :sequel 11 | Dir.glob(Merb.dir_for(:model) / Merb.glob_for(:model)).each do |filename| 12 | File.read(filename).scan(/class ([\w\d_\-:]+)/).flatten.each do |model_name| 13 | add_model(model_name) 14 | end 15 | end 16 | when :datamapper 17 | DataMapper::Model.descendants.each do |model| 18 | add_model(model.to_s) 19 | end 20 | else 21 | raise "MerbAdmin does not support the #{orm} ORM" 22 | end 23 | @models.sort!{|x, y| x.model.to_s <=> y.model.to_s} 24 | end 25 | 26 | def self.add_model(model_name) 27 | model = lookup(model_name) 28 | @models << new(model) if model 29 | end 30 | 31 | # Given a string +model_name+, finds the corresponding model class 32 | def self.lookup(model_name) 33 | return nil if MerbAdmin[:excluded_models].include?(model_name) 34 | begin 35 | model = Object.full_const_get(model_name) 36 | rescue NameError 37 | raise "MerbAdmin could not find model #{model_name}" 38 | end 39 | 40 | case Merb.orm 41 | when :activerecord 42 | model if superclasses(model).include?(ActiveRecord::Base) 43 | when :sequel 44 | model if superclasses(model).include?(Sequel::Model) 45 | else 46 | model 47 | end 48 | end 49 | 50 | attr_accessor :model 51 | 52 | def initialize(model) 53 | model = self.class.lookup(model.to_s.camel_case) unless model.is_a?(Class) 54 | orm = Merb.orm 55 | @model = model 56 | self.extend(GenericSupport) 57 | case orm 58 | when :activerecord 59 | require 'active_record_support' 60 | self.extend(ActiverecordSupport) 61 | when :datamapper 62 | require 'datamapper_support' 63 | self.extend(DatamapperSupport) 64 | when :sequel 65 | require 'sequel_support' 66 | self.extend(SequelSupport) 67 | else 68 | raise "MerbAdmin does not support the #{orm} ORM" 69 | end 70 | end 71 | 72 | private 73 | 74 | def self.superclasses(klass) 75 | superclasses = [] 76 | while klass 77 | superclasses << klass.superclass if klass && klass.superclass 78 | klass = klass.superclass 79 | end 80 | superclasses 81 | end 82 | 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MerbAdmin 2 | MerbAdmin is a Merb plugin that provides an easy-to-use interface for managing your data. 3 | 4 | [It offers an array of features.](http://sferik.tadalist.com/lists/1352791/public) 5 | 6 | [Take it for a test drive with sample data.](http://merb-admin.heroku.com/) 7 | 8 | ## Screenshots 9 | ![List view](https://github.com/sferik/merb-admin/raw/master/screenshots/list.png "List view") 10 | ![Edit view](https://github.com/sferik/merb-admin/raw/master/screenshots/edit.png "Edit view") 11 | 12 | ## Installation 13 | In your app, add the following dependency to Gemfile: 14 | 15 | gem "merb-admin", "~> 0.8.8" 16 | Bundle it: 17 | 18 | bundle install 19 | Add the following route to config/router.rb: 20 | 21 | add_slice(:merb_admin, :path_prefix => "admin") 22 | Then, run the following rake task: 23 | 24 | rake slices:merb-admin:install 25 | 26 | ## Configuration (optional) 27 | If you're feeling crafty, you can set a couple configuration options in config/init.rb: 28 | 29 | Merb::BootLoader.before_app_loads do 30 | Merb::Slices::config[:merb_admin][:app_name] = "My App" 31 | Merb::Slices::config[:merb_admin][:per_page] = 100 32 | Merb::Slices::config[:merb_admin][:excluded_models] = ["Top", "Secret"] 33 | end 34 | 35 | ## Usage 36 | Start the server: 37 | 38 | merb 39 | You should now be able to administer your site at 40 | [http://localhost:4000/admin](http://localhost:4000/admin). 41 | 42 | ## WARNING 43 | MerbAdmin does not implement any authorization scheme. Make sure to apply 44 | authorization logic before deploying to production! 45 | 46 | ## Acknowledgments 47 | Many thanks to: 48 | 49 | * [Wilson Miner](http://www.wilsonminer.com/) for contributing the stylesheets and javascripts from [Django](http://www.djangoproject.com/) 50 | * [Aaron Wheeler](http://fightinjoe.com/) for contributing libraries from [Merb AutoScaffold](https://github.com/fightinjoe/merb-autoscaffold) 51 | * [Lori Holden](http://loriholden.com/) for contributing the [merb-pagination](https://github.com/lholden/merb-pagination) helper 52 | * [Jacques Crocker](http://merbjedi.com/) for adding support for [namespaced models](https://github.com/merbjedi/merb-admin/commit/8139e2241038baf9b72452056fcdc7c340d79275) 53 | * [Jeremy Evans](http://code.jeremyevans.net/) and [Pavel Kunc](http://www.merboutpost.com) for reviewing the [patch](https://github.com/sferik/merb-admin/commit/061fa28f652fc9214e9cf480d66870140181edef) to add [Sequel](http://sequel.rubyforge.org/) support 54 | * [Jonah Honeyman](https://github.com/jonuts) for fixing a [bug](https://github.com/sferik/merb-admin/commit/9064d10382eadd1ed7a882ef40e2c6a65edfef2c) and adding the [:excluded_models option](https://github.com/sferik/merb-admin/commit/f6157d1c471dd85162481d6926578164be1b9673) 55 | -------------------------------------------------------------------------------- /public/javascripts/timeparse.js: -------------------------------------------------------------------------------- 1 | var timeParsePatterns = [ 2 | // 9 3 | { re: /^\d{1,2}$/i, 4 | handler: function(bits) { 5 | if (bits[0].length == 1) { 6 | return '0' + bits[0] + ':00'; 7 | } else { 8 | return bits[0] + ':00'; 9 | } 10 | } 11 | }, 12 | // 13:00 13 | { re: /^\d{2}[:.]\d{2}$/i, 14 | handler: function(bits) { 15 | return bits[0].replace('.', ':'); 16 | } 17 | }, 18 | // 9:00 19 | { re: /^\d[:.]\d{2}$/i, 20 | handler: function(bits) { 21 | return '0' + bits[0].replace('.', ':'); 22 | } 23 | }, 24 | // 3 am / 3 a.m. / 3am 25 | { re: /^(\d+)\s*([ap])(?:.?m.?)?$/i, 26 | handler: function(bits) { 27 | var hour = parseInt(bits[1]); 28 | if (hour == 12) { 29 | hour = 0; 30 | } 31 | if (bits[2].toLowerCase() == 'p') { 32 | if (hour == 12) { 33 | hour = 0; 34 | } 35 | return (hour + 12) + ':00'; 36 | } else { 37 | if (hour < 10) { 38 | return '0' + hour + ':00'; 39 | } else { 40 | return hour + ':00'; 41 | } 42 | } 43 | } 44 | }, 45 | // 3.30 am / 3:15 a.m. / 3.00am 46 | { re: /^(\d+)[.:](\d{2})\s*([ap]).?m.?$/i, 47 | handler: function(bits) { 48 | var hour = parseInt(bits[1]); 49 | var mins = parseInt(bits[2]); 50 | if (mins < 10) { 51 | mins = '0' + mins; 52 | } 53 | if (hour == 12) { 54 | hour = 0; 55 | } 56 | if (bits[3].toLowerCase() == 'p') { 57 | if (hour == 12) { 58 | hour = 0; 59 | } 60 | return (hour + 12) + ':' + mins; 61 | } else { 62 | if (hour < 10) { 63 | return '0' + hour + ':' + mins; 64 | } else { 65 | return hour + ':' + mins; 66 | } 67 | } 68 | } 69 | }, 70 | // noon 71 | { re: /^no/i, 72 | handler: function(bits) { 73 | return '12:00'; 74 | } 75 | }, 76 | // midnight 77 | { re: /^mid/i, 78 | handler: function(bits) { 79 | return '00:00'; 80 | } 81 | } 82 | ]; 83 | 84 | function parseTimeString(s) { 85 | for (var i = 0; i < timeParsePatterns.length; i++) { 86 | var re = timeParsePatterns[i].re; 87 | var handler = timeParsePatterns[i].handler; 88 | var bits = re.exec(s); 89 | if (bits) { 90 | return handler(bits); 91 | } 92 | } 93 | return s; 94 | } 95 | -------------------------------------------------------------------------------- /app/views/layout/form.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | slice_name = "MerbAdmin" + (MerbAdmin[:app_name].blank? ? "" : " for #{MerbAdmin[:app_name]}") 3 | page_name = action_name.capitalize + " " + @abstract_model.pretty_name.downcase 4 | %> 5 | 6 | 7 | 8 | 9 | 10 | <%= page_name %> | <%= slice_name %> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 33 | 38 | <%= partial('layout/message', :message => message) unless message.blank? -%> 39 |
40 |

41 | <%= page_name %> 42 |

43 | <%= catch_content(:for_layout) -%> 44 |
45 | 46 |
47 | 48 | 49 | -------------------------------------------------------------------------------- /public/javascripts/RelatedObjectLookups.js: -------------------------------------------------------------------------------- 1 | // Handles related-objects functionality: lookup link for raw_id_fields 2 | // and Add Another links. 3 | 4 | function html_unescape(text) { 5 | // Unescape a string that was escaped using django.utils.html.escape. 6 | text = text.replace(/</g, '<'); 7 | text = text.replace(/>/g, '>'); 8 | text = text.replace(/"/g, '"'); 9 | text = text.replace(/'/g, "'"); 10 | text = text.replace(/&/g, '&'); 11 | return text; 12 | } 13 | 14 | // IE doesn't accept periods or dashes in the window name, but the element IDs 15 | // we use to generate popup window names may contain them, therefore we map them 16 | // to allowed characters in a reversible way so that we can locate the correct 17 | // element when the popup window is dismissed. 18 | function id_to_windowname(text) { 19 | text = text.replace(/\./g, '__dot__'); 20 | text = text.replace(/\-/g, '__dash__'); 21 | return text; 22 | } 23 | 24 | function windowname_to_id(text) { 25 | text = text.replace(/__dot__/g, '.'); 26 | text = text.replace(/__dash__/g, '-'); 27 | return text; 28 | } 29 | 30 | function showRelatedObjectLookupPopup(triggeringLink) { 31 | var name = triggeringLink.id.replace(/^lookup_/, ''); 32 | name = id_to_windowname(name); 33 | var href; 34 | if (triggeringLink.href.search(/\?/) >= 0) { 35 | href = triggeringLink.href + '&pop=1'; 36 | } else { 37 | href = triggeringLink.href + '?pop=1'; 38 | } 39 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 40 | win.focus(); 41 | return false; 42 | } 43 | 44 | function dismissRelatedLookupPopup(win, chosenId) { 45 | var name = windowname_to_id(win.name); 46 | var elem = document.getElementById(name); 47 | if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) { 48 | elem.value += ',' + chosenId; 49 | } else { 50 | document.getElementById(name).value = chosenId; 51 | } 52 | win.close(); 53 | } 54 | 55 | function showAddAnotherPopup(triggeringLink) { 56 | var name = triggeringLink.id.replace(/^add_/, ''); 57 | name = id_to_windowname(name); 58 | href = triggeringLink.href 59 | if (href.indexOf('?') == -1) { 60 | href += '?_popup=1'; 61 | } else { 62 | href += '&_popup=1'; 63 | } 64 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 65 | win.focus(); 66 | return false; 67 | } 68 | 69 | function dismissAddAnotherPopup(win, newId, newRepr) { 70 | // newId and newRepr are expected to have previously been escaped by 71 | // django.utils.html.escape. 72 | newId = html_unescape(newId); 73 | newRepr = html_unescape(newRepr); 74 | var name = windowname_to_id(win.name); 75 | var elem = document.getElementById(name); 76 | if (elem) { 77 | if (elem.nodeName == 'SELECT') { 78 | var o = new Option(newRepr, newId); 79 | elem.options[elem.options.length] = o; 80 | o.selected = true; 81 | } else if (elem.nodeName == 'INPUT') { 82 | if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) { 83 | elem.value += ',' + newId; 84 | } else { 85 | elem.value = newId; 86 | } 87 | } 88 | } else { 89 | var toId = name + "_to"; 90 | elem = document.getElementById(toId); 91 | var o = new Option(newRepr, newId); 92 | SelectBox.add_to_cache(toId, o); 93 | SelectBox.redisplay(toId); 94 | } 95 | win.close(); 96 | } 97 | -------------------------------------------------------------------------------- /lib/merb-admin.rb: -------------------------------------------------------------------------------- 1 | if defined?(Merb::Plugins) 2 | 3 | require 'merb-slices' 4 | Merb::Plugins.add_rakefiles "merb-admin/merbtasks", "merb-admin/slicetasks", "merb-admin/spectasks" 5 | 6 | # Register the Slice for the current host application 7 | Merb::Slices::register(__FILE__) 8 | 9 | # Slice configuration - set this in a before_app_loads callback. 10 | # By default a Slice uses its own layout, so you can swicht to 11 | # the main application layout or no layout at all if needed. 12 | # 13 | # Configuration options: 14 | # :layout - the layout to use; defaults to :merb-admin 15 | # :mirror - which path component types to use on copy operations; defaults to all 16 | Merb::Slices::config[:merb_admin][:layout] ||= :merb_admin 17 | Merb::Slices::config[:merb_admin][:per_page] ||= 100 18 | Merb::Slices::config[:merb_admin][:excluded_models] ||= [] 19 | 20 | # All Slice code is expected to be namespaced inside a module 21 | module MerbAdmin 22 | 23 | # Slice metadata 24 | self.description = "MerbAdmin is a Merb plugin that provides an easy-to-use interface for managing your data." 25 | require File.expand_path('../merb-admin/version', __FILE__) 26 | self.version = VERSION 27 | self.author = "Erik Michaels-Ober" 28 | 29 | # Stub classes loaded hook - runs before LoadClasses BootLoader 30 | # right after a slice's classes have been loaded internally. 31 | def self.loaded 32 | end 33 | 34 | # Initialization hook - runs before AfterAppLoads BootLoader 35 | def self.init 36 | end 37 | 38 | # Activation hook - runs after AfterAppLoads BootLoader 39 | def self.activate 40 | end 41 | 42 | # Deactivation hook - triggered by Merb::Slices.deactivate(MerbAdmin) 43 | def self.deactivate 44 | end 45 | 46 | def self.setup_router(scope) 47 | scope.match("/", :method => :get). 48 | to(:controller => "main", :action => "index"). 49 | name(:dashboard) 50 | 51 | scope.match("/:model_name", :method => :get). 52 | to(:controller => "main", :action => "list"). 53 | name(:list) 54 | 55 | scope.match("/:model_name/new", :method => :get). 56 | to(:controller => "main", :action => "new"). 57 | name(:new) 58 | 59 | scope.match("/:model_name/:id/edit", :method => :get). 60 | to(:controller => "main", :action => "edit"). 61 | name(:edit) 62 | 63 | scope.match("/:model_name", :method => :post). 64 | to(:controller => "main", :action => "create"). 65 | name(:create) 66 | 67 | scope.match("/:model_name/:id", :method => :put). 68 | to(:controller => "main", :action => "update"). 69 | name(:update) 70 | 71 | scope.match("/:model_name/:id/delete", :method => :get). 72 | to(:controller => "main", :action => "delete"). 73 | name(:delete) 74 | 75 | scope.match("/:model_name/:id(.:format)", :method => :delete). 76 | to(:controller => "main", :action => "destroy"). 77 | name(:destroy) 78 | end 79 | 80 | end 81 | 82 | # Setup the slice layout for MerbAdmin 83 | # 84 | # Use MerbAdmin.push_path and MerbAdmin.push_app_path 85 | # to set paths to merb-admin-level and app-level paths. Example: 86 | # 87 | # MerbAdmin.push_path(:application, MerbAdmin.root) 88 | # MerbAdmin.push_app_path(:application, Merb.root / 'slices' / 'merb-admin') 89 | # ... 90 | # 91 | # Any component path that hasn't been set will default to MerbAdmin.root 92 | # 93 | # Or just call setup_default_structure! to setup a basic Merb MVC structure. 94 | MerbAdmin.setup_default_structure! 95 | 96 | # Add dependencies for other MerbAdmin classes below. Example: 97 | # dependency "merb-admin/other" 98 | 99 | end 100 | -------------------------------------------------------------------------------- /public/stylesheets/rtl.css: -------------------------------------------------------------------------------- 1 | body { 2 | direction: rtl; 3 | } 4 | 5 | /* LOGIN */ 6 | 7 | .login .form-row { 8 | float: right; 9 | } 10 | 11 | .login .form-row label { 12 | float: right; 13 | padding-left: 0.5em; 14 | padding-right: 0; 15 | text-align: left; 16 | } 17 | 18 | .login .submit-row { 19 | clear: both; 20 | padding: 1em 9.4em 0 0; 21 | } 22 | 23 | /* GLOBAL */ 24 | 25 | th { 26 | text-align: right; 27 | } 28 | 29 | .module h2, .module caption { 30 | text-align: right; 31 | } 32 | 33 | .addlink, .changelink { 34 | padding-left: 0px; 35 | padding-right: 12px; 36 | background-position: 100% 0.2em; 37 | } 38 | 39 | .deletelink { 40 | padding-left: 0px; 41 | padding-right: 12px; 42 | background-position: 100% 0.25em; 43 | } 44 | 45 | .object-tools { 46 | float: left; 47 | } 48 | 49 | /* LAYOUT */ 50 | 51 | #user-tools { 52 | right: auto; 53 | left: 0; 54 | text-align: left; 55 | } 56 | 57 | div.breadcrumbs { 58 | text-align: right; 59 | } 60 | 61 | #content-main { 62 | float: right; 63 | } 64 | 65 | #content-related { 66 | float: left; 67 | margin-left: -19em; 68 | margin-right: auto; 69 | } 70 | 71 | .colMS { 72 | margin-left: 20em !important; 73 | margin-right: 10px !important; 74 | } 75 | 76 | /* dashboard styles */ 77 | 78 | .dashboard .module table td a { 79 | padding-left: .6em; 80 | padding-right: 12px; 81 | } 82 | 83 | /* changelists styles */ 84 | 85 | .change-list .filtered { 86 | background: white url(../images/changelist-bg_rtl.gif) top left repeat-y !important; 87 | } 88 | 89 | .change-list .filtered table { 90 | border-left: 1px solid #ddd; 91 | border-right: 0px none; 92 | } 93 | 94 | #changelist-filter { 95 | right: auto; 96 | left: 0; 97 | border-left: 0px none; 98 | border-right: 1px solid #ddd; 99 | } 100 | 101 | .change-list .filtered table, .change-list .filtered .paginator, .filtered #toolbar, .filtered div.xfull { 102 | margin-right: 0px !important; 103 | margin-left: 160px !important; 104 | } 105 | 106 | #changelist-filter li.selected { 107 | border-left: 0px none; 108 | padding-left: 0px; 109 | margin-left: 0; 110 | border-right: 5px solid #ccc; 111 | padding-right: 5px; 112 | margin-right: -10px; 113 | } 114 | 115 | /* FORMS */ 116 | 117 | .aligned label { 118 | padding: 0 0 3px 1em; 119 | float: right; 120 | } 121 | 122 | .submit-row { 123 | text-align: left 124 | } 125 | 126 | .submit-row p.deletelink-box { 127 | float: right; 128 | } 129 | 130 | .submit-row .deletelink { 131 | background: url(../images/icon_deletelink.gif) 0 50% no-repeat; 132 | padding-right: 14px; 133 | } 134 | 135 | .vDateField, .vTimeField { 136 | margin-left: 2px; 137 | } 138 | 139 | form ul.inline li { 140 | float: right; 141 | padding-right: 0; 142 | padding-left: 7px; 143 | } 144 | 145 | input[type=submit].default, .submit-row input.default { 146 | float: left; 147 | } 148 | 149 | fieldset .field-box { 150 | float: right; 151 | margin-left: 20px; 152 | } 153 | 154 | .errorlist li { 155 | background-position: 100% .3em; 156 | padding: 4px 25px 4px 5px; 157 | } 158 | 159 | .errornote { 160 | background-position: 100% .3em; 161 | padding: 4px 25px 4px 5px; 162 | } 163 | 164 | /* WIDGETS */ 165 | 166 | .calendarnav-previous { 167 | top: 0; 168 | left: auto; 169 | right: 0; 170 | } 171 | 172 | .calendarnav-next { 173 | top: 0; 174 | right: auto; 175 | left: 0; 176 | } 177 | 178 | .calendar caption, .calendarbox h2 { 179 | text-align: center; 180 | } 181 | 182 | .selector { 183 | float: right; 184 | } 185 | 186 | .selector .selector-filter { 187 | text-align: right; 188 | } 189 | 190 | /* MISC */ 191 | 192 | .inline-related h2 { 193 | text-align: right 194 | } 195 | 196 | .inline-related h3 span.delete { 197 | padding-right: 20px; 198 | padding-left: inherit; 199 | left: 10px; 200 | right: inherit; 201 | } 202 | 203 | .inline-related h3 span.delete label { 204 | margin-left: inherit; 205 | margin-right: 2px; 206 | } 207 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'merb-core' 2 | require 'merb-slices' 3 | require 'spec' 4 | 5 | # Add merb-admin.rb to the search path 6 | Merb::Plugins.config[:merb_slices][:auto_register] = true 7 | Merb::Plugins.config[:merb_slices][:search_path] = File.join(File.dirname(__FILE__), '..', 'lib', 'merb-admin.rb') 8 | 9 | # Require merb-admin.rb explicitly so any dependencies are loaded 10 | require Merb::Plugins.config[:merb_slices][:search_path] 11 | 12 | # Using Merb.root below makes sure that the correct root is set for 13 | # - testing standalone, without being installed as a gem and no host application 14 | # - testing from within the host application; its root will be used 15 | Merb.start_environment( 16 | :testing => true, 17 | :adapter => 'runner', 18 | :environment => ENV['MERB_ENV'] || 'test', 19 | :merb_root => Merb.root, 20 | :session_store => 'memory' 21 | ) 22 | 23 | module Merb 24 | module Test 25 | module SliceHelper 26 | 27 | # The absolute path to the current slice 28 | def current_slice_root 29 | @current_slice_root ||= File.expand_path(File.join(File.dirname(__FILE__), '..')) 30 | end 31 | 32 | # Whether the specs are being run from a host application or standalone 33 | def standalone? 34 | Merb.root == ::MerbAdmin.root 35 | end 36 | 37 | def setup_orm(orm = nil) 38 | orm = set_orm(orm) 39 | orm = orm.to_s.downcase.to_sym 40 | case orm 41 | when :activerecord 42 | require 'active_record' 43 | require_models(orm) 44 | unless ActiveRecord::Base.connected? 45 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:') 46 | ActiveRecord::Migration.verbose = false 47 | ActiveRecord::Migrator.run(:up, File.join(File.dirname(__FILE__), "migrations", "activerecord"), 1) 48 | ActiveRecord::Migrator.run(:up, File.join(File.dirname(__FILE__), "migrations", "activerecord"), 2) 49 | ActiveRecord::Migrator.run(:up, File.join(File.dirname(__FILE__), "migrations", "activerecord"), 3) 50 | ActiveRecord::Migrator.run(:up, File.join(File.dirname(__FILE__), "migrations", "activerecord"), 4) 51 | ActiveRecord::Migrator.run(:up, File.join(File.dirname(__FILE__), "migrations", "activerecord"), 5) 52 | end 53 | when :datamapper 54 | require 'dm-core' 55 | require 'dm-aggregates' 56 | require 'dm-migrations' 57 | require 'dm-validations' 58 | require_models(orm) 59 | unless DataMapper::Repository.adapters.key?(:default) 60 | DataMapper.setup(:default, 'sqlite3::memory:') 61 | DataMapper.auto_migrate! 62 | end 63 | when :sequel 64 | require 'sequel' 65 | require 'sequel/extensions/migration' 66 | Sequel::Migrator.apply(Sequel.sqlite, File.join(File.dirname(__FILE__), "migrations", "sequel")) 67 | require_models(orm) 68 | else 69 | raise "MerbAdmin does not support the #{orm} ORM" 70 | end 71 | Merb.orm = orm 72 | end 73 | 74 | private 75 | 76 | def require_models(orm = nil) 77 | orm ||= set_orm 78 | Dir.glob(File.dirname(__FILE__) / "models" / orm.to_s.downcase / Merb.glob_for(:model)).each do |model_filename| 79 | require model_filename 80 | end 81 | end 82 | 83 | def set_orm(orm = nil) 84 | orm || ENV['MERB_ORM'] || (Merb.orm != :none ? Merb.orm : nil) || :activerecord 85 | end 86 | 87 | end 88 | end 89 | end 90 | 91 | Spec::Runner.configure do |config| 92 | config.include(Merb::Test::ViewHelper) 93 | config.include(Merb::Test::RouteHelper) 94 | config.include(Merb::Test::ControllerHelper) 95 | config.include(Merb::Test::SliceHelper) 96 | config.before(:each) do 97 | setup_orm 98 | end 99 | end 100 | 101 | # You can add your own helpers here 102 | # 103 | Merb::Test.add_helpers do 104 | def mount_slice 105 | if standalone? 106 | Merb::Router.reset! 107 | Merb::Router.prepare{add_slice(:merb_admin, :path_prefix => "admin")} 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /public/javascripts/CollapsedFieldsets.js: -------------------------------------------------------------------------------- 1 | // Finds all fieldsets with class="collapse", collapses them, and gives each 2 | // one a "Show" link that uncollapses it. The "Show" link becomes a "Hide" 3 | // link when the fieldset is visible. 4 | 5 | function findForm(node) { 6 | // returns the node of the form containing the given node 7 | if (node.tagName.toLowerCase() != 'form') { 8 | return findForm(node.parentNode); 9 | } 10 | return node; 11 | } 12 | 13 | var CollapsedFieldsets = { 14 | collapse_re: /\bcollapse\b/, // Class of fieldsets that should be dealt with. 15 | collapsed_re: /\bcollapsed\b/, // Class that fieldsets get when they're hidden. 16 | collapsed_class: 'collapsed', 17 | init: function() { 18 | var fieldsets = document.getElementsByTagName('fieldset'); 19 | var collapsed_seen = false; 20 | for (var i = 0, fs; fs = fieldsets[i]; i++) { 21 | // Collapse this fieldset if it has the correct class, and if it 22 | // doesn't have any errors. (Collapsing shouldn't apply in the case 23 | // of error messages.) 24 | if (fs.className.match(CollapsedFieldsets.collapse_re) && !CollapsedFieldsets.fieldset_has_errors(fs)) { 25 | collapsed_seen = true; 26 | // Give it an additional class, used by CSS to hide it. 27 | fs.className += ' ' + CollapsedFieldsets.collapsed_class; 28 | // (Show) 29 | var collapse_link = document.createElement('a'); 30 | collapse_link.className = 'collapse-toggle'; 31 | collapse_link.id = 'fieldsetcollapser' + i; 32 | collapse_link.onclick = new Function('CollapsedFieldsets.show('+i+'); return false;'); 33 | collapse_link.href = '#'; 34 | collapse_link.innerHTML = gettext('Show'); 35 | var h2 = fs.getElementsByTagName('h2')[0]; 36 | h2.appendChild(document.createTextNode(' (')); 37 | h2.appendChild(collapse_link); 38 | h2.appendChild(document.createTextNode(')')); 39 | } 40 | } 41 | if (collapsed_seen) { 42 | // Expand all collapsed fieldsets when form is submitted. 43 | addEvent(findForm(document.getElementsByTagName('fieldset')[0]), 'submit', function() { CollapsedFieldsets.uncollapse_all(); }); 44 | } 45 | }, 46 | fieldset_has_errors: function(fs) { 47 | // Returns true if any fields in the fieldset have validation errors. 48 | var divs = fs.getElementsByTagName('div'); 49 | for (var i=0; i [:preflight, :setup_directories, :copy_assets, :migrate] 6 | 7 | desc "Test for any dependencies" 8 | task :preflight do # see slicetasks.rb 9 | end 10 | 11 | desc "Setup directories" 12 | task :setup_directories do 13 | puts "Creating directories for host application" 14 | MerbAdmin.mirrored_components.each do |type| 15 | if File.directory?(MerbAdmin.dir_for(type)) 16 | if !File.directory?(dst_path = MerbAdmin.app_dir_for(type)) 17 | relative_path = dst_path.relative_path_from(Merb.root) 18 | puts "- creating directory :#{type} #{File.basename(Merb.root) / relative_path}" 19 | mkdir_p(dst_path) 20 | end 21 | end 22 | end 23 | end 24 | 25 | # desc "Copy stub files to host application" 26 | # task :stubs do 27 | # puts "Copying stubs for MerbAdmin - resolves any collisions" 28 | # copied, preserved = MerbAdmin.mirror_stubs! 29 | # puts "- no files to copy" if copied.empty? && preserved.empty? 30 | # copied.each { |f| puts "- copied #{f}" } 31 | # preserved.each { |f| puts "! preserved override as #{f}" } 32 | # end 33 | 34 | # desc "Copy stub files and views to host application" 35 | # task :patch => [ "stubs", "freeze:views" ] 36 | 37 | desc "Copy public assets to host application" 38 | task :copy_assets do 39 | puts "Copying assets for MerbAdmin - resolves any collisions" 40 | copied, preserved = MerbAdmin.mirror_public! 41 | puts "- no files to copy" if copied.empty? && preserved.empty? 42 | copied.each { |f| puts "- copied #{f}" } 43 | preserved.each { |f| puts "! preserved override as #{f}" } 44 | end 45 | 46 | desc "Migrate the database" 47 | task :migrate do # see slicetasks.rb 48 | end 49 | 50 | desc "Freeze MerbAdmin into your app (only merb-admin/app)" 51 | task :freeze => [ "freeze:app" ] 52 | 53 | namespace :freeze do 54 | 55 | # desc "Freezes MerbAdmin by installing the gem into application/gems" 56 | # task :gem do 57 | # ENV["GEM"] ||= "merb-admin" 58 | # Rake::Task['slices:install_as_gem'].invoke 59 | # end 60 | 61 | desc "Freezes MerbAdmin by copying all files from merb-admin/app to your application" 62 | task :app do 63 | puts "Copying all merb-admin/app files to your application - resolves any collisions" 64 | copied, preserved = MerbAdmin.mirror_app! 65 | puts "- no files to copy" if copied.empty? && preserved.empty? 66 | copied.each { |f| puts "- copied #{f}" } 67 | preserved.each { |f| puts "! preserved override as #{f}" } 68 | end 69 | 70 | desc "Freeze all views into your application for easy modification" 71 | task :views do 72 | puts "Copying all view templates to your application - resolves any collisions" 73 | copied, preserved = MerbAdmin.mirror_files_for :view 74 | puts "- no files to copy" if copied.empty? && preserved.empty? 75 | copied.each { |f| puts "- copied #{f}" } 76 | preserved.each { |f| puts "! preserved override as #{f}" } 77 | end 78 | 79 | desc "Freeze all models into your application for easy modification" 80 | task :models do 81 | puts "Copying all models to your application - resolves any collisions" 82 | copied, preserved = MerbAdmin.mirror_files_for :model 83 | puts "- no files to copy" if copied.empty? && preserved.empty? 84 | copied.each { |f| puts "- copied #{f}" } 85 | preserved.each { |f| puts "! preserved override as #{f}" } 86 | end 87 | 88 | desc "Freezes MerbAdmin as a gem and copies over merb-admin/app" 89 | task :app_with_gem => [:gem, :app] 90 | 91 | desc "Freezes MerbAdmin by unpacking all files into your application" 92 | task :unpack do 93 | puts "Unpacking MerbAdmin files to your application - resolves any collisions" 94 | copied, preserved = MerbAdmin.unpack_slice! 95 | puts "- no files to copy" if copied.empty? && preserved.empty? 96 | copied.each { |f| puts "- copied #{f}" } 97 | preserved.each { |f| puts "! preserved override as #{f}" } 98 | end 99 | 100 | end 101 | 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /public/javascripts/ordering.js: -------------------------------------------------------------------------------- 1 | addEvent(window, 'load', reorder_init); 2 | 3 | var lis; 4 | var top = 0; 5 | var left = 0; 6 | var height = 30; 7 | 8 | function reorder_init() { 9 | lis = document.getElementsBySelector('ul#orderthese li'); 10 | var input = document.getElementsBySelector('input[name=order_]')[0]; 11 | setOrder(input.value.split(',')); 12 | input.disabled = true; 13 | draw(); 14 | // Now initialise the dragging behaviour 15 | var limit = (lis.length - 1) * height; 16 | for (var i = 0; i < lis.length; i++) { 17 | var li = lis[i]; 18 | var img = document.getElementById('handle'+li.id); 19 | li.style.zIndex = 1; 20 | Drag.init(img, li, left + 10, left + 10, top + 10, top + 10 + limit); 21 | li.onDragStart = startDrag; 22 | li.onDragEnd = endDrag; 23 | img.style.cursor = 'move'; 24 | } 25 | } 26 | 27 | function submitOrderForm() { 28 | var inputOrder = document.getElementsBySelector('input[name=order_]')[0]; 29 | inputOrder.value = getOrder(); 30 | inputOrder.disabled=false; 31 | } 32 | 33 | function startDrag() { 34 | this.style.zIndex = '10'; 35 | this.className = 'dragging'; 36 | } 37 | 38 | function endDrag(x, y) { 39 | this.style.zIndex = '1'; 40 | this.className = ''; 41 | // Work out how far along it has been dropped, using x co-ordinate 42 | var oldIndex = this.index; 43 | var newIndex = Math.round((y - 10 - top) / height); 44 | // 'Snap' to the correct position 45 | this.style.top = (10 + top + newIndex * height) + 'px'; 46 | this.index = newIndex; 47 | moveItem(oldIndex, newIndex); 48 | } 49 | 50 | function moveItem(oldIndex, newIndex) { 51 | // Swaps two items, adjusts the index and left co-ord for all others 52 | if (oldIndex == newIndex) { 53 | return; // Nothing to swap; 54 | } 55 | var direction, lo, hi; 56 | if (newIndex > oldIndex) { 57 | lo = oldIndex; 58 | hi = newIndex; 59 | direction = -1; 60 | } else { 61 | direction = 1; 62 | hi = oldIndex; 63 | lo = newIndex; 64 | } 65 | var lis2 = new Array(); // We will build the new order in this array 66 | for (var i = 0; i < lis.length; i++) { 67 | if (i < lo || i > hi) { 68 | // Position of items not between the indexes is unaffected 69 | lis2[i] = lis[i]; 70 | continue; 71 | } else if (i == newIndex) { 72 | lis2[i] = lis[oldIndex]; 73 | continue; 74 | } else { 75 | // Item is between the two indexes - move it along 1 76 | lis2[i] = lis[i - direction]; 77 | } 78 | } 79 | // Re-index everything 80 | reIndex(lis2); 81 | lis = lis2; 82 | draw(); 83 | // document.getElementById('hiddenOrder').value = getOrder(); 84 | document.getElementsBySelector('input[name=order_]')[0].value = getOrder(); 85 | } 86 | 87 | function reIndex(lis) { 88 | for (var i = 0; i < lis.length; i++) { 89 | lis[i].index = i; 90 | } 91 | } 92 | 93 | function draw() { 94 | for (var i = 0; i < lis.length; i++) { 95 | var li = lis[i]; 96 | li.index = i; 97 | li.style.position = 'absolute'; 98 | li.style.left = (10 + left) + 'px'; 99 | li.style.top = (10 + top + (i * height)) + 'px'; 100 | } 101 | } 102 | 103 | function getOrder() { 104 | var order = new Array(lis.length); 105 | for (var i = 0; i < lis.length; i++) { 106 | order[i] = lis[i].id.substring(1, 100); 107 | } 108 | return order.join(','); 109 | } 110 | 111 | function setOrder(id_list) { 112 | /* Set the current order to match the lsit of IDs */ 113 | var temp_lis = new Array(); 114 | for (var i = 0; i < id_list.length; i++) { 115 | var id = 'p' + id_list[i]; 116 | temp_lis[temp_lis.length] = document.getElementById(id); 117 | } 118 | reIndex(temp_lis); 119 | lis = temp_lis; 120 | draw(); 121 | } 122 | 123 | function addEvent(elm, evType, fn, useCapture) 124 | // addEvent and removeEvent 125 | // cross-browser event handling for IE5+, NS6 and Mozilla 126 | // By Scott Andrew 127 | { 128 | if (elm.addEventListener){ 129 | elm.addEventListener(evType, fn, useCapture); 130 | return true; 131 | } else if (elm.attachEvent){ 132 | var r = elm.attachEvent("on"+evType, fn); 133 | return r; 134 | } else { 135 | elm['on'+evType] = fn; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /public/javascripts/SelectBox.js: -------------------------------------------------------------------------------- 1 | var SelectBox = { 2 | cache: new Object(), 3 | init: function(id) { 4 | var box = document.getElementById(id); 5 | var node; 6 | SelectBox.cache[id] = new Array(); 7 | var cache = SelectBox.cache[id]; 8 | for (var i = 0; (node = box.options[i]); i++) { 9 | cache.push({value: node.value, text: node.text, displayed: 1}); 10 | } 11 | }, 12 | redisplay: function(id) { 13 | // Repopulate HTML select box from cache 14 | var box = document.getElementById(id); 15 | box.options.length = 0; // clear all options 16 | for (var i = 0, j = SelectBox.cache[id].length; i < j; i++) { 17 | var node = SelectBox.cache[id][i]; 18 | if (node.displayed) { 19 | box.options[box.options.length] = new Option(node.text, node.value, false, false); 20 | } 21 | } 22 | }, 23 | filter: function(id, text) { 24 | // Redisplay the HTML select box, displaying only the choices containing ALL 25 | // the words in text. (It's an AND search.) 26 | var tokens = text.toLowerCase().split(/\s+/); 27 | var node, token; 28 | for (var i = 0; (node = SelectBox.cache[id][i]); i++) { 29 | node.displayed = 1; 30 | for (var j = 0; (token = tokens[j]); j++) { 31 | if (node.text.toLowerCase().indexOf(token) == -1) { 32 | node.displayed = 0; 33 | } 34 | } 35 | } 36 | SelectBox.redisplay(id); 37 | }, 38 | delete_from_cache: function(id, value) { 39 | var node, delete_index = null; 40 | for (var i = 0; (node = SelectBox.cache[id][i]); i++) { 41 | if (node.value == value) { 42 | delete_index = i; 43 | break; 44 | } 45 | } 46 | var j = SelectBox.cache[id].length - 1; 47 | for (var i = delete_index; i < j; i++) { 48 | SelectBox.cache[id][i] = SelectBox.cache[id][i+1]; 49 | } 50 | SelectBox.cache[id].length--; 51 | }, 52 | add_to_cache: function(id, option) { 53 | SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); 54 | }, 55 | cache_contains: function(id, value) { 56 | // Check if an item is contained in the cache 57 | var node; 58 | for (var i = 0; (node = SelectBox.cache[id][i]); i++) { 59 | if (node.value == value) { 60 | return true; 61 | } 62 | } 63 | return false; 64 | }, 65 | move: function(from, to) { 66 | var from_box = document.getElementById(from); 67 | var to_box = document.getElementById(to); 68 | var option; 69 | for (var i = 0; (option = from_box.options[i]); i++) { 70 | if (option.selected && SelectBox.cache_contains(from, option.value)) { 71 | SelectBox.add_to_cache(to, {value: option.value, text: option.text, displayed: 1}); 72 | SelectBox.delete_from_cache(from, option.value); 73 | } 74 | } 75 | SelectBox.redisplay(from); 76 | SelectBox.redisplay(to); 77 | }, 78 | move_all: function(from, to) { 79 | var from_box = document.getElementById(from); 80 | var to_box = document.getElementById(to); 81 | var option; 82 | for (var i = 0; (option = from_box.options[i]); i++) { 83 | if (SelectBox.cache_contains(from, option.value)) { 84 | SelectBox.add_to_cache(to, {value: option.value, text: option.text, displayed: 1}); 85 | SelectBox.delete_from_cache(from, option.value); 86 | } 87 | } 88 | SelectBox.redisplay(from); 89 | SelectBox.redisplay(to); 90 | }, 91 | sort: function(id) { 92 | SelectBox.cache[id].sort( function(a, b) { 93 | a = a.text.toLowerCase(); 94 | b = b.text.toLowerCase(); 95 | try { 96 | if (a > b) return 1; 97 | if (a < b) return -1; 98 | } 99 | catch (e) { 100 | // silently fail on IE 'unknown' exception 101 | } 102 | return 0; 103 | } ); 104 | }, 105 | select_all: function(id) { 106 | var box = document.getElementById(id); 107 | for (var i = 0; i < box.options.length; i++) { 108 | box.options[i].selected = 'selected'; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/datamapper_support.rb: -------------------------------------------------------------------------------- 1 | require 'dm-core' 2 | require 'dm-aggregates' 3 | require 'dm-types' 4 | require 'dm-validations' 5 | 6 | module MerbAdmin 7 | class AbstractModel 8 | module DatamapperSupport 9 | def get(id) 10 | model.get(id).extend(InstanceMethods) 11 | end 12 | 13 | def count(options = {}) 14 | model.count(options.reject{|key, value| [:sort, :sort_reverse].include?(key)}) 15 | end 16 | 17 | def first(options = {}) 18 | model.first(merge_order(options)).extend(InstanceMethods) 19 | end 20 | 21 | def last(options = {}) 22 | model.last(merge_order(options)).extend(InstanceMethods) 23 | end 24 | 25 | def all(options = {}) 26 | model.all(merge_order(options)) 27 | end 28 | 29 | def paginated(options = {}) 30 | page = options.delete(:page) || 1 31 | per_page = options.delete(:per_page) || MerbAdmin[:per_page] 32 | 33 | page_count = (count(options).to_f / per_page).ceil 34 | 35 | options.merge!({ 36 | :limit => per_page, 37 | :offset => (page - 1) * per_page 38 | }) 39 | 40 | [page_count, all(options)] 41 | end 42 | 43 | def create(params = {}) 44 | model.create(params).extend(InstanceMethods) 45 | end 46 | 47 | def new(params = {}) 48 | model.new(params).extend(InstanceMethods) 49 | end 50 | 51 | def destroy_all! 52 | model.all.destroy! 53 | end 54 | 55 | def has_many_associations 56 | associations.select do |association| 57 | association[:type] == :has_many 58 | end 59 | end 60 | 61 | def has_one_associations 62 | associations.select do |association| 63 | association[:type] == :has_one 64 | end 65 | end 66 | 67 | def belongs_to_associations 68 | associations.select do |association| 69 | association[:type] == :belongs_to 70 | end 71 | end 72 | 73 | def associations 74 | model.relationships.to_a.map do |name, association| 75 | { 76 | :name => name, 77 | :pretty_name => name.to_s.gsub("_", " ").capitalize, 78 | :type => association_type_lookup(association), 79 | :parent_model => association.parent_model, 80 | :parent_key => association.parent_key.map{|r| r.name}, 81 | :child_model => association.child_model, 82 | :child_key => association.child_key.map{|r| r.name}, 83 | } 84 | end 85 | end 86 | 87 | def properties 88 | model.properties.map do |property| 89 | { 90 | :name => property.name, 91 | :pretty_name => property.name.to_s.gsub(/_id$/, "").gsub("_", " ").capitalize, 92 | :type => type_lookup(property), 93 | :length => property.respond_to?(:length) ? property.length : nil, 94 | :nullable? => property.allow_nil?, 95 | :serial? => property.serial?, 96 | } 97 | end 98 | end 99 | 100 | private 101 | 102 | def merge_order(options) 103 | @sort ||= options.delete(:sort) || :id 104 | @sort_order ||= options.delete(:sort_reverse) ? :desc : :asc 105 | options.merge(:order => [@sort.to_sym.send(@sort_order)]) 106 | end 107 | 108 | def association_type_lookup(association) 109 | if self.model == association.parent_model 110 | association.options[:max] > 1 ? :has_many : :has_one 111 | elsif self.model == association.child_model 112 | :belongs_to 113 | else 114 | raise "Unknown association type" 115 | end 116 | end 117 | 118 | def type_lookup(property) 119 | type = { 120 | BigDecimal => :big_decimal, 121 | DataMapper::Types::Boolean => :boolean, 122 | DataMapper::Types::Serial => :integer, 123 | DataMapper::Types::Text => :text, 124 | Date => :date, 125 | DateTime => :datetime, 126 | FalseClass => :boolean, 127 | Fixnum => :integer, 128 | Float => :float, 129 | Integer => :integer, 130 | String => :string, 131 | Time => :time, 132 | TrueClass => :boolean, 133 | } 134 | type[property.type] || type[property.primitive] 135 | end 136 | 137 | module InstanceMethods 138 | def update_attributes(attributes) 139 | update(attributes) 140 | end 141 | end 142 | 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/active_record_support.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | module MerbAdmin 4 | class AbstractModel 5 | module ActiverecordSupport 6 | def get(id) 7 | model.find_by_id(id).extend(InstanceMethods) 8 | rescue ActiveRecord::RecordNotFound 9 | nil 10 | end 11 | 12 | def count(options = {}) 13 | model.count(options.reject{|key, value| [:sort, :sort_reverse].include?(key)}) 14 | end 15 | 16 | def first(options = {}) 17 | model.first(merge_order(options)).extend(InstanceMethods) 18 | end 19 | 20 | def last(options = {}) 21 | model.last(merge_order(options)).extend(InstanceMethods) 22 | end 23 | 24 | def all(options = {}) 25 | model.all(merge_order(options)) 26 | end 27 | 28 | def paginated(options = {}) 29 | page = options.delete(:page) || 1 30 | per_page = options.delete(:per_page) || MerbAdmin[:per_page] 31 | 32 | page_count = (count(options).to_f / per_page).ceil 33 | 34 | options.merge!({ 35 | :limit => per_page, 36 | :offset => (page - 1) * per_page 37 | }) 38 | 39 | [page_count, all(options)] 40 | end 41 | 42 | def create(params = {}) 43 | model.create(params).extend(InstanceMethods) 44 | end 45 | 46 | def new(params = {}) 47 | model.new(params).extend(InstanceMethods) 48 | end 49 | 50 | def destroy_all! 51 | model.all.each do |object| 52 | object.destroy 53 | end 54 | end 55 | 56 | def has_many_associations 57 | associations.select do |association| 58 | association[:type] == :has_many 59 | end 60 | end 61 | 62 | def has_one_associations 63 | associations.select do |association| 64 | association[:type] == :has_one 65 | end 66 | end 67 | 68 | def belongs_to_associations 69 | associations.select do |association| 70 | association[:type] == :belongs_to 71 | end 72 | end 73 | 74 | def associations 75 | model.reflect_on_all_associations.map do |association| 76 | { 77 | :name => association.name, 78 | :pretty_name => association.name.to_s.gsub('_', ' ').capitalize, 79 | :type => association.macro, 80 | :parent_model => association_parent_model_lookup(association), 81 | :parent_key => association_parent_key_lookup(association), 82 | :child_model => association_child_model_lookup(association), 83 | :child_key => association_child_key_lookup(association), 84 | } 85 | end 86 | end 87 | 88 | def properties 89 | model.columns.map do |property| 90 | { 91 | :name => property.name.to_sym, 92 | :pretty_name => property.human_name, 93 | :type => property.type, 94 | :length => property.limit, 95 | :nullable? => property.null, 96 | :serial? => property.primary, 97 | } 98 | end 99 | end 100 | 101 | private 102 | 103 | def merge_order(options) 104 | @sort ||= options.delete(:sort) || "id" 105 | @sort_order ||= options.delete(:sort_reverse) ? "desc" : "asc" 106 | options.merge(:order => ["#{@sort} #{@sort_order}"]) 107 | end 108 | 109 | def association_parent_model_lookup(association) 110 | case association.macro 111 | when :belongs_to 112 | association.klass 113 | when :has_one, :has_many 114 | association.active_record 115 | else 116 | raise "Unknown association type" 117 | end 118 | end 119 | 120 | def association_parent_key_lookup(association) 121 | [:id] 122 | end 123 | 124 | def association_child_model_lookup(association) 125 | case association.macro 126 | when :belongs_to 127 | association.active_record 128 | when :has_one, :has_many 129 | association.klass 130 | else 131 | raise "Unknown association type" 132 | end 133 | end 134 | 135 | def association_child_key_lookup(association) 136 | case association.macro 137 | when :belongs_to 138 | ["#{association.class_name.snake_case}_id".to_sym] 139 | when :has_one, :has_many 140 | [association.primary_key_name.to_sym] 141 | else 142 | raise "Unknown association type" 143 | end 144 | end 145 | 146 | module InstanceMethods 147 | end 148 | 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /app/views/main/list.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | params = request.params.except(:action, :controller, :model_name) 3 | query = params[:query] 4 | filter = params[:filter] 5 | sort = params[:sort] 6 | sort_reverse = params[:sort_reverse] 7 | filters_exist = !@properties.select{|property| property[:type] == :boolean}.empty? 8 | %> 9 |
10 |
    11 |
  • 12 | <%= link_to("Add #{@abstract_model.pretty_name.downcase}", url(:merb_admin_new, :model_name => @abstract_model.to_param), :class => "addlink") %> 13 |
  • 14 |
15 |
" id="changelist"> 16 |
17 | 32 |
33 | 34 | <% if filters_exist %> 35 |
36 |

Filter

37 | <% @properties.each do |property| %> 38 | <% property_type = property[:type] %> 39 | <% property_name = property[:name] %> 40 | <% property_pretty_name = property[:pretty_name] %> 41 | <% if property_type == :boolean %> 42 |

By <%= property_pretty_name %>

43 |
    44 |
  • "> 45 | <%= link_to("All", "?" + Merb::Parse.params_to_query_string(params.merge(:filter => (filter || {}).reject{|key, value| key.to_sym == property_name}))) %> 46 |
  • 47 |
  • "> 48 | <%= link_to("Yes", "?" + Merb::Parse.params_to_query_string(params.merge(:filter => (filter || {}).merge({property_name => "true"})))) %> 49 |
  • 50 |
  • "> 51 | <%= link_to("No", "?" + Merb::Parse.params_to_query_string(params.merge(:filter => (filter || {}).merge({property_name => "false"})))) %> 52 |
  • 53 |
54 | <% end %> 55 | <% end %> 56 |
57 | <% end %> 58 | 59 | 60 | 61 | <% @properties.each do |property| %> 62 | <% property_name = property[:name] %> 63 | <% property_pretty_name = property[:pretty_name] %> 64 | 67 | <% end %> 68 | 69 | 70 | 71 | <% @objects.each_with_index do |object, index| %> 72 | "> 73 | <% @properties.each do |property| %> 74 | 77 | <% end %> 78 | 79 | <% end %> 80 | 81 |
"> 65 | <%= link_to(property_pretty_name, "?" + Merb::Parse.params_to_query_string(params.merge(:sort => property_name).reject{|key, value| key.to_sym == :sort_reverse}.merge(sort == property_name.to_s && sort_reverse != "true" ? {:sort_reverse => "true"} : {}))) %> 66 |
75 | <%= link_to(object_property(object, property), url(:merb_admin_edit, :model_name => @abstract_model.to_param, :id => object.id)) %> 76 |
82 |

83 | <% if @page_count.to_i > 1 %> 84 | <%= paginate(@current_page, @page_count, :url => "?" + Merb::Parse.params_to_query_string(params)) %> 85 | <% end %> 86 | <%= @record_count %> <%= @record_count == 1 ? @abstract_model.pretty_name.downcase : @abstract_model.pretty_name.downcase.pluralize %> 87 | <% if @page_count.to_i == 2 %> 88 | <%= link_to("Show all", "?" + Merb::Parse.params_to_query_string(params.merge(:all => true)), :class => "showall") %> 89 | <% end %> 90 |

91 |
92 |
93 |
94 | -------------------------------------------------------------------------------- /public/javascripts/calendar.js: -------------------------------------------------------------------------------- 1 | /* 2 | calendar.js - Calendar functions by Adrian Holovaty 3 | */ 4 | 5 | function removeChildren(a) { // "a" is reference to an object 6 | while (a.hasChildNodes()) a.removeChild(a.lastChild); 7 | } 8 | 9 | // quickElement(tagType, parentReference, textInChildNode, [, attribute, attributeValue ...]); 10 | function quickElement() { 11 | var obj = document.createElement(arguments[0]); 12 | if (arguments[2] != '' && arguments[2] != null) { 13 | var textNode = document.createTextNode(arguments[2]); 14 | obj.appendChild(textNode); 15 | } 16 | var len = arguments.length; 17 | for (var i = 3; i < len; i += 2) { 18 | obj.setAttribute(arguments[i], arguments[i+1]); 19 | } 20 | arguments[1].appendChild(obj); 21 | return obj; 22 | } 23 | 24 | // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions 25 | var CalendarNamespace = { 26 | monthsOfYear: gettext('January February March April May June July August September October November December').split(' '), 27 | daysOfWeek: gettext('S M T W T F S').split(' '), 28 | isLeapYear: function(year) { 29 | return (((year % 4)==0) && ((year % 100)!=0) || ((year % 400)==0)); 30 | }, 31 | getDaysInMonth: function(month,year) { 32 | var days; 33 | if (month==1 || month==3 || month==5 || month==7 || month==8 || month==10 || month==12) { 34 | days = 31; 35 | } 36 | else if (month==4 || month==6 || month==9 || month==11) { 37 | days = 30; 38 | } 39 | else if (month==2 && CalendarNamespace.isLeapYear(year)) { 40 | days = 29; 41 | } 42 | else { 43 | days = 28; 44 | } 45 | return days; 46 | }, 47 | draw: function(month, year, div_id, callback) { // month = 1-12, year = 1-9999 48 | month = parseInt(month); 49 | year = parseInt(year); 50 | var calDiv = document.getElementById(div_id); 51 | removeChildren(calDiv); 52 | var calTable = document.createElement('table'); 53 | quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month-1] + ' ' + year); 54 | var tableBody = quickElement('tbody', calTable); 55 | 56 | // Draw days-of-week header 57 | var tableRow = quickElement('tr', tableBody); 58 | for (var i = 0; i < 7; i++) { 59 | quickElement('th', tableRow, CalendarNamespace.daysOfWeek[i]); 60 | } 61 | 62 | var startingPos = new Date(year, month-1, 1).getDay(); 63 | var days = CalendarNamespace.getDaysInMonth(month, year); 64 | 65 | // Draw blanks before first of month 66 | tableRow = quickElement('tr', tableBody); 67 | for (var i = 0; i < startingPos; i++) { 68 | var _cell = quickElement('td', tableRow, ' '); 69 | _cell.style.backgroundColor = '#f3f3f3'; 70 | } 71 | 72 | // Draw days of month 73 | var currentDay = 1; 74 | for (var i = startingPos; currentDay <= days; i++) { 75 | if (i%7 == 0 && currentDay != 1) { 76 | tableRow = quickElement('tr', tableBody); 77 | } 78 | var cell = quickElement('td', tableRow, ''); 79 | quickElement('a', cell, currentDay, 'href', 'javascript:void(' + callback + '('+year+','+month+','+currentDay+'));'); 80 | currentDay++; 81 | } 82 | 83 | // Draw blanks after end of month (optional, but makes for valid code) 84 | while (tableRow.childNodes.length < 7) { 85 | var _cell = quickElement('td', tableRow, ' '); 86 | _cell.style.backgroundColor = '#f3f3f3'; 87 | } 88 | 89 | calDiv.appendChild(calTable); 90 | } 91 | } 92 | 93 | // Calendar -- A calendar instance 94 | function Calendar(div_id, callback) { 95 | // div_id (string) is the ID of the element in which the calendar will 96 | // be displayed 97 | // callback (string) is the name of a JavaScript function that will be 98 | // called with the parameters (year, month, day) when a day in the 99 | // calendar is clicked 100 | this.div_id = div_id; 101 | this.callback = callback; 102 | this.today = new Date(); 103 | this.currentMonth = this.today.getMonth() + 1; 104 | this.currentYear = this.today.getFullYear(); 105 | } 106 | Calendar.prototype = { 107 | drawCurrent: function() { 108 | CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback); 109 | }, 110 | drawDate: function(month, year) { 111 | this.currentMonth = month; 112 | this.currentYear = year; 113 | this.drawCurrent(); 114 | }, 115 | drawPreviousMonth: function() { 116 | if (this.currentMonth == 1) { 117 | this.currentMonth = 12; 118 | this.currentYear--; 119 | } 120 | else { 121 | this.currentMonth--; 122 | } 123 | this.drawCurrent(); 124 | }, 125 | drawNextMonth: function() { 126 | if (this.currentMonth == 12) { 127 | this.currentMonth = 1; 128 | this.currentYear++; 129 | } 130 | else { 131 | this.currentMonth++; 132 | } 133 | this.drawCurrent(); 134 | }, 135 | drawPreviousYear: function() { 136 | this.currentYear--; 137 | this.drawCurrent(); 138 | }, 139 | drawNextYear: function() { 140 | this.currentYear++; 141 | this.drawCurrent(); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /public/javascripts/urlify.js: -------------------------------------------------------------------------------- 1 | var LATIN_MAP = { 2 | 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', 'Ç': 3 | 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 4 | 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ö': 5 | 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', 'Ü': 'U', 'Ű': 'U', 6 | 'Ý': 'Y', 'Þ': 'TH', 'ß': 'ss', 'à':'a', 'á':'a', 'â': 'a', 'ã': 'a', 'ä': 7 | 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 8 | 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 9 | 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 10 | 'û': 'u', 'ü': 'u', 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' 11 | } 12 | var LATIN_SYMBOLS_MAP = { 13 | '©':'(c)' 14 | } 15 | var GREEK_MAP = { 16 | 'α':'a', 'β':'b', 'γ':'g', 'δ':'d', 'ε':'e', 'ζ':'z', 'η':'h', 'θ':'8', 17 | 'ι':'i', 'κ':'k', 'λ':'l', 'μ':'m', 'ν':'n', 'ξ':'3', 'ο':'o', 'π':'p', 18 | 'ρ':'r', 'σ':'s', 'τ':'t', 'υ':'y', 'φ':'f', 'χ':'x', 'ψ':'ps', 'ω':'w', 19 | 'ά':'a', 'έ':'e', 'ί':'i', 'ό':'o', 'ύ':'y', 'ή':'h', 'ώ':'w', 'ς':'s', 20 | 'ϊ':'i', 'ΰ':'y', 'ϋ':'y', 'ΐ':'i', 21 | 'Α':'A', 'Β':'B', 'Γ':'G', 'Δ':'D', 'Ε':'E', 'Ζ':'Z', 'Η':'H', 'Θ':'8', 22 | 'Ι':'I', 'Κ':'K', 'Λ':'L', 'Μ':'M', 'Ν':'N', 'Ξ':'3', 'Ο':'O', 'Π':'P', 23 | 'Ρ':'R', 'Σ':'S', 'Τ':'T', 'Υ':'Y', 'Φ':'F', 'Χ':'X', 'Ψ':'PS', 'Ω':'W', 24 | 'Ά':'A', 'Έ':'E', 'Ί':'I', 'Ό':'O', 'Ύ':'Y', 'Ή':'H', 'Ώ':'W', 'Ϊ':'I', 25 | 'Ϋ':'Y' 26 | } 27 | var TURKISH_MAP = { 28 | 'ş':'s', 'Ş':'S', 'ı':'i', 'İ':'I', 'ç':'c', 'Ç':'C', 'ü':'u', 'Ü':'U', 29 | 'ö':'o', 'Ö':'O', 'ğ':'g', 'Ğ':'G' 30 | } 31 | var RUSSIAN_MAP = { 32 | 'а':'a', 'б':'b', 'в':'v', 'г':'g', 'д':'d', 'е':'e', 'ё':'yo', 'ж':'zh', 33 | 'з':'z', 'и':'i', 'й':'j', 'к':'k', 'л':'l', 'м':'m', 'н':'n', 'о':'o', 34 | 'п':'p', 'р':'r', 'с':'s', 'т':'t', 'у':'u', 'ф':'f', 'х':'h', 'ц':'c', 35 | 'ч':'ch', 'ш':'sh', 'щ':'sh', 'ъ':'', 'ы':'y', 'ь':'', 'э':'e', 'ю':'yu', 36 | 'я':'ya', 37 | 'А':'A', 'Б':'B', 'В':'V', 'Г':'G', 'Д':'D', 'Е':'E', 'Ё':'Yo', 'Ж':'Zh', 38 | 'З':'Z', 'И':'I', 'Й':'J', 'К':'K', 'Л':'L', 'М':'M', 'Н':'N', 'О':'O', 39 | 'П':'P', 'Р':'R', 'С':'S', 'Т':'T', 'У':'U', 'Ф':'F', 'Х':'H', 'Ц':'C', 40 | 'Ч':'Ch', 'Ш':'Sh', 'Щ':'Sh', 'Ъ':'', 'Ы':'Y', 'Ь':'', 'Э':'E', 'Ю':'Yu', 41 | 'Я':'Ya' 42 | } 43 | var UKRAINIAN_MAP = { 44 | 'Є':'Ye', 'І':'I', 'Ї':'Yi', 'Ґ':'G', 'є':'ye', 'і':'i', 'ї':'yi', 'ґ':'g' 45 | } 46 | var CZECH_MAP = { 47 | 'č':'c', 'ď':'d', 'ě':'e', 'ň': 'n', 'ř':'r', 'š':'s', 'ť':'t', 'ů':'u', 48 | 'ž':'z', 'Č':'C', 'Ď':'D', 'Ě':'E', 'Ň': 'N', 'Ř':'R', 'Š':'S', 'Ť':'T', 49 | 'Ů':'U', 'Ž':'Z' 50 | } 51 | 52 | var POLISH_MAP = { 53 | 'ą':'a', 'ć':'c', 'ę':'e', 'ł':'l', 'ń':'n', 'ó':'o', 'ś':'s', 'ź':'z', 54 | 'ż':'z', 'Ą':'A', 'Ć':'C', 'Ę':'e', 'Ł':'L', 'Ń':'N', 'Ó':'o', 'Ś':'S', 55 | 'Ź':'Z', 'Ż':'Z' 56 | } 57 | 58 | var LATVIAN_MAP = { 59 | 'ā':'a', 'č':'c', 'ē':'e', 'ģ':'g', 'ī':'i', 'ķ':'k', 'ļ':'l', 'ņ':'n', 60 | 'š':'s', 'ū':'u', 'ž':'z', 'Ā':'A', 'Č':'C', 'Ē':'E', 'Ģ':'G', 'Ī':'i', 61 | 'Ķ':'k', 'Ļ':'L', 'Ņ':'N', 'Š':'S', 'Ū':'u', 'Ž':'Z' 62 | } 63 | 64 | var ALL_DOWNCODE_MAPS=new Array() 65 | ALL_DOWNCODE_MAPS[0]=LATIN_MAP 66 | ALL_DOWNCODE_MAPS[1]=LATIN_SYMBOLS_MAP 67 | ALL_DOWNCODE_MAPS[2]=GREEK_MAP 68 | ALL_DOWNCODE_MAPS[3]=TURKISH_MAP 69 | ALL_DOWNCODE_MAPS[4]=RUSSIAN_MAP 70 | ALL_DOWNCODE_MAPS[5]=UKRAINIAN_MAP 71 | ALL_DOWNCODE_MAPS[6]=CZECH_MAP 72 | ALL_DOWNCODE_MAPS[7]=POLISH_MAP 73 | ALL_DOWNCODE_MAPS[8]=LATVIAN_MAP 74 | 75 | var Downcoder = new Object(); 76 | Downcoder.Initialize = function() 77 | { 78 | if (Downcoder.map) // already made 79 | return ; 80 | Downcoder.map ={} 81 | Downcoder.chars = '' ; 82 | for(var i in ALL_DOWNCODE_MAPS) 83 | { 84 | var lookup = ALL_DOWNCODE_MAPS[i] 85 | for (var c in lookup) 86 | { 87 | Downcoder.map[c] = lookup[c] ; 88 | Downcoder.chars += c ; 89 | } 90 | } 91 | Downcoder.regex = new RegExp('[' + Downcoder.chars + ']|[^' + Downcoder.chars + ']+','g') ; 92 | } 93 | 94 | downcode= function( slug ) 95 | { 96 | Downcoder.Initialize() ; 97 | var downcoded ="" 98 | var pieces = slug.match(Downcoder.regex); 99 | if(pieces) 100 | { 101 | for (var i = 0 ; i < pieces.length ; i++) 102 | { 103 | if (pieces[i].length == 1) 104 | { 105 | var mapped = Downcoder.map[pieces[i]] ; 106 | if (mapped != null) 107 | { 108 | downcoded+=mapped; 109 | continue ; 110 | } 111 | } 112 | downcoded+=pieces[i]; 113 | } 114 | } 115 | else 116 | { 117 | downcoded = slug; 118 | } 119 | return downcoded; 120 | } 121 | 122 | 123 | function URLify(s, num_chars) { 124 | // changes, e.g., "Petty theft" to "petty_theft" 125 | // remove all these words from the string before urlifying 126 | s = downcode(s); 127 | removelist = ["a", "an", "as", "at", "before", "but", "by", "for", "from", 128 | "is", "in", "into", "like", "of", "off", "on", "onto", "per", 129 | "since", "than", "the", "this", "that", "to", "up", "via", 130 | "with"]; 131 | r = new RegExp('\\b(' + removelist.join('|') + ')\\b', 'gi'); 132 | s = s.replace(r, ''); 133 | // if downcode doesn't hit, the char will be stripped here 134 | s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars 135 | s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces 136 | s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens 137 | s = s.toLowerCase(); // convert to lowercase 138 | return s.substring(0, num_chars);// trim to first num_chars chars 139 | } 140 | 141 | -------------------------------------------------------------------------------- /public/stylesheets/changelists.css: -------------------------------------------------------------------------------- 1 | /* CHANGELISTS */ 2 | 3 | #changelist { 4 | position: relative; 5 | width: 100%; 6 | } 7 | 8 | #changelist table { 9 | width: 100%; 10 | } 11 | 12 | .change-list .filtered table { 13 | border-right: 1px solid #ddd; 14 | } 15 | 16 | .change-list .filtered { 17 | min-height: 400px; 18 | } 19 | 20 | .change-list .filtered { 21 | background: white url(../images/changelist-bg.gif) top right repeat-y !important; 22 | } 23 | 24 | .change-list .filtered table, .change-list .filtered .paginator, .filtered #toolbar, .filtered div.xfull { 25 | margin-right: 160px !important; 26 | width: auto !important; 27 | } 28 | 29 | .change-list .filtered table tbody th { 30 | padding-right: 1em; 31 | } 32 | 33 | #changelist .toplinks { 34 | border-bottom: 1px solid #ccc !important; 35 | } 36 | 37 | #changelist .paginator { 38 | color: #666; 39 | border-top: 1px solid #eee; 40 | border-bottom: 1px solid #eee; 41 | background: white url(../images/nav-bg.gif) 0 180% repeat-x; 42 | overflow: hidden; 43 | } 44 | 45 | .change-list .filtered .paginator { 46 | border-right: 1px solid #ddd; 47 | } 48 | 49 | /* CHANGELIST TABLES */ 50 | 51 | #changelist table thead th { 52 | white-space: nowrap; 53 | vertical-align: middle; 54 | } 55 | 56 | #changelist table thead th:first-child { 57 | width: 1.5em; 58 | text-align: center; 59 | } 60 | 61 | #changelist table tbody td { 62 | border-left: 1px solid #ddd; 63 | } 64 | 65 | #changelist table tbody td:first-child { 66 | border-left: 0; 67 | border-right: 1px solid #ddd; 68 | text-align: center; 69 | } 70 | 71 | #changelist table tfoot { 72 | color: #666; 73 | } 74 | 75 | /* TOOLBAR */ 76 | 77 | #changelist #toolbar { 78 | padding: 3px; 79 | border-bottom: 1px solid #ddd; 80 | background: #e1e1e1 url(../images/nav-bg.gif) top left repeat-x; 81 | color: #666; 82 | } 83 | 84 | #changelist #toolbar form input { 85 | font-size: 11px; 86 | padding: 1px 2px; 87 | } 88 | 89 | #changelist #toolbar form #searchbar { 90 | padding: 2px; 91 | } 92 | 93 | #changelist #changelist-search img { 94 | vertical-align: middle; 95 | } 96 | 97 | /* FILTER COLUMN */ 98 | 99 | #changelist-filter { 100 | position: absolute; 101 | top: 0; 102 | right: 0; 103 | z-index: 1000; 104 | width: 160px; 105 | border-left: 1px solid #ddd; 106 | background: #efefef; 107 | margin: 0; 108 | } 109 | 110 | #changelist-filter h2 { 111 | font-size: 11px; 112 | padding: 2px 5px; 113 | border-bottom: 1px solid #ddd; 114 | } 115 | 116 | #changelist-filter h3 { 117 | font-size: 12px; 118 | margin-bottom: 0; 119 | } 120 | 121 | #changelist-filter ul { 122 | padding-left: 0; 123 | margin-left: 10px; 124 | } 125 | 126 | #changelist-filter li { 127 | list-style-type: none; 128 | margin-left: 0; 129 | padding-left: 0; 130 | } 131 | 132 | #changelist-filter a { 133 | color: #999; 134 | } 135 | 136 | #changelist-filter a:hover { 137 | color: #036; 138 | } 139 | 140 | #changelist-filter li.selected { 141 | border-left: 5px solid #ccc; 142 | padding-left: 5px; 143 | margin-left: -10px; 144 | } 145 | 146 | #changelist-filter li.selected a { 147 | color: #5b80b2 !important; 148 | } 149 | 150 | /* DATE DRILLDOWN */ 151 | 152 | .change-list ul.toplinks { 153 | display: block; 154 | background: white url(../images/nav-bg-reverse.gif) 0 -10px repeat-x; 155 | border-top: 1px solid white; 156 | float: left; 157 | padding: 0 !important; 158 | margin: 0 !important; 159 | width: 100%; 160 | } 161 | 162 | .change-list ul.toplinks li { 163 | float: left; 164 | width: 9em; 165 | padding: 3px 6px; 166 | font-weight: bold; 167 | list-style-type: none; 168 | } 169 | 170 | .change-list ul.toplinks .date-back a { 171 | color: #999; 172 | } 173 | 174 | .change-list ul.toplinks .date-back a:hover { 175 | color: #036; 176 | } 177 | 178 | /* PAGINATOR */ 179 | 180 | .paginator { 181 | font-size: 11px; 182 | padding-top: 10px; 183 | padding-bottom: 10px; 184 | line-height: 22px; 185 | margin: 0; 186 | border-top: 1px solid #ddd; 187 | } 188 | 189 | .paginator a:link, .paginator a:visited { 190 | padding: 2px 6px; 191 | border: solid 1px #ccc; 192 | background: white; 193 | text-decoration: none; 194 | } 195 | 196 | .paginator a.showall { 197 | padding: 0 !important; 198 | border: none !important; 199 | } 200 | 201 | .paginator a.showall:hover { 202 | color: #036 !important; 203 | background: transparent !important; 204 | } 205 | 206 | .paginator .end { 207 | border-width: 2px !important; 208 | margin-right: 6px; 209 | } 210 | 211 | .paginator .this-page { 212 | padding: 2px 6px; 213 | font-weight: bold; 214 | font-size: 13px; 215 | vertical-align: top; 216 | } 217 | 218 | .paginator a:hover { 219 | color: white; 220 | background: #5b80b2; 221 | border-color: #036; 222 | } 223 | 224 | /* ACTIONS */ 225 | 226 | .filtered .actions { 227 | margin-right: 160px !important; 228 | border-right: 1px solid #ddd; 229 | } 230 | 231 | #changelist table input { 232 | margin: 0; 233 | } 234 | 235 | #changelist table tbody tr.selected { 236 | background-color: #FFFFCC; 237 | } 238 | 239 | #changelist .actions { 240 | color: #999; 241 | padding: 3px; 242 | border-top: 1px solid #fff; 243 | border-bottom: 1px solid #ddd; 244 | background: white url(../images/nav-bg-reverse.gif) 0 -10px repeat-x; 245 | } 246 | 247 | #changelist .actions:last-child { 248 | border-bottom: none; 249 | } 250 | 251 | #changelist .actions select { 252 | border: 1px solid #aaa; 253 | margin-left: 0.5em; 254 | padding: 1px 2px; 255 | } 256 | 257 | #changelist .actions label { 258 | font-size: 11px; 259 | margin-left: 0.5em; 260 | } 261 | 262 | #changelist #action-toggle { 263 | display: none; 264 | } 265 | 266 | #changelist .actions .button { 267 | font-size: 11px; 268 | padding: 1px 2px; 269 | } 270 | -------------------------------------------------------------------------------- /app/controllers/main.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', '..', 'lib', 'abstract_model' ) 2 | 3 | class MerbAdmin::Main < MerbAdmin::Application 4 | include Merb::MerbAdmin::MainHelper 5 | 6 | before :get_model, :exclude => ['index'] 7 | before :get_object, :only => ['edit', 'update', 'delete', 'destroy'] 8 | before :get_attributes, :only => ['create', 'update'] 9 | 10 | def index 11 | @abstract_models = MerbAdmin::AbstractModel.all 12 | render(:layout => 'dashboard') 13 | end 14 | 15 | def list 16 | options = {} 17 | options.merge!(get_sort_hash) 18 | options.merge!(get_sort_reverse_hash) 19 | options.merge!(get_query_hash(options)) 20 | options.merge!(get_filter_hash(options)) 21 | per_page = MerbAdmin[:per_page] 22 | if params[:all] 23 | options.merge!(:limit => per_page * 2) 24 | @objects = @abstract_model.all(options).reverse 25 | else 26 | @current_page = (params[:page] || 1).to_i 27 | options.merge!(:page => @current_page, :per_page => per_page) 28 | @page_count, @objects = @abstract_model.paginated(options) 29 | options.delete(:page) 30 | options.delete(:per_page) 31 | options.delete(:offset) 32 | options.delete(:limit) 33 | end 34 | @record_count = @abstract_model.count(options) 35 | render(:layout => 'list') 36 | end 37 | 38 | def new 39 | @object = @abstract_model.new 40 | render(:layout => 'form') 41 | end 42 | 43 | def create 44 | @object = @abstract_model.new(@attributes) 45 | if @object.save && update_all_associations 46 | redirect_on_success 47 | else 48 | render_error(:new) 49 | end 50 | end 51 | 52 | def edit 53 | render(:layout => 'form') 54 | end 55 | 56 | def update 57 | if @object.update_attributes(@attributes) && update_all_associations 58 | redirect_on_success 59 | else 60 | render_error(:edit) 61 | end 62 | end 63 | 64 | def delete 65 | render(:layout => 'form') 66 | end 67 | 68 | def destroy 69 | if @object.destroy 70 | redirect(url(:merb_admin_list, :model_name => @abstract_model.to_param), :message => {:notice => "#{@abstract_model.pretty_name} was successfully destroyed"}) 71 | else 72 | raise BadRequest 73 | end 74 | end 75 | 76 | private 77 | 78 | def get_model 79 | model_name = to_model_name(params[:model_name]) 80 | @abstract_model = MerbAdmin::AbstractModel.new(model_name) 81 | @properties = @abstract_model.properties 82 | end 83 | 84 | def get_object 85 | @object = @abstract_model.get(params[:id]) 86 | raise NotFound unless @object 87 | end 88 | 89 | def get_sort_hash 90 | sort = params[:sort] 91 | sort ? {:sort => sort} : {} 92 | end 93 | 94 | def get_sort_reverse_hash 95 | sort_reverse = params[:sort_reverse] 96 | sort_reverse ? {:sort_reverse => sort_reverse == "true"} : {} 97 | end 98 | 99 | def get_query_hash(options) 100 | query = params[:query] 101 | return {} unless query 102 | statements = [] 103 | values = [] 104 | conditions = options[:conditions] || [""] 105 | 106 | @properties.select{|property| property[:type] == :string}.each do |property| 107 | statements << "(#{property[:name]} LIKE ?)" 108 | values << "%#{query}%" 109 | end 110 | 111 | conditions[0] += " AND " unless conditions == [""] 112 | conditions[0] += statements.join(" OR ") 113 | conditions += values 114 | conditions != [""] ? {:conditions => conditions} : {} 115 | end 116 | 117 | def get_filter_hash(options) 118 | filter = params[:filter] 119 | return {} unless filter 120 | statements = [] 121 | values = [] 122 | conditions = options[:conditions] || [""] 123 | 124 | filter.each_pair do |key, value| 125 | @properties.select{|property| property[:type] == :boolean && property[:name] == key.to_sym}.each do |property| 126 | statements << "(#{key} = ?)" 127 | values << (value == "true") 128 | end 129 | end 130 | 131 | conditions[0] += " AND " unless conditions == [""] 132 | conditions[0] += statements.join(" AND ") 133 | conditions += values 134 | conditions != [""] ? {:conditions => conditions} : {} 135 | end 136 | 137 | def get_attributes 138 | @attributes = params[@abstract_model.to_param] || {} 139 | # Delete fields that are blank 140 | @attributes.each do |key, value| 141 | @attributes[key] = nil if value.blank? 142 | end 143 | end 144 | 145 | def update_all_associations 146 | @abstract_model.associations.each do |association| 147 | ids = (params[:associations] || {}).delete(association[:name]) 148 | case association[:type] 149 | when :has_one 150 | update_association(association, ids) 151 | when :has_many 152 | update_associations(association, ids.to_a) 153 | end 154 | end 155 | end 156 | 157 | def update_association(association, id = nil) 158 | associated_model = MerbAdmin::AbstractModel.new(association[:child_model]) 159 | if object = associated_model.get(id) 160 | object.update_attributes(association[:child_key].first => @object.id) 161 | end 162 | end 163 | 164 | def update_associations(association, ids = []) 165 | @object.send(association[:name]).clear 166 | ids.each do |id| 167 | update_association(association, id) 168 | end 169 | @object.save 170 | end 171 | 172 | def redirect_on_success 173 | param = @abstract_model.to_param 174 | pretty_name = @abstract_model.pretty_name 175 | action = params[:action] 176 | if params[:_continue] 177 | redirect(url(:merb_admin_edit, :model_name => param, :id => @object.id), :message => {:notice => "#{pretty_name} was successfully #{action}d"}) 178 | elsif params[:_add_another] 179 | redirect(url(:merb_admin_new, :model_name => param), :message => {:notice => "#{pretty_name} was successfully #{action}d"}) 180 | else 181 | redirect(url(:merb_admin_list, :model_name => param), :message => {:notice => "#{pretty_name} was successfully #{action}d"}) 182 | end 183 | end 184 | 185 | def render_error(template) 186 | action = params[:action] 187 | message[:error] = "#{@abstract_model.pretty_name} failed to be #{action}d" 188 | render(template, :layout => 'form') 189 | end 190 | 191 | end 192 | -------------------------------------------------------------------------------- /public/javascripts/core.js: -------------------------------------------------------------------------------- 1 | // Core javascript helper functions 2 | 3 | // basic browser identification & version 4 | var isOpera = (navigator.userAgent.indexOf("Opera")>=0) && parseFloat(navigator.appVersion); 5 | var isIE = ((document.all) && (!isOpera)) && parseFloat(navigator.appVersion.split("MSIE ")[1].split(";")[0]); 6 | 7 | // Cross-browser event handlers. 8 | function addEvent(obj, evType, fn) { 9 | if (obj.addEventListener) { 10 | obj.addEventListener(evType, fn, false); 11 | return true; 12 | } else if (obj.attachEvent) { 13 | var r = obj.attachEvent("on" + evType, fn); 14 | return r; 15 | } else { 16 | return false; 17 | } 18 | } 19 | 20 | function removeEvent(obj, evType, fn) { 21 | if (obj.removeEventListener) { 22 | obj.removeEventListener(evType, fn, false); 23 | return true; 24 | } else if (obj.detachEvent) { 25 | obj.detachEvent("on" + evType, fn); 26 | return true; 27 | } else { 28 | return false; 29 | } 30 | } 31 | 32 | // quickElement(tagType, parentReference, textInChildNode, [, attribute, attributeValue ...]); 33 | function quickElement() { 34 | var obj = document.createElement(arguments[0]); 35 | if (arguments[2] != '' && arguments[2] != null) { 36 | var textNode = document.createTextNode(arguments[2]); 37 | obj.appendChild(textNode); 38 | } 39 | var len = arguments.length; 40 | for (var i = 3; i < len; i += 2) { 41 | obj.setAttribute(arguments[i], arguments[i+1]); 42 | } 43 | arguments[1].appendChild(obj); 44 | return obj; 45 | } 46 | 47 | // ---------------------------------------------------------------------------- 48 | // Cross-browser xmlhttp object 49 | // from http://jibbering.com/2002/4/httprequest.html 50 | // ---------------------------------------------------------------------------- 51 | var xmlhttp; 52 | /*@cc_on @*/ 53 | /*@if (@_jscript_version >= 5) 54 | try { 55 | xmlhttp = new ActiveXObject("Msxml2.XMLHTTP"); 56 | } catch (e) { 57 | try { 58 | xmlhttp = new ActiveXObject("Microsoft.XMLHTTP"); 59 | } catch (E) { 60 | xmlhttp = false; 61 | } 62 | } 63 | @else 64 | xmlhttp = false; 65 | @end @*/ 66 | if (!xmlhttp && typeof XMLHttpRequest != 'undefined') { 67 | xmlhttp = new XMLHttpRequest(); 68 | } 69 | 70 | // ---------------------------------------------------------------------------- 71 | // Find-position functions by PPK 72 | // See http://www.quirksmode.org/js/findpos.html 73 | // ---------------------------------------------------------------------------- 74 | function findPosX(obj) { 75 | var curleft = 0; 76 | if (obj.offsetParent) { 77 | while (obj.offsetParent) { 78 | curleft += obj.offsetLeft - ((isOpera) ? 0 : obj.scrollLeft); 79 | obj = obj.offsetParent; 80 | } 81 | // IE offsetParent does not include the top-level 82 | if (isIE && obj.parentElement){ 83 | curleft += obj.offsetLeft - obj.scrollLeft; 84 | } 85 | } else if (obj.x) { 86 | curleft += obj.x; 87 | } 88 | return curleft; 89 | } 90 | 91 | function findPosY(obj) { 92 | var curtop = 0; 93 | if (obj.offsetParent) { 94 | while (obj.offsetParent) { 95 | curtop += obj.offsetTop - ((isOpera) ? 0 : obj.scrollTop); 96 | obj = obj.offsetParent; 97 | } 98 | // IE offsetParent does not include the top-level 99 | if (isIE && obj.parentElement){ 100 | curtop += obj.offsetTop - obj.scrollTop; 101 | } 102 | } else if (obj.y) { 103 | curtop += obj.y; 104 | } 105 | return curtop; 106 | } 107 | 108 | //----------------------------------------------------------------------------- 109 | // Date object extensions 110 | // ---------------------------------------------------------------------------- 111 | Date.prototype.getCorrectYear = function() { 112 | // Date.getYear() is unreliable -- 113 | // see http://www.quirksmode.org/js/introdate.html#year 114 | var y = this.getYear() % 100; 115 | return (y < 38) ? y + 2000 : y + 1900; 116 | } 117 | 118 | Date.prototype.getTwoDigitMonth = function() { 119 | return (this.getMonth() < 9) ? '0' + (this.getMonth()+1) : (this.getMonth()+1); 120 | } 121 | 122 | Date.prototype.getTwoDigitDate = function() { 123 | return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); 124 | } 125 | 126 | Date.prototype.getTwoDigitHour = function() { 127 | return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); 128 | } 129 | 130 | Date.prototype.getTwoDigitMinute = function() { 131 | return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); 132 | } 133 | 134 | Date.prototype.getTwoDigitSecond = function() { 135 | return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); 136 | } 137 | 138 | Date.prototype.getISODate = function() { 139 | return this.getCorrectYear() + '-' + this.getTwoDigitMonth() + '-' + this.getTwoDigitDate(); 140 | } 141 | 142 | Date.prototype.getHourMinute = function() { 143 | return this.getTwoDigitHour() + ':' + this.getTwoDigitMinute(); 144 | } 145 | 146 | Date.prototype.getHourMinuteSecond = function() { 147 | return this.getTwoDigitHour() + ':' + this.getTwoDigitMinute() + ':' + this.getTwoDigitSecond(); 148 | } 149 | 150 | // ---------------------------------------------------------------------------- 151 | // String object extensions 152 | // ---------------------------------------------------------------------------- 153 | String.prototype.pad_left = function(pad_length, pad_string) { 154 | var new_string = this; 155 | for (var i = 0; new_string.length < pad_length; i++) { 156 | new_string = pad_string + new_string; 157 | } 158 | return new_string; 159 | } 160 | 161 | // ---------------------------------------------------------------------------- 162 | // Get the computed style for and element 163 | // ---------------------------------------------------------------------------- 164 | function getStyle(oElm, strCssRule){ 165 | var strValue = ""; 166 | if(document.defaultView && document.defaultView.getComputedStyle){ 167 | strValue = document.defaultView.getComputedStyle(oElm, "").getPropertyValue(strCssRule); 168 | } 169 | else if(oElm.currentStyle){ 170 | strCssRule = strCssRule.replace(/\-(\w)/g, function (strMatch, p1){ 171 | return p1.toUpperCase(); 172 | }); 173 | strValue = oElm.currentStyle[strCssRule]; 174 | } 175 | return strValue; 176 | } 177 | -------------------------------------------------------------------------------- /public/javascripts/SelectFilter2.js: -------------------------------------------------------------------------------- 1 | /* 2 | SelectFilter2 - Turns a multiple-select box into a filter interface. 3 | 4 | Different than SelectFilter because this is coupled to the admin framework. 5 | 6 | Requires core.js, SelectBox.js and addevent.js. 7 | */ 8 | 9 | function findForm(node) { 10 | // returns the node of the form containing the given node 11 | if (node.tagName.toLowerCase() != 'form') { 12 | return findForm(node.parentNode); 13 | } 14 | return node; 15 | } 16 | 17 | var SelectFilter = { 18 | init: function(field_id, field_name, is_stacked, admin_media_prefix) { 19 | var from_box = document.getElementById(field_id); 20 | from_box.id += '_from'; // change its ID 21 | from_box.className = 'filtered'; 22 | 23 | // Remove

, because it just gets in the way. 24 | var ps = from_box.parentNode.getElementsByTagName('p'); 25 | for (var i=0; i or

30 | var selector_div = quickElement('div', from_box.parentNode); 31 | selector_div.className = is_stacked ? 'selector stacked' : 'selector'; 32 | 33 | //
34 | var selector_available = quickElement('div', selector_div, ''); 35 | selector_available.className = 'selector-available'; 36 | quickElement('h2', selector_available, interpolate(gettext('Available %s'), [field_name])); 37 | var filter_p = quickElement('p', selector_available, ''); 38 | filter_p.className = 'selector-filter'; 39 | quickElement('img', filter_p, '', 'src', admin_media_prefix + '/selector-search.gif'); 40 | filter_p.appendChild(document.createTextNode(' ')); 41 | var filter_input = quickElement('input', filter_p, '', 'type', 'text'); 42 | filter_input.id = field_id + '_input'; 43 | selector_available.appendChild(from_box); 44 | var choose_all = quickElement('a', selector_available, gettext('Choose all'), 'href', 'javascript: (function(){ SelectBox.move_all("' + field_id + '_from", "' + field_id + '_to"); })()'); 45 | choose_all.className = 'selector-chooseall'; 46 | 47 | //
    48 | var selector_chooser = quickElement('ul', selector_div, ''); 49 | selector_chooser.className = 'selector-chooser'; 50 | var add_link = quickElement('a', quickElement('li', selector_chooser, ''), gettext('Add'), 'href', 'javascript: (function(){ SelectBox.move("' + field_id + '_from","' + field_id + '_to");})()'); 51 | add_link.className = 'selector-add'; 52 | var remove_link = quickElement('a', quickElement('li', selector_chooser, ''), gettext('Remove'), 'href', 'javascript: (function(){ SelectBox.move("' + field_id + '_to","' + field_id + '_from");})()'); 53 | remove_link.className = 'selector-remove'; 54 | 55 | //
    56 | var selector_chosen = quickElement('div', selector_div, ''); 57 | selector_chosen.className = 'selector-chosen'; 58 | quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s'), [field_name])); 59 | var selector_filter = quickElement('p', selector_chosen, gettext('Select your choice(s) and click ')); 60 | selector_filter.className = 'selector-filter'; 61 | quickElement('img', selector_filter, '', 'src', admin_media_prefix + (is_stacked ? '/selector_stacked-add.gif':'/selector-add.gif'), 'alt', 'Add'); 62 | var to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', 'multiple', 'size', from_box.size, 'name', from_box.getAttribute('name')); 63 | to_box.className = 'filtered'; 64 | var clear_all = quickElement('a', selector_chosen, gettext('Clear all'), 'href', 'javascript: (function() { SelectBox.move_all("' + field_id + '_to", "' + field_id + '_from");})()'); 65 | clear_all.className = 'selector-clearall'; 66 | 67 | from_box.setAttribute('name', from_box.getAttribute('name') + '_old'); 68 | 69 | // Set up the JavaScript event handlers for the select box filter interface 70 | addEvent(filter_input, 'keyup', function(e) { SelectFilter.filter_key_up(e, field_id); }); 71 | addEvent(filter_input, 'keydown', function(e) { SelectFilter.filter_key_down(e, field_id); }); 72 | addEvent(from_box, 'dblclick', function() { SelectBox.move(field_id + '_from', field_id + '_to'); }); 73 | addEvent(to_box, 'dblclick', function() { SelectBox.move(field_id + '_to', field_id + '_from'); }); 74 | addEvent(findForm(from_box), 'submit', function() { SelectBox.select_all(field_id + '_to'); }); 75 | SelectBox.init(field_id + '_from'); 76 | SelectBox.init(field_id + '_to'); 77 | // Move selected from_box options to to_box 78 | SelectBox.move(field_id + '_from', field_id + '_to'); 79 | }, 80 | filter_key_up: function(event, field_id) { 81 | from = document.getElementById(field_id + '_from'); 82 | // don't submit form if user pressed Enter 83 | if ((event.which && event.which == 13) || (event.keyCode && event.keyCode == 13)) { 84 | from.selectedIndex = 0; 85 | SelectBox.move(field_id + '_from', field_id + '_to'); 86 | from.selectedIndex = 0; 87 | return false; 88 | } 89 | var temp = from.selectedIndex; 90 | SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value); 91 | from.selectedIndex = temp; 92 | return true; 93 | }, 94 | filter_key_down: function(event, field_id) { 95 | from = document.getElementById(field_id + '_from'); 96 | // right arrow -- move across 97 | if ((event.which && event.which == 39) || (event.keyCode && event.keyCode == 39)) { 98 | var old_index = from.selectedIndex; 99 | SelectBox.move(field_id + '_from', field_id + '_to'); 100 | from.selectedIndex = (old_index == from.length) ? from.length - 1 : old_index; 101 | return false; 102 | } 103 | // down arrow -- wrap around 104 | if ((event.which && event.which == 40) || (event.keyCode && event.keyCode == 40)) { 105 | from.selectedIndex = (from.length == from.selectedIndex + 1) ? 0 : from.selectedIndex + 1; 106 | } 107 | // up arrow -- wrap around 108 | if ((event.which && event.which == 38) || (event.keyCode && event.keyCode == 38)) { 109 | from.selectedIndex = (from.selectedIndex == 0) ? from.length - 1 : from.selectedIndex - 1; 110 | } 111 | return true; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /public/stylesheets/forms.css: -------------------------------------------------------------------------------- 1 | @import url('widgets.css'); 2 | 3 | /* FORM ROWS */ 4 | 5 | .form-row { 6 | overflow: hidden; 7 | padding: 8px 12px; 8 | font-size: 11px; 9 | border-bottom: 1px solid #eee; 10 | } 11 | 12 | .form-row img, .form-row input { 13 | vertical-align: middle; 14 | } 15 | 16 | form .form-row p { 17 | padding-left: 0; 18 | font-size: 11px; 19 | } 20 | 21 | /* FORM LABELS */ 22 | 23 | form h4 { 24 | margin: 0 !important; 25 | padding: 0 !important; 26 | border: none !important; 27 | } 28 | 29 | label { 30 | font-weight: normal !important; 31 | color: #666; 32 | font-size: 12px; 33 | } 34 | 35 | .required label, label.required { 36 | font-weight: bold !important; 37 | color: #333 !important; 38 | } 39 | 40 | /* RADIO BUTTONS */ 41 | 42 | form ul.radiolist li { 43 | list-style-type: none; 44 | } 45 | 46 | form ul.radiolist label { 47 | float: none; 48 | display: inline; 49 | } 50 | 51 | form ul.inline { 52 | margin-left: 0; 53 | padding: 0; 54 | } 55 | 56 | form ul.inline li { 57 | float: left; 58 | padding-right: 7px; 59 | } 60 | 61 | /* ALIGNED FIELDSETS */ 62 | 63 | .aligned label { 64 | display: block; 65 | padding: 3px 10px 0 0; 66 | float: left; 67 | width: 8em; 68 | } 69 | 70 | .colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { 71 | width: 350px; 72 | } 73 | 74 | form .aligned p, form .aligned ul { 75 | margin-left: 7em; 76 | padding-left: 30px; 77 | } 78 | 79 | form .aligned table p { 80 | margin-left: 0; 81 | padding-left: 0; 82 | } 83 | 84 | form .aligned p.help { 85 | padding-left: 38px; 86 | } 87 | 88 | .aligned .vCheckboxLabel { 89 | float: none !important; 90 | display: inline; 91 | padding-left: 4px; 92 | } 93 | 94 | .colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { 95 | width: 610px; 96 | } 97 | 98 | .checkbox-row p.help { 99 | margin-left: 0; 100 | padding-left: 0 !important; 101 | } 102 | 103 | fieldset .field-box { 104 | float: left; 105 | margin-right: 20px; 106 | } 107 | 108 | /* WIDE FIELDSETS */ 109 | 110 | .wide label { 111 | width: 15em !important; 112 | } 113 | 114 | form .wide p { 115 | margin-left: 15em; 116 | } 117 | 118 | form .wide p.help { 119 | padding-left: 38px; 120 | } 121 | 122 | .colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { 123 | width: 450px; 124 | } 125 | 126 | /* COLLAPSED FIELDSETS */ 127 | 128 | fieldset.collapsed * { 129 | display: none; 130 | } 131 | 132 | fieldset.collapsed h2, fieldset.collapsed { 133 | display: block !important; 134 | } 135 | 136 | fieldset.collapsed h2 { 137 | background-image: url(../images/nav-bg.gif); 138 | background-position: bottom left; 139 | color: #999; 140 | } 141 | 142 | fieldset.collapsed .collapse-toggle { 143 | padding: 3px 5px !important; 144 | background: transparent; 145 | display: inline !important; 146 | } 147 | 148 | /* MONOSPACE TEXTAREAS */ 149 | 150 | fieldset.monospace textarea { 151 | font-family: "Bitstream Vera Sans Mono",Monaco,"Courier New",Courier,monospace; 152 | } 153 | 154 | /* SUBMIT ROW */ 155 | 156 | .submit-row { 157 | padding: 5px 7px; 158 | text-align: right; 159 | background: white url(../images/nav-bg.gif) 0 100% repeat-x; 160 | border: 1px solid #ccc; 161 | margin: 5px 0; 162 | overflow: hidden; 163 | } 164 | 165 | .submit-row input { 166 | margin: 0 0 0 5px; 167 | } 168 | 169 | .submit-row p { 170 | margin: 0.3em; 171 | } 172 | 173 | .submit-row p.deletelink-box { 174 | float: left; 175 | } 176 | 177 | .submit-row .deletelink { 178 | background: url(../images/icon_deletelink.gif) 0 50% no-repeat; 179 | padding-left: 14px; 180 | } 181 | 182 | /* CUSTOM FORM FIELDS */ 183 | 184 | .vSelectMultipleField { 185 | vertical-align: top !important; 186 | } 187 | 188 | .vCheckboxField { 189 | border: none; 190 | } 191 | 192 | .vDateField, .vTimeField { 193 | margin-right: 2px; 194 | } 195 | 196 | .vURLField { 197 | width: 30em; 198 | } 199 | 200 | .vLargeTextField, .vXMLLargeTextField { 201 | width: 48em; 202 | } 203 | 204 | .flatpages-flatpage #id_content { 205 | height: 40.2em; 206 | } 207 | 208 | .module table .vPositiveSmallIntegerField { 209 | width: 2.2em; 210 | } 211 | 212 | .vTextField { 213 | width: 20em; 214 | } 215 | 216 | .vIntegerField { 217 | width: 5em; 218 | } 219 | 220 | .vForeignKeyRawIdAdminField { 221 | width: 5em; 222 | } 223 | 224 | /* INLINES */ 225 | 226 | .inline-group { 227 | padding: 0; 228 | border: 1px solid #ccc; 229 | margin: 10px 0; 230 | } 231 | 232 | .inline-group .aligned label { 233 | width: 8em; 234 | } 235 | 236 | .inline-related { 237 | position: relative; 238 | } 239 | 240 | .inline-related h3 { 241 | margin: 0; 242 | color: #666; 243 | padding: 3px 5px; 244 | font-size: 11px; 245 | background: #e1e1e1 url(../images/nav-bg.gif) top left repeat-x; 246 | border-bottom: 1px solid #ddd; 247 | } 248 | 249 | .inline-related h3 span.delete { 250 | padding-left: 20px; 251 | position: absolute; 252 | top: 2px; 253 | right: 10px; 254 | } 255 | 256 | .inline-related h3 span.delete label { 257 | margin-left: 2px; 258 | font-size: 11px; 259 | } 260 | 261 | .inline-related fieldset { 262 | margin: 0; 263 | background: #fff; 264 | border: none; 265 | } 266 | 267 | .inline-related fieldset.module h3 { 268 | margin: 0; 269 | padding: 2px 5px 3px 5px; 270 | font-size: 11px; 271 | text-align: left; 272 | font-weight: bold; 273 | background: #bcd; 274 | color: #fff; 275 | } 276 | 277 | .inline-related.tabular fieldset.module table { 278 | width: 100%; 279 | } 280 | 281 | .last-related fieldset { 282 | border: none; 283 | } 284 | 285 | .inline-group .tabular tr.has_original td { 286 | padding-top: 2em; 287 | } 288 | 289 | .inline-group .tabular tr td.original { 290 | padding: 2px 0 0 0; 291 | width: 0; 292 | _position: relative; 293 | } 294 | 295 | .inline-group .tabular th.original { 296 | width: 0px; 297 | padding: 0; 298 | } 299 | 300 | .inline-group .tabular td.original p { 301 | position: absolute; 302 | left: 0; 303 | height: 1.1em; 304 | padding: 2px 7px; 305 | overflow: hidden; 306 | font-size: 9px; 307 | font-weight: bold; 308 | color: #666; 309 | _width: 700px; 310 | } 311 | 312 | .inline-group ul.tools { 313 | padding: 0; 314 | margin: 0; 315 | list-style: none; 316 | } 317 | 318 | .inline-group ul.tools li { 319 | display: inline; 320 | padding: 0 5px; 321 | } 322 | 323 | .inline-group ul.tools a.add { 324 | background: url(../images/icon_addlink.gif) 0 50% no-repeat; 325 | padding-left: 14px; 326 | } 327 | 328 | -------------------------------------------------------------------------------- /app/helpers/main_helper.rb: -------------------------------------------------------------------------------- 1 | require 'builder' 2 | module Merb 3 | module MerbAdmin 4 | module MainHelper 5 | def to_model_name(param) 6 | param.split("::").map{|x| x.camel_case}.join("::") 7 | end 8 | 9 | def object_label(object) 10 | if object.nil? 11 | nil 12 | elsif object.respond_to?(:name) && object.name 13 | object.name 14 | elsif object.respond_to?(:title) && object.title 15 | object.title 16 | else 17 | "#{object.class.to_s} ##{object.id}" 18 | end 19 | end 20 | 21 | def object_property(object, property) 22 | property_type = property[:type] 23 | property_name = property[:name] 24 | case property_type 25 | when :boolean 26 | if object.send(property_name) == true 27 | Builder::XmlMarkup.new.img(:src => image_path("icon-yes.gif"), :alt => "True") 28 | else 29 | Builder::XmlMarkup.new.img(:src => image_path("icon-no.gif"), :alt => "False") 30 | end 31 | when :datetime 32 | value = object.send(property_name) 33 | value.respond_to?(:strftime) ? value.strftime("%b. %d, %Y, %I:%M%p") : nil 34 | when :date 35 | value = object.send(property_name) 36 | value.respond_to?(:strftime) ? value.strftime("%b. %d, %Y") : nil 37 | when :time 38 | value = object.send(property_name) 39 | value.respond_to?(:strftime) ? value.strftime("%I:%M%p") : nil 40 | when :string 41 | if property_name.to_s =~ /(image|logo|photo|photograph|picture|thumb|thumbnail)_ur(i|l)/i 42 | Builder::XmlMarkup.new.img(:src => object.send(property_name), :width => 10, :height => 10) 43 | else 44 | object.send(property_name).to_s.truncate(50) 45 | end 46 | when :text 47 | object.send(property_name).to_s.truncate(50) 48 | when :integer 49 | association = @abstract_model.belongs_to_associations.select{|a| a[:child_key].first == property_name}.first 50 | if association 51 | object_label(object.send(association[:name])) 52 | else 53 | object.send(property_name) 54 | end 55 | else 56 | object.send(property_name) 57 | end 58 | end 59 | 60 | # Given a page count and the current page, we generate a set of pagination 61 | # links. 62 | # 63 | # * We use an inner and outer window into a list of links. For a set of 64 | # 20 pages with the current page being 10: 65 | # outer_window: 66 | # 1 2 ..... 19 20 67 | # inner_window 68 | # 5 6 7 8 9 10 11 12 13 14 69 | # 70 | # This is totally adjustable, or can be turned off by giving the 71 | # :inner_window setting a value of nil. 72 | # 73 | # * Options 74 | # :left_cut_label => text_for_cut:: 75 | # Used when the page numbers need to be cut off to prevent the set of 76 | # pagination links from being too long. 77 | # Defaults to '…' 78 | # :right_cut_label => text_for_cut:: 79 | # Same as :left_cut_label but for the right side of numbers. 80 | # Defaults to '…' 81 | # :outer_window => number_of_pages:: 82 | # Sets the number of pages to include in the outer 'window' 83 | # Defaults to 2 84 | # :inner_window => number_of_pages:: 85 | # Sets the number of pags to include in the inner 'window' 86 | # Defaults to 7 87 | # :page_param => name_of_page_paramiter 88 | # Sets the name of the paramiter the paginator uses to return what 89 | # page is being requested. 90 | # Defaults to 'page' 91 | # :url => url_for_links 92 | # Provides the base url to use in the page navigation links. 93 | # Defaults to '' 94 | def paginate(current_page, page_count, options = {}) 95 | options[:left_cut_label] ||= '…' 96 | options[:right_cut_label] ||= '…' 97 | options[:outer_window] ||= 2 98 | options[:inner_window] ||= 7 99 | options[:page_param] ||= 'page' 100 | options[:url] ||= '' 101 | 102 | url = options.delete(:url) 103 | url << (url.include?('?') ? '&' : '?') << options[:page_param] 104 | 105 | pages = { 106 | :all => (1..page_count).to_a, 107 | :left => [], 108 | :center => [], 109 | :right => [] 110 | } 111 | 112 | # Only worry about using our 'windows' if the page count is less then 113 | # our windows combined. 114 | if options[:inner_window].nil? || ((options[:outer_window] * 2) + options[:inner_window] + 2) >= page_count 115 | pages[:center] = pages[:all] 116 | else 117 | pages[:left] = pages[:all][0, options[:outer_window]] 118 | pages[:right] = pages[:all][page_count - options[:outer_window], options[:outer_window]] 119 | pages[:center] = case current_page 120 | # allow the inner 'window' to shift to right when close to the left edge 121 | # Ex: 1 2 [3] 4 5 6 7 8 9 ... 20 122 | when -infinity .. (options[:inner_window] / 2) + 3 123 | pages[:all][options[:outer_window], options[:inner_window]] + 124 | [options[:right_cut_label]] 125 | # allow the inner 'window' to shift left when close to the right edge 126 | # Ex: 1 2 ... 12 13 14 15 16 [17] 18 19 20 127 | when (page_count - (options[:inner_window] / 2.0).ceil) - 1 .. infinity 128 | [options[:left_cut_label]] + 129 | pages[:all][page_count - options[:inner_window] - options[:outer_window], options[:inner_window]] 130 | # Display the unshifed window 131 | # ex: 1 2 ... 5 6 7 [8] 9 10 11 ... 19 20 132 | else 133 | [options[:left_cut_label]] + 134 | pages[:all][current_page - (options[:inner_window] / 2) - 1, options[:inner_window]] + 135 | [options[:right_cut_label]] 136 | end 137 | end 138 | 139 | b = [] 140 | 141 | [pages[:left], pages[:center], pages[:right]].each do |p| 142 | p.each do |page_number| 143 | case page_number 144 | when String 145 | b << page_number 146 | when current_page 147 | b << Builder::XmlMarkup.new.span(page_number, :class => "this-page") 148 | when page_count 149 | b << Builder::XmlMarkup.new.a(page_number, :class => "end", :href => "#{url}=#{page_number}") 150 | else 151 | b << Builder::XmlMarkup.new.a(page_number, :href => "#{url}=#{page_number}") 152 | end 153 | end 154 | end 155 | 156 | b.join(" ") 157 | end 158 | 159 | private 160 | 161 | def infinity 162 | 1.0 / 0 163 | end 164 | 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /public/javascripts/getElementsBySelector.js: -------------------------------------------------------------------------------- 1 | /* document.getElementsBySelector(selector) 2 | - returns an array of element objects from the current document 3 | matching the CSS selector. Selectors can contain element names, 4 | class names and ids and can be nested. For example: 5 | 6 | elements = document.getElementsBySelect('div#main p a.external') 7 | 8 | Will return an array of all 'a' elements with 'external' in their 9 | class attribute that are contained inside 'p' elements that are 10 | contained inside the 'div' element which has id="main" 11 | 12 | New in version 0.4: Support for CSS2 and CSS3 attribute selectors: 13 | See http://www.w3.org/TR/css3-selectors/#attribute-selectors 14 | 15 | Version 0.4 - Simon Willison, March 25th 2003 16 | -- Works in Phoenix 0.5, Mozilla 1.3, Opera 7, Internet Explorer 6, Internet Explorer 5 on Windows 17 | -- Opera 7 fails 18 | */ 19 | 20 | function getAllChildren(e) { 21 | // Returns all children of element. Workaround required for IE5/Windows. Ugh. 22 | return e.all ? e.all : e.getElementsByTagName('*'); 23 | } 24 | 25 | document.getElementsBySelector = function(selector) { 26 | // Attempt to fail gracefully in lesser browsers 27 | if (!document.getElementsByTagName) { 28 | return new Array(); 29 | } 30 | // Split selector in to tokens 31 | var tokens = selector.split(' '); 32 | var currentContext = new Array(document); 33 | for (var i = 0; i < tokens.length; i++) { 34 | token = tokens[i].replace(/^\s+/,'').replace(/\s+$/,'');; 35 | if (token.indexOf('#') > -1) { 36 | // Token is an ID selector 37 | var bits = token.split('#'); 38 | var tagName = bits[0]; 39 | var id = bits[1]; 40 | var element = document.getElementById(id); 41 | if (!element || (tagName && element.nodeName.toLowerCase() != tagName)) { 42 | // ID not found or tag with that ID not found, return false. 43 | return new Array(); 44 | } 45 | // Set currentContext to contain just this element 46 | currentContext = new Array(element); 47 | continue; // Skip to next token 48 | } 49 | if (token.indexOf('.') > -1) { 50 | // Token contains a class selector 51 | var bits = token.split('.'); 52 | var tagName = bits[0]; 53 | var className = bits[1]; 54 | if (!tagName) { 55 | tagName = '*'; 56 | } 57 | // Get elements matching tag, filter them for class selector 58 | var found = new Array; 59 | var foundCount = 0; 60 | for (var h = 0; h < currentContext.length; h++) { 61 | var elements; 62 | if (tagName == '*') { 63 | elements = getAllChildren(currentContext[h]); 64 | } else { 65 | try { 66 | elements = currentContext[h].getElementsByTagName(tagName); 67 | } 68 | catch(e) { 69 | elements = []; 70 | } 71 | } 72 | for (var j = 0; j < elements.length; j++) { 73 | found[foundCount++] = elements[j]; 74 | } 75 | } 76 | currentContext = new Array; 77 | var currentContextIndex = 0; 78 | for (var k = 0; k < found.length; k++) { 79 | if (found[k].className && found[k].className.match(new RegExp('\\b'+className+'\\b'))) { 80 | currentContext[currentContextIndex++] = found[k]; 81 | } 82 | } 83 | continue; // Skip to next token 84 | } 85 | // Code to deal with attribute selectors 86 | if (token.match(/^(\w*)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/)) { 87 | var tagName = RegExp.$1; 88 | var attrName = RegExp.$2; 89 | var attrOperator = RegExp.$3; 90 | var attrValue = RegExp.$4; 91 | if (!tagName) { 92 | tagName = '*'; 93 | } 94 | // Grab all of the tagName elements within current context 95 | var found = new Array; 96 | var foundCount = 0; 97 | for (var h = 0; h < currentContext.length; h++) { 98 | var elements; 99 | if (tagName == '*') { 100 | elements = getAllChildren(currentContext[h]); 101 | } else { 102 | elements = currentContext[h].getElementsByTagName(tagName); 103 | } 104 | for (var j = 0; j < elements.length; j++) { 105 | found[foundCount++] = elements[j]; 106 | } 107 | } 108 | currentContext = new Array; 109 | var currentContextIndex = 0; 110 | var checkFunction; // This function will be used to filter the elements 111 | switch (attrOperator) { 112 | case '=': // Equality 113 | checkFunction = function(e) { return (e.getAttribute(attrName) == attrValue); }; 114 | break; 115 | case '~': // Match one of space seperated words 116 | checkFunction = function(e) { return (e.getAttribute(attrName).match(new RegExp('\\b'+attrValue+'\\b'))); }; 117 | break; 118 | case '|': // Match start with value followed by optional hyphen 119 | checkFunction = function(e) { return (e.getAttribute(attrName).match(new RegExp('^'+attrValue+'-?'))); }; 120 | break; 121 | case '^': // Match starts with value 122 | checkFunction = function(e) { return (e.getAttribute(attrName).indexOf(attrValue) == 0); }; 123 | break; 124 | case '$': // Match ends with value - fails with "Warning" in Opera 7 125 | checkFunction = function(e) { return (e.getAttribute(attrName).lastIndexOf(attrValue) == e.getAttribute(attrName).length - attrValue.length); }; 126 | break; 127 | case '*': // Match ends with value 128 | checkFunction = function(e) { return (e.getAttribute(attrName).indexOf(attrValue) > -1); }; 129 | break; 130 | default : 131 | // Just test for existence of attribute 132 | checkFunction = function(e) { return e.getAttribute(attrName); }; 133 | } 134 | currentContext = new Array; 135 | var currentContextIndex = 0; 136 | for (var k = 0; k < found.length; k++) { 137 | if (checkFunction(found[k])) { 138 | currentContext[currentContextIndex++] = found[k]; 139 | } 140 | } 141 | // alert('Attribute Selector: '+tagName+' '+attrName+' '+attrOperator+' '+attrValue); 142 | continue; // Skip to next token 143 | } 144 | // If we get here, token is JUST an element (not a class or ID selector) 145 | tagName = token; 146 | var found = new Array; 147 | var foundCount = 0; 148 | for (var h = 0; h < currentContext.length; h++) { 149 | var elements = currentContext[h].getElementsByTagName(tagName); 150 | for (var j = 0; j < elements.length; j++) { 151 | found[foundCount++] = elements[j]; 152 | } 153 | } 154 | currentContext = found; 155 | } 156 | return currentContext; 157 | } 158 | 159 | /* That revolting regular expression explained 160 | /^(\w+)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/ 161 | \---/ \---/\-------------/ \-------/ 162 | | | | | 163 | | | | The value 164 | | | ~,|,^,$,* or = 165 | | Attribute 166 | Tag 167 | */ 168 | -------------------------------------------------------------------------------- /lib/merb-admin/slicetasks.rb: -------------------------------------------------------------------------------- 1 | require 'abstract_model' 2 | 3 | namespace :slices do 4 | namespace :"merb-admin" do 5 | 6 | # # Uncomment the following lines and edit the pre defined tasks 7 | # 8 | # # implement this to test for structural/code dependencies 9 | # # like certain directories or availability of other files 10 | # desc "Test for any dependencies" 11 | # task :preflight do 12 | # end 13 | # 14 | # # implement this to perform any database related setup steps 15 | # desc "Migrate the database" 16 | # task :migrate do 17 | # end 18 | 19 | desc "Copies sample models, copies and runs sample migrations, and loads sample data into your app" 20 | task :activerecord => ["activerecord:copy_sample_models", "activerecord:copy_sample_migrations", "activerecord:migrate", "load_sample_data"] 21 | namespace :activerecord do 22 | desc "Copies sample models into your app" 23 | task :copy_sample_models do 24 | copy_models(:activerecord) 25 | end 26 | 27 | desc "Copies sample migrations into your app" 28 | task :copy_sample_migrations do 29 | copy_migrations(:activerecord) 30 | end 31 | 32 | desc "Migrate the database to the latest version" 33 | task :migrate do 34 | Rake::Task["db:migrate"].reenable 35 | Rake::Task["db:migrate"].invoke 36 | end 37 | end 38 | 39 | desc "Copies sample models, runs sample migrations, and loads sample data into your app" 40 | task :datamapper => ["datamapper:copy_sample_models", "datamapper:migrate", "load_sample_data"] 41 | namespace :datamapper do 42 | desc "Copies sample models into your app" 43 | task :copy_sample_models do 44 | copy_models(:datamapper) 45 | end 46 | 47 | desc "Perform non destructive automigration" 48 | task :migrate do 49 | Rake::Task["db:automigrate"].reenable 50 | Rake::Task["db:automigrate"].invoke 51 | end 52 | end 53 | 54 | desc "Copies sample models, copies and runs sample migrations, and loads sample data" 55 | task :sequel => ["sequel:copy_sample_models", "sequel:copy_sample_migrations", "sequel:migrate", "load_sample_data"] 56 | namespace :sequel do 57 | desc "Copies sample models into your app" 58 | task :copy_sample_models do 59 | copy_models(:sequel) 60 | end 61 | 62 | desc "Copies sample migrations into your app" 63 | task :copy_sample_migrations do 64 | copy_migrations(:sequel) 65 | end 66 | 67 | desc "Perform migration using migrations in schema/migrations" 68 | task :migrate do 69 | require 'sequel/extensions/migration' 70 | Rake::Task["sequel:db:migrate"].reenable 71 | Rake::Task["sequel:db:migrate"].invoke 72 | end 73 | end 74 | 75 | desc "Loads sample data into your app" 76 | task :load_sample_data do 77 | load_data 78 | end 79 | 80 | end 81 | end 82 | 83 | private 84 | 85 | def load_data 86 | require "mlb" 87 | 88 | require_models 89 | 90 | puts "Loading current MLB leagues, divisions, teams, and players" 91 | MLB::Team.all.each do |mlb_team| 92 | unless league = MerbAdmin::AbstractModel.new("League").first(:conditions => ["name = ?", mlb_team.league]) 93 | league = MerbAdmin::AbstractModel.new("League").create(:name => mlb_team.league) 94 | end 95 | unless division = MerbAdmin::AbstractModel.new("Division").first(:conditions => ["name = ?", mlb_team.division]) 96 | division = MerbAdmin::AbstractModel.new("Division").create(:name => mlb_team.division, :league => league) 97 | end 98 | unless team = MerbAdmin::AbstractModel.new("Team").first(:conditions => ["name = ?", mlb_team.name]) 99 | team = MerbAdmin::AbstractModel.new("Team").create(:name => mlb_team.name, :logo_url => mlb_team.logo_url, :manager => mlb_team.manager, :ballpark => mlb_team.ballpark, :mascot => mlb_team.mascot, :founded => mlb_team.founded, :wins => mlb_team.wins, :losses => mlb_team.losses, :win_percentage => ("%.3f" % (mlb_team.wins.to_f / (mlb_team.wins + mlb_team.losses))).to_f, :division => division, :league => league) 100 | end 101 | mlb_team.players.reject{|player| player.number.nil?}.each do |player| 102 | MerbAdmin::AbstractModel.new("Player").create(:name => player.name, :number => player.number, :position => player.position, :team => team) 103 | end 104 | end 105 | end 106 | 107 | def copy_models(orm = nil) 108 | orm ||= set_orm 109 | puts "Copying sample #{orm} models into host application - resolves any collisions" 110 | seen, copied, duplicated = [], [], [] 111 | Dir.glob(File.dirname(__FILE__) / ".." / ".." / "spec" / "models" / orm.to_s.downcase / MerbAdmin.glob_for(:model)).each do |source_filename| 112 | next if seen.include?(source_filename) 113 | destination_filename = Merb.dir_for(:model) / File.basename(source_filename) 114 | mirror_file(source_filename, destination_filename, copied, duplicated) 115 | seen << source_filename 116 | end 117 | copied.each { |f| puts "- copied #{f}" } 118 | duplicated.each { |f| puts "! duplicated override as #{f}" } 119 | end 120 | 121 | def copy_migrations(orm = nil) 122 | orm ||= set_orm 123 | puts "Copying sample #{orm} migrations into host application - resolves any collisions" 124 | seen, copied, duplicated = [], [], [] 125 | Dir.glob(File.dirname(__FILE__) / ".." / ".." / "spec" / "migrations" / orm.to_s.downcase / "*.rb").each do |source_filename| 126 | next if seen.include?(source_filename) 127 | destination_filename = Merb.root / "schema" / "migrations" / File.basename(source_filename) 128 | mirror_file(source_filename, destination_filename, copied, duplicated) 129 | seen << source_filename 130 | end 131 | copied.each { |f| puts "- copied #{f}" } 132 | duplicated.each { |f| puts "! duplicated override as #{f}" } 133 | end 134 | 135 | def require_models 136 | Dir.glob(Merb.dir_for(:model) / Merb.glob_for(:model)).each do |model_filename| 137 | require model_filename 138 | end 139 | end 140 | 141 | def set_orm(orm = nil) 142 | orm || ENV['MERB_ORM'] || (Merb.orm != :none ? Merb.orm : nil) || :activerecord 143 | end 144 | 145 | def mirror_file(source, dest, copied = [], duplicated = [], postfix = '_override') 146 | base, rest = split_name(source) 147 | dst_dir = File.dirname(dest) 148 | dup_path = dst_dir / "#{base}#{postfix}.#{rest}" 149 | if File.file?(source) 150 | FileUtils.mkdir_p(dst_dir) unless File.directory?(dst_dir) 151 | if File.exists?(dest) && !File.exists?(dup_path) && !FileUtils.identical?(source, dest) 152 | # copy app-level override to *_override.ext 153 | FileUtils.copy_entry(dest, dup_path, false, false, true) 154 | duplicated << dup_path.relative_path_from(Merb.root) 155 | end 156 | # copy gem-level original to location 157 | if !File.exists?(dest) || (File.exists?(dest) && !FileUtils.identical?(source, dest)) 158 | FileUtils.copy_entry(source, dest, false, false, true) 159 | copied << dest.relative_path_from(Merb.root) 160 | end 161 | end 162 | end 163 | 164 | def split_name(name) 165 | file_name = File.basename(name) 166 | mres = /^([^\/\.]+)\.(.+)$/i.match(file_name) 167 | mres.nil? ? [file_name, ''] : [mres[1], mres[2]] 168 | end 169 | -------------------------------------------------------------------------------- /public/javascripts/dateparse.js: -------------------------------------------------------------------------------- 1 | /* 'Magic' date parsing, by Simon Willison (6th October 2003) 2 | http://simon.incutio.com/archive/2003/10/06/betterDateInput 3 | Adapted for 6newslawrence.com, 28th January 2004 4 | */ 5 | 6 | /* Finds the index of the first occurence of item in the array, or -1 if not found */ 7 | if (typeof Array.prototype.indexOf == 'undefined') { 8 | Array.prototype.indexOf = function(item) { 9 | var len = this.length; 10 | for (var i = 0; i < len; i++) { 11 | if (this[i] == item) { 12 | return i; 13 | } 14 | } 15 | return -1; 16 | }; 17 | } 18 | /* Returns an array of items judged 'true' by the passed in test function */ 19 | if (typeof Array.prototype.filter == 'undefined') { 20 | Array.prototype.filter = function(test) { 21 | var matches = []; 22 | var len = this.length; 23 | for (var i = 0; i < len; i++) { 24 | if (test(this[i])) { 25 | matches[matches.length] = this[i]; 26 | } 27 | } 28 | return matches; 29 | }; 30 | } 31 | 32 | var monthNames = gettext("January February March April May June July August September October November December").split(" "); 33 | var weekdayNames = gettext("Sunday Monday Tuesday Wednesday Thursday Friday Saturday").split(" "); 34 | 35 | /* Takes a string, returns the index of the month matching that string, throws 36 | an error if 0 or more than 1 matches 37 | */ 38 | function parseMonth(month) { 39 | var matches = monthNames.filter(function(item) { 40 | return new RegExp("^" + month, "i").test(item); 41 | }); 42 | if (matches.length == 0) { 43 | throw new Error("Invalid month string"); 44 | } 45 | if (matches.length > 1) { 46 | throw new Error("Ambiguous month"); 47 | } 48 | return monthNames.indexOf(matches[0]); 49 | } 50 | /* Same as parseMonth but for days of the week */ 51 | function parseWeekday(weekday) { 52 | var matches = weekdayNames.filter(function(item) { 53 | return new RegExp("^" + weekday, "i").test(item); 54 | }); 55 | if (matches.length == 0) { 56 | throw new Error("Invalid day string"); 57 | } 58 | if (matches.length > 1) { 59 | throw new Error("Ambiguous weekday"); 60 | } 61 | return weekdayNames.indexOf(matches[0]); 62 | } 63 | 64 | /* Array of objects, each has 're', a regular expression and 'handler', a 65 | function for creating a date from something that matches the regular 66 | expression. Handlers may throw errors if string is unparseable. 67 | */ 68 | var dateParsePatterns = [ 69 | // Today 70 | { re: /^tod/i, 71 | handler: function() { 72 | return new Date(); 73 | } 74 | }, 75 | // Tomorrow 76 | { re: /^tom/i, 77 | handler: function() { 78 | var d = new Date(); 79 | d.setDate(d.getDate() + 1); 80 | return d; 81 | } 82 | }, 83 | // Yesterday 84 | { re: /^yes/i, 85 | handler: function() { 86 | var d = new Date(); 87 | d.setDate(d.getDate() - 1); 88 | return d; 89 | } 90 | }, 91 | // 4th 92 | { re: /^(\d{1,2})(st|nd|rd|th)?$/i, 93 | handler: function(bits) { 94 | var d = new Date(); 95 | d.setDate(parseInt(bits[1], 10)); 96 | return d; 97 | } 98 | }, 99 | // 4th Jan 100 | { re: /^(\d{1,2})(?:st|nd|rd|th)? (\w+)$/i, 101 | handler: function(bits) { 102 | var d = new Date(); 103 | d.setDate(parseInt(bits[1], 10)); 104 | d.setMonth(parseMonth(bits[2])); 105 | return d; 106 | } 107 | }, 108 | // 4th Jan 2003 109 | { re: /^(\d{1,2})(?:st|nd|rd|th)? (\w+),? (\d{4})$/i, 110 | handler: function(bits) { 111 | var d = new Date(); 112 | d.setDate(parseInt(bits[1], 10)); 113 | d.setMonth(parseMonth(bits[2])); 114 | d.setYear(bits[3]); 115 | return d; 116 | } 117 | }, 118 | // Jan 4th 119 | { re: /^(\w+) (\d{1,2})(?:st|nd|rd|th)?$/i, 120 | handler: function(bits) { 121 | var d = new Date(); 122 | d.setDate(parseInt(bits[2], 10)); 123 | d.setMonth(parseMonth(bits[1])); 124 | return d; 125 | } 126 | }, 127 | // Jan 4th 2003 128 | { re: /^(\w+) (\d{1,2})(?:st|nd|rd|th)?,? (\d{4})$/i, 129 | handler: function(bits) { 130 | var d = new Date(); 131 | d.setDate(parseInt(bits[2], 10)); 132 | d.setMonth(parseMonth(bits[1])); 133 | d.setYear(bits[3]); 134 | return d; 135 | } 136 | }, 137 | // next Tuesday - this is suspect due to weird meaning of "next" 138 | { re: /^next (\w+)$/i, 139 | handler: function(bits) { 140 | var d = new Date(); 141 | var day = d.getDay(); 142 | var newDay = parseWeekday(bits[1]); 143 | var addDays = newDay - day; 144 | if (newDay <= day) { 145 | addDays += 7; 146 | } 147 | d.setDate(d.getDate() + addDays); 148 | return d; 149 | } 150 | }, 151 | // last Tuesday 152 | { re: /^last (\w+)$/i, 153 | handler: function(bits) { 154 | throw new Error("Not yet implemented"); 155 | } 156 | }, 157 | // mm/dd/yyyy (American style) 158 | { re: /(\d{1,2})\/(\d{1,2})\/(\d{4})/, 159 | handler: function(bits) { 160 | var d = new Date(); 161 | d.setYear(bits[3]); 162 | d.setDate(parseInt(bits[2], 10)); 163 | d.setMonth(parseInt(bits[1], 10) - 1); // Because months indexed from 0 164 | return d; 165 | } 166 | }, 167 | // yyyy-mm-dd (ISO style) 168 | { re: /(\d{4})-(\d{1,2})-(\d{1,2})/, 169 | handler: function(bits) { 170 | var d = new Date(); 171 | d.setYear(parseInt(bits[1])); 172 | d.setMonth(parseInt(bits[2], 10) - 1); 173 | d.setDate(parseInt(bits[3], 10)); 174 | return d; 175 | } 176 | }, 177 | ]; 178 | 179 | function parseDateString(s) { 180 | for (var i = 0; i < dateParsePatterns.length; i++) { 181 | var re = dateParsePatterns[i].re; 182 | var handler = dateParsePatterns[i].handler; 183 | var bits = re.exec(s); 184 | if (bits) { 185 | return handler(bits); 186 | } 187 | } 188 | throw new Error("Invalid date string"); 189 | } 190 | 191 | function fmt00(x) { 192 | // fmt00: Tags leading zero onto numbers 0 - 9. 193 | // Particularly useful for displaying results from Date methods. 194 | // 195 | if (Math.abs(parseInt(x)) < 10){ 196 | x = "0"+ Math.abs(x); 197 | } 198 | return x; 199 | } 200 | 201 | function parseDateStringISO(s) { 202 | try { 203 | var d = parseDateString(s); 204 | return d.getFullYear() + '-' + (fmt00(d.getMonth() + 1)) + '-' + fmt00(d.getDate()) 205 | } 206 | catch (e) { return s; } 207 | } 208 | function magicDate(input) { 209 | var messagespan = input.id + 'Msg'; 210 | try { 211 | var d = parseDateString(input.value); 212 | input.value = d.getFullYear() + '-' + (fmt00(d.getMonth() + 1)) + '-' + 213 | fmt00(d.getDate()); 214 | input.className = ''; 215 | // Human readable date 216 | if (document.getElementById(messagespan)) { 217 | document.getElementById(messagespan).firstChild.nodeValue = d.toDateString(); 218 | document.getElementById(messagespan).className = 'normal'; 219 | } 220 | } 221 | catch (e) { 222 | input.className = 'error'; 223 | var message = e.message; 224 | // Fix for IE6 bug 225 | if (message.indexOf('is null or not an object') > -1) { 226 | message = 'Invalid date string'; 227 | } 228 | if (document.getElementById(messagespan)) { 229 | document.getElementById(messagespan).firstChild.nodeValue = message; 230 | document.getElementById(messagespan).className = 'error'; 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /public/stylesheets/global.css: -------------------------------------------------------------------------------- 1 | body { margin:0; padding:0; font-size:12px; font-family:"Lucida Grande","DejaVu Sans","Bitstream Vera Sans",Verdana,Arial,sans-serif; color:#333; background:#fff; } 2 | 3 | /* LINKS */ 4 | a:link, a:visited { color: #5b80b2; text-decoration:none; } 5 | a:hover { color: #036; } 6 | a img { border:none; } 7 | a.section:link, a.section:visited { color: white; text-decoration:none; } 8 | 9 | /* GLOBAL DEFAULTS */ 10 | p, ol, ul, dl { margin:.2em 0 .8em 0; } 11 | p { padding:0; line-height:140%; } 12 | 13 | h1,h2,h3,h4,h5 { font-weight:bold; } 14 | h1 { font-size:18px; color:#666; padding:0 6px 0 0; margin:0 0 .2em 0; } 15 | h2 { font-size:16px; margin:1em 0 .5em 0; } 16 | h2.subhead { font-weight:normal;margin-top:0; } 17 | h3 { font-size:14px; margin:.8em 0 .3em 0; color:#666; font-weight:bold; } 18 | h4 { font-size:12px; margin:1em 0 .8em 0; padding-bottom:3px; } 19 | h5 { font-size:10px; margin:1.5em 0 .5em 0; color:#666; text-transform:uppercase; letter-spacing:1px; } 20 | 21 | ul li { list-style-type:square; padding:1px 0; } 22 | ul.plainlist { margin-left:0 !important; } 23 | ul.plainlist li { list-style-type:none; } 24 | li ul { margin-bottom:0; } 25 | li, dt, dd { font-size:11px; line-height:14px; } 26 | dt { font-weight:bold; margin-top:4px; } 27 | dd { margin-left:0; } 28 | 29 | form { margin:0; padding:0; } 30 | fieldset { margin:0; padding:0; } 31 | 32 | blockquote { font-size:11px; color:#777; margin-left:2px; padding-left:10px; border-left:5px solid #ddd; } 33 | code, pre { font-family:"Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace; background:inherit; color:#666; font-size:11px; } 34 | pre.literal-block { margin:10px; background:#eee; padding:6px 8px; } 35 | code strong { color:#930; } 36 | hr { clear:both; color:#eee; background-color:#eee; height:1px; border:none; margin:0; padding:0; font-size:1px; line-height:1px; } 37 | 38 | /* TEXT STYLES & MODIFIERS */ 39 | .small { font-size:11px; } 40 | .tiny { font-size:10px; } 41 | p.tiny { margin-top:-2px; } 42 | .mini { font-size:9px; } 43 | p.mini { margin-top:-3px; } 44 | .help, p.help { font-size:10px !important; color:#999; } 45 | p img, h1 img, h2 img, h3 img, h4 img, td img { vertical-align:middle; } 46 | .quiet, a.quiet:link, a.quiet:visited { color:#999 !important;font-weight:normal !important; } 47 | .quiet strong { font-weight:bold !important; } 48 | .float-right { float:right; } 49 | .float-left { float:left; } 50 | .clear { clear:both; } 51 | .align-left { text-align:left; } 52 | .align-right { text-align:right; } 53 | .example { margin:10px 0; padding:5px 10px; background:#efefef; } 54 | .nowrap { white-space:nowrap; } 55 | 56 | /* TABLES */ 57 | table { border-collapse:collapse; border-color:#ccc; } 58 | td, th { font-size:11px; line-height:13px; border-bottom:1px solid #eee; vertical-align:top; padding:5px; font-family:"Lucida Grande", Verdana, Arial, sans-serif; } 59 | th { text-align:left; font-size:12px; font-weight:bold; } 60 | thead th, 61 | tfoot td { color:#666; padding:2px 5px; font-size:11px; background:#e1e1e1 url(../images/nav-bg.gif) top left repeat-x; border-left:1px solid #ddd; border-bottom:1px solid #ddd; } 62 | tfoot td { border-bottom:none; border-top:1px solid #ddd; } 63 | thead th:first-child, 64 | tfoot td:first-child { border-left:none !important; } 65 | thead th.optional { font-weight:normal !important; } 66 | fieldset table { border-right:1px solid #eee; } 67 | tr.row-label td { font-size:9px; padding-top:2px; padding-bottom:0; border-bottom:none; color:#666; margin-top:-1px; } 68 | tr.alt { background:#f6f6f6; } 69 | .row1 { background:#EDF3FE; } 70 | .row2 { background:white; } 71 | 72 | /* SORTABLE TABLES */ 73 | thead th a:link, thead th a:visited { color:#666; display:block; } 74 | table thead th.sorted { background-position:bottom left !important; } 75 | table thead th.sorted a { padding-right:13px; } 76 | table thead th.ascending a { background:url(../images/arrow-down.gif) right .4em no-repeat; } 77 | table thead th.descending a { background:url(../images/arrow-up.gif) right .4em no-repeat; } 78 | 79 | /* ORDERABLE TABLES */ 80 | table.orderable tbody tr td:hover { cursor:move; } 81 | table.orderable tbody tr td:first-child { padding-left:14px; background-image:url(../images/nav-bg-grabber.gif); background-repeat:repeat-y; } 82 | table.orderable-initalized .order-cell, body>tr>td.order-cell { display:none; } 83 | 84 | /* FORM DEFAULTS */ 85 | input, textarea, select { margin:2px 0; padding:2px 3px; vertical-align:middle; font-family:"Lucida Grande", Verdana, Arial, sans-serif; font-weight:normal; font-size:11px; } 86 | textarea { vertical-align:top !important; } 87 | input[type=text], input[type=password], textarea, select, .vTextField { border:1px solid #ccc; } 88 | 89 | /* FORM BUTTONS */ 90 | .button, input[type=submit], input[type=button], .submit-row input { background:white url(../images/nav-bg.gif) bottom repeat-x; padding:3px; color:black; border:1px solid #bbb; border-color:#ddd #aaa #aaa #ddd; } 91 | .button:active, input[type=submit]:active, input[type=button]:active { background-image:url(../images/nav-bg-reverse.gif); background-position:top; } 92 | .button.default, input[type=submit].default, .submit-row input.default { border:2px solid #5b80b2; background:#7CA0C7 url(../images/default-bg.gif) bottom repeat-x; font-weight:bold; color:white; float:right; } 93 | .button.default:active, input[type=submit].default:active { background-image:url(../images/default-bg-reverse.gif); background-position:top; } 94 | 95 | /* MODULES */ 96 | .module { border:1px solid #ccc; margin-bottom:5px; background:white; } 97 | .module p, .module ul, .module h3, .module h4, .module dl, .module pre { padding-left:10px; padding-right:10px; } 98 | .module blockquote { margin-left:12px; } 99 | .module ul, .module ol { margin-left:1.5em; } 100 | .module h3 { margin-top:.6em; } 101 | .module h2, .module caption, .inline-group h2 { margin:0; padding:2px 5px 3px 5px; font-size:11px; text-align:left; font-weight:bold; background:#7CA0C7 url(../images/default-bg.gif) top left repeat-x; color:white; } 102 | .module table { border-collapse: collapse; } 103 | 104 | /* MESSAGES & ERRORS */ 105 | ul.messagelist { padding:0 0 5px 0; margin:0; } 106 | ul.messagelist li { font-size:12px; display:block; padding:4px 5px 4px 25px; margin:0 0 3px 0; border-bottom:1px solid #ddd; color:#666; background:#ffc url(../images/icon_success.gif) 5px .3em no-repeat; } 107 | .errornote { font-size:12px !important; display:block; padding:4px 5px 4px 25px; margin:0 0 3px 0; border:1px solid red; color:red;background:#ffc url(../images/icon_error.gif) 5px .3em no-repeat; } 108 | ul.errorlist { margin:0 !important; padding:0 !important; } 109 | .errorlist li { font-size:12px !important; display:block; padding:4px 5px 4px 25px; margin:0 0 3px 0; border:1px solid red; color:white; background:red url(../images/icon_alert.gif) 5px .3em no-repeat; } 110 | td ul.errorlist { margin:0 !important; padding:0 !important; } 111 | td ul.errorlist li { margin:0 !important; } 112 | .errors { background:#ffc; } 113 | .errors input, .errors select { border:1px solid red; } 114 | div.system-message { background: #ffc; margin: 10px; padding: 6px 8px; font-size: .8em; } 115 | div.system-message p.system-message-title { padding:4px 5px 4px 25px; margin:0; color:red; background:#ffc url(../images/icon_error.gif) 5px .3em no-repeat; } 116 | .description { font-size:12px; padding:5px 0 0 12px; } 117 | 118 | /* BREADCRUMBS */ 119 | div.breadcrumbs { background:white url(../images/nav-bg-reverse.gif) 0 -10px repeat-x; padding:2px 8px 3px 8px; font-size:11px; color:#999; border-top:1px solid white; border-bottom:1px solid #ccc; text-align:left; } 120 | 121 | /* ACTION ICONS */ 122 | .addlink { padding-left:12px; background:url(../images/icon_addlink.gif) 0 .2em no-repeat; } 123 | .changelink { padding-left:12px; background:url(../images/icon_changelink.gif) 0 .2em no-repeat; } 124 | .deletelink { padding-left:12px; background:url(../images/icon_deletelink.gif) 0 .25em no-repeat; } 125 | a.deletelink:link, a.deletelink:visited { color:#CC3434; } 126 | a.deletelink:hover { color:#993333; } 127 | 128 | /* OBJECT TOOLS */ 129 | .object-tools { font-size:10px; font-weight:bold; font-family:Arial,Helvetica,sans-serif; padding-left:0; float:right; position:relative; margin-top:-2.4em; margin-bottom:-2em; } 130 | .form-row .object-tools { margin-top:5px; margin-bottom:5px; float:none; height:2em; padding-left:3.5em; } 131 | .object-tools li { display:block; float:left; background:url(../images/tool-left.gif) 0 0 no-repeat; padding:0 0 0 8px; margin-left:2px; height:16px; } 132 | .object-tools li:hover { background:url(../images/tool-left_over.gif) 0 0 no-repeat; } 133 | .object-tools a:link, .object-tools a:visited { display:block; float:left; color:white; padding:.1em 14px .1em 8px; height:14px; background:#999 url(../images/tool-right.gif) 100% 0 no-repeat; } 134 | .object-tools a:hover, .object-tools li:hover a { background:#5b80b2 url(../images/tool-right_over.gif) 100% 0 no-repeat; } 135 | .object-tools a.viewsitelink, .object-tools a.golink { background:#999 url(../images/tooltag-arrowright.gif) top right no-repeat; padding-right:28px; } 136 | .object-tools a.viewsitelink:hover, .object-tools a.golink:hover { background:#5b80b2 url(../images/tooltag-arrowright_over.gif) top right no-repeat; } 137 | .object-tools a.addlink { background:#999 url(../images/tooltag-add.gif) top right no-repeat; padding-right:28px; } 138 | .object-tools a.addlink:hover { background:#5b80b2 url(../images/tooltag-add_over.gif) top right no-repeat; } 139 | 140 | /* OBJECT HISTORY */ 141 | table#change-history { width:100%; } 142 | table#change-history tbody th { width:16em; } 143 | -------------------------------------------------------------------------------- /lib/sequel_support.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | require 'sequel/extensions/pagination' 3 | 4 | class Sequel::Model 5 | =begin 6 | # Intialize each column to the default value for new model objects 7 | def after_initialize 8 | super 9 | model.columns.each do |x| 10 | if !@values.include?(x) && db_schema[x][:allow_null] 11 | send("#{x}=", db_schema[x][:ruby_default]) 12 | end 13 | end 14 | end 15 | =end 16 | 17 | # Return an empty array for *_to_many association methods for new model objects 18 | def _load_associated_objects(opts) 19 | opts.returns_array? && new? ? [] : super 20 | end 21 | end 22 | 23 | module MerbAdmin 24 | class AbstractModel 25 | module SequelSupport 26 | def get(id) 27 | model.first(:id => id).extend(InstanceMethods) 28 | end 29 | 30 | def count(options = {}) 31 | if options[:conditions] && !options[:conditions].empty? 32 | model.where(options[:conditions]).count 33 | else 34 | model.count 35 | end 36 | end 37 | 38 | def first(options = {}) 39 | sort = options.delete(:sort) || :id 40 | sort_order = options.delete(:sort_reverse) ? :desc : :asc 41 | 42 | if options[:conditions] && !options[:conditions].empty? 43 | model.order(sort.to_sym.send(sort_order)).first(options[:conditions]).extend(InstanceMethods) 44 | else 45 | model.order(sort.to_sym.send(sort_order)).first.extend(InstanceMethods) 46 | end 47 | end 48 | 49 | def last(options = {}) 50 | sort = options.delete(:sort) || :id 51 | sort_order = options.delete(:sort_reverse) ? :desc : :asc 52 | 53 | if options[:conditions] && !options[:conditions].empty? 54 | model.order(sort.to_sym.send(sort_order)).last(options[:conditions]).extend(InstanceMethods) 55 | else 56 | model.order(sort.to_sym.send(sort_order)).last.extend(InstanceMethods) 57 | end 58 | end 59 | 60 | def all(options = {}) 61 | offset = options.delete(:offset) 62 | limit = options.delete(:limit) 63 | 64 | sort = options.delete(:sort) || :id 65 | sort_order = options.delete(:sort_reverse) ? :desc : :asc 66 | 67 | if options[:conditions] && !options[:conditions].empty? 68 | model.where(options[:conditions]).order(sort.to_sym.send(sort_order)) 69 | else 70 | model.order(sort.to_sym.send(sort_order)) 71 | end 72 | end 73 | 74 | def paginated(options = {}) 75 | page = options.delete(:page) || 1 76 | per_page = options.delete(:per_page) || MerbAdmin[:per_page] 77 | page_count = (count(options).to_f / per_page).ceil 78 | 79 | sort = options.delete(:sort) || :id 80 | sort_order = options.delete(:sort_reverse) ? :desc : :asc 81 | 82 | if options[:conditions] && !options[:conditions].empty? 83 | [page_count, model.paginate(page.to_i, per_page).where(options[:conditions]).order(sort.to_sym.send(sort_order))] 84 | else 85 | [page_count, model.paginate(page.to_i, per_page).order(sort.to_sym.send(sort_order))] 86 | end 87 | end 88 | 89 | def create(params = {}) 90 | model.create(params).extend(InstanceMethods) 91 | end 92 | 93 | def new(params = {}) 94 | model.new(params).extend(InstanceMethods) 95 | end 96 | 97 | def destroy_all! 98 | model.destroy 99 | end 100 | 101 | def has_many_associations 102 | associations.select do |association| 103 | association[:type] == :has_many 104 | end 105 | end 106 | 107 | def has_one_associations 108 | associations.select do |association| 109 | association[:type] == :has_one 110 | end 111 | end 112 | 113 | def belongs_to_associations 114 | associations.select do |association| 115 | association[:type] == :belongs_to 116 | end 117 | end 118 | 119 | def associations 120 | model.all_association_reflections.map do |association| 121 | { 122 | :name => association_name_lookup(association), 123 | :pretty_name => association_pretty_name_lookup(association), 124 | :type => association_type_lookup(association), 125 | :parent_model => association_parent_model_lookup(association), 126 | :parent_key => association_parent_key_lookup(association), 127 | :child_model => association_child_model_lookup(association), 128 | :child_key => association_child_key_lookup(association), 129 | } 130 | end 131 | end 132 | 133 | def properties 134 | model.columns.map do |property| 135 | { 136 | :name => property, 137 | :pretty_name => property.to_s.gsub(/_id$/, "").gsub("_", " ").capitalize, 138 | :type => property_type_lookup(property), 139 | :length => property_length_lookup(property), 140 | :nullable? => model.db_schema[property][:allow_null], 141 | :serial? => model.db_schema[property][:primary_key], 142 | } 143 | end 144 | end 145 | 146 | private 147 | 148 | def property_type_lookup(property) 149 | case model.db_schema[property][:db_type] 150 | when /\A(?:medium|small)?int(?:eger)?(?:\((?:\d+)\))?\z/io 151 | :integer 152 | when /\Atinyint(?:\((\d+)\))?\z/io 153 | :boolean 154 | when /\Abigint(?:\((?:\d+)\))?\z/io 155 | :integer 156 | when /\A(?:real|float|double(?: precision)?)\z/io 157 | :float 158 | when 'boolean' 159 | :boolean 160 | when /\A(?:(?:tiny|medium|long|n)?text|clob)\z/io 161 | :text 162 | when 'date' 163 | :date 164 | when /\A(?:small)?datetime\z/io 165 | :datetime 166 | when /\Atimestamp(?: with(?:out)? time zone)?\z/io 167 | :datetime 168 | when /\Atime(?: with(?:out)? time zone)?\z/io 169 | :time 170 | when /\An?char(?:acter)?(?:\((\d+)\))?\z/io 171 | :string 172 | when /\A(?:n?varchar|character varying|bpchar|string)(?:\((\d+)\))?\z/io 173 | :string 174 | when /\A(?:small)?money\z/io 175 | :big_decimal 176 | when /\A(?:decimal|numeric|number)(?:\((\d+)(?:,\s*(\d+))?\))?\z/io 177 | :big_decimal 178 | when 'year' 179 | :integer 180 | else 181 | :string 182 | end 183 | end 184 | 185 | def property_length_lookup(property) 186 | case model.db_schema[property][:db_type] 187 | when /\An?char(?:acter)?(?:\((\d+)\))?\z/io 188 | $1 ? $1.to_i : 255 189 | when /\A(?:n?varchar|character varying|bpchar|string)(?:\((\d+)\))?\z/io 190 | $1 ? $1.to_i : 255 191 | else 192 | nil 193 | end 194 | end 195 | 196 | def association_name_lookup(association) 197 | case association[:type] 198 | when :one_to_many 199 | association[:name] 200 | when :one_to_one 201 | # association[:name].to_s.singularize.to_sym 202 | association[:name].to_s.to_sym 203 | when :many_to_one 204 | association[:name] 205 | else 206 | raise "Unknown association type" 207 | end 208 | end 209 | 210 | def association_pretty_name_lookup(association) 211 | case association[:type] 212 | when :one_to_many 213 | association[:name].to_s.gsub('_', ' ').capitalize 214 | when :one_to_one 215 | association[:name].to_s.singularize.gsub('_', ' ').capitalize 216 | when :many_to_one 217 | association[:name].to_s.gsub('_', ' ').capitalize 218 | else 219 | raise "Unknown association type" 220 | end 221 | end 222 | 223 | def association_type_lookup(association) 224 | case association[:type] 225 | when :one_to_many 226 | :has_many 227 | when :one_to_one 228 | :has_one 229 | when :many_to_one 230 | :belongs_to 231 | else 232 | raise "Unknown association type" 233 | end 234 | end 235 | 236 | def association_parent_model_lookup(association) 237 | case association[:type] 238 | when :one_to_many, :one_to_one 239 | association[:model] 240 | when :many_to_one 241 | Object.const_get(association[:class_name]) 242 | else 243 | raise "Unknown association type" 244 | end 245 | end 246 | 247 | def association_parent_key_lookup(association) 248 | [:id] 249 | end 250 | 251 | def association_child_model_lookup(association) 252 | case association[:type] 253 | when :one_to_many, :one_to_one 254 | Object.const_get(association[:class_name]) 255 | when :many_to_one 256 | association[:model] 257 | else 258 | raise "Unknown association type" 259 | end 260 | end 261 | 262 | def association_child_key_lookup(association) 263 | case association[:type] 264 | when :one_to_many, :one_to_one 265 | association[:keys] 266 | when :many_to_one 267 | ["#{association[:class_name].snake_case}_id".to_sym] 268 | else 269 | raise "Unknown association type" 270 | end 271 | end 272 | 273 | module InstanceMethods 274 | def update_attributes(attributes) 275 | update(attributes) 276 | true 277 | end 278 | end 279 | 280 | end 281 | end 282 | end 283 | --------------------------------------------------------------------------------