formbuilder-rb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +24 -0
  4. data/app/controllers/formbuilder/forms_controller.rb +53 -0
  5. data/app/models/formbuilder/entry_attachment.rb +15 -0
  6. data/app/models/formbuilder/form.rb +12 -0
  7. data/app/models/formbuilder/response_field.rb +85 -0
  8. data/app/models/formbuilder/response_field_address.rb +349 -0
  9. data/app/models/formbuilder/response_field_checkboxes.rb +68 -0
  10. data/app/models/formbuilder/response_field_date.rb +46 -0
  11. data/app/models/formbuilder/response_field_dropdown.rb +30 -0
  12. data/app/models/formbuilder/response_field_email.rb +26 -0
  13. data/app/models/formbuilder/response_field_file.rb +44 -0
  14. data/app/models/formbuilder/response_field_number.rb +33 -0
  15. data/app/models/formbuilder/response_field_paragraph.rb +21 -0
  16. data/app/models/formbuilder/response_field_price.rb +47 -0
  17. data/app/models/formbuilder/response_field_radio.rb +52 -0
  18. data/app/models/formbuilder/response_field_section_break.rb +17 -0
  19. data/app/models/formbuilder/response_field_text.rb +16 -0
  20. data/app/models/formbuilder/response_field_time.rb +53 -0
  21. data/app/models/formbuilder/response_field_website.rb +31 -0
  22. data/app/uploaders/formbuilder/entry_attachment_uploader.rb +26 -0
  23. data/db/migrate/20130924185726_create_formbuilder_forms.rb +10 -0
  24. data/db/migrate/20130924185814_create_formbuilder_response_fields.rb +16 -0
  25. data/db/migrate/20130924185815_create_formbuilder_entry_attachments.rb +10 -0
  26. data/lib/formbuilder/engine.rb +5 -0
  27. data/lib/formbuilder/entry.rb +242 -0
  28. data/lib/formbuilder/entry_renderer.rb +47 -0
  29. data/lib/formbuilder/entry_table_renderer.rb +58 -0
  30. data/lib/formbuilder/entry_validator.rb +107 -0
  31. data/lib/formbuilder/form_renderer.rb +102 -0
  32. data/lib/formbuilder/version.rb +3 -0
  33. data/lib/formbuilder.rb +12 -0
  34. data/lib/tasks/formbuilder_tasks.rake +4 -0
  35. metadata +258 -0
