formbuilder-rb 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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