├── .gitignore ├── LICENSE ├── README.md ├── Rakefile ├── bureaucrat.gemspec ├── examples └── fields_test │ ├── config.ru │ ├── rackapp1.html │ └── style.css ├── lib ├── bureaucrat.rb └── bureaucrat │ ├── fields.rb │ ├── forms.rb │ ├── formsets.rb │ ├── quickfields.rb │ ├── temporary_uploaded_file.rb │ ├── utils.rb │ ├── validators.rb │ └── widgets.rb └── test ├── fields_test.rb ├── forms_test.rb ├── formsets_test.rb ├── test_helper.rb └── widgets_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/ 2 | tmp/ 3 | doc/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2009 Bruno Deferrari 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bureaucrat 2 | ========== 3 | 4 | Form handling for Ruby inspired by [Django forms](https://docs.djangoproject.com/en/dev/#forms). 5 | 6 | Description 7 | ----------- 8 | 9 | Bureaucrat is a library for handling the processing, validation and rendering of HTML forms. 10 | 11 | Structure of a Form 12 | ------------------- 13 | 14 | Form ----> valid?, errors/cleaned_data 15 | ______|________ 16 | / | \ 17 | Field Field Field ----> clean 18 | | | | 19 | Widget Widget Widget ----> render 20 | 21 | **Form**: 22 | Collection of named Fields, handles global validation and the last pass of 23 | data conversion. 24 | After validation, a valid Form responds to `cleaned_data` by returning a 25 | hash of validated values and an invalid Form responds to `errors` by 26 | returning a hash of field_name => error_messages. 27 | 28 | **Field**: 29 | Handles the validation and data conversion of each field belonging to the Form. Each Field is associated to a name on the parent Form. 30 | 31 | **Widget**: 32 | Handles the rendering of a Form field. Each Field has two widgets associated, one for normal rendering, and another for hidden inputs rendering. Every type of Field has default Widgets defined, but they can be overriden on a per-Form basis. 33 | 34 | Usage examples 35 | -------------- 36 | 37 | ```ruby 38 | require 'bureaucrat' 39 | require 'bureaucrat/quickfields' 40 | 41 | class MyForm < Bureaucrat::Forms::Form 42 | extend Bureaucrat::Quickfields 43 | 44 | string :nickname, max_length: 50 45 | string :realname, required: false 46 | email :email 47 | integer :age, min_value: 0 48 | boolean :newsletter, required: false 49 | 50 | # Note: Bureaucrat doesn't define save 51 | def save 52 | user = User.create!(cleaned_data) 53 | Mailer.deliver_confirmation_mail(user) 54 | user 55 | end 56 | end 57 | 58 | # A Form initialized without parameters is an unbound Form. 59 | unbound_form = MyForm.new 60 | unbound_form.valid? # => false 61 | unbound_form.errors # => {} 62 | unbound_form.cleaned_data # => nil 63 | unbound_form[:nickname].to_s # => '' 64 | unbound_form[:nickname].errors # => [] 65 | unbound_form[:nickname].label_tag # => '' 66 | 67 | # Bound form with validation errors 68 | invalid_bound_form = MyForm.new(nickname: 'bureaucrat', email: 'badformat', age: '30') 69 | invalid_bound_form.valid? # => false 70 | invalid_bound_form.errors # {email: ["Enter a valid e-mail address."]} 71 | invalid_bound_form.cleaned_data # => nil 72 | invalid_bound_form[:email].to_s # => '' 73 | invalid_bound_form[:email].errors # => ["Enter a valid e-mail address."] 74 | invalid_bound_form[:email].label_tag # => '' 75 | 76 | # Bound form without validation errors 77 | valid_bound_form = MyForm.new(nickname: 'bureaucrat', email: 'valid@email.com', age: '30') 78 | valid_bound_form.valid? # => true 79 | valid_bound_form.errors # {} 80 | valid_bound_form.cleaned_data # => {age: 30, newsletter: false, nickname: "bureaucrat", realname: "", :email = >"valid@email.com"} 81 | 82 | valid_bound_form.save # A new User is created and a confirmation mail is delivered 83 | ``` 84 | 85 | Examples of different ways of defining forms 86 | -------------- 87 | 88 | ```ruby 89 | require 'bureaucrat' 90 | require 'bureaucrat/quickfields' 91 | 92 | class MyForm < Bureaucrat::Forms::Form 93 | include Bureaucrat::Fields 94 | 95 | field :nickname, CharField.new(max_length: 50) 96 | field :realname, CharField.new(required: false) 97 | field :email, EmailField.new 98 | field :age, IntegerField.new(min_value: 0) 99 | field :newsletter, BooleanField.new(required: false) 100 | end 101 | 102 | class MyFormQuick < Bureaucrat::Forms::Form 103 | extend Bureaucrat::Quickfields 104 | 105 | string :nickname, max_length: 50 106 | string :realname, required: false 107 | email :email 108 | integer :age, min_value: 0 109 | boolean :newsletter, required: false 110 | end 111 | 112 | def inline_form 113 | f = Class.new(Bureaucrat::Forms::Form) 114 | f.extend(Bureaucrat::Quickfields) 115 | yield f 116 | f 117 | end 118 | 119 | form_maker = inline_form do |f| 120 | f.string :nickname, max_length: 50 121 | f.string :realname, required: false 122 | f.email :email 123 | f.integer :age, min_value: 0 124 | f.boolean :newsletter, required: false 125 | end 126 | ``` 127 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | 3 | task :default => :test 4 | 5 | desc 'Run all tests' 6 | Rake::TestTask.new(:test) do |t| 7 | t.pattern = './test/**/*_test.rb' 8 | t.verbose = false 9 | end 10 | -------------------------------------------------------------------------------- /bureaucrat.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'bureaucrat' 3 | s.version = '0.10.2' 4 | s.summary = "Form handling for Ruby inspired by Django forms." 5 | s.description = "Bureaucrat is a form handling library for Ruby." 6 | s.author = "Bruno Deferrari" 7 | s.email = "utizoc@gmail.com" 8 | s.homepage = "http://github.com/tizoc/bureaucrat" 9 | 10 | s.files = [ 11 | "lib/bureaucrat/fields.rb", 12 | "lib/bureaucrat/forms.rb", 13 | "lib/bureaucrat/formsets.rb", 14 | "lib/bureaucrat/quickfields.rb", 15 | "lib/bureaucrat/temporary_uploaded_file.rb", 16 | "lib/bureaucrat/utils.rb", 17 | "lib/bureaucrat/validators.rb", 18 | "lib/bureaucrat/widgets.rb", 19 | "lib/bureaucrat.rb", 20 | "README.md", 21 | "LICENSE", 22 | "test/fields_test.rb", 23 | "test/forms_test.rb", 24 | "test/formsets_test.rb", 25 | "test/test_helper.rb", 26 | "test/widgets_test.rb", 27 | "Rakefile", 28 | "bureaucrat.gemspec" 29 | ] 30 | 31 | s.require_paths = ['lib'] 32 | end 33 | -------------------------------------------------------------------------------- /examples/fields_test/config.ru: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'bigdecimal' 3 | require 'prettyprint' 4 | require 'rack' 5 | require 'bureaucrat' 6 | require 'bureaucrat/quickfields' 7 | 8 | class TestForm1 < Bureaucrat::Forms::Form 9 | extend Bureaucrat::Quickfields 10 | 11 | string :string_field 12 | string :string_field2, :required => false,\ 13 | :label => 'Another String' 14 | text :text_field 15 | password :password, :required => false 16 | password :password_confirmation, :required => false 17 | integer :integer 18 | decimal :decimal 19 | regex :regex, /\w\d\d\w+/, :help_text => '\w\d\d\w+' 20 | email :email 21 | #file :file, :required => false 22 | boolean :boolean 23 | null_boolean :null_boolean 24 | choice :choice, [['', 'Select a letter'], 25 | ['a', 'Letter A'], 26 | ['b', 'Letter B']] 27 | multiple_choice :multiple_choice, [['', 'Select some letters'], 28 | ['a', 'Letter A'], 29 | ['b', 'Letter B'], 30 | ['c', 'Letter C']] 31 | radio_choice :radio_choice, [['a', 'Letter A'], 32 | ['b', 'Letter B'], 33 | ['c', 'Letter C']] 34 | checkbox_multiple_choice :checkbox_multiple_choice,\ 35 | [['a', 'Letter A'], 36 | ['b', 'Letter B'], 37 | ['c', 'Letter C']] 38 | 39 | def initialize(*args) 40 | super(*args) 41 | @error_css_class = 'with-errors' 42 | @required_css_class = 'required' 43 | end 44 | 45 | def save 46 | puts cleaned_data 47 | end 48 | end 49 | 50 | template_path = File.join(File.dirname(__FILE__), 'rackapp1.html') 51 | template = File.open(template_path) do |f| 52 | ERB.new(f.read) 53 | end 54 | 55 | stylesheet_path = File.join(File.dirname(__FILE__), 'style.css') 56 | styles = File.open(stylesheet_path) do |f| 57 | f.read 58 | end 59 | 60 | fields_test_app = lambda do |env| 61 | req = Rack::Request.new(env) 62 | 63 | unless req.path == '/' 64 | return [404, {'Content-Type' => 'text', 'Content-Length' => '0'}, ''] 65 | end 66 | 67 | if req.post? 68 | form = TestForm1.new(req.POST) 69 | else 70 | form = TestForm1.new 71 | end 72 | 73 | result = template.result(binding) 74 | 75 | [200, {'Content-Type' => 'text', 'Content-Length' => result.length.to_s}, [result]] 76 | end 77 | 78 | run fields_test_app 79 | -------------------------------------------------------------------------------- /examples/fields_test/rackapp1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 1 5 | 8 | 9 | 10 |
11 |

Form Example 1

12 | 13 |
14 | <% form.each do |field| %> 15 | <% with_errors = !field.errors.empty? %> 16 |
17 | <%= field.label_tag %> 18 | <%= field %> 19 | <% unless field.errors.empty? %> 20 | [x]<%= field.errors %> 21 | <% end %> 22 | <% unless field.help_text.empty? %> 23 | [?]<%= field.help_text %> 24 | <% end %> 25 |
26 | <% end %> 27 | 28 | 29 |
30 |
31 | 32 | <% if req.post? && form.valid? %> 33 |

Result:

34 |
35 |       
36 |         <% form.cleaned_data.each do |label, data| %>
37 |         <%= "#{label}: #{ERB::Util.html_escape(data.inspect)}" %>
38 |         <% end %>
39 |       
40 |     
41 | <% end %> 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/fields_test/style.css: -------------------------------------------------------------------------------- 1 | body{ 2 | font-family:"Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; 3 | font-size:12px; 4 | } 5 | 6 | p, h1, form, button { 7 | border:0; 8 | margin:0; 9 | padding:0; 10 | } 11 | 12 | .form { 13 | border:solid 2px #b7ddf2; 14 | background:#ebf4fb; 15 | } 16 | 17 | .form form { 18 | margin:0 auto; 19 | width:960px; 20 | padding:14px; 21 | } 22 | 23 | .form h1 { 24 | font-size:14px; 25 | font-weight:bold; 26 | margin-bottom:8px; 27 | } 28 | .form ul { 29 | list-style-type: none; 30 | } 31 | .form .field { 32 | padding: 10px 0; 33 | width: 470px; 34 | float: left; 35 | } 36 | .form .field.with-errors label { 37 | color: #dd0000; 38 | } 39 | .form .required label { 40 | color: #000000; 41 | } 42 | .form label { 43 | color: #999999; 44 | display:block; 45 | font-weight:bold; 46 | text-align:right; 47 | padding: 6px 2px; 48 | width:140px; 49 | float:left; 50 | } 51 | .form ul label { 52 | width: auto; 53 | } 54 | .form .help-text { 55 | color:#ffffff; 56 | background-color: #222244; 57 | border: solid 2px #ffffff; 58 | display:none; 59 | font-size:11px; 60 | font-weight:normal; 61 | text-align:right; 62 | padding: 6px 2px; 63 | position: absolute; 64 | top: -30px; 65 | right: -30px; 66 | } 67 | .form .field-errors { 68 | color:#ffffff; 69 | background-color: #bb2222; 70 | border: solid 2px #ffffff; 71 | display:none; 72 | font-size:11px; 73 | font-weight:normal; 74 | text-align:right; 75 | padding: 6px 2px; 76 | position: absolute; 77 | top: -30px; 78 | right: -30px; 79 | } 80 | .form .help, .form .error { 81 | position: relative; 82 | } 83 | .form .help:hover .help-text { 84 | display: block; 85 | } 86 | .form .error:hover .field-errors { 87 | display: block; 88 | } 89 | .form input, .form textarea, .form select { 90 | float:left; 91 | font-size:12px; 92 | padding:4px 2px; 93 | border:solid 1px #aacfe4; 94 | width:220px; 95 | margin:2px 0 20px 10px; 96 | } 97 | .form ul input { 98 | width: auto; 99 | } 100 | .form button { 101 | clear:both; 102 | margin-left:450px; 103 | width:125px; 104 | height:31px; 105 | background:#000000; 106 | text-align:center; 107 | line-height:31px; 108 | color:#FFFFFF; 109 | font-size:11px; 110 | font-weight:bold; 111 | } 112 | -------------------------------------------------------------------------------- /lib/bureaucrat.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | VERSION = '0.10.0' 3 | 4 | class ValidationError < Exception 5 | attr_reader :code, :params, :messages 6 | 7 | def initialize(message, code = nil, params = nil) 8 | if message.is_a? Array 9 | @messages = message 10 | else 11 | @code = code 12 | @params = params 13 | @messages = [message] 14 | end 15 | end 16 | 17 | def to_s 18 | "ValidationError(#{@messages.inspect})" 19 | end 20 | end 21 | 22 | require_relative 'bureaucrat/utils' 23 | require_relative 'bureaucrat/validators' 24 | require_relative 'bureaucrat/widgets' 25 | require_relative 'bureaucrat/fields' 26 | require_relative 'bureaucrat/forms' 27 | require_relative 'bureaucrat/temporary_uploaded_file' 28 | end 29 | -------------------------------------------------------------------------------- /lib/bureaucrat/fields.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Bureaucrat 4 | module Fields 5 | 6 | class ErrorList < Array 7 | include Utils 8 | 9 | def to_s 10 | as_ul 11 | end 12 | 13 | def as_ul 14 | if empty? 15 | '' 16 | else 17 | ul = '' 18 | li = '
  • %s
  • ' 19 | 20 | result = ul % map{|e| li % conditional_escape(e)}.join("\n") 21 | mark_safe(result) 22 | end 23 | end 24 | 25 | def as_text 26 | empty? ? '' : map{|e| '* %s' % e}.join("\n") 27 | end 28 | end 29 | 30 | class ErrorHash < Hash 31 | include Utils 32 | 33 | def to_s 34 | as_ul 35 | end 36 | 37 | def as_ul 38 | ul = '' 39 | li = '
  • %s%s
  • ' 40 | empty? ? '' : mark_safe(ul % map {|k, v| li % [k, v]}.join) 41 | end 42 | 43 | def as_text 44 | map do |k, v| 45 | "* %s\n%s" % [k, v.map{|i| ' * %s'}.join("\n")] 46 | end.join("\n") 47 | end 48 | end 49 | 50 | class Field 51 | attr_accessor :required, :label, :initial, :error_messages, :widget, :hidden_widget, :show_hidden_initial, :help_text, :validators 52 | 53 | def initialize(options={}) 54 | @required = options.fetch(:required, true) 55 | @show_hidden_initial = options.fetch(:show_hidden_initial, false) 56 | @label = options[:label] 57 | @initial = options[:initial] 58 | @help_text = options.fetch(:help_text, '') 59 | @widget = options.fetch(:widget, default_widget) 60 | 61 | @widget = @widget.new if @widget.is_a?(Class) 62 | @widget.attrs.update(widget_attrs(@widget)) 63 | @widget.is_required = @required 64 | 65 | @hidden_widget = options.fetch(:hidden_widget, default_hidden_widget) 66 | @hidden_widget = @hidden_widget.new if @hidden_widget.is_a?(Class) 67 | 68 | @error_messages = default_error_messages. 69 | merge(options.fetch(:error_messages, {})) 70 | 71 | @validators = default_validators + options.fetch(:validators, []) 72 | end 73 | 74 | # Default error messages for this kind of field. Override on subclasses to add or replace messages 75 | def default_error_messages 76 | { 77 | required: 'This field is required', 78 | invalid: 'Enter a valid value' 79 | } 80 | end 81 | 82 | # Default validators for this kind of field. 83 | def default_validators 84 | [] 85 | end 86 | 87 | # Default widget for this kind of field. Override on subclasses to customize. 88 | def default_widget 89 | Widgets::TextInput 90 | end 91 | 92 | # Default hidden widget for this kind of field. Override on subclasses to customize. 93 | def default_hidden_widget 94 | Widgets::HiddenInput 95 | end 96 | 97 | def prepare_value(value) 98 | value 99 | end 100 | 101 | def to_object(value) 102 | value 103 | end 104 | 105 | def validate(value) 106 | if required && Validators.empty_value?(value) 107 | raise ValidationError.new(error_messages[:required]) 108 | end 109 | end 110 | 111 | def run_validators(value) 112 | if Validators.empty_value?(value) 113 | return 114 | end 115 | 116 | errors = [] 117 | 118 | validators.each do |v| 119 | begin 120 | v.call(value) 121 | rescue ValidationError => e 122 | if e.code && error_messages.has_key?(e.code) 123 | message = error_messages[e.code] 124 | 125 | if e.params 126 | message = Utils.format_string(message, e.params) 127 | end 128 | 129 | errors << message 130 | else 131 | errors += e.messages 132 | end 133 | end 134 | end 135 | 136 | unless errors.empty? 137 | raise ValidationError.new(errors) 138 | end 139 | end 140 | 141 | def clean(value) 142 | value = to_object(value) 143 | validate(value) 144 | run_validators(value) 145 | value 146 | end 147 | 148 | # The data to be displayed when rendering for a bound form 149 | def bound_data(data, initial) 150 | data 151 | end 152 | 153 | # List of attributes to add on the widget. Override to add field specific attributes 154 | def widget_attrs(widget) 155 | {} 156 | end 157 | 158 | # Populates object.name if posible 159 | def populate_object(object, name, value) 160 | setter = :"#{name}=" 161 | 162 | if object.respond_to?(setter) 163 | object.send(setter, value) 164 | end 165 | end 166 | 167 | def initialize_copy(original) 168 | super(original) 169 | @initial = original.initial 170 | begin 171 | @initial = @initial.dup 172 | rescue TypeError 173 | # non-clonable 174 | end 175 | @label = original.label && original.label.dup 176 | @widget = original.widget && original.widget.dup 177 | @validators = original.validators.dup 178 | @error_messages = original.error_messages.dup 179 | end 180 | 181 | end 182 | 183 | class CharField < Field 184 | attr_accessor :max_length, :min_length 185 | 186 | def initialize(options = {}) 187 | @max_length = options.delete(:max_length) 188 | @min_length = options.delete(:min_length) 189 | super(options) 190 | 191 | if @min_length 192 | validators << Validators::MinLengthValidator.new(@min_length) 193 | end 194 | 195 | if @max_length 196 | validators << Validators::MaxLengthValidator.new(@max_length) 197 | end 198 | end 199 | 200 | def to_object(value) 201 | if Validators.empty_value?(value) 202 | '' 203 | else 204 | value 205 | end 206 | end 207 | 208 | def widget_attrs(widget) 209 | super(widget).tap do |attrs| 210 | if @max_length && (widget.kind_of?(Widgets::TextInput) || 211 | widget.kind_of?(Widgets::PasswordInput)) 212 | attrs.merge(maxlength: @max_length.to_s) 213 | end 214 | end 215 | end 216 | end 217 | 218 | class IntegerField < Field 219 | def initialize(options={}) 220 | @max_value = options.delete(:max_value) 221 | @min_value = options.delete(:min_value) 222 | super(options) 223 | 224 | if @min_value 225 | validators << Validators::MinValueValidator.new(@min_value) 226 | end 227 | 228 | if @max_value 229 | validators << Validators::MaxValueValidator.new(@max_value) 230 | end 231 | end 232 | 233 | def default_error_messages 234 | super.merge(invalid: 'Enter a whole number.', 235 | max_value: 'Ensure this value is less than or equal to %(max)s.', 236 | min_value: 'Ensure this value is greater than or equal to %(min)s.') 237 | end 238 | 239 | def to_object(value) 240 | value = super(value) 241 | 242 | if Validators.empty_value?(value) 243 | return nil 244 | end 245 | 246 | begin 247 | Integer(value.to_s) 248 | rescue ArgumentError 249 | raise ValidationError.new(error_messages[:invalid]) 250 | end 251 | end 252 | end 253 | 254 | class FloatField < IntegerField 255 | def default_error_messages 256 | super.merge(invalid: 'Enter a number.') 257 | end 258 | 259 | def to_object(value) 260 | if Validators.empty_value?(value) 261 | return nil 262 | end 263 | 264 | begin 265 | Utils.make_float(value.to_s) 266 | rescue ArgumentError 267 | raise ValidationError.new(error_messages[:invalid]) 268 | end 269 | end 270 | end 271 | 272 | class BigDecimalField < Field 273 | def initialize(options={}) 274 | @max_value = options.delete(:max_value) 275 | @min_value = options.delete(:min_value) 276 | @max_digits = options.delete(:max_digits) 277 | @max_decimal_places = options.delete(:max_decimal_places) 278 | 279 | if @max_digits && @max_decimal_places 280 | @max_whole_digits = @max_digits - @decimal_places 281 | end 282 | 283 | super(options) 284 | 285 | if @min_value 286 | validators << Validators::MinValueValidator.new(@min_value) 287 | end 288 | 289 | if @max_value 290 | validators << Validators::MaxValueValidator.new(@max_value) 291 | end 292 | end 293 | 294 | def default_error_messages 295 | super.merge(invalid: 'Enter a number.', 296 | max_value: 'Ensure this value is less than or equal to %(max)s.', 297 | min_value: 'Ensure this value is greater than or equal to %(min)s.', 298 | max_digits: 'Ensure that there are no more than %(max)s digits in total.', 299 | max_decimal_places: 'Ensure that there are no more than %(max)s decimal places.', 300 | max_whole_digits: 'Ensure that there are no more than %(max)s digits before the decimal point.') 301 | end 302 | 303 | def to_object(value) 304 | if Validators.empty_value?(value) 305 | return nil 306 | end 307 | 308 | begin 309 | Utils.make_float(value) 310 | BigDecimal.new(value) 311 | rescue ArgumentError 312 | raise ValidationError.new(error_messages[:invalid]) 313 | end 314 | end 315 | 316 | def validate(value) 317 | super(value) 318 | 319 | if Validators.empty_value?(value) 320 | return nil 321 | end 322 | 323 | if value.nan? || value.infinite? 324 | raise ValidationError.new(error_messages[:invalid]) 325 | end 326 | 327 | sign, alldigits, _, whole_digits = value.split 328 | 329 | if @max_digits && alldigits.length > @max_digits 330 | msg = Utils.format_string(error_messages[:max_digits], 331 | max: @max_digits) 332 | raise ValidationError.new(msg) 333 | end 334 | 335 | decimals = alldigits.length - whole_digits 336 | 337 | if @max_decimal_places && decimals > @max_decimal_places 338 | msg = Utils.format_string(error_messages[:max_decimal_places], 339 | max: @max_decimal_places) 340 | raise ValidationError.new(msg) 341 | end 342 | 343 | if @max_whole_digits && whole_digits > @max_whole_digits 344 | msg = Utils.format_string(error_messages[:max_whole_digits], 345 | max: @max_whole_digits) 346 | raise ValidationError.new(msg) 347 | end 348 | 349 | value 350 | end 351 | end 352 | 353 | # DateField 354 | # TimeField 355 | # DateTimeField 356 | 357 | class RegexField < CharField 358 | def initialize(regex, options={}) 359 | error_message = options.delete(:error_message) 360 | 361 | if error_message 362 | options[:error_messages] ||= {} 363 | options[:error_messages][:invalid] = error_message 364 | end 365 | 366 | super(options) 367 | 368 | @regex = regex 369 | 370 | validators << Validators::RegexValidator.new(regex: regex) 371 | end 372 | end 373 | 374 | class EmailField < CharField 375 | def default_error_messages 376 | super.merge(invalid: 'Enter a valid e-mail address.') 377 | end 378 | 379 | def default_validators 380 | [Validators::ValidateEmail] 381 | end 382 | 383 | def clean(value) 384 | value = to_object(value).strip 385 | super(value) 386 | end 387 | end 388 | 389 | # TODO: rewrite 390 | class FileField < Field 391 | def initialize(options) 392 | @max_length = options.delete(:max_length) 393 | @allow_empty_file = options.delete(:allow_empty_file) 394 | super(options) 395 | end 396 | 397 | def default_error_messages 398 | super.merge(invalid: 'No file was submitted. Check the encoding type on the form.', 399 | missing: 'No file was submitted.', 400 | empty: 'The submitted file is empty.', 401 | max_length: 'Ensure this filename has at most %(max)d characters (it has %(length)d).', 402 | contradiction: 'Please either submit a file or check the clear checkbox, not both.') 403 | end 404 | 405 | def default_widget 406 | Widgets::ClearableFileInput 407 | end 408 | 409 | def to_object(data) 410 | if Validators.empty_value?(data) 411 | return nil 412 | end 413 | 414 | # UploadedFile objects should have name and size attributes. 415 | begin 416 | file_name = data.name 417 | file_size = data.size 418 | rescue NoMethodError 419 | raise ValidationError.new(error_messages[:invalid]) 420 | end 421 | 422 | if @max_length && file_name.length > @max_length 423 | msg = Utils.format_string(error_messages[:max_length], 424 | max: @max_length, 425 | length: file_name.length) 426 | raise ValidationError.new(msg) 427 | end 428 | 429 | if Utils.blank_value?(file_name) 430 | raise ValidationError.new(error_messages[:invalid]) 431 | end 432 | 433 | if !@allow_empty_file && !file_size 434 | raise ValidationError.new(error_messages[:empty]) 435 | end 436 | 437 | data 438 | end 439 | 440 | def clean(data, initial = nil) 441 | # If the widget got contradictory inputs, we raise a validation error 442 | if data.object_id == Widgets::ClearableFileInput::FILE_INPUT_CONTRADICTION.object_id 443 | raise ValidationError.new(error_messages[:contradiction]) 444 | end 445 | 446 | # False means the field value should be cleared; further validation is 447 | # not needed. 448 | if data == false 449 | unless @required 450 | return false 451 | end 452 | 453 | # If the field is required, clearing is not possible (the widget 454 | # shouldn't return false data in that case anyway). false is not 455 | # an 'empty_value'; if a false value makes it this far 456 | # it should be validated from here on out as nil (so it will be 457 | # caught by the required check). 458 | data = nil 459 | end 460 | 461 | if !data && initial 462 | initial 463 | else 464 | super(data) 465 | end 466 | end 467 | 468 | def bound_data(data, initial) 469 | if data.nil? || data.object_id == Widgets::ClearableFileInput::FILE_INPUT_CONTRADICTION.object_id 470 | initial 471 | else 472 | data 473 | end 474 | end 475 | end 476 | 477 | #class ImageField < FileField 478 | #end 479 | 480 | # URLField 481 | 482 | class BooleanField < Field 483 | def default_widget 484 | Widgets::CheckboxInput 485 | end 486 | 487 | def to_object(value) 488 | if value.kind_of?(String) && ['false', '0'].include?(value.downcase) 489 | value = false 490 | else 491 | value = Utils.make_bool(value) 492 | end 493 | 494 | value = super(value) 495 | 496 | if !value && required 497 | raise ValidationError.new(error_messages[:required]) 498 | end 499 | 500 | value 501 | end 502 | end 503 | 504 | class NullBooleanField < BooleanField 505 | def default_widget 506 | Widgets::NullBooleanSelect 507 | end 508 | 509 | def to_object(value) 510 | case value 511 | when true, 'true', '1', 'on' then true 512 | when false, 'false', '0' then false 513 | else nil 514 | end 515 | end 516 | 517 | def validate(value) 518 | end 519 | end 520 | 521 | class ChoiceField < Field 522 | def initialize(choices=[], options={}) 523 | options[:required] = options.fetch(:required, true) 524 | super(options) 525 | self.choices = choices 526 | end 527 | 528 | def initialize_copy(original) 529 | super(original) 530 | self.choices = original.choices.dup 531 | end 532 | 533 | def default_error_messages 534 | super.merge(invalid_choice: 'Select a valid choice. %(value)s is not one of the available choices.') 535 | end 536 | 537 | def default_widget 538 | Widgets::Select 539 | end 540 | 541 | def choices 542 | @choices 543 | end 544 | 545 | def choices=(value) 546 | @choices = @widget.choices = value 547 | end 548 | 549 | def to_object(value) 550 | if Validators.empty_value?(value) 551 | '' 552 | else 553 | value.to_s 554 | end 555 | end 556 | 557 | def validate(value) 558 | super(value) 559 | 560 | unless !value || Validators.empty_value?(value) || valid_value?(value) 561 | msg = Utils.format_string(error_messages[:invalid_choice], 562 | value: value) 563 | raise ValidationError.new(msg) 564 | end 565 | end 566 | 567 | def valid_value?(value) 568 | @choices.each do |k, v| 569 | if v.is_a?(Array) 570 | # This is an optgroup, so look inside the group for options 571 | v.each do |k2, v2| 572 | return true if value == k2.to_s 573 | end 574 | elsif k.is_a?(Hash) 575 | # this is a hash valued choice list 576 | return true if value == k[:value].to_s 577 | else 578 | return true if value == k.to_s 579 | end 580 | end 581 | 582 | false 583 | end 584 | end 585 | 586 | class TypedChoiceField < ChoiceField 587 | def initialize(choices=[], options={}) 588 | @coerce = options.delete(:coerce) || lambda{|val| val} 589 | @empty_value = options.fetch(:empty_value, '') 590 | options.delete(:empty_value) 591 | super(choices, options) 592 | end 593 | 594 | def to_object(value) 595 | value = super(value) 596 | original_validate(value) 597 | 598 | if value == @empty_value || Validators.empty_value?(value) 599 | return @empty_value 600 | end 601 | 602 | begin 603 | @coerce.call(value) 604 | rescue TypeError, ValidationError 605 | msg = Utils.format_string(error_messages[:invalid_choice], 606 | value: value) 607 | raise ValidationError.new(msg) 608 | end 609 | end 610 | 611 | alias_method :original_validate, :validate 612 | 613 | def validate(value) 614 | end 615 | end 616 | 617 | class MultipleChoiceField < ChoiceField 618 | def default_error_messages 619 | super.merge(invalid_choice: 'Select a valid choice. %(value)s is not one of the available choices.', 620 | invalid_list: 'Enter a list of values.') 621 | end 622 | 623 | def default_widget 624 | Widgets::SelectMultiple 625 | end 626 | 627 | def default_hidden_widget 628 | Widgets::MultipleHiddenInput 629 | end 630 | 631 | def to_object(value) 632 | if !value || Validators.empty_value?(value) 633 | [] 634 | elsif !value.is_a?(Array) 635 | raise ValidationError.new(error_messages[:invalid_list]) 636 | else 637 | value.map(&:to_s) 638 | end 639 | end 640 | 641 | def validate(value) 642 | if required && (!value || Validators.empty_value?(value)) 643 | raise ValidationError.new(error_messages[:required]) 644 | end 645 | 646 | value.each do |val| 647 | unless valid_value?(val) 648 | msg = Utils.format_string(error_messages[:invalid_choice], 649 | value: val) 650 | raise ValidationError.new(msg) 651 | end 652 | end 653 | end 654 | end 655 | 656 | # TypedMultipleChoiceField < MultipleChoiceField 657 | 658 | # TODO: tests 659 | class ComboField < Field 660 | def initialize(fields=[], *args) 661 | super(*args) 662 | fields.each {|f| f.required = false} 663 | @fields = fields 664 | end 665 | 666 | def clean(value) 667 | super(value) 668 | @fields.each {|f| value = f.clean(value)} 669 | value 670 | end 671 | end 672 | 673 | # MultiValueField 674 | # FilePathField 675 | # SplitDateTimeField 676 | 677 | class IPAddressField < CharField 678 | def default_error_messages 679 | super.merge(invalid: 'Enter a valid IPv4 address.') 680 | end 681 | 682 | def default_validators 683 | [Validators::ValidateIPV4Address] 684 | end 685 | end 686 | 687 | class SlugField < CharField 688 | def default_error_messages 689 | super.merge(invalid: "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens.") 690 | end 691 | 692 | def default_validators 693 | [Validators::ValidateSlug] 694 | end 695 | end 696 | end 697 | end 698 | -------------------------------------------------------------------------------- /lib/bureaucrat/forms.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | module Forms 3 | 4 | # Instances of +BoundField+ represent a fields with associated data. 5 | # +BoundField+s are used internally by the +Form+ class. 6 | class BoundField 7 | include Utils 8 | 9 | # Field label text 10 | attr_accessor :label, :form, :field, :name, :html_name, :html_initial_name, :help_text 11 | 12 | # Instantiates a new +BoundField+ associated to +form+'s field +field+ 13 | # named +name+. 14 | def initialize(form, field, name) 15 | @form = form 16 | @field = field 17 | @name = name 18 | @html_name = form.add_prefix(name) 19 | @html_initial_name = form.add_initial_prefix(name) 20 | @label = @field.label || pretty_name(name) 21 | @help_text = @field.help_text || '' 22 | end 23 | 24 | # Renders the field. 25 | def to_s 26 | @field.show_hidden_initial ? as_widget + as_hidden(nil, true) : as_widget 27 | end 28 | 29 | # Errors for this field. 30 | def errors 31 | @form.errors.fetch(@name, @form.error_class.new) 32 | end 33 | 34 | # Renders this field with the option of using alternate widgets 35 | # and attributes. 36 | def as_widget(widget=nil, attrs=nil, only_initial=false) 37 | widget ||= @field.widget 38 | attrs ||= {} 39 | auto_id = self.auto_id 40 | attrs[:id] ||= auto_id if auto_id && !widget.attrs.key?(:id) 41 | 42 | if !@form.bound? 43 | data = @form.initial.fetch(@name, @field.initial) 44 | data = data.call if data.respond_to?(:call) 45 | else 46 | if @field.is_a?(Fields::FileField) && @data.nil? 47 | data = @form.initial.fetch(@name, @field.initial) 48 | else 49 | data = self.data 50 | end 51 | end 52 | 53 | name = only_initial ? @html_initial_name : @html_name 54 | widget.render(name.to_s, data, attrs) 55 | end 56 | 57 | # Renders this field as a text input. 58 | def as_text(attrs=nil, only_initial=false) 59 | as_widget(Widgets::TextInput.new, attrs, only_initial) 60 | end 61 | 62 | # Renders this field as a text area. 63 | def as_textarea(attrs=nil, only_initial=false) 64 | as_widget(Widgets::Textarea.new, attrs, only_initial) 65 | end 66 | 67 | # Renders this field as hidden. 68 | def as_hidden(attrs=nil, only_initial=false) 69 | as_widget(@field.hidden_widget, attrs, only_initial) 70 | end 71 | 72 | # The data associated to this field. 73 | def data 74 | @field.widget.value_from_formdata(@form.data, @html_name) 75 | end 76 | 77 | def value 78 | # Returns the value for this BoundField, using the initial value if 79 | # the form is not bound or the data otherwise. 80 | 81 | if form.bound? 82 | val = field.bound_data(data, form.initial.fetch(name, field.initial)) 83 | else 84 | val = form.initial.fetch(name, field.initial) 85 | if val.respond_to?(:call) 86 | val = val.call 87 | end 88 | end 89 | 90 | field.prepare_value(val) 91 | end 92 | 93 | # Renders the label tag for this field. 94 | def label_tag(contents=nil, attrs=nil) 95 | contents ||= conditional_escape(@label) 96 | widget = @field.widget 97 | id_ = widget.attrs[:id] || self.auto_id 98 | 99 | if id_ 100 | attrs = attrs ? flatatt(attrs) : '' 101 | contents = "" 102 | end 103 | 104 | mark_safe(contents) 105 | end 106 | 107 | def css_classes(extra_classes = nil) 108 | # Returns a string of space-separated CSS classes for this field. 109 | 110 | if extra_classes.respond_to?(:split) 111 | extra_classes = extra_classes.split 112 | end 113 | 114 | extra_classes = Set.new(extra_classes) 115 | 116 | if !errors.empty? && !Utils.blank_value?(form.error_css_class) 117 | extra_classes << form.error_css_class 118 | end 119 | 120 | if field.required && !Utils.blank_value?(form.required_css_class) 121 | extra_classes << form.required_css_class 122 | end 123 | 124 | extra_classes.to_a.join(' ') 125 | end 126 | 127 | # true if the widget for this field is of the hidden kind. 128 | def hidden? 129 | @field.widget.hidden? 130 | end 131 | 132 | # Generates the id for this field. 133 | def auto_id 134 | fauto_id = @form.auto_id 135 | fauto_id ? fauto_id % @html_name : '' 136 | end 137 | end 138 | 139 | # Base class for forms. Forms are a collection of fields with data that 140 | # knows how to render and validate itself. 141 | # 142 | # === Bound vs Unbound forms 143 | # A form is 'bound' if it was initialized with a set of data for its fields, 144 | # otherwise it is 'unbound'. Only bound forms can be validated. Unbound 145 | # forms always respond with false to +valid?+ and return an empty 146 | # list of errors. 147 | 148 | class Form 149 | include Utils 150 | 151 | # Fields associated to the form class 152 | def self.base_fields 153 | @base_fields ||= {} 154 | end 155 | 156 | # Declares a named field to be used on this form. 157 | def self.field(name, field_obj) 158 | base_fields[name] = field_obj 159 | end 160 | 161 | # Copy data to the child class 162 | def self.inherited(c) 163 | super(c) 164 | c.instance_variable_set(:@base_fields, base_fields.dup) 165 | end 166 | 167 | # Error object class for this form 168 | attr_accessor :error_class 169 | # Required class for this form 170 | attr_accessor :required_css_class 171 | # Required class for this form 172 | attr_accessor :error_css_class 173 | # Format string for field id generator 174 | attr_accessor :auto_id 175 | # Hash of {field_name => initial_value} 176 | attr_accessor :initial 177 | # Data associated to this form {field_name => value} 178 | attr_accessor :data 179 | # TODO: complete implementation 180 | attr_accessor :files 181 | # Validated and cleaned data 182 | attr_accessor :cleaned_data 183 | # Fields belonging to this form 184 | attr_accessor :fields 185 | 186 | # Checks if this form was initialized with data. 187 | def bound? ; @is_bound; end 188 | 189 | # Instantiates a new form bound to the passed data (or unbound if data is nil) 190 | # 191 | # +data+ is a hash of {field_name => value} for this form to be bound 192 | # (will be unbound if nil) 193 | # 194 | # Possible options are: 195 | # :prefix prefix that will be used for fields when rendered 196 | # :auto_id format string that will be used when generating 197 | # field ids (default: 'id_%s') 198 | # :initial hash of {field_name => default_value} 199 | # (doesn't make a form bound) 200 | # :error_class class used to represent errors (default: ErrorList) 201 | # :label_suffix suffix string that will be appended to labels' text 202 | # (default: ':') 203 | # :empty_permitted boolean value that specifies if this form is valid 204 | # when empty 205 | 206 | def initialize(data=nil, options={}) 207 | @is_bound = !data.nil? 208 | @data = StringAccessHash.new(data || {}) 209 | @files = options.fetch(:files, {}) 210 | @auto_id = options.fetch(:auto_id, 'id_%s') 211 | @prefix = options[:prefix] 212 | @initial = StringAccessHash.new(options.fetch(:initial, {})) 213 | @error_class = options.fetch(:error_class, Fields::ErrorList) 214 | @label_suffix = options.fetch(:label_suffix, ':') 215 | @empty_permitted = options.fetch(:empty_permitted, false) 216 | @errors = nil 217 | @changed_data = nil 218 | 219 | @fields = self.class.base_fields.dup 220 | @fields.each { |key, value| @fields[key] = value.dup } 221 | end 222 | 223 | # Iterates over the fields 224 | def each 225 | @fields.each do |name, field| 226 | yield BoundField.new(self, field, name) 227 | end 228 | end 229 | 230 | # Access a named field 231 | def [](name) 232 | field = @fields[name] or return nil 233 | BoundField.new(self, field, name) 234 | end 235 | 236 | # Errors for this forms (runs validations) 237 | def errors 238 | full_clean if @errors.nil? 239 | @errors 240 | end 241 | 242 | # Perform validation and returns true if there are no errors 243 | def valid? 244 | @is_bound && (errors.nil? || errors.empty?) 245 | end 246 | 247 | # Generates a prefix for field named +field_name+ 248 | def add_prefix(field_name) 249 | @prefix ? "#{@prefix}-#{field_name}" : field_name 250 | end 251 | 252 | # Generates an initial-prefix for field named +field_name+ 253 | def add_initial_prefix(field_name) 254 | "initial-#{add_prefix(field_name)}" 255 | end 256 | 257 | # true if the form is valid when empty 258 | def empty_permitted? 259 | @empty_permitted 260 | end 261 | 262 | # Returns the list of errors that aren't associated to a specific field 263 | def non_field_errors 264 | errors.fetch(:__NON_FIELD_ERRORS, @error_class.new) 265 | end 266 | 267 | # Runs all the validations for this form. If the form is invalid 268 | # the list of errors is populated, if it is valid, cleaned_data is 269 | # populated 270 | def full_clean 271 | @errors = Fields::ErrorHash.new 272 | 273 | return unless bound? 274 | 275 | @cleaned_data = StringAccessHash.new 276 | 277 | return if empty_permitted? && !changed? 278 | 279 | @fields.each do |name, field| 280 | value = field.widget. 281 | value_from_formdata(@data, add_prefix(name)) 282 | 283 | begin 284 | if field.is_a?(Fields::FileField) 285 | initial = @initial.fetch(name, field.initial) 286 | @cleaned_data[name] = field.clean(value, initial) 287 | else 288 | @cleaned_data[name] = field.clean(value) 289 | end 290 | 291 | clean_method = 'clean_%s' % name 292 | @cleaned_data[name] = send(clean_method) if respond_to?(clean_method) 293 | rescue ValidationError => e 294 | @errors[name] = e.messages 295 | @cleaned_data.delete(name) 296 | end 297 | end 298 | 299 | begin 300 | @cleaned_data = clean 301 | rescue ValidationError => e 302 | @errors[:__NON_FIELD_ERRORS] = e.messages 303 | end 304 | @cleaned_data = nil if @errors && !@errors.empty? 305 | end 306 | 307 | # Performs the last step of validations on the form, override in subclasses 308 | # to customize behaviour. 309 | def clean 310 | @cleaned_data 311 | end 312 | 313 | # true if the form has data that isn't equal to its initial data 314 | def changed? 315 | changed_data && !changed_data.empty? 316 | end 317 | 318 | # List names for fields that have changed data 319 | def changed_data 320 | if @changed_data.nil? 321 | @changed_data = [] 322 | 323 | @fields.each do |name, field| 324 | prefixed_name = add_prefix(name) 325 | data_value = field.widget. 326 | value_from_formdata(@data, prefixed_name) 327 | 328 | if !field.show_hidden_initial 329 | initial_value = @initial.fetch(name, field.initial) 330 | else 331 | initial_prefixed_name = add_initial_prefix(name) 332 | hidden_widget = field.hidden_widget.new 333 | initial_value = hidden_widget. 334 | value_from_formdata(@data, initial_prefixed_name) 335 | end 336 | 337 | @changed_data << name if 338 | field.widget.has_changed?(initial_value, data_value) 339 | end 340 | end 341 | 342 | @changed_data 343 | end 344 | 345 | # true if this form contains fields that require the form to be 346 | # multipart 347 | def multipart? 348 | @fields.any? {|f| f.widget.multipart_form?} 349 | end 350 | 351 | # List of hidden fields. 352 | def hidden_fields 353 | @fields.select {|f| f.hidden?} 354 | end 355 | 356 | # List of visible fields 357 | def visible_fields 358 | @fields.select {|f| !f.hidden?} 359 | end 360 | 361 | # Attributes for labels, override in subclasses to customize behaviour 362 | def label_attributes(name, field) 363 | {} 364 | end 365 | 366 | # Populates the passed object's attributes with data from the fields 367 | def populate_object(object) 368 | @fields.each do |name, field| 369 | field.populate_object(object, name, @cleaned_data[name]) 370 | end 371 | end 372 | 373 | private 374 | 375 | # Returns the value for the field name +field_name+ from the associated 376 | # data 377 | def raw_value(fieldname) 378 | field = @fields.fetch(fieldname) 379 | prefix = add_prefix(fieldname) 380 | field.widget.value_from_formdata(@data, prefix) 381 | end 382 | 383 | end 384 | end 385 | end 386 | -------------------------------------------------------------------------------- /lib/bureaucrat/formsets.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | module Formsets 3 | TOTAL_FORM_COUNT = :'TOTAL_FORMS' 4 | INITIAL_FORM_COUNT = :'INITIAL_FORMS' 5 | MAX_NUM_FORM_COUNT = :'MAX_NUM_FORMS' 6 | ORDERING_FIELD_NAME = :'ORDER' 7 | DELETION_FIELD_NAME = :'DELETE' 8 | 9 | class ManagementForm < Forms::Form 10 | include Fields 11 | include Widgets 12 | 13 | field TOTAL_FORM_COUNT, IntegerField.new(widget: HiddenInput) 14 | field INITIAL_FORM_COUNT, IntegerField.new(widget: HiddenInput) 15 | field MAX_NUM_FORM_COUNT, IntegerField.new(widget: HiddenInput, 16 | required: false) 17 | end 18 | 19 | class BaseFormSet 20 | include Utils 21 | include Fields 22 | include Forms 23 | 24 | def self.default_prefix 25 | 'form' 26 | end 27 | 28 | # All forms in this formset 29 | attr_accessor :forms 30 | # Form class used for this formset forms 31 | attr_accessor :form 32 | # Amount of extra (empty) forms on this formset 33 | attr_accessor :extra 34 | # Boolean that determines is this formset supports reordering 35 | attr_accessor :can_order 36 | # Boolean that determines is this formset supports deletion 37 | attr_accessor :can_delete 38 | # Maximum count of forms allowed on this formset 39 | attr_accessor :max_num 40 | # Prefix that will be used when rendering form fields 41 | attr_accessor :prefix 42 | # Error object class for this formset 43 | attr_accessor :error_class 44 | # Initial data for the forms in this formset. Array of Hashes of {field_name => initial_value} 45 | attr_accessor :initial 46 | # Data associated to this formset. Hash of {prefixed_field_name => value} 47 | attr_accessor :data 48 | # Format string for field id generator 49 | attr_accessor :auto_id 50 | 51 | def initialize(data=nil, options={}) 52 | set_defaults 53 | @is_bound = !data.nil? 54 | @prefix = options.fetch(:prefix, self.class.default_prefix) 55 | @auto_id = options.fetch(:auto_id, 'id_%s') 56 | @data = data || {} 57 | @initial = options[:initial] 58 | @error_class = options.fetch(:error_class, ErrorList) 59 | @errors = nil 60 | @non_form_errors = nil 61 | 62 | construct_forms 63 | end 64 | 65 | def each(&block) 66 | forms.each(&block) 67 | end 68 | 69 | def [](index) 70 | forms[index] 71 | end 72 | 73 | def length 74 | forms.length 75 | end 76 | 77 | def management_form 78 | if @is_bound 79 | form = ManagementForm.new(@data, auto_id: @auto_id, 80 | prefix: @prefix) 81 | unless form.valid? 82 | msg = 'ManagementForm data is missing or has been tampered with' 83 | raise ValidationError.new(msg) 84 | end 85 | else 86 | form = ManagementForm.new(nil, auto_id: @auto_id, 87 | prefix: @prefix, 88 | initial: { 89 | TOTAL_FORM_COUNT => total_form_count, 90 | INITIAL_FORM_COUNT => initial_form_count, 91 | MAX_NUM_FORM_COUNT => self.max_num 92 | }) 93 | end 94 | form 95 | end 96 | 97 | def total_form_count 98 | if @is_bound 99 | management_form.cleaned_data[TOTAL_FORM_COUNT] 100 | else 101 | initial_forms = initial_form_count 102 | total_forms = initial_form_count + self.extra 103 | 104 | # Allow all existing related objects/inlines to be displayed, 105 | # but don't allow extra beyond max_num. 106 | if self.max_num > 0 && initial_forms > self.max_num 107 | initial_forms 108 | elsif self.max_num > 0 && total_forms > self.max_num 109 | max_num 110 | else 111 | total_forms 112 | end 113 | end 114 | end 115 | 116 | def initial_form_count 117 | if @is_bound 118 | management_form.cleaned_data[INITIAL_FORM_COUNT] 119 | else 120 | n = @initial ? @initial.length : 0 121 | 122 | (self.max_num > 0 && n > self.max_num) ? self.max_num : n 123 | end 124 | end 125 | 126 | def construct_forms 127 | @forms = (0...total_form_count).map { |i| construct_form(i) } 128 | end 129 | 130 | def construct_form(i, options={}) 131 | defaults = {auto_id: @auto_id, prefix: add_prefix(i)} 132 | defaults[:initial] = @initial[i] if @initial && @initial[i] 133 | 134 | # Allow extra forms to be empty. 135 | defaults[:empty_permitted] = true if i >= initial_form_count 136 | defaults.merge!(options) 137 | form_data = @is_bound ? @data : nil 138 | form = self.form.new(form_data, defaults) 139 | add_fields(form, i) 140 | form 141 | end 142 | 143 | def initial_forms 144 | @forms[0, initial_form_count] 145 | end 146 | 147 | def extra_forms 148 | n = initial_form_count 149 | @forms[n, @forms.length - n] 150 | end 151 | 152 | # Maybe this should just go away? 153 | def cleaned_data 154 | unless valid? 155 | raise NoMethodError.new("'#{self.class.name}' object has no method 'cleaned_data'") 156 | end 157 | @forms.collect(&:cleaned_data) 158 | end 159 | 160 | def deleted_forms 161 | unless valid? && self.can_delete 162 | raise NoMethodError.new("'#{self.class.name}' object has no method 'deleted_forms'") 163 | end 164 | 165 | if @deleted_form_indexes.nil? 166 | @deleted_form_indexes = (0...total_form_count).select do |i| 167 | form = @forms[i] 168 | 169 | if i >= initial_form_count && !form.changed? 170 | false 171 | else 172 | should_delete_form?(form) 173 | end 174 | end 175 | end 176 | 177 | @deleted_form_indexes.map {|i| @forms[i]} 178 | end 179 | 180 | def ordered_forms 181 | unless valid? && self.can_order 182 | raise NoMethodError.new("'#{self.class.name}' object has no method 'ordered_forms'") 183 | end 184 | 185 | if @ordering.nil? 186 | @ordering = (0...total_form_count).map do |i| 187 | form = @forms[i] 188 | next if i >= initial_form_count && !form.changed? 189 | next if self.can_delete && should_delete_form?(form) 190 | [i, form.cleaned_data[ORDERING_FIELD_NAME]] 191 | end.compact 192 | @ordering.sort! do |a, b| 193 | if x[1].nil? then 1 194 | elsif y[1].nil? then -1 195 | else x[1] - y[1] 196 | end 197 | end 198 | end 199 | 200 | @ordering.map {|i| @forms[i.first]} 201 | end 202 | 203 | def non_form_errors 204 | @non_form_errors || @error_class.new 205 | end 206 | 207 | def errors 208 | full_clean if @errors.nil? 209 | @errors 210 | end 211 | 212 | def should_delete_form?(form) 213 | field = form.fields[DELETION_FIELD_NAME] 214 | raw_value = form.send(:raw_value, DELETION_FIELD_NAME) 215 | field.clean(raw_value) 216 | end 217 | 218 | def valid? 219 | return false unless @is_bound 220 | 221 | forms_valid = true 222 | 223 | (0...total_form_count).each do |i| 224 | form = @forms[i] 225 | next if self.can_delete && should_delete_form?(form) 226 | 227 | forms_valid = false unless errors[i].empty? 228 | end 229 | 230 | forms_valid && non_form_errors.empty? 231 | end 232 | 233 | def full_clean 234 | if @is_bound 235 | @errors = @forms.collect(&:errors) 236 | 237 | begin 238 | self.clean 239 | rescue ValidationError => e 240 | @non_form_errors = @error_class.new(e.messages) 241 | end 242 | else 243 | @errors = [] 244 | end 245 | end 246 | 247 | def clean 248 | end 249 | 250 | def add_fields(form, index) 251 | if can_order 252 | attrs = {label: 'Order', required: false} 253 | attrs[:initial] = index + 1 if index && index < initial_form_count 254 | form.fields[ORDERING_FIELD_NAME] = IntegerField.new(attrs) 255 | end 256 | if can_delete 257 | field = BooleanField.new(label: 'Delete', required: false) 258 | form.fields[DELETION_FIELD_NAME] = field 259 | end 260 | end 261 | 262 | def add_prefix(index) 263 | '%s-%s' % [@prefix, index] 264 | end 265 | 266 | def multipart? 267 | @forms && @forms.first.multipart? 268 | end 269 | end 270 | 271 | module_function 272 | 273 | def make_formset_class(form, options={}) 274 | formset = options.fetch(:formset, BaseFormSet) 275 | 276 | Class.new(formset) do 277 | define_method :set_defaults do 278 | @form = form 279 | @extra = options.fetch(:extra, 1) 280 | @can_order = options.fetch(:can_order, false) 281 | @can_delete = options.fetch(:can_delete, false) 282 | @max_num = options.fetch(:max_num, 0) 283 | end 284 | private :set_defaults 285 | end 286 | end 287 | 288 | def all_valid?(formsets) 289 | valid = true 290 | formsets.each do |formset| 291 | valid = false unless formset.valid? 292 | end 293 | valid 294 | end 295 | 296 | end 297 | end 298 | -------------------------------------------------------------------------------- /lib/bureaucrat/quickfields.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | # Shortcuts for declaring form fields 3 | module Quickfields 4 | include Fields 5 | 6 | # Hide field named +name+ 7 | def hide(name) 8 | base_fields[name] = base_fields[name].dup 9 | base_fields[name].widget = Widgets::HiddenInput.new 10 | end 11 | 12 | # Delete field named +name+ 13 | def delete(name) 14 | base_fields.delete name 15 | end 16 | 17 | # Declare a +CharField+ with text input widget 18 | def string(name, options = {}) 19 | field name, CharField.new(options) 20 | end 21 | 22 | # Declare a +CharField+ with text area widget 23 | def text(name, options = {}) 24 | field name, CharField.new(options.merge(widget: Widgets::Textarea.new)) 25 | end 26 | 27 | # Declare a +CharField+ with password widget 28 | def password(name, options = {}) 29 | field name, CharField.new(options.merge(widget: Widgets::PasswordInput.new)) 30 | end 31 | 32 | # Declare an +IntegerField+ 33 | def integer(name, options = {}) 34 | field name, IntegerField.new(options) 35 | end 36 | 37 | # Declare a +BigDecimalField+ 38 | def decimal(name, options = {}) 39 | field name, BigDecimalField.new(options) 40 | end 41 | 42 | # Declare a +RegexField+ 43 | def regex(name, regexp, options = {}) 44 | field name, RegexField.new(regexp, options) 45 | end 46 | 47 | # Declare an +EmailField+ 48 | def email(name, options = {}) 49 | field name, EmailField.new(options) 50 | end 51 | 52 | # Declare a +FileField+ 53 | def file(name, options = {}) 54 | field name, FileField.new(options) 55 | end 56 | 57 | # Declare a +BooleanField+ 58 | def boolean(name, options = {}) 59 | field name, BooleanField.new(options) 60 | end 61 | 62 | # Declare a +NullBooleanField+ 63 | def null_boolean(name, options = {}) 64 | field name, NullBooleanField.new(options) 65 | end 66 | 67 | # Declare a +ChoiceField+ with +choices+ 68 | def choice(name, choices = [], options = {}) 69 | field name, ChoiceField.new(choices, options) 70 | end 71 | 72 | # Declare a +TypedChoiceField+ with +choices+ 73 | def typed_choice(name, choices = [], options = {}) 74 | field name, TypedChoiceField.new(choices, options) 75 | end 76 | 77 | # Declare a +MultipleChoiceField+ with +choices+ 78 | def multiple_choice(name, choices = [], options = {}) 79 | field name, MultipleChoiceField.new(choices, options) 80 | end 81 | 82 | # Declare a +ChoiceField+ using the +RadioSelect+ widget 83 | def radio_choice(name, choices = [], options = {}) 84 | field name, ChoiceField.new(choices, options.merge(widget: Widgets::RadioSelect.new)) 85 | end 86 | 87 | # Declare a +MultipleChoiceField+ with the +CheckboxSelectMultiple+ widget 88 | def checkbox_multiple_choice(name, choices = [], options = {}) 89 | field name, MultipleChoiceField.new(choices, options.merge(widget: Widgets::CheckboxSelectMultiple.new)) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/bureaucrat/temporary_uploaded_file.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | class TemporaryUploadedFile 3 | attr_accessor :filename, :content_type, :name, :tempfile, :head, :size 4 | 5 | def initialize(data) 6 | @filename = data[:filename] 7 | @content_type = data[:content_type] 8 | @name = data[:name] 9 | @tempfile = data[:tempfile] 10 | @size = @tempfile.size 11 | @head = data[:head] 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/bureaucrat/utils.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | module Utils 3 | extend self 4 | 5 | module SafeData 6 | end 7 | 8 | class SafeString < String 9 | include SafeData 10 | 11 | def +(rhs) 12 | rhs.is_a?(SafeString) ? SafeString.new(super(rhs)) : super(rhs) 13 | end 14 | end 15 | 16 | class StringAccessHash < Hash 17 | def initialize(other = {}) 18 | super() 19 | update(other) 20 | end 21 | 22 | def []=(key, value) 23 | super(key.to_s, value) 24 | end 25 | 26 | def [](key) 27 | super(key.to_s) 28 | end 29 | 30 | def fetch(key, *args) 31 | super(key.to_s, *args) 32 | end 33 | 34 | def include?(key) 35 | super(key.to_s) 36 | end 37 | 38 | def update(other) 39 | other.each_pair{|k, v| self[k] = v} 40 | self 41 | end 42 | 43 | def merge(other) 44 | dup.update(other) 45 | end 46 | 47 | def delete(key) 48 | super(key.to_s) 49 | end 50 | end 51 | 52 | def blank_value?(value) 53 | !value || value == '' 54 | end 55 | 56 | def mark_safe(s) 57 | s.is_a?(SafeData) ? s : SafeString.new(s.to_s) 58 | end 59 | 60 | ESCAPES = { 61 | '&' => '&', 62 | '<' => '<', 63 | '>' => '>', 64 | '"' => '"', 65 | "'" => ''' 66 | } 67 | def escape(html) 68 | mark_safe(html.gsub(/[&<>"']/) {|match| ESCAPES[match]}) 69 | end 70 | 71 | def conditional_escape(html) 72 | html.is_a?(SafeData) ? html : escape(html) 73 | end 74 | 75 | def flatatt(attrs) 76 | attrs.map {|k, v| " #{k}=\"#{conditional_escape(v)}\""}.join('') 77 | end 78 | 79 | def format_string(string, values) 80 | output = string.dup 81 | values.each_pair do |variable, value| 82 | output.gsub!(/%\(#{variable}\)s/, value.to_s) 83 | end 84 | output 85 | end 86 | 87 | def pretty_name(name) 88 | name.to_s.capitalize.gsub(/_/, ' ') 89 | end 90 | 91 | def make_float(value) 92 | value += '0' if value.is_a?(String) && value != '.' && value[-1,1] == '.' 93 | Float(value) 94 | end 95 | 96 | def make_bool(value) 97 | !(value.respond_to?(:empty?) ? value.empty? : [0, nil, false].include?(value)) 98 | end 99 | 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/bureaucrat/validators.rb: -------------------------------------------------------------------------------- 1 | module Bureaucrat 2 | module Validators 3 | def empty_value?(value) 4 | value.nil? || value == '' || value == [] || value == {} 5 | end 6 | module_function :empty_value? 7 | 8 | class RegexValidator 9 | attr_accessor :regex, :message, :code 10 | 11 | def initialize(options = {}) 12 | @regex = Regexp.new(options.fetch(:regex, '')) 13 | @message = options.fetch(:message, 'Enter a valid value.') 14 | @code = options.fetch(:code, :invalid) 15 | end 16 | 17 | # Validates that the input validates the regular expression 18 | def call(value) 19 | if regex !~ value 20 | raise ValidationError.new(@message, code, regex: regex) 21 | end 22 | end 23 | end 24 | 25 | ValidateInteger = lambda do |value| 26 | begin 27 | Integer(value) 28 | rescue ArgumentError 29 | raise ValidationError.new('') 30 | end 31 | end 32 | 33 | # Original from Django's EmailField: 34 | # email_re = re.compile( 35 | # r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom 36 | # r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"' # quoted-string 37 | # r')@(?:[A-Z0-9]+(?:-*[A-Z0-9]+)*\.)+[A-Z]{2,6}$', re.IGNORECASE) # domain 38 | EMAIL_RE = / 39 | (^[-!#\$%&'*+\/=?^_`{}|~0-9A-Z]+(\.[-!#\$%&'*+\/=?^_`{}|~0-9A-Z]+)* 40 | |^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*" 41 | )@(?:[A-Z0-9]+(?:-*[A-Z0-9]+)*\.)+[A-Z]{2,6}$ 42 | /xi 43 | 44 | ValidateEmail = 45 | RegexValidator.new(regex: EMAIL_RE, 46 | message: 'Enter a valid e-mail address.') 47 | 48 | SLUG_RE = /^[-\w]+$/ 49 | 50 | ValidateSlug = 51 | RegexValidator.new(regex: SLUG_RE, 52 | message: "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens.") 53 | 54 | IPV4_RE = /^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$/ 55 | 56 | IPV4Validator = 57 | RegexValidator.new(regex: IPV4_RE, 58 | message: 'Enter a valid IPv4 address.') 59 | 60 | COMMA_SEPARATED_INT_LIST_RE = /^[\d,]+$/ 61 | 62 | ValidateCommaSeparatedIntegerList = 63 | RegexValidator.new(regex: COMMA_SEPARATED_INT_LIST_RE, 64 | message: 'Enter only digits separated by commas.', 65 | code: :invalid) 66 | 67 | class BaseValidator 68 | def initialize(limit_value) 69 | @limit_value = limit_value 70 | end 71 | 72 | def message 73 | 'Ensure this value is %(limit_value)s (it is %(show_value)s).' 74 | end 75 | 76 | def code 77 | :limit_value 78 | end 79 | 80 | def compare(a, b) 81 | a.object_id != b.object_id 82 | end 83 | 84 | def clean(x) 85 | x 86 | end 87 | 88 | def call(value) 89 | cleaned = clean(value) 90 | params = { limit_value: @limit_value, show_value: cleaned } 91 | 92 | if compare(cleaned, @limit_value) 93 | msg = Utils.format_string(message, params) 94 | raise ValidationError.new(msg, code, params) 95 | end 96 | end 97 | end 98 | 99 | class MaxValueValidator < BaseValidator 100 | def message 101 | 'Ensure this value is less than or equal to %(limit_value)s.' 102 | end 103 | 104 | def code 105 | :max_value 106 | end 107 | 108 | def compare(a, b) 109 | a > b 110 | end 111 | end 112 | 113 | class MinValueValidator < BaseValidator 114 | def message 115 | 'Ensure this value is greater than or equal to %(limit_value)s.' 116 | end 117 | 118 | def code 119 | :min_value 120 | end 121 | 122 | def compare(a, b) 123 | a < b 124 | end 125 | end 126 | 127 | class MinLengthValidator < BaseValidator 128 | def message 129 | 'Ensure this value has at least %(limit_value)d characters (it has %(show_value)d).' 130 | end 131 | 132 | def code 133 | :min_length 134 | end 135 | 136 | def compare(a, b) 137 | a < b 138 | end 139 | 140 | def clean(x) 141 | x.length 142 | end 143 | end 144 | 145 | class MaxLengthValidator < BaseValidator 146 | def message 147 | 'Ensure this value has at most %(limit_value)d characters (it has %(show_value)d).' 148 | end 149 | 150 | def code 151 | :max_length 152 | end 153 | 154 | def compare(a, b) 155 | a > b 156 | end 157 | 158 | def clean(x) 159 | x.length 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/bureaucrat/widgets.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module Bureaucrat 4 | module Widgets 5 | # Base class for widgets 6 | class Widget 7 | include Utils 8 | 9 | attr_accessor :is_required 10 | attr_reader :attrs 11 | 12 | def initialize(attrs = nil) 13 | @attrs = attrs.nil? ? {} : attrs.dup 14 | end 15 | 16 | def initialize_copy(original) 17 | super(original) 18 | @attrs = original.attrs.dup 19 | end 20 | 21 | def render(name, value, attrs = nil) 22 | raise NotImplementedError 23 | end 24 | 25 | def build_attrs(extra_attrs = nil, options = {}) 26 | attrs = @attrs.merge(options) 27 | attrs.update(extra_attrs) if extra_attrs 28 | attrs 29 | end 30 | 31 | def value_from_formdata(data, name) 32 | data[name] 33 | end 34 | 35 | def self.id_for_label(id_) 36 | id_ 37 | end 38 | 39 | def has_changed?(initial, data) 40 | data_value = data || '' 41 | initial_value = initial || '' 42 | initial_value != data_value 43 | end 44 | 45 | def needs_multipart? 46 | false 47 | end 48 | 49 | def hidden? 50 | false 51 | end 52 | end 53 | 54 | # Base class for input widgets 55 | class Input < Widget 56 | def render(name, value, attrs=nil) 57 | value ||= '' 58 | final_attrs = build_attrs(attrs, 59 | type: input_type.to_s, 60 | name: name.to_s) 61 | final_attrs[:value] = value.to_s unless value == '' 62 | mark_safe("") 63 | end 64 | 65 | def input_type 66 | nil 67 | end 68 | end 69 | 70 | # Class for text inputs 71 | class TextInput < Input 72 | def input_type 73 | 'text' 74 | end 75 | end 76 | 77 | # Class for password inputs 78 | class PasswordInput < Input 79 | def initialize(attrs = nil, render_value = false) 80 | super(attrs) 81 | @render_value = render_value 82 | end 83 | 84 | def input_type 85 | 'password' 86 | end 87 | 88 | def render(name, value, attrs=nil) 89 | value = nil unless @render_value 90 | super(name, value, attrs) 91 | end 92 | end 93 | 94 | # Class for hidden inputs 95 | class HiddenInput < Input 96 | def input_type 97 | 'hidden' 98 | end 99 | 100 | def hidden? 101 | true 102 | end 103 | end 104 | 105 | class MultipleHiddenInput < HiddenInput 106 | # Used by choice fields 107 | attr_accessor :choices 108 | 109 | def initialize(attrs=nil, choices=[]) 110 | super(attrs) 111 | # choices can be any enumerable 112 | @choices = choices 113 | end 114 | 115 | def render(name, value, attrs=nil, choices=[]) 116 | value ||= [] 117 | final_attrs = build_attrs(attrs, type: input_type.to_s, 118 | name: "#{name}[]") 119 | 120 | id = final_attrs[:id] 121 | inputs = [] 122 | 123 | value.each.with_index do |v, i| 124 | input_attrs = final_attrs.merge(value: v.to_s) 125 | 126 | if id 127 | input_attrs[:id] = "#{id}_#{i}" 128 | end 129 | 130 | inputs << "" 131 | end 132 | 133 | mark_safe(inputs.join("\n")) 134 | end 135 | end 136 | 137 | class FileInput < Input 138 | def render(name, value, attrs=nil) 139 | super(name, nil, attrs) 140 | end 141 | 142 | def value_from_formdata(data, name) 143 | data[name] && TemporaryUploadedFile.new(data[name]) 144 | end 145 | 146 | def has_changed?(initial, data) 147 | data.nil? 148 | end 149 | 150 | def input_type 151 | 'file' 152 | end 153 | 154 | def needs_multipart? 155 | true 156 | end 157 | end 158 | 159 | class ClearableFileInput < FileInput 160 | FILE_INPUT_CONTRADICTION = Object.new 161 | 162 | def initial_text 163 | 'Currently' 164 | end 165 | 166 | def input_text 167 | 'Change' 168 | end 169 | 170 | def clear_checkbox_label 171 | 'Clear' 172 | end 173 | 174 | def template_with_initial 175 | '%(initial_text)s: %(initial)s %(clear_template)s
    %(input_text)s: %(input)s' 176 | end 177 | 178 | def template_with_clear 179 | '%(clear)s ' 180 | end 181 | 182 | def clear_checkbox_name(name) 183 | "#{name}-clear" 184 | end 185 | 186 | def clear_checkbox_id(checkbox_name) 187 | "#{checkbox_name}_id" 188 | end 189 | 190 | def render(name, value, attrs = nil) 191 | substitutions = { 192 | initial_text: initial_text, 193 | input_text: input_text, 194 | clear_template: '', 195 | clear_checkbox_label: clear_checkbox_label 196 | } 197 | template = '%(input)s' 198 | substitutions[:input] = super(name, value, attrs) 199 | 200 | if value && value.respond_to?(:url) && value.url 201 | template = template_with_initial 202 | substitutions[:initial] = '%s' % [escape(value.url), 203 | escape(value.to_s)] 204 | unless is_required 205 | checkbox_name = clear_checkbox_name(name) 206 | checkbox_id = clear_checkbox_id(checkbox_name) 207 | substitutions[:clear_checkbox_name] = conditional_escape(checkbox_name) 208 | substitutions[:clear_checkbox_id] = conditional_escape(checkbox_id) 209 | substitutions[:clear] = CheckboxInput.new. 210 | render(checkbox_name, false, {id: checkbox_id}) 211 | substitutions[:clear_template] = 212 | Utils.format_string(template_with_clear, substitutions) 213 | end 214 | end 215 | 216 | mark_safe(Utils.format_string(template, substitutions)) 217 | end 218 | 219 | def value_from_formdata(data, name) 220 | upload = super(data, name) 221 | checked = CheckboxInput.new. 222 | value_from_formdata(data, clear_checkbox_name(name)) 223 | 224 | if !is_required && checked 225 | if upload 226 | # If the user contradicts themselves (uploads a new file AND 227 | # checks the "clear" checkbox), we return a unique marker 228 | # object that FileField will turn into a ValidationError. 229 | FILE_INPUT_CONTRADICTION 230 | else 231 | # False signals to clear any existing value, as opposed to just None 232 | false 233 | end 234 | else 235 | upload 236 | end 237 | end 238 | end 239 | 240 | class Textarea < Widget 241 | def initialize(attrs=nil) 242 | # The 'rows' and 'cols' attributes are required for HTML correctness. 243 | default_attrs = {cols: '40', rows: '10'} 244 | default_attrs.merge!(attrs) if attrs 245 | 246 | super(default_attrs) 247 | end 248 | 249 | def render(name, value, attrs=nil) 250 | value ||= '' 251 | final_attrs = build_attrs(attrs, name: name) 252 | mark_safe("#{conditional_escape(value.to_s)}") 253 | end 254 | end 255 | 256 | # DateInput 257 | # DateTimeInput 258 | # TimeInput 259 | 260 | class CheckboxInput < Widget 261 | def initialize(attrs=nil, check_test=nil) 262 | super(attrs) 263 | @check_test = check_test || lambda {|v| make_bool(v)} 264 | end 265 | 266 | def render(name, value, attrs=nil) 267 | final_attrs = build_attrs(attrs, type: 'checkbox', name: name.to_s) 268 | 269 | # FIXME: this is horrible, shouldn't just rescue everything 270 | result = @check_test.call(value) rescue false 271 | 272 | if result 273 | final_attrs[:checked] = 'checked' 274 | end 275 | 276 | unless ['', true, false, nil].include?(value) 277 | final_attrs[:value] = value.to_s 278 | end 279 | 280 | mark_safe("") 281 | end 282 | 283 | def value_from_formdata(data, name) 284 | if data.include?(name) 285 | value = data[name] 286 | 287 | if value.is_a?(String) 288 | case value.downcase 289 | when 'true' then true 290 | when 'false' then false 291 | else value 292 | end 293 | else 294 | value 295 | end 296 | else 297 | false 298 | end 299 | end 300 | 301 | def has_changed(initial, data) 302 | make_bool(initial) != make_bool(data) 303 | end 304 | end 305 | 306 | class Select < Widget 307 | attr_accessor :choices 308 | 309 | def initialize(attrs=nil, choices=[]) 310 | super(attrs) 311 | @choices = choices.collect 312 | end 313 | 314 | def render(name, value, attrs=nil, choices=[]) 315 | value = '' if value.nil? 316 | final_attrs = build_attrs(attrs, name: name) 317 | output = [""] 318 | options = render_options(choices, [value]) 319 | output << options if options && !options.empty? 320 | output << '' 321 | mark_safe(output.join("\n")) 322 | end 323 | 324 | def render_options(choices, selected_choices) 325 | selected_choices = selected_choices.map(&:to_s).uniq 326 | output = [] 327 | (@choices.to_a + choices.to_a).each do |option_value, option_label| 328 | option_label ||= option_value 329 | if option_label.is_a?(Array) 330 | output << '' % escape(option_value.to_s) 331 | option_label.each do |option| 332 | val, label = option 333 | output << render_option(val, label, selected_choices) 334 | end 335 | output << '' 336 | else 337 | output << render_option(option_value, option_label, 338 | selected_choices) 339 | end 340 | end 341 | output.join("\n") 342 | end 343 | 344 | def render_option(option_attributes, option_label, selected_choices) 345 | unless option_attributes.is_a?(Hash) 346 | option_attributes = { value: option_attributes.to_s } 347 | end 348 | 349 | if selected_choices.include?(option_attributes[:value]) 350 | option_attributes[:selected] = "selected" 351 | end 352 | 353 | attributes = [] 354 | 355 | option_attributes.each_pair do |attr_name, attr_value| 356 | attributes << %Q[#{attr_name.to_s}="#{escape(attr_value.to_s)}"] 357 | end 358 | 359 | "" 360 | end 361 | end 362 | 363 | class NullBooleanSelect < Select 364 | def initialize(attrs=nil) 365 | choices = [['1', 'Unknown'], ['2', 'Yes'], ['3', 'No']] 366 | super(attrs, choices) 367 | end 368 | 369 | def render(name, value, attrs=nil, choices=[]) 370 | value = case value 371 | when true, '2' then '2' 372 | when false, '3' then '3' 373 | else '1' 374 | end 375 | super(name, value, attrs, choices) 376 | end 377 | 378 | def value_from_formdata(data, name) 379 | case data[name] 380 | when '2', true, 'true' then true 381 | when '3', false, 'false' then false 382 | else nil 383 | end 384 | end 385 | 386 | def has_changed?(initial, data) 387 | unless initial.nil? 388 | initial = make_bool(initial) 389 | end 390 | 391 | unless data.nil? 392 | data = make_bool(data) 393 | end 394 | 395 | initial != data 396 | end 397 | end 398 | 399 | class SelectMultiple < Select 400 | def render(name, value, attrs=nil, choices=[]) 401 | value = [] if value.nil? 402 | final_attrs = build_attrs(attrs, name: "#{name}[]") 403 | output = ["' 407 | mark_safe(output.join("\n")) 408 | end 409 | 410 | def has_changed?(initial, data) 411 | initial = [] if initial.nil? 412 | data = [] if data.nil? 413 | 414 | if initial.length != data.length 415 | return true 416 | end 417 | 418 | Set.new(initial.map(&:to_s)) != Set.new(data.map(&:to_s)) 419 | end 420 | end 421 | 422 | class RadioInput 423 | include Utils 424 | 425 | def initialize(name, value, attrs, choice, index) 426 | @name = name 427 | @value = value 428 | @attrs = attrs 429 | @choice_value = choice[0].to_s 430 | @choice_label = choice[1].to_s 431 | @index = index 432 | end 433 | 434 | def to_s 435 | label_for = @attrs.include?(:id) ? " for=\"#{@attrs[:id]}_#{@index}\"" : '' 436 | choice_label = conditional_escape(@choice_label.to_s) 437 | mark_safe("#{tag} #{choice_label}") 438 | end 439 | 440 | def checked? 441 | @value == @choice_value 442 | end 443 | 444 | def tag 445 | @attrs[:id] = "#{@attrs[:id]}_#{@index}" if @attrs.include?(:id) 446 | final_attrs = @attrs.merge(type: 'radio', name: @name, 447 | value: @choice_value) 448 | final_attrs[:checked] = 'checked' if checked? 449 | mark_safe("") 450 | end 451 | end 452 | 453 | class RadioFieldRenderer 454 | include Utils 455 | 456 | def initialize(name, value, attrs, choices) 457 | @name = name 458 | @value = value 459 | @attrs = attrs 460 | @choices = choices 461 | end 462 | 463 | def each 464 | @choices.each_with_index do |choice, i| 465 | yield RadioInput.new(@name, @value, @attrs.dup, choice, i) 466 | end 467 | end 468 | 469 | def [](idx) 470 | choice = @choices[idx] 471 | RadioInput.new(@name, @value, @attrs.dup, choice, idx) 472 | end 473 | 474 | def to_s 475 | render 476 | end 477 | 478 | def render 479 | list = [] 480 | each {|radio| list << "
  • #{radio}
  • "} 481 | mark_safe("
      \n#{list.join("\n")}\n
    ") 482 | end 483 | end 484 | 485 | class RadioSelect < Select 486 | def self.id_for_label(id_) 487 | id_.empty? ? id_ : id_ + '_0' 488 | end 489 | 490 | def renderer 491 | RadioFieldRenderer 492 | end 493 | 494 | def initialize(*args) 495 | options = args.last.is_a?(Hash) ? args.last : {} 496 | @renderer = options.fetch(:renderer, renderer) 497 | super 498 | end 499 | 500 | def get_renderer(name, value, attrs=nil, choices=[]) 501 | value ||= '' 502 | str_value = value.to_s 503 | final_attrs = build_attrs(attrs) 504 | choices = @choices.to_a + choices.to_a 505 | @renderer.new(name, str_value, final_attrs, choices) 506 | end 507 | 508 | def render(name, value, attrs=nil, choices=[]) 509 | get_renderer(name, value, attrs, choices).render 510 | end 511 | end 512 | 513 | class CheckboxSelectMultiple < SelectMultiple 514 | def self.id_for_label(id_) 515 | id_.empty? ? id_ : id_ + '_0' 516 | end 517 | 518 | def render(name, values, attrs=nil, choices=[]) 519 | values ||= [] 520 | multi_name = "#{name}[]" 521 | has_id = attrs && attrs.include?(:id) 522 | final_attrs = build_attrs(attrs, name: multi_name) 523 | output = ['
      '] 524 | str_values = {} 525 | values.each {|val| str_values[(val.to_s)] = true} 526 | 527 | (@choices.to_a + choices.to_a).each_with_index do |opt_pair, i| 528 | opt_val, opt_label = opt_pair 529 | if has_id 530 | final_attrs[:id] = "#{attrs[:id]}_#{i}" 531 | label_for = " for=\"#{final_attrs[:id]}\"" 532 | else 533 | label_for = '' 534 | end 535 | 536 | check_test = lambda{|value| str_values[value]} 537 | cb = CheckboxInput.new(final_attrs, check_test) 538 | opt_val = opt_val.to_s 539 | rendered_cb = cb.render(multi_name, opt_val) 540 | opt_label = conditional_escape(opt_label.to_s) 541 | output << "
    • #{rendered_cb} #{opt_label}
    • " 542 | end 543 | output << '
    ' 544 | mark_safe(output.join("\n")) 545 | end 546 | end 547 | 548 | # TODO: MultiWidget < Widget 549 | # TODO: SplitDateTimeWidget < MultiWidget 550 | # TODO: SplitHiddenDateTimeWidget < SplitDateTimeWidget 551 | 552 | end 553 | end 554 | -------------------------------------------------------------------------------- /test/fields_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | module FieldTests 4 | class Test_with_empty_options < BureaucratTestCase 5 | def setup 6 | @field = Fields::Field.new 7 | end 8 | 9 | def test_be_required 10 | blank_value = '' 11 | assert_raises(ValidationError) do 12 | @field.clean(blank_value) 13 | end 14 | end 15 | end 16 | 17 | class Test_with_required_as_false < BureaucratTestCase 18 | def setup 19 | @field = Fields::Field.new(required: false) 20 | end 21 | 22 | def test_not_be_required 23 | blank_value = '' 24 | assert_nothing_raised do 25 | @field.clean(blank_value) 26 | end 27 | end 28 | end 29 | 30 | class Test_on_clean < BureaucratTestCase 31 | def setup 32 | @field = Fields::Field.new 33 | end 34 | 35 | def test_return_the_original_value_if_valid 36 | value = 'test' 37 | assert_equal(value, @field.clean(value)) 38 | end 39 | end 40 | 41 | class Test_when_copied < BureaucratTestCase 42 | def setup 43 | @field = Fields::Field.new(initial: 'initial', 44 | label: 'label') 45 | @field_copy = @field.dup 46 | end 47 | 48 | def test_have_its_own_copy_of_initial_value 49 | assert_not_equal(@field.initial.object_id, @field_copy.initial.object_id) 50 | end 51 | 52 | def test_have_its_own_copy_of_the_label 53 | assert_not_equal(@field.label.object_id, @field_copy.label.object_id) 54 | end 55 | 56 | def test_have_its_own_copy_of_the_widget 57 | assert_not_equal(@field.widget.object_id, @field_copy.widget.object_id) 58 | end 59 | 60 | def test_have_its_own_copy_of_validators 61 | assert_not_equal(@field.validators.object_id, @field_copy.validators.object_id) 62 | end 63 | 64 | def test_have_its_own_copy_of_the_error_messaes 65 | assert_not_equal(@field.error_messages.object_id, @field_copy.error_messages.object_id) 66 | end 67 | end 68 | end 69 | 70 | module CharFieldTests 71 | class Test_with_empty_options < BureaucratTestCase 72 | def setup 73 | @field = Fields::CharField.new 74 | end 75 | 76 | def test_not_validate_max_length 77 | assert_nothing_raised do 78 | @field.clean("string" * 1000) 79 | end 80 | end 81 | 82 | def test_not_validate_min_length 83 | assert_nothing_raised do 84 | @field.clean("1") 85 | end 86 | end 87 | end 88 | 89 | class Test_with_max_length < BureaucratTestCase 90 | def setup 91 | @field = Fields::CharField.new(max_length: 10) 92 | end 93 | 94 | def test_allow_values_with_length_less_than_or_equal_to_max_length 95 | assert_nothing_raised do 96 | @field.clean('a' * 10) 97 | @field.clean('a' * 5) 98 | end 99 | end 100 | 101 | def test_not_allow_values_with_length_greater_than_max_length 102 | assert_raises(ValidationError) do 103 | @field.clean('a' * 11) 104 | end 105 | end 106 | end 107 | 108 | class Test_with_min_length < BureaucratTestCase 109 | def setup 110 | @field = Fields::CharField.new(min_length: 10) 111 | end 112 | 113 | def test_allow_values_with_length_greater_or_equal_to_min_length 114 | assert_nothing_raised do 115 | @field.clean('a' * 10) 116 | @field.clean('a' * 20) 117 | end 118 | end 119 | 120 | def test_not_allow_values_with_length_less_than_min_length 121 | assert_raises(ValidationError) do 122 | @field.clean('a' * 9) 123 | end 124 | end 125 | end 126 | 127 | class Test_on_clean < BureaucratTestCase 128 | def setup 129 | @field = Fields::CharField.new 130 | end 131 | 132 | def test_return_the_original_value_if_valid 133 | valid_value = 'test' 134 | assert_equal(valid_value, @field.clean(valid_value)) 135 | end 136 | 137 | def test_return_a_blank_string_if_value_is_nil_and_required_is_false 138 | @field.required = false 139 | nil_value = nil 140 | assert_equal('', @field.clean(nil_value)) 141 | end 142 | 143 | def test_return_a_blank_string_if_value_is_empty_and_required_is_false 144 | @field.required = false 145 | empty_value = '' 146 | assert_equal('', @field.clean(empty_value)) 147 | end 148 | end 149 | 150 | end 151 | 152 | module IntegerFieldTests 153 | class Test_with_max_value < BureaucratTestCase 154 | def setup 155 | @field = Fields::IntegerField.new(max_value: 10) 156 | end 157 | 158 | def test_allow_values_less_or_equal_to_max_value 159 | assert_nothing_raised do 160 | @field.clean('10') 161 | @field.clean('4') 162 | end 163 | end 164 | 165 | def test_not_allow_values_greater_than_max_value 166 | assert_raises(ValidationError) do 167 | @field.clean('11') 168 | end 169 | end 170 | end 171 | 172 | class Test_with_min_value < BureaucratTestCase 173 | def setup 174 | @field = Fields::IntegerField.new(min_value: 10) 175 | end 176 | 177 | def test_allow_values_greater_or_equal_to_min_value 178 | assert_nothing_raised do 179 | @field.clean('10') 180 | @field.clean('20') 181 | end 182 | end 183 | 184 | def test_not_allow_values_less_than_min_value 185 | assert_raises(ValidationError) do 186 | @field.clean('9') 187 | end 188 | end 189 | 190 | end 191 | 192 | class Test_on_clean < BureaucratTestCase 193 | def setup 194 | @field = Fields::IntegerField.new 195 | end 196 | 197 | def test_return_an_integer_if_valid 198 | valid_value = '123' 199 | assert_equal(123, @field.clean(valid_value)) 200 | end 201 | 202 | def test_return_nil_if_value_is_nil_and_required_is_false 203 | @field.required = false 204 | assert_nil(@field.clean(nil)) 205 | end 206 | 207 | def test_return_nil_if_value_is_empty_and_required_is_false 208 | @field.required = false 209 | empty_value = '' 210 | assert_nil(@field.clean(empty_value)) 211 | end 212 | 213 | def test_not_validate_invalid_formats 214 | invalid_formats = ['a', 'hello', '23eeee', '.', 'hi323', 215 | 'joe@example.com', '___3232___323', 216 | '123.0', '123..4'] 217 | 218 | invalid_formats.each do |invalid| 219 | assert_raises(ValidationError) do 220 | @field.clean(invalid) 221 | end 222 | end 223 | end 224 | 225 | def test_validate_valid_formats 226 | valid_formats = ['3', '100', '-100', '0', '-0'] 227 | 228 | assert_nothing_raised do 229 | valid_formats.each do |valid| 230 | @field.clean(valid) 231 | end 232 | end 233 | end 234 | 235 | def test_return_an_instance_of_Integer_if_valid 236 | result = @field.clean('7') 237 | assert_kind_of(Integer, result) 238 | end 239 | end 240 | 241 | end 242 | 243 | module FloatFieldTests 244 | class Test_with_max_value < BureaucratTestCase 245 | def setup 246 | @field = Fields::FloatField.new(max_value: 10.5) 247 | end 248 | 249 | def test_allow_values_less_or_equal_to_max_value 250 | assert_nothing_raised do 251 | @field.clean('10.5') 252 | @field.clean('5') 253 | end 254 | end 255 | 256 | def test_not_allow_values_greater_than_max_value 257 | assert_raises(ValidationError) do 258 | @field.clean('10.55') 259 | end 260 | end 261 | end 262 | 263 | class Test_with_min_value < BureaucratTestCase 264 | def setup 265 | @field = Fields::FloatField.new(min_value: 10.5) 266 | end 267 | 268 | def test_allow_values_greater_or_equal_than_min_value 269 | assert_nothing_raised do 270 | @field.clean('10.5') 271 | @field.clean('20.5') 272 | end 273 | end 274 | 275 | def test_not_allow_values_less_than_min_value 276 | assert_raises(ValidationError) do 277 | @field.clean('10.49') 278 | end 279 | end 280 | end 281 | 282 | class Test_on_clean < BureaucratTestCase 283 | def setup 284 | @field = Fields::FloatField.new 285 | end 286 | 287 | def test_return_nil_if_value_is_nil_and_required_is_false 288 | @field.required = false 289 | assert_nil(@field.clean(nil)) 290 | end 291 | 292 | def test_return_nil_if_value_is_empty_and_required_is_false 293 | @field.required = false 294 | empty_value = '' 295 | assert_nil(@field.clean(empty_value)) 296 | end 297 | 298 | def test_not_validate_invalid_formats 299 | invalid_formats = ['a', 'hello', '23eeee', '.', 'hi323', 300 | 'joe@example.com', '___3232___323', 301 | '123..', '123..4'] 302 | 303 | invalid_formats.each do |invalid| 304 | assert_raises(ValidationError) do 305 | @field.clean(invalid) 306 | end 307 | end 308 | end 309 | 310 | def test_validate_valid_formats 311 | valid_formats = ['3.14', "100", "1233.", ".3333", "0.434", "0.0"] 312 | 313 | assert_nothing_raised do 314 | valid_formats.each do |valid| 315 | @field.clean(valid) 316 | end 317 | end 318 | end 319 | 320 | def test_return_an_instance_of_Float_if_valid 321 | result = @field.clean('3.14') 322 | assert_instance_of(Float, result) 323 | end 324 | end 325 | end 326 | 327 | module BigDecimalFieldTests 328 | class Test_with_max_value < BureaucratTestCase 329 | def setup 330 | @field = Fields::BigDecimalField.new(max_value: 10.5) 331 | end 332 | 333 | def test_allow_values_less_or_equal_to_max_value 334 | assert_nothing_raised do 335 | @field.clean('10.5') 336 | @field.clean('5') 337 | end 338 | end 339 | 340 | def test_not_allow_values_greater_than_max_value 341 | assert_raises(ValidationError) do 342 | @field.clean('10.55') 343 | end 344 | end 345 | end 346 | 347 | class Test_with_min_value < BureaucratTestCase 348 | def setup 349 | @field = Fields::BigDecimalField.new(min_value: 10.5) 350 | end 351 | 352 | def test_allow_values_greater_or_equal_to_min_value 353 | assert_nothing_raised do 354 | @field.clean('10.5') 355 | @field.clean('20.5') 356 | end 357 | end 358 | 359 | def test_not_allow_values_less_than_min_value 360 | assert_raises(ValidationError) do 361 | @field.clean('10.49') 362 | end 363 | end 364 | end 365 | 366 | class Test_on_clean < BureaucratTestCase 367 | def setup 368 | @field = Fields::BigDecimalField.new 369 | end 370 | 371 | def test_return_nil_if_value_is_nil_and_required_is_false 372 | @field.required = false 373 | assert_nil(@field.clean(nil)) 374 | end 375 | 376 | def test_return_nil_if_value_is_empty_and_required_is_false 377 | @field.required = false 378 | empty_value = '' 379 | assert_nil(@field.clean(empty_value)) 380 | end 381 | 382 | def test_not_validate_invalid_formats 383 | invalid_formats = ['a', 'hello', '23eeee', '.', 'hi323', 384 | 'joe@example.com', '___3232___323', 385 | '123..', '123..4'] 386 | 387 | invalid_formats.each do |invalid| 388 | assert_raises(ValidationError) do 389 | @field.clean(invalid) 390 | end 391 | end 392 | end 393 | 394 | def test_validate_valid_formats 395 | valid_formats = ['3.14', "100", "1233.", ".3333", "0.434", "0.0"] 396 | 397 | assert_nothing_raised do 398 | valid_formats.each do |valid| 399 | @field.clean(valid) 400 | end 401 | end 402 | end 403 | 404 | def test_return_an_instance_of_BigDecimal_if_valid 405 | result = @field.clean('3.14') 406 | assert_instance_of(BigDecimal, result) 407 | end 408 | end 409 | end 410 | 411 | module RegexFieldTests 412 | class Test_on_clean < BureaucratTestCase 413 | def setup 414 | @field = Fields::RegexField.new(/ba(na){2,}/) 415 | end 416 | 417 | def test_validate_matching_values 418 | valid_values = ['banana', 'bananananana'] 419 | valid_values.each do |valid| 420 | assert_nothing_raised do 421 | @field.clean(valid) 422 | end 423 | end 424 | end 425 | 426 | def test_not_validate_non_matching_values 427 | invalid_values = ['bana', 'spoon'] 428 | assert_raises(ValidationError) do 429 | invalid_values.each do |invalid| 430 | @field.clean(invalid) 431 | end 432 | end 433 | end 434 | 435 | def test_return_a_blank_string_if_value_is_empty_and_required_is_false 436 | @field.required = false 437 | empty_value = '' 438 | assert_equal('', @field.clean(empty_value)) 439 | end 440 | end 441 | end 442 | 443 | module EmailFieldTests 444 | class Test_on_clean < BureaucratTestCase 445 | def setup 446 | @field = Fields::EmailField.new 447 | end 448 | 449 | def test_validate_email_matching_values 450 | valid_values = ['email@domain.com', 'email+extra@domain.com', 451 | 'email@domain.fm', 'email@domain.co.uk'] 452 | valid_values.each do |valid| 453 | assert_nothing_raised do 454 | @field.clean(valid) 455 | end 456 | end 457 | end 458 | 459 | def test_not_validate_non_email_matching_values 460 | invalid_values = ['banana', 'spoon', 'invalid@dom#ain.com', 461 | 'invalid@@domain.com', 'invalid@domain', 462 | 'invalid@.com'] 463 | invalid_values.each do |invalid| 464 | assert_raises(ValidationError) do 465 | @field.clean(invalid) 466 | end 467 | end 468 | end 469 | end 470 | end 471 | 472 | module BooleanFieldTests 473 | class Test_on_clean < BureaucratTestCase 474 | def setup 475 | @true_values = [1, true, 'true', '1'] 476 | @false_values = [nil, 0, false, 'false', '0'] 477 | @field = Fields::BooleanField.new 478 | end 479 | 480 | def test_return_true_for_true_values 481 | @true_values.each do |true_value| 482 | assert_equal(true, @field.clean(true_value)) 483 | end 484 | end 485 | 486 | def test_return_false_for_false_values 487 | @field.required = false 488 | @false_values.each do |false_value| 489 | assert_equal(false, @field.clean(false_value)) 490 | end 491 | end 492 | 493 | def test_validate_on_true_values_when_required 494 | assert_nothing_raised do 495 | @true_values.each do |true_value| 496 | @field.clean(true_value) 497 | end 498 | end 499 | end 500 | 501 | def test_not_validate_on_false_values_when_required 502 | @false_values.each do |false_value| 503 | assert_raises(ValidationError) do 504 | @field.clean(false_value) 505 | end 506 | end 507 | end 508 | 509 | def test_validate_on_false_values_when_not_required 510 | @field.required = false 511 | assert_nothing_raised do 512 | @false_values.each do |false_value| 513 | @field.clean(false_value) 514 | end 515 | end 516 | end 517 | end 518 | end 519 | 520 | module NullBooleanFieldTests 521 | class Test_on_clean < BureaucratTestCase 522 | def setup 523 | @true_values = [true, 'true', '1'] 524 | @false_values = [false, 'false', '0'] 525 | @null_values = [nil, '', 'banana'] 526 | @field = Fields::NullBooleanField.new 527 | end 528 | 529 | def test_return_true_for_true_values 530 | @true_values.each do |true_value| 531 | assert_equal(true, @field.clean(true_value)) 532 | end 533 | end 534 | 535 | def test_return_false_for_false_values 536 | @false_values.each do |false_value| 537 | assert_equal(false, @field.clean(false_value)) 538 | end 539 | end 540 | 541 | def test_return_nil_for_null_values 542 | @null_values.each do |null_value| 543 | assert_equal(nil, @field.clean(null_value)) 544 | end 545 | end 546 | 547 | def test_validate_on_all_values 548 | all_values = @true_values + @false_values + @null_values 549 | assert_nothing_raised do 550 | all_values.each do |value| 551 | @field.clean(value) 552 | end 553 | end 554 | end 555 | end 556 | end 557 | 558 | module ChoiceFieldTests 559 | class Test_when_copied < BureaucratTestCase 560 | def setup 561 | @choices = [['tea', 'Tea'], ['milk', 'Milk']] 562 | @field = Fields::ChoiceField.new(@choices) 563 | @field_copy = @field.dup 564 | end 565 | 566 | def test_have_its_own_copy_of_choices 567 | assert_not_equal(@field.choices.object_id, @field_copy.choices.object_id) 568 | end 569 | end 570 | 571 | class Test_on_clean < BureaucratTestCase 572 | def setup 573 | @choices = [['tea', 'Tea'], ['milk', 'Milk']] 574 | @choices_hash = [[{ value: "able" }, "able"], [{ value: "baker" }, "Baker"]] 575 | @field = Fields::ChoiceField.new(@choices) 576 | @field_hash = Fields::ChoiceField.new(@choices_hash) 577 | end 578 | 579 | def test_validate_all_values_in_choices_list 580 | assert_nothing_raised do 581 | @choices.collect(&:first).each do |valid| 582 | @field.clean(valid) 583 | end 584 | end 585 | end 586 | 587 | def test_validate_all_values_in_a_hash_choices_list 588 | assert_nothing_raised do 589 | @choices_hash.collect(&:first).each do |valid| 590 | @field_hash.clean(valid[:value]) 591 | end 592 | end 593 | end 594 | 595 | def test_not_validate_a_value_not_in_choices_list 596 | assert_raises(ValidationError) do 597 | @field.clean('not_in_choices') 598 | end 599 | end 600 | 601 | def test_not_validate_a_value_not_in_a_hash_choices_list 602 | assert_raises(ValidationError) do 603 | @field_hash.clean('not_in_choices') 604 | end 605 | end 606 | 607 | def test_return_the_original_value_if_valid 608 | value = 'tea' 609 | result = @field.clean(value) 610 | assert_equal(value, result) 611 | end 612 | 613 | def test_return_the_original_value_if_valid_from_a_hash_choices_list 614 | value = 'baker' 615 | result = @field_hash.clean(value) 616 | assert_equal(value, result) 617 | end 618 | 619 | def test_return_an_empty_string_if_value_is_empty_and_not_required 620 | @field.required = false 621 | result = @field.clean('') 622 | assert_equal('', result) 623 | end 624 | 625 | def test_return_an_empty_string_if_value_is_empty_and_not_required_from_a_hash_choices_list 626 | @field_hash.required = false 627 | result = @field_hash.clean('') 628 | assert_equal('', result) 629 | end 630 | end 631 | end 632 | 633 | module TypedChoiceFieldTests 634 | class Test_on_clean < BureaucratTestCase 635 | def setup 636 | @choices = [[1, 'One'], [2, 'Two'], ['3', 'Three']] 637 | to_int = lambda{|val| Integer(val)} 638 | @field = Fields::TypedChoiceField.new(@choices, 639 | coerce: to_int) 640 | end 641 | 642 | def test_validate_all_values_in_choices_list 643 | assert_nothing_raised do 644 | @choices.collect(&:first).each do |valid| 645 | @field.clean(valid) 646 | end 647 | end 648 | end 649 | 650 | def test_not_validate_a_value_not_in_choices_list 651 | assert_raises(ValidationError) do 652 | @field.clean('four') 653 | end 654 | end 655 | 656 | def test_return_the_original_value_if_valid 657 | value = 1 658 | result = @field.clean(value) 659 | assert_equal(value, result) 660 | end 661 | 662 | def test_return_a_coerced_version_of_the_original_value_if_valid_but_of_different_type 663 | value = 2 664 | result = @field.clean(value.to_s) 665 | assert_equal(value, result) 666 | end 667 | 668 | def test_return_an_empty_string_if_value_is_empty_and_not_required 669 | @field.required = false 670 | result = @field.clean('') 671 | assert_equal('', result) 672 | end 673 | end 674 | end 675 | 676 | module MultipleChoiceFieldTests 677 | class Test_on_clean < BureaucratTestCase 678 | def setup 679 | @choices = [['tea', 'Tea'], ['milk', 'Milk'], ['coffee', 'Coffee']] 680 | @field = Fields::MultipleChoiceField.new(@choices) 681 | end 682 | 683 | def test_validate_all_single_values_in_choices_list 684 | assert_nothing_raised do 685 | @choices.collect(&:first).each do |valid| 686 | @field.clean([valid]) 687 | end 688 | end 689 | end 690 | 691 | def test_validate_multiple_values 692 | values = ['tea', 'coffee'] 693 | assert_nothing_raised do 694 | @field.clean(values) 695 | end 696 | end 697 | 698 | def test_not_validate_a_value_not_in_choices_list 699 | assert_raises(ValidationError) do 700 | @field.clean(['tea', 'not_in_choices']) 701 | end 702 | end 703 | 704 | def test_return_the_original_value_if_valid 705 | value = 'tea' 706 | result = @field.clean([value]) 707 | assert_equal([value], result) 708 | end 709 | 710 | def test_return_an_empty_list_if_value_is_empty_and_not_required 711 | @field.required = false 712 | result = @field.clean([]) 713 | assert_equal([], result) 714 | end 715 | end 716 | end 717 | -------------------------------------------------------------------------------- /test/forms_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | module FormTests 4 | class Test_inherited_form_with_a_CharField < BureaucratTestCase 5 | class OneForm < Forms::Form 6 | include Bureaucrat::Fields 7 | 8 | field :name, CharField.new 9 | end 10 | 11 | def test_have_a_BoundField 12 | form = OneForm.new 13 | assert_kind_of(Forms::BoundField, form[:name]) 14 | end 15 | 16 | def test_be_bound_when_data_is_provided 17 | form = OneForm.new(name: 'name') 18 | assert_equal(true, form.bound?) 19 | end 20 | 21 | class Test_when_calling_valid < BureaucratTestCase 22 | def test_return_false_when_data_isnt_valid 23 | form = OneForm.new(name: nil) 24 | assert_equal(false, form.valid?) 25 | end 26 | 27 | def test_return_true_when_data_is_valid 28 | form = OneForm.new(name: 'valid') 29 | assert_equal(true, form.valid?) 30 | end 31 | end 32 | 33 | class Test_when_calling_errors < BureaucratTestCase 34 | def test_have_errors_when_invalid 35 | form = OneForm.new(name: nil) 36 | assert_operator(form.errors.size, :>, 0) 37 | end 38 | 39 | def test_not_have_errors_when_valid 40 | form = OneForm.new(name: 'valid') 41 | assert_equal(form.errors.size, 0) 42 | end 43 | end 44 | 45 | class Test_when_calling_changed_data < BureaucratTestCase 46 | def test_return_an_empty_list_if_no_field_was_changed 47 | form = OneForm.new 48 | assert_equal([], form.changed_data) 49 | end 50 | 51 | def test_return_a_list_of_changed_fields_when_modified 52 | form = OneForm.new(name: 'changed') 53 | assert_equal([:name], form.changed_data) 54 | end 55 | end 56 | end 57 | 58 | class Test_form_with_custom_clean_proc_on_field < BureaucratTestCase 59 | class CustomCleanForm < Forms::Form 60 | include Bureaucrat::Fields 61 | 62 | field :name, CharField.new 63 | 64 | def clean_name 65 | value = cleaned_data[:name] 66 | unless value == 'valid_name' 67 | raise Bureaucrat::ValidationError.new("Invalid name") 68 | end 69 | value.upcase 70 | end 71 | end 72 | 73 | def test_not_be_valid_if_clean_method_fails 74 | form = CustomCleanForm.new(name: 'other') 75 | assert_equal(false, form.valid?) 76 | end 77 | 78 | def test_be_valid_if_clean_method_passes 79 | form = CustomCleanForm.new(name: 'valid_name') 80 | assert_equal(true, form.valid?) 81 | end 82 | 83 | def test_set_the_value_to_the_one_returned_by_the_custom_clean_method 84 | form = CustomCleanForm.new(name: 'valid_name') 85 | form.valid? 86 | assert_equal('VALID_NAME', form.cleaned_data[:name]) 87 | end 88 | 89 | end 90 | 91 | class Test_populating_objects < BureaucratTestCase 92 | class PopulatorForm < Forms::Form 93 | include Bureaucrat::Fields 94 | 95 | field :name, CharField.new(required: false) 96 | field :color, CharField.new(required: false) 97 | field :number, IntegerField.new(required: false) 98 | end 99 | 100 | def test_correctly_populate_an_object_with_all_fields 101 | obj = Struct.new(:name, :color, :number).new 102 | name_value = 'The Name' 103 | color_value = 'Black' 104 | number_value = 10 105 | 106 | form = PopulatorForm.new(name: name_value, 107 | color: color_value, 108 | number: number_value.to_s) 109 | 110 | assert form.valid? 111 | 112 | form.populate_object(obj) 113 | 114 | assert_equal(name_value, obj.name) 115 | assert_equal(color_value, obj.color) 116 | assert_equal(number_value, obj.number) 117 | end 118 | 119 | def test_correctly_populate_an_object_without_all_fields 120 | obj = Struct.new(:name, :number).new 121 | name_value = 'The Name' 122 | color_value = 'Black' 123 | number_value = 10 124 | 125 | form = PopulatorForm.new(name: name_value, 126 | color: color_value, 127 | number: number_value.to_s) 128 | 129 | assert form.valid? 130 | 131 | form.populate_object(obj) 132 | 133 | assert_equal(name_value, obj.name) 134 | assert_equal(number_value, obj.number) 135 | end 136 | 137 | def test_correctly_populate_an_object_with_all_fields_with_some_missing_values 138 | obj = Struct.new(:name, :color, :number).new('a', 'b', 2) 139 | 140 | form = PopulatorForm.new({}) 141 | 142 | assert form.valid? 143 | 144 | form.populate_object(obj) 145 | 146 | assert_equal('', obj.name) 147 | assert_equal('', obj.color) 148 | assert_equal(nil, obj.number) 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/formsets_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | module FormsetTests 4 | class SimpleForm < Bureaucrat::Forms::Form 5 | include Bureaucrat::Fields 6 | 7 | field :name, CharField.new 8 | end 9 | 10 | SimpleFormFormSet = 11 | Bureaucrat::Formsets.make_formset_class(SimpleForm, extra: 2) 12 | 13 | class CustomFormSet < Bureaucrat::Formsets::BaseFormSet 14 | def clean 15 | raise Bureaucrat::ValidationError.new('This is wrong!') 16 | end 17 | end 18 | 19 | SimpleFormCustomFormSet = 20 | Bureaucrat::Formsets.make_formset_class(SimpleForm, 21 | extra: 2, 22 | formset: CustomFormSet) 23 | 24 | class Test_formset_with_empty_data < BureaucratTestCase 25 | def setup 26 | management_form_data = { 27 | :'form-TOTAL_FORMS' => '2', 28 | :'form-INITIAL_FORMS' => '2' 29 | } 30 | valid_data = {:'form-0-name' => 'Lynch', :'form-1-name' => 'Tio'} 31 | invalid_data = {:'form-0-name' => 'Lynch', :'form-1-name' => ''} 32 | @set = SimpleFormFormSet.new 33 | @valid_bound_set = SimpleFormFormSet.new(management_form_data.merge(valid_data)) 34 | @invalid_bound_set = SimpleFormFormSet.new(management_form_data.merge(invalid_data)) 35 | end 36 | 37 | def test_#valid?_returns_true_if_all_forms_are_valid 38 | assert(@valid_bound_set.valid?) 39 | end 40 | 41 | def test_#valid?_returns_false_if_there_is_an_invalid_form 42 | assert(!@invalid_bound_set.valid?) 43 | end 44 | 45 | def test_correctly_return_the_list_of_errors 46 | assert_equal([{}, {name: ["This field is required"]}], 47 | @invalid_bound_set.errors) 48 | end 49 | 50 | def test_correctly_return_the_list_of_cleaned_data 51 | expected = [{'name' => 'Lynch'}, {'name' => 'Tio'}] 52 | result = @valid_bound_set.cleaned_data 53 | assert_equal(expected, result) 54 | end 55 | end 56 | 57 | class Test_Formset_with_clean_method_raising_a_ValidationError_exception < BureaucratTestCase 58 | def setup 59 | management_form_data = { 60 | :'form-TOTAL_FORMS' => '2', 61 | :'form-INITIAL_FORMS' => '2' 62 | } 63 | valid_data = {:'form-0-name' => 'Lynch', :'form-1-name' => 'Tio'} 64 | @bound_set = 65 | SimpleFormCustomFormSet.new(management_form_data.merge(valid_data)) 66 | end 67 | 68 | def test_not_be_valid 69 | assert_equal(false, @bound_set.valid?) 70 | end 71 | 72 | def test_add_clean_errors_to_nonfield_errors 73 | @bound_set.valid? 74 | assert_equal(["This is wrong!"], @bound_set.non_form_errors) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bigdecimal' 2 | require 'minitest/autorun' 3 | 4 | require_relative '../lib/bureaucrat' 5 | require_relative '../lib/bureaucrat/formsets' 6 | 7 | # Used to compare rendered htmls 8 | require 'rexml/document' 9 | 10 | class BureaucratTestCase < MiniTest::Unit::TestCase 11 | include Bureaucrat 12 | 13 | def assert_nothing_raised(&block) 14 | block.call 15 | assert true 16 | end 17 | 18 | def assert_not_equal(value, other) 19 | assert value != other, "should be different from #{value}" 20 | end 21 | 22 | def normalize_html(html) 23 | begin 24 | node = REXML::Document.new("#{html.strip}") 25 | node.to_s.gsub!(/<\/?DUMMYROOT>/, '') 26 | rescue 27 | html 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/widgets_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | module WidgetTests 4 | class Test_TextInput_widget < BureaucratTestCase 5 | class Test_with_empty_attributes < BureaucratTestCase 6 | def test_correctly_render 7 | input = Widgets::TextInput.new 8 | expected = normalize_html('') 9 | rendered = normalize_html(input.render('test', 'hi')) 10 | assert_equal(expected, rendered) 11 | end 12 | end 13 | 14 | class Test_with_attributes < BureaucratTestCase 15 | def test_correctly_render 16 | input = Widgets::TextInput.new(attribute: 'value') 17 | expected = normalize_html('') 18 | rendered = normalize_html(input.render('test', 'hi')) 19 | assert_equal(expected, rendered) 20 | end 21 | end 22 | 23 | class Test_without_value < BureaucratTestCase 24 | def test_not_render_a_value 25 | input = Widgets::TextInput.new 26 | expected = normalize_html('') 27 | rendered = normalize_html(input.render('test', nil)) 28 | assert_equal(expected, rendered) 29 | end 30 | end 31 | 32 | class Test_when_copied < BureaucratTestCase 33 | def test_have_a_copy_of_the_attributes 34 | input1 = Widgets::TextInput.new(attribute: 2) 35 | input2 = input1.dup 36 | assert_not_equal(input1.attrs.object_id, input2.attrs.object_id) 37 | end 38 | end 39 | end 40 | 41 | class Test_PasswordInput_widget < BureaucratTestCase 42 | class Test_with_render_value_true < BureaucratTestCase 43 | def test_render_correctly_including_value 44 | input = Widgets::PasswordInput.new(nil, true) 45 | expected = normalize_html("") 46 | rendered = normalize_html(input.render('test', 'secret')) 47 | assert_equal(expected, rendered) 48 | end 49 | end 50 | 51 | class Test_with_render_value_false < BureaucratTestCase 52 | def test_render_correctly_not_including_value 53 | input = Widgets::PasswordInput.new(nil, false) 54 | expected = normalize_html("") 55 | rendered = normalize_html(input.render('test', 'secret')) 56 | assert_equal(expected, rendered) 57 | end 58 | end 59 | end 60 | 61 | class Test_HiddenInput_widget < BureaucratTestCase 62 | def test_correctly_render 63 | input = Widgets::HiddenInput.new 64 | expected = normalize_html("") 65 | rendered = normalize_html(input.render('test', 'secret')) 66 | assert_equal(expected, rendered) 67 | end 68 | end 69 | 70 | class Test_MultipleHiddenInput_widget < BureaucratTestCase 71 | def test_correctly_render 72 | input = Widgets::MultipleHiddenInput.new 73 | expected = normalize_html("\n") 74 | rendered = normalize_html(input.render('test', ['v1', 'v2'])) 75 | assert_equal(expected, rendered) 76 | end 77 | # TODO: value_from_datahash 78 | end 79 | 80 | class Test_FileInput_widget < BureaucratTestCase 81 | def test_correctly_render 82 | input = Widgets::FileInput.new 83 | expected = normalize_html("") 84 | rendered = normalize_html(input.render('test', "anything")) 85 | assert_equal(expected, rendered) 86 | end 87 | # TODO: value_from_datahash, has_changed? 88 | end 89 | 90 | class Test_Textarea_widget < BureaucratTestCase 91 | def test_correctly_render 92 | input = Widgets::Textarea.new(cols: '50', rows: '15') 93 | expected = normalize_html("") 94 | rendered = normalize_html(input.render('test', "hello")) 95 | assert_equal(expected, rendered) 96 | end 97 | 98 | def test_correctly_render_multiline 99 | input = Widgets::Textarea.new(cols: '50', rows: '15') 100 | expected = normalize_html("") 101 | rendered = normalize_html(input.render('test', "hello\n\ntest")) 102 | assert_equal(expected, rendered) 103 | end 104 | end 105 | 106 | class Test_CheckboxInput_widget < BureaucratTestCase 107 | def test_correctly_render_with_a_false_value 108 | input = Widgets::CheckboxInput.new 109 | expected = normalize_html("") 110 | rendered = normalize_html(input.render('test', false)) 111 | assert_equal(expected, rendered) 112 | end 113 | 114 | def test_correctly_render_with_a_true_value 115 | input = Widgets::CheckboxInput.new 116 | expected ="" 117 | rendered = normalize_html(input.render('test', true)) 118 | assert_equal(expected, rendered) 119 | end 120 | 121 | def test_correctly_render_with_a_non_boolean_value 122 | input = Widgets::CheckboxInput.new 123 | expected = "" 124 | rendered = normalize_html(input.render('test', 'anything')) 125 | assert_equal(expected, rendered) 126 | end 127 | # TODO: value_from_datahash, has_changed? 128 | end 129 | 130 | module SelectWidgetTests 131 | class Test_with_empty_choices < BureaucratTestCase 132 | def test_correctly_render 133 | input = Widgets::Select.new 134 | expected = normalize_html("") 135 | rendered = normalize_html(input.render('test', 'hello')) 136 | assert_equal(expected, rendered) 137 | end 138 | end 139 | 140 | class Test_with_flat_choices < BureaucratTestCase 141 | def setup 142 | @choices = [['1', 'One'], ['2', 'Two']] 143 | end 144 | 145 | def test_correctly_render_none_selected 146 | input = Widgets::Select.new(nil, @choices) 147 | expected = normalize_html("") 148 | rendered = normalize_html(input.render('test', 'hello')) 149 | assert_equal(expected, rendered) 150 | end 151 | 152 | def test_correctly_render_with_selected 153 | input = Widgets::Select.new(nil, @choices) 154 | expected = normalize_html("") 155 | rendered = normalize_html(input.render('test', '2')) 156 | assert_equal(expected, rendered) 157 | end 158 | end 159 | 160 | class Test_with_group_choices < BureaucratTestCase 161 | def setup 162 | @groupchoices = [['numbers', ['1', 'One'], ['2', 'Two']], 163 | ['words', [['spoon', 'Spoon'], ['banana', 'Banana']]]] 164 | end 165 | 166 | def test_correctly_render_none_selected 167 | input = Widgets::Select.new(nil, @groupchoices) 168 | expected = normalize_html("") 169 | rendered = normalize_html(input.render('test', 'hello')) 170 | assert_equal(expected, rendered) 171 | end 172 | 173 | def test_correctly_render_with_selected 174 | input = Widgets::Select.new(nil, @groupchoices) 175 | expected = normalize_html("") 176 | rendered = normalize_html(input.render('test', 'banana')) 177 | assert_equal(expected, rendered) 178 | end 179 | end 180 | 181 | class Test_with_simple_choices < BureaucratTestCase 182 | def setup 183 | @simplechoices = [ "able", "baker", "charlie" ] 184 | end 185 | 186 | def test_correctly_render_none_selected 187 | input = Widgets::Select.new(nil, @simplechoices) 188 | expected = normalize_html("") 189 | rendered = normalize_html(input.render('test', 'hello')) 190 | assert_equal(expected, rendered) 191 | end 192 | 193 | def test_correctly_render_with_selected 194 | input = Widgets::Select.new(nil, @simplechoices) 195 | expected = normalize_html("") 196 | rendered = normalize_html(input.render('test', 'baker')) 197 | assert_equal(expected, rendered) 198 | end 199 | end 200 | 201 | class Test_with_option_choices < BureaucratTestCase 202 | def setup 203 | @optionchoices = [[{ value: "foo", disabled: "disabled", onSelect: "doSomething();" }, "Foo"], 204 | [{ value: "bar" }, "Bar"]] 205 | @optionchoicesselected = [[{ value: "foo", disabled: "disabled" }, "Foo"], 206 | [{ value: "bar", selected: "selected" }, "Bar"]] 207 | end 208 | 209 | def test_correctly_render_none_selected 210 | input = Widgets::Select.new(nil, @optionchoices) 211 | expected = normalize_html("") 212 | rendered = normalize_html(input.render('test', 'hello')) 213 | assert_equal(expected, rendered) 214 | end 215 | 216 | def test_correctly_render_traditional_selected 217 | input = Widgets::Select.new(nil, @optionchoices) 218 | expected = normalize_html("") 219 | rendered = normalize_html(input.render('test', 'bar')) 220 | assert_equal(expected, rendered) 221 | end 222 | 223 | def test_correctly_render_option_selected 224 | input = Widgets::Select.new(nil, @optionchoicesselected) 225 | expected = normalize_html("") 226 | rendered = normalize_html(input.render('test', 'hello')) 227 | assert_equal(expected, rendered) 228 | end 229 | end 230 | end 231 | 232 | class Test_NullBooleanSelect_widget < BureaucratTestCase 233 | def test_correctly_render_with_Unknown_as_the_default_value_when_none_is_selected 234 | input = Widgets::NullBooleanSelect.new 235 | expected = normalize_html("") 236 | rendered = normalize_html(input.render('test', nil)) 237 | assert_equal(expected, rendered) 238 | end 239 | 240 | def test_correctly_render_with_selected 241 | input = Widgets::NullBooleanSelect.new 242 | expected = normalize_html("") 243 | rendered = normalize_html(input.render('test', '2')) 244 | assert_equal(expected, rendered) 245 | end 246 | end 247 | 248 | module SelectMultipleTests 249 | class Test_with_empty_choices < BureaucratTestCase 250 | def test_correctly_render 251 | input = Widgets::SelectMultiple.new 252 | expected = normalize_html("") 253 | rendered = normalize_html(input.render('test', ['hello'])) 254 | assert_equal(expected, rendered) 255 | end 256 | end 257 | 258 | class Test_with_flat_choices < BureaucratTestCase 259 | def setup 260 | @choices = [['1', 'One'], ['2', 'Two']] 261 | end 262 | 263 | def test_correctly_render_none_selected 264 | input = Widgets::SelectMultiple.new(nil, @choices) 265 | expected = normalize_html("") 266 | rendered = normalize_html(input.render('test', ['hello'])) 267 | assert_equal(expected, rendered) 268 | end 269 | 270 | def test_correctly_render_with_selected 271 | input = Widgets::SelectMultiple.new(nil, @choices) 272 | expected = normalize_html("") 273 | rendered = normalize_html(input.render('test', ['1', '2'])) 274 | assert_equal(expected, rendered) 275 | end 276 | end 277 | 278 | class Test_with_group_choices < BureaucratTestCase 279 | def setup 280 | @groupchoices = [['numbers', ['1', 'One'], ['2', 'Two']], 281 | ['words', [['spoon', 'Spoon'], ['banana', 'Banana']]]] 282 | end 283 | 284 | def test_correctly_render_none_selected 285 | input = Widgets::SelectMultiple.new(nil, @groupchoices) 286 | expected = normalize_html("") 287 | rendered = normalize_html(input.render('test', ['hello'])) 288 | assert_equal(expected, rendered) 289 | end 290 | 291 | def test_correctly_render_with_selected 292 | input = Widgets::SelectMultiple.new(nil, @groupchoices) 293 | expected = normalize_html("") 294 | rendered = normalize_html(input.render('test', ['banana', 'spoon'])) 295 | assert_equal(expected, rendered) 296 | end 297 | end 298 | end 299 | 300 | class Test_RadioSelect_widget < BureaucratTestCase 301 | def test_correctly_render_none_selected 302 | input = Widgets::RadioSelect.new(nil, [['1', 'One'], ['2', 'Two']]) 303 | expected = normalize_html("
      \n
    • \n
    • \n
    ") 304 | rendered = normalize_html(input.render('radio', '', id: 'id_radio')) 305 | assert_equal(expected, rendered) 306 | end 307 | 308 | def test_correctly_render_with_selected 309 | input = Widgets::RadioSelect.new(nil, [['1', 'One'], ['2', 'Two']]) 310 | expected = normalize_html("
      \n
    • \n
    • \n
    ") 311 | rendered = normalize_html(input.render('radio', '1', id: 'id_radio')) 312 | assert_equal(expected, rendered) 313 | end 314 | end 315 | 316 | module CheckboxSelectMultipleTests 317 | class Test_with_empty_choices < BureaucratTestCase 318 | def test_render_an_empty_ul 319 | input = Widgets::CheckboxSelectMultiple.new 320 | expected = normalize_html("
      \n
    ") 321 | rendered = normalize_html(input.render('test', ['hello'], id: 'id_checkboxes')) 322 | assert_equal(expected, rendered) 323 | end 324 | end 325 | 326 | class Test_with_choices < BureaucratTestCase 327 | def setup 328 | @choices = [['1', 'One'], ['2', 'Two'], ['3', 'Three']] 329 | end 330 | 331 | def test_correctly_renders_none_selected 332 | input = Widgets::CheckboxSelectMultiple.new(nil, @choices) 333 | expected = normalize_html("
      \n
    • \n
    • \n
    • \n
    ") 334 | rendered = normalize_html(input.render('test', ['hello'], id: 'id_checkboxes')) 335 | assert_equal(expected, rendered) 336 | end 337 | 338 | def test_correctly_renders_with_selected 339 | input = Widgets::CheckboxSelectMultiple.new(nil, @choices) 340 | expected = normalize_html("
      \n
    • \n
    • \n
    • \n
    ") 341 | rendered = normalize_html(input.render('test', ['1', '2'], 342 | id: 'id_checkboxes')) 343 | assert_equal(expected, rendered) 344 | end 345 | end 346 | end 347 | 348 | end 349 | --------------------------------------------------------------------------------