@@ -0,0 +1,242 @@
1
+ module Formbuilder
2
+ module Entry
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ attr_accessor :old_responses
7
+ attr_accessor :skip_validation
8
+ validates_with Formbuilder::EntryValidator
9
+ before_save :calculate_responses_text, if: :responses_changed?
10
+ include ActionView::Helpers::TextHelper
11
+ end
12
+
13
+ def submit!(skip_validation = false)
14
+ return false if !skip_validation && !valid?
15
+
16
+ self.audit_responses
17
+
18
+ self.update_attributes(
19
+ submitted_at: Time.now,
20
+ skip_validation: true # don't validate twice
21
+ )
22
+ end
23
+
24
+ def unsubmit!
25
+ self.update_attributes(
26
+ submitted_at: nil,
27
+ skip_validation: true
28
+ )
29
+ end
30
+
31
+ def submitted?
32
+ self.submitted_at.present?
33
+ end
34
+
35
+ def value_present?(response_field)
36
+ value = self.response_value(response_field)
37
+
38
+ # value isn't blank (ignore hashes)
39
+ return true if (value && value.present? && !value.is_a?(Hash))
40
+
41
+ # no options are available
42
+ return true if (response_field.options_field && Array(response_field.field_options["options"]).empty?)
43
+
44
+ # there is at least one value (for hashes)
45
+ # reject select fields
46
+ return true if (value.is_a?(Hash) && value.reject { |k, v| k.in? ['am_pm', 'country'] }.find { |k, v| v.present? })
47
+
48
+ # otherwise, it's not present
49
+ return false
50
+ end
51
+
52
+ # checkboxes can have no values, yet still need to show up as unchecked
53
+ def value_present_or_checkboxes?(response_field)
54
+ response_field.field_type == 'checkboxes' || value_present?(response_field)
55
+ end
56
+
57
+ def response_value(response_field)
58
+ value = responses && responses[response_field.id.to_s]
59
+
60
+ if value
61
+ response_field.serialized ? YAML::load(value) : value
62
+ elsif !value && response_field.serialized && response_field.field_type != 'checkboxes'
63
+ {}
64
+ else # for checkboxes, we need to know the difference between no value and none selected
65
+ nil
66
+ end
67
+ end
68
+
69
+ def save_responses(response_field_params, response_fields)
70
+ self.old_responses = self.responses.try(:clone) || {}
71
+ self.responses = {}
72
+
73
+ response_fields.reject { |rf| !rf.input_field }.each do |response_field|
74
+ self.save_response(response_field_params.try(:[], response_field.id.to_s), response_field, response_field_params)
75
+ end
76
+ end
77
+
78
+ def save_response(raw_value, response_field, response_field_params = {})
79
+ value = case response_field.field_type
80
+ when "checkboxes"
81
+ # transform checkboxes into {label => on/off} pairs
82
+ values = {}
83
+
84
+ (response_field[:field_options]["options"] || []).each_with_index do |option, index|
85
+ label = response_field.field_options["options"][index]["label"]
86
+ values[option["label"]] = raw_value && raw_value[index.to_s] == "on"
87
+ end
88
+
89
+ if raw_value && raw_value['other_checkbox'] == 'on'
90
+ values['Other'] = raw_value['other']
91
+ else
92
+ values.delete('Other') # @todo this might cause unexpected behavior to the user. we should hide/show the other field in the frontend, too
93
+ end
94
+
95
+ # Save 'other' value
96
+ responses["#{response_field.id}_other"] = raw_value && raw_value['other_checkbox'] == 'on' ?
97
+ true :
98
+ nil
99
+
100
+ values
101
+
102
+ when "file"
103
+ # if the file is already uploaded and we're not uploading another,
104
+ # be sure to keep it
105
+ if raw_value.blank?
106
+ if old_responses && old_responses[response_field.id.to_s]
107
+ old_responses[response_field.id.to_s]
108
+ end
109
+ else
110
+ remove_entry_attachment(responses[response_field.id.to_s]) if responses
111
+ attachment = EntryAttachment.create(upload: raw_value)
112
+ attachment.id
113
+ end
114
+ when "radio"
115
+ # Save 'other' value
116
+ responses["#{response_field.id}_other"] = raw_value == 'Other' ?
117
+ response_field_params["#{response_field.id}_other"] :
118
+ nil
119
+
120
+ raw_value
121
+ else
122
+ raw_value
123
+ end
124
+
125
+ self.responses ||= {}
126
+
127
+ if value.present?
128
+ self.responses["#{response_field.id}"] = response_field.serialized ? value.to_yaml : value
129
+ calculate_sortable_value(response_field, value)
130
+ end
131
+
132
+ self.responses_will_change! # hack to make sure column is marked as dirty
133
+ end
134
+
135
+ def destroy_response(response_field)
136
+ case response_field.field_type
137
+ when "file"
138
+ self.remove_entry_attachment(responses[response_field.id.to_s])
139
+ end
140
+
141
+ id = response_field.id.to_s
142
+ new_responses = self.responses.reject { |k, v| k.in?([id, "#{id}_sortable_value"]) }
143
+ self.responses = new_responses
144
+
145
+ self.responses_will_change! # hack to make sure column is marked as dirty
146
+ end
147
+
148
+ def remove_entry_attachment(entry_attachment_id)
149
+ return unless entry_attachment_id.present?
150
+ EntryAttachment.where(id: entry_attachment_id).first.try(:destroy)
151
+ end
152
+
153
+ def error_for(response_field)
154
+ (self.errors.messages[:"responses_#{response_field.id}"] || [])[0]
155
+ end
156
+
157
+ def calculate_responses_text
158
+ return unless self.respond_to?(:"responses_text=")
159
+ selected_responses = self.responses.select { |k, v| Integer(k) rescue nil }
160
+ self.responses_text = selected_responses.values.join(' ')
161
+ end
162
+
163
+ # useful when migrating
164
+ def calculate_sortable_values
165
+ response_fieldable.response_fields.reject { |rf| !rf.input_field }.each do |response_field|
166
+ calculate_sortable_value(response_field, response_value(response_field))
167
+ end
168
+
169
+ self.responses_will_change! # hack to make sure column is marked as dirty
170
+ end
171
+
172
+ def calculate_additional_info
173
+ response_fieldable.response_fields.reject { |rf| !rf.input_field }.each do |response_field|
174
+ value = response_value(response_field)
175
+ next unless value.present?
176
+
177
+ case response_field.field_type
178
+ when 'address'
179
+ begin
180
+ coords = Geocoder.coordinates("#{value['street']} #{value['city']} #{value['state']} #{value['zipcode']} #{value['country']}")
181
+ self.responses["#{response_field.id}_x"] = coords[0]
182
+ self.responses["#{response_field.id}_y"] = coords[1]
183
+ rescue
184
+ self.responses["#{response_field.id}_x"] = nil
185
+ self.responses["#{response_field.id}_y"] = nil
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ # def normalize_responses
192
+ # response_fieldable.response_fields.reject { |rf| !rf.input_field }.each do |response_field|
193
+ # value = response_value(response_field)
194
+ # next unless value.present?
195
+
196
+ # case response_field.field_type
197
+ # when 'website'
198
+ # unless value[/^http:\/\//] || value[/^https:\/\//]
199
+ # save_response("http://#{value}", response_field)
200
+ # end
201
+ # end
202
+ # end
203
+ # end
204
+
205
+ def audit_responses
206
+ form.response_fields.each do |response_field|
207
+ response_field.audit_response(self.response_value(response_field), self.responses)
208
+ end
209
+ end
210
+
211
+ def calculate_sortable_value(response_field, value)
212
+ return unless value.present?
213
+
214
+ self.responses["#{response_field.id}_sortable_value"] = case response_field.field_type
215
+ when "date"
216
+ ['year', 'month', 'day'].each { |x| return 0 unless value[x] && !value[x].blank? }
217
+ DateTime.new(value['year'].to_i, value['month'].to_i, value['day'].to_i).to_i rescue 0
218
+ when "time"
219
+ hours = value['hours'].to_i
220
+ hours += 12 if value['am_pm'] && value['am_pm'] == 'PM'
221
+ (hours*60*60) + (value['minutes'].to_i * 60) + value['seconds'].to_i
222
+ when "file"
223
+ value ? 1 : 0
224
+ when "checkboxes"
225
+ calculate_sortable_value_for_checkboxes(response_field, value)
226
+ return nil
227
+ when "price"
228
+ "#{value['dollars'] || '0'}.#{value['cents'] || '0'}".to_f
229
+ else
230
+ # do we really need to sort more than the first 10 characters of a string?
231
+ value[0..10]
232
+ end
233
+ end
234
+
235
+ def calculate_sortable_value_for_checkboxes(response_field, value)
236
+ (response_field.field_options['options'] || []).each do |option|
237
+ self.responses["#{response_field.id}_sortable_values_#{option['label']}"] = value[option['label']]
238
+ end
239
+ end
240
+
241
+ end
242
+ end
@@ -0,0 +1,47 @@
1
+ module Formbuilder
2
+ class EntryRenderer
3
+ include ActionView::Context
4
+ include ActionView::Helpers::TagHelper
5
+
6
+ def initialize(entry, form, opts = {})
7
+ @entry, @form = entry, form
8
+ @options = opts # merge defaults?
9
+ end
10
+
11
+ def fields
12
+ return_fields = @form.response_fields.reject { |rf| !rf.input_field }
13
+ return_fields.reject! { |rf| rf.blind? } unless @options[:show_blind]
14
+ return_fields
15
+ end
16
+
17
+ def to_html
18
+ content_tag 'dl', class: 'entry-dl' do
19
+ fields.map do |rf|
20
+ """
21
+ <dt>#{field_labels(rf)}</dt>
22
+ <dd>
23
+ #{field_value(rf) || no_value}
24
+ </dd>
25
+ """
26
+ end.join('').html_safe
27
+ end
28
+ end
29
+
30
+ def field_labels(rf)
31
+ """
32
+ #{rf.label}
33
+ #{rf.blind? ? '<span class="label">Blind</span>' : ''}
34
+ #{rf.admin_only? ? '<span class="label">Admin Only</span>' : ''}
35
+ """
36
+ end
37
+
38
+ def no_value
39
+ "<span class='no-response'>No response</span>"
40
+ end
41
+
42
+ def field_value(rf)
43
+ value = @entry.response_value(rf)
44
+ rf.render_entry(value, entry: @entry)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ module Formbuilder
2
+ class EntryTableRenderer < EntryRenderer
3
+ # include ActionView::Context
4
+ # include ActionView::Helpers::TagHelper
5
+
6
+ # def initialize(entry, form, opts = {})
7
+ # @entry, @form = entry, form
8
+ # @options = opts # merge defaults?
9
+ # end
10
+
11
+ # def fields
12
+ # return_fields = @form.response_fields.reject { |rf| rf.non_input_field? }
13
+ # return_fields.reject! { |rf| rf.blind? } unless @options[:show_blind]
14
+ # return_fields
15
+ # end
16
+
17
+ def to_html
18
+ content_tag 'dl', class: 'entry-dl' do
19
+ fields.map do |rf|
20
+ field_value(rf)
21
+ end.join('').html_safe
22
+ end
23
+ end
24
+
25
+ def field_value(rf)
26
+ value = @entry.response_value(rf)
27
+ rf.field_class.render_entry_for_table(rf, value, entry: @entry)
28
+ end
29
+
30
+ # def field_labels(rf)
31
+ # """
32
+ # #{rf.label}
33
+ # #{rf.blind? ? '<span class="label">Blind</span>' : ''}
34
+ # #{rf.admin_only? ? '<span class="label">Admin Only</span>' : ''}
35
+ # """
36
+ # end
37
+
38
+ # def no_value
39
+ # "<span class='no-response'>No response</span>"
40
+ # end
41
+
42
+ # def field_value(rf)
43
+ # value = @entry.response_value(rf)
44
+ # Formbuilder::Fields.const_get(rf.field_type.camelize + 'Field').render_entry(rf, value, entry: @entry)
45
+ # end
46
+
47
+ # def checkboxes_for_table
48
+ # (@response_field.field_options['options'] || []).map do |option|
49
+ # """
50
+ # <td data-column-id='#{@response_field.id}_sortable_values_#{option['label']}'>
51
+ # <i class='#{@value && @value[option['label']] ? 'icon-ok' : 'icon-remove'}'></i>
52
+ # </td>
53
+ # """
54
+ # end.join('')
55
+ # end
56
+
57
+ end
58
+ end
@@ -0,0 +1,107 @@
1
+ module Formbuilder
2
+ class EntryValidator < ActiveModel::Validator
3
+ SHARED_VALIDATION_METHODS = [:min_max_length, :min_max, :integer_only]
4
+
5
+ def validate(record)
6
+ @record = record
7
+
8
+ # I guess it's valid if there's no form?
9
+ return unless record.form
10
+
11
+ # It's also valid if it has already been submitted
12
+ return if record.submitted_at_was
13
+
14
+ # we can also skip validation by setting this flag
15
+ return if record.skip_validation
16
+
17
+ record.form.response_fields.not_admin_only.reject { |rf| !rf.input_field }.each do |response_field|
18
+ @response_field = response_field
19
+ @value = @record.response_value(@response_field)
20
+
21
+ if @response_field.required? && !@record.value_present?(@response_field)
22
+ add_error "can't be blank"
23
+ next
24
+ end
25
+
26
+ if @record.value_present?(@response_field)
27
+ # Field-specific validation
28
+ add_error(@response_field.validate_response(@value))
29
+
30
+ SHARED_VALIDATION_METHODS.each do |method_name|
31
+ run_validation(method_name)
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+ def add_error(msg)
39
+ return unless msg.present?
40
+ @record.errors["responses_#{@response_field.id}"] << msg
41
+ end
42
+
43
+ def run_validation(method_name)
44
+ if (error_message = send(method_name))
45
+ add_error(error_message)
46
+ end
47
+ end
48
+
49
+ def min_max_length
50
+ return unless @response_field.field_options["minlength"].present? ||
51
+ @response_field.field_options["maxlength"].present?
52
+
53
+ if @response_field.field_options["min_max_length_units"] == 'words'
54
+ min_max_length_words
55
+ else
56
+ min_max_length_characters
57
+ end
58
+ end
59
+
60
+ def min_max_length_characters
61
+ if @response_field.field_options["minlength"].present? && (@value.length < @response_field.field_options["minlength"].to_i)
62
+ return "is too short. It should be #{@response_field.field_options["minlength"]} characters or more."
63
+ end
64
+
65
+ if @response_field.field_options["maxlength"].present? && (@value.length > @response_field.field_options["maxlength"].to_i)
66
+ return "is too long. It should be #{@response_field.field_options["maxlength"]} characters or less."
67
+ end
68
+ end
69
+
70
+ def min_max_length_words
71
+ if @response_field.field_options["minlength"].present? && (@value.scan(/\w+/).count < @response_field.field_options["minlength"].to_i)
72
+ return "is too short. It should be #{@response_field.field_options["minlength"]} words or more."
73
+ end
74
+
75
+ if @response_field.field_options["maxlength"].present? && (@value.scan(/\w+/).count > @response_field.field_options["maxlength"].to_i)
76
+ return "is too long. It should be #{@response_field.field_options["maxlength"]} words or less."
77
+ end
78
+ end
79
+
80
+ def min_max
81
+ return unless @response_field.field_options["min"].present? ||
82
+ @response_field.field_options["max"].present?
83
+
84
+ value_for_comparison = case @response_field.field_type
85
+ when 'price'
86
+ "#{@value['dollars'] || 0}.#{@value['cents'] || 0}".to_f
87
+ else
88
+ @value.to_f
89
+ end
90
+
91
+ if @response_field.field_options["min"].present? && (value_for_comparison < @response_field.field_options["min"].to_f)
92
+ return "is too small. It should be #{@response_field.field_options["min"]} or more."
93
+ end
94
+
95
+ if @response_field.field_options["max"].present? && (value_for_comparison > @response_field.field_options["max"].to_f)
96
+ return "is too large. It should be #{@response_field.field_options["max"]} or less."
97
+ end
98
+ end
99
+
100
+ def integer_only
101
+ if @response_field.field_options["integer_only"] && !(Integer(@value) rescue false)
102
+ "only integers allowed."
103
+ end
104
+ end
105
+
106
+ end
107
+ end
@@ -0,0 +1,102 @@
1
+ module Formbuilder
2
+ class FormRenderer
3
+
4
+ include ActionView::Helpers::UrlHelper
5
+ include ActionView::Helpers::FormTagHelper
6
+ include ActionView::Context
7
+
8
+ def protect_against_forgery?; false; end;
9
+
10
+ DEFAULT_OPTIONS = {
11
+ action: '',
12
+ method: 'POST'
13
+ }
14
+
15
+ def initialize(form, entry, options = {})
16
+ @form, @entry = form, entry
17
+ @options = self.class::DEFAULT_OPTIONS.merge(options)
18
+ end
19
+
20
+ def to_html
21
+ form_tag @options[:action], method: @options[:method], class: 'formbuilder-form', multipart: true do
22
+ hidden_fields +
23
+ render_fields +
24
+ render_actions
25
+ end
26
+ end
27
+
28
+ def hidden_fields
29
+ """
30
+ <input type='hidden' name='draft_only' />
31
+ """.html_safe
32
+ end
33
+
34
+ def render_fields
35
+ @form.response_fields.map do |field|
36
+ @field = field
37
+ render_field
38
+ end.join('').html_safe
39
+ end
40
+
41
+ def render_field
42
+ value = @entry.try(:response_value, @field)
43
+
44
+ """
45
+ <div class='response-field-wrapper response-field-#{@field.field_type} #{@entry.try(:error_for, @field) && 'error'}'>
46
+ #{render_label}
47
+ #{@field.render_input(value, entry: @entry)}
48
+ <div class='cf'></div>
49
+ #{render_error}
50
+ #{render_description}
51
+ #{render_length_validations}
52
+ #{render_min_max_validations}
53
+ </div>
54
+ """
55
+ end
56
+
57
+ def render_label
58
+ return unless @field.input_field
59
+
60
+ """
61
+ <label>
62
+ #{@field[:label]}
63
+ #{render_label_required if @field.required?}
64
+ </label>
65
+ """
66
+ end
67
+
68
+ def render_label_required
69
+ "<abbr title='required'>*</abbr>"
70
+ end
71
+
72
+ def render_error
73
+ return unless @field.input_field
74
+ return unless @entry.error_for(@field)
75
+ "<span class='help-block validation-message-wrapper'>#{@entry.error_for(@field)}</span>"
76
+ end
77
+
78
+ def render_description
79
+ return unless @field.input_field
80
+ return if @field[:field_options]["description"].blank?
81
+ "<span class='help-block'>#{simple_format(@field[:field_options]["description"])}</span>"
82
+ end
83
+
84
+ def render_length_validations
85
+ return unless @field.input_field
86
+ return unless !@field.has_length_validations?
87
+ end
88
+
89
+ def render_min_max_validations
90
+ return unless @field.input_field
91
+ end
92
+
93
+ def render_actions
94
+ """
95
+ <div class='form-actions'>
96
+ <button class='button'>Submit</button>
97
+ <a class='button save-draft-button' data-loading-text='All changes saved'>Save draft</a>
98
+ </div>
99
+ """.html_safe
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,3 @@
1
+ module Formbuilder
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,12 @@
1
+ require "formbuilder/engine"
2
+ require "formbuilder/entry"
3
+ require "formbuilder/entry_renderer"
4
+ require "formbuilder/entry_table_renderer"
5
+ require "formbuilder/entry_validator"
6
+ require "formbuilder/form_renderer"
7
+
8
+ module Formbuilder
9
+ def self.root
10
+ File.expand_path('../..', __FILE__)
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :formbuilder do
3
+ # # Task goes here
4
+ # end