cama_contact_form 0.1.0 → 0.1.13
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.
- checksums.yaml +4 -4
- data/README.md +203 -0
- data/Rakefile +6 -21
- data/app/assets/javascripts/plugins/cama_contact_form/admin_editor.js +4 -4
- data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +240 -65
- data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +614 -42
- data/app/controllers/plugins/cama_contact_form/front_controller.rb +29 -5
- data/app/helpers/plugins/cama_contact_form/main_helper.rb +217 -103
- data/app/models/plugins/cama_contact_form/cama_contact_form.rb +60 -25
- data/app/views/plugins/cama_contact_form/admin_forms/_item_field.html.erb +8 -1
- data/app/views/plugins/cama_contact_form/forms_shorcode.html.erb +7 -3
- data/config/camaleon_plugin.json +2 -2
- data/config/initializers/custom_models.rb +5 -2
- data/config/locales/es.yml +8 -1
- data/config/locales/zh-CN.yml +7 -0
- data/config/routes.rb +18 -17
- data/lib/cama_contact_form/engine.rb +12 -0
- data/lib/cama_contact_form/version.rb +3 -1
- data/lib/cama_contact_form.rb +5 -5
- data/lib/tasks/cama_contact_form_tasks.rake +2 -0
- metadata +7 -97
- data/test/cama_contact_form_test.rb +0 -7
- data/test/dummy/README.rdoc +0 -28
- data/test/dummy/Rakefile +0 -6
- data/test/dummy/app/assets/javascripts/application.js +0 -13
- data/test/dummy/app/assets/stylesheets/application.css +0 -15
- data/test/dummy/app/controllers/application_controller.rb +0 -5
- data/test/dummy/app/helpers/application_helper.rb +0 -2
- data/test/dummy/app/views/layouts/application.html.erb +0 -14
- data/test/dummy/bin/bundle +0 -3
- data/test/dummy/bin/rails +0 -4
- data/test/dummy/bin/rake +0 -4
- data/test/dummy/bin/setup +0 -29
- data/test/dummy/config/application.rb +0 -26
- data/test/dummy/config/boot.rb +0 -5
- data/test/dummy/config/database.yml +0 -25
- data/test/dummy/config/environment.rb +0 -5
- data/test/dummy/config/environments/development.rb +0 -41
- data/test/dummy/config/environments/production.rb +0 -79
- data/test/dummy/config/environments/test.rb +0 -42
- data/test/dummy/config/initializers/assets.rb +0 -11
- data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
- data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/test/dummy/config/initializers/inflections.rb +0 -16
- data/test/dummy/config/initializers/mime_types.rb +0 -4
- data/test/dummy/config/initializers/session_store.rb +0 -3
- data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/test/dummy/config/locales/en.yml +0 -23
- data/test/dummy/config/routes.rb +0 -56
- data/test/dummy/config/secrets.yml +0 -22
- data/test/dummy/config.ru +0 -4
- data/test/dummy/public/404.html +0 -67
- data/test/dummy/public/422.html +0 -67
- data/test/dummy/public/500.html +0 -66
- data/test/dummy/public/favicon.ico +0 -0
- data/test/integration/navigation_test.rb +0 -8
- data/test/test_helper.rb +0 -20
|
@@ -1,27 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Public endpoint that receives a form submission (`save_form`) and redisplays the form on refusal.
|
|
1
4
|
class Plugins::CamaContactForm::FrontController < CamaleonCms::Apps::PluginsFrontController
|
|
2
5
|
include Plugins::CamaContactForm::MainHelper
|
|
3
6
|
include Plugins::CamaContactForm::ContactFormControllerConcern
|
|
4
|
-
|
|
7
|
+
|
|
5
8
|
# here add your custom functions
|
|
6
9
|
def save_form
|
|
7
10
|
flash[:contact_form] = {}
|
|
8
|
-
@form = current_site.contact_forms.
|
|
11
|
+
@form = current_site.contact_forms.find_by(id: params[:id])
|
|
9
12
|
fields = params[:fields]
|
|
10
13
|
errors = []
|
|
11
14
|
success = []
|
|
12
15
|
|
|
13
|
-
args = {form: @form, values: fields, flag: true}
|
|
16
|
+
args = { form: @form, values: fields, flag: true }
|
|
17
|
+
hooks_run('contact_form_before_submit', args)
|
|
14
18
|
if args[:flag]
|
|
15
19
|
perform_save_form(@form, fields, success, errors)
|
|
16
20
|
if success.present?
|
|
17
21
|
flash[:contact_form][:notice] = success.join('<br>')
|
|
18
22
|
else
|
|
19
23
|
flash[:contact_form][:error] = errors.join('<br>')
|
|
20
|
-
|
|
24
|
+
# Redisplaying the submission is the only reason it ever reaches the page, so a submission
|
|
25
|
+
# containing anything malign is refused whole and echoed back not at all. Filtering it down
|
|
26
|
+
# to the safe fields would be more work for a worse result: the visitor gets a half-filled
|
|
27
|
+
# form, and every future edit to the rendering has to stay in step with the filter.
|
|
28
|
+
#
|
|
29
|
+
# Stamped with the form it was judged against. The gate walks `@form.fields` for the form
|
|
30
|
+
# named by params[:id], but the redisplay page hands flash[:values] to *every* shortcode on
|
|
31
|
+
# it, and auto-generated cids are `c1, c2, …` in every form -- so a value cleared as textarea
|
|
32
|
+
# content for one form was being rendered into a value attribute by another.
|
|
33
|
+
#
|
|
34
|
+
# `@form` is whatever `find_by_id` returned, which is nil for a deleted or bogus id -- a
|
|
35
|
+
# POST anyone can make without credentials. `unsafe_submitted?` fails closed on that, so the
|
|
36
|
+
# guard below is belt and braces for the `@form.id` read on the line after it.
|
|
37
|
+
if @form.present? && fields.respond_to?(:delete_if) && !unsafe_submitted?(@form, fields)
|
|
38
|
+
flash[:values] = fields.delete_if { |_k, v| v.instance_of?(::ActionDispatch::Http::UploadedFile) }
|
|
39
|
+
flash[:values_form_id] = @form.id
|
|
40
|
+
end
|
|
21
41
|
end
|
|
22
42
|
end
|
|
23
43
|
if Rails::VERSION::MAJOR >= 5
|
|
24
|
-
params[:format] == 'json'
|
|
44
|
+
if params[:format] == 'json'
|
|
45
|
+
render(json: flash.discard(:contact_form).to_hash)
|
|
46
|
+
else
|
|
47
|
+
redirect_back fallback_location: cama_root_path
|
|
48
|
+
end
|
|
25
49
|
else
|
|
26
50
|
params[:format] == 'json' ? render(json: flash.discard(:contact_form).to_hash) : (redirect_to :back)
|
|
27
51
|
end
|
|
@@ -1,70 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# View helpers that render a form's fields to Bootstrap markup for the `[forms]` shortcode.
|
|
1
4
|
module Plugins::CamaContactForm::MainHelper
|
|
2
5
|
include Recaptcha::Adapters::ViewMethods
|
|
6
|
+
|
|
3
7
|
def self.included(klass)
|
|
4
|
-
klass.helper_method [
|
|
8
|
+
klass.helper_method %i[cama_form_element_bootstrap_object cama_form_shortcode]
|
|
9
|
+
rescue StandardError
|
|
10
|
+
''
|
|
11
|
+
# here your methods accessible from views
|
|
5
12
|
end
|
|
6
13
|
|
|
7
14
|
def contact_form_on_export(args)
|
|
8
|
-
args[:obj][:plugins][self_plugin_key] = JSON.parse(current_site.contact_forms.to_json(:
|
|
15
|
+
args[:obj][:plugins][self_plugin_key] = JSON.parse(current_site.contact_forms.to_json(include: [:responses]))
|
|
9
16
|
end
|
|
10
17
|
|
|
11
18
|
def contact_form_on_import(args)
|
|
12
19
|
plugins = args[:data][:plugins]
|
|
13
|
-
if plugins[self_plugin_key.to_sym].
|
|
14
|
-
plugins[self_plugin_key.to_sym].each do |contact|
|
|
15
|
-
unless current_site.contact_forms.where(slug: contact[:slug]).first.present?
|
|
16
|
-
sba_data = ActionController::Parameters.new(contact)
|
|
17
|
-
contact_new = current_site.contact_forms.new(sba_data.permit(:name, :slug, :count, :description, :value, :settings))
|
|
18
|
-
if contact_new.save!
|
|
19
|
-
if contact[:get_field_groups] # save group fields
|
|
20
|
-
save_field_group(contact_new, contact[:get_field_groups])
|
|
21
|
-
end
|
|
22
|
-
save_field_values(contact_new, contact[:field_values])
|
|
20
|
+
return if plugins[self_plugin_key.to_sym].blank?
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
plugins[self_plugin_key.to_sym].each do |contact|
|
|
23
|
+
next if current_site.contact_forms.where(slug: contact[:slug]).first.present?
|
|
24
|
+
|
|
25
|
+
sba_data = ActionController::Parameters.new(contact)
|
|
26
|
+
contact_new = current_site.contact_forms.new(sba_data.permit(:name, :slug, :count, :description, :value,
|
|
27
|
+
:settings))
|
|
28
|
+
next unless contact_new.save!
|
|
29
|
+
|
|
30
|
+
save_field_group(contact_new, contact[:get_field_groups]) if contact[:get_field_groups] # save group fields
|
|
31
|
+
save_field_values(contact_new, contact[:field_values])
|
|
32
|
+
|
|
33
|
+
if contact[:responses].present? # saving responses for this contact
|
|
34
|
+
contact[:responses].each do |response|
|
|
35
|
+
sba_data = ActionController::Parameters.new(response)
|
|
36
|
+
contact_new.responses.create!(sba_data.permit(:name, :slug, :count, :description, :value, :settings))
|
|
32
37
|
end
|
|
33
38
|
end
|
|
39
|
+
args[:messages] << "Saved Plugin Contact Form: #{contact_new.name}"
|
|
34
40
|
end
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
# here all actions on plugin destroying
|
|
38
44
|
# plugin: plugin model
|
|
39
|
-
def contact_form_on_destroy(plugin)
|
|
40
|
-
|
|
41
|
-
end
|
|
45
|
+
def contact_form_on_destroy(plugin); end
|
|
42
46
|
|
|
43
47
|
# here all actions on going to active
|
|
44
48
|
# you can run sql commands like this:
|
|
45
49
|
# results = ActiveRecord::Base.connection.execute(query);
|
|
46
50
|
# plugin: plugin model
|
|
47
|
-
def contact_form_on_active(plugin)
|
|
48
|
-
|
|
49
|
-
end
|
|
51
|
+
def contact_form_on_active(plugin); end
|
|
50
52
|
|
|
51
53
|
# here all actions on going to inactive
|
|
52
54
|
# plugin: plugin model
|
|
53
|
-
def contact_form_on_inactive(plugin)
|
|
54
|
-
|
|
55
|
-
end
|
|
55
|
+
def contact_form_on_inactive(plugin); end
|
|
56
56
|
|
|
57
57
|
def contact_form_admin_before_load
|
|
58
|
-
admin_menu_append_menu_item(
|
|
58
|
+
admin_menu_append_menu_item(
|
|
59
|
+
'settings',
|
|
60
|
+
{ icon: 'envelope-o',
|
|
61
|
+
title: t('plugins.cama_contact_form.title', default: 'Contact Form'),
|
|
62
|
+
url: admin_plugins_cama_contact_form_admin_forms_path,
|
|
63
|
+
datas: "data-intro='This plugin permit you to create you contact forms with desired fields " \
|
|
64
|
+
"and paste your short_code in any content.' data-position='right'" }
|
|
65
|
+
)
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def contact_form_app_before_load
|
|
62
|
-
shortcode_add('forms', plugin_view(
|
|
69
|
+
shortcode_add('forms', plugin_view('forms_shorcode'),
|
|
70
|
+
'This is a shortocode for contact form to permit you to put your contact form in any ' \
|
|
71
|
+
"content. Sample: [forms slug='key-for-my-form']")
|
|
63
72
|
end
|
|
64
73
|
|
|
65
|
-
def contact_form_front_before_load
|
|
66
|
-
|
|
67
|
-
end
|
|
74
|
+
def contact_form_front_before_load; end
|
|
68
75
|
|
|
69
76
|
# ============== HTML ==================
|
|
70
77
|
# This returns the format of the plugin shortcode.
|
|
@@ -72,95 +79,202 @@ module Plugins::CamaContactForm::MainHelper
|
|
|
72
79
|
"[forms slug=#{slug}]"
|
|
73
80
|
end
|
|
74
81
|
|
|
82
|
+
# Nothing in this file escapes anything, by design. Every value it interpolates -- an author's
|
|
83
|
+
# labels, descriptions, classes, default values, wrappers and templates, a visitor's redisplayed
|
|
84
|
+
# submission, and the structural identifiers the form builder generates -- reaches the page
|
|
85
|
+
# VERBATIM.
|
|
86
|
+
#
|
|
87
|
+
# That is safe because it is enforced at the gate rather than at the sink. The admin controller
|
|
88
|
+
# refuses to store content an untrusted author may not write, and refuses structurally malformed
|
|
89
|
+
# values from anyone; the front controller refuses a submission carrying anything malign and
|
|
90
|
+
# echoes it back not at all. Stored content therefore always equals written content, so rendering
|
|
91
|
+
# it verbatim introduces nothing the writer did not put there -- and an author holding
|
|
92
|
+
# :manage, :contact_form_unfiltered_html can put markup, or script, anywhere in a form and have
|
|
93
|
+
# guests receive it exactly as written.
|
|
94
|
+
#
|
|
95
|
+
# Emit a field's custom attributes verbatim.
|
|
96
|
+
#
|
|
97
|
+
# Camaleon's Hash#to_attr_format is deliberately NOT used here. That method escapes values and
|
|
98
|
+
# drops keys that are not valid attribute names, which is exactly right for its other callers --
|
|
99
|
+
# plugins and themes handing it data from who-knows-where -- but wrong for this one.
|
|
100
|
+
# field_attributes is authored content: it is validated when the form is saved, and an author
|
|
101
|
+
# holding the unfiltered-HTML grant is entitled to emit an event handler through it.
|
|
102
|
+
def cf_attrs(attrs)
|
|
103
|
+
(attrs || {}).map { |key, value| "#{key} = \"#{value}\"" }.join(' ')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Substitute every placeholder in ONE pass, so no replacement is ever scanned for the next
|
|
107
|
+
# placeholder. Substituting in sequence meant `[label ci]` was searched for in the field markup
|
|
108
|
+
# `[ci]` had just produced -- so a visitor typing the literal `[label ci]` into a message had the
|
|
109
|
+
# author's label spliced into the middle of the textarea that was echoing it back.
|
|
110
|
+
#
|
|
111
|
+
# `[ci]` and `[descr ci]` keep first-occurrence semantics and `[label ci]` all-occurrence, which is
|
|
112
|
+
# what the three separate sub/sub/gsub calls did.
|
|
113
|
+
#
|
|
114
|
+
# The block form of #gsub is used throughout: a String replacement would treat `\1` or `\\` in the
|
|
115
|
+
# value as a backreference and silently mangle it.
|
|
116
|
+
CF_PLACEHOLDER = /\[(?:ci|label ci|descr ci)\]/
|
|
117
|
+
CF_REPEATABLE_PLACEHOLDERS = ['[label ci]'].freeze
|
|
118
|
+
|
|
119
|
+
def cf_substitute(template, replacements)
|
|
120
|
+
seen = Hash.new(0)
|
|
121
|
+
template.to_s.gsub(CF_PLACEHOLDER) do |placeholder|
|
|
122
|
+
seen[placeholder] += 1
|
|
123
|
+
if seen[placeholder] == 1 || CF_REPEATABLE_PLACEHOLDERS.include?(placeholder)
|
|
124
|
+
replacements.fetch(placeholder, placeholder)
|
|
125
|
+
else
|
|
126
|
+
placeholder
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
75
131
|
# form contact with css bootstrap
|
|
76
132
|
def cama_form_element_bootstrap_object(form, object, values)
|
|
77
|
-
html =
|
|
78
|
-
object.each do |
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
html = ''
|
|
134
|
+
object.each do |obj|
|
|
135
|
+
obj[:label] = obj[:label].to_s.translate
|
|
136
|
+
obj[:description] = obj[:description].to_s.translate
|
|
137
|
+
template = obj[:field_options][:template].presence ||
|
|
138
|
+
Plugins::CamaContactForm::CamaContactForm.field_template
|
|
139
|
+
r = { field: obj, form: form, template: template, custom_class: begin
|
|
140
|
+
obj[:field_options][:field_class]
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end, custom_attrs: { id: obj[:cid] }.merge(begin
|
|
144
|
+
JSON.parse(obj[:field_options][:field_attributes])
|
|
145
|
+
rescue StandardError
|
|
146
|
+
{}
|
|
147
|
+
end) }
|
|
148
|
+
hooks_run('contact_form_item_render', r)
|
|
149
|
+
obj = r[:field]
|
|
150
|
+
obj[:custom_class] = r[:custom_class]
|
|
151
|
+
obj[:custom_attrs] = r[:custom_attrs]
|
|
152
|
+
# `cama_true?`, not `to_bool`: `to_bool` raises ArgumentError on anything outside its two
|
|
153
|
+
# patterns, so a stored `required` of `maybe` was a 500 on every visit to the page.
|
|
154
|
+
obj[:custom_attrs][:required] = 'true' if obj[:required].to_s.cama_true?
|
|
155
|
+
field_options = obj[:field_options]
|
|
156
|
+
for_name = obj[:label].to_s
|
|
157
|
+
f_name = "fields[#{obj[:cid]}]"
|
|
158
|
+
cid = obj[:cid].to_sym
|
|
159
|
+
|
|
160
|
+
temp2 = ''
|
|
161
|
+
|
|
162
|
+
# Both a redisplayed submission and the author's default_value render verbatim: each was
|
|
163
|
+
# validated before it could be stored or stashed, so neither can carry anything the submitter
|
|
164
|
+
# was not permitted to write.
|
|
165
|
+
current_value = values[cid] || obj[:default_value].to_s.translate
|
|
166
|
+
|
|
167
|
+
case obj[:field_type].to_s
|
|
168
|
+
when 'paragraph', 'textarea'
|
|
169
|
+
temp2 = "<textarea #{cf_attrs(obj[:custom_attrs])} name=\"#{f_name}\" " \
|
|
170
|
+
"maxlength=\"#{field_options[:maxlength] || 500}\" " \
|
|
171
|
+
"class=\"#{obj[:custom_class].presence || 'form-control'} \">#{current_value}</textarea>"
|
|
172
|
+
when 'radio'
|
|
173
|
+
temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], obj[:field_type], values)
|
|
174
|
+
when 'checkboxes'
|
|
175
|
+
temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], 'checkbox', values)
|
|
176
|
+
when 'submit'
|
|
177
|
+
temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type=\"#{obj[:field_type]}\" name=\"#{f_name}\" " \
|
|
178
|
+
"class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
|
|
179
|
+
when 'button'
|
|
180
|
+
temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type='button' name=\"#{f_name}\" " \
|
|
181
|
+
"class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
|
|
182
|
+
when 'reset_button'
|
|
183
|
+
temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type='reset' name=\"#{f_name}\" " \
|
|
184
|
+
"class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
|
|
185
|
+
when 'text', 'website', 'email'
|
|
186
|
+
class_type = ''
|
|
187
|
+
class_type = "railscf-field-#{obj[:field_type]}" if obj[:field_type] == 'website'
|
|
188
|
+
class_type = "railscf-field-#{obj[:field_type]}" if obj[:field_type] == 'email'
|
|
189
|
+
temp2 = "<input #{cf_attrs(obj[:custom_attrs])} type=\"#{obj[:field_type]}\" value=\"#{current_value}\" " \
|
|
190
|
+
"name=\"#{f_name}\" class=\"#{obj[:custom_class].presence || 'form-control'} #{class_type}\">"
|
|
191
|
+
when 'captcha'
|
|
192
|
+
if form.recaptcha_enabled?
|
|
193
|
+
temp2 = recaptcha_tags
|
|
122
194
|
else
|
|
195
|
+
# The one field type whose attributes do not go through cf_attrs: cama_captcha_tag builds
|
|
196
|
+
# its markup with Rails' `tag`, which escapes values and rewrites malformed names. That is
|
|
197
|
+
# a documented exception to this file's verbatim contract rather than a gap in it -- `tag`
|
|
198
|
+
# is the stricter of the two, so a trusted author's quoted value is escaped here and a
|
|
199
|
+
# malformed attribute name is mangled rather than emitted. Left as it is deliberately:
|
|
200
|
+
# reproducing cf_attrs would mean rebuilding the helper's output by string surgery, which
|
|
201
|
+
# is a real risk of breaking the captcha for a consistency gain and no security gain.
|
|
202
|
+
captcha_class = "#{obj[:custom_class].presence || 'form-control'} field-captcha required"
|
|
203
|
+
temp2 = cama_captcha_tag(5, {}, { class: captcha_class }.merge(obj[:custom_attrs]))
|
|
204
|
+
end
|
|
205
|
+
when 'file'
|
|
206
|
+
temp2 = "<input multiple=\"multiple\" type=\"file\" value=\"\" name=\"#{f_name}[]\" " \
|
|
207
|
+
"#{cf_attrs(obj[:custom_attrs])} class=\"#{obj[:custom_class].presence || 'form-control'}\">"
|
|
208
|
+
when 'dropdown'
|
|
209
|
+
temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], 'select', values)
|
|
123
210
|
end
|
|
124
|
-
r[:template] = r[:template]
|
|
125
|
-
|
|
126
|
-
|
|
211
|
+
r[:template] = cf_substitute(r[:template],
|
|
212
|
+
'[ci]' => temp2,
|
|
213
|
+
'[descr ci]' => field_options[:description].to_s.translate,
|
|
214
|
+
'[label ci]' => for_name).sub('<p></p>', '')
|
|
215
|
+
html += r[:template]
|
|
127
216
|
end
|
|
128
217
|
html
|
|
129
218
|
end
|
|
130
219
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
220
|
+
# Field control types rendered as a group of <input> tags rather than a <select>.
|
|
221
|
+
RADIO_CHECKBOX_CONTROL_TYPES = %w[radio checkbox].freeze
|
|
222
|
+
|
|
223
|
+
def cama_form_select_multiple_bootstrap(obj, title, type, values)
|
|
224
|
+
# Defensive, because a form saved before the gate required a well-formed option list carries
|
|
225
|
+
# whatever it carries -- and `nil.each`, or `op[:label]` on a String, is a 500 on every visit to
|
|
226
|
+
# the page. `Array()` alone will not do: on a Hash it yields key/value pairs.
|
|
227
|
+
raw_options = obj[:field_options][:options]
|
|
228
|
+
options = (raw_options.is_a?(Hash) ? raw_options.values : Array(raw_options))
|
|
229
|
+
.grep(Hash)
|
|
230
|
+
include_other_option = obj[:field_options][:include_other_option]
|
|
231
|
+
other_input = ''
|
|
135
232
|
|
|
136
|
-
f_name = "fields[#{
|
|
137
|
-
cid =
|
|
138
|
-
html =
|
|
233
|
+
f_name = "fields[#{obj[:cid]}]"
|
|
234
|
+
cid = obj[:cid].to_sym
|
|
235
|
+
html = ''
|
|
139
236
|
|
|
140
|
-
|
|
141
|
-
|
|
237
|
+
custom_class = obj[:custom_class].to_s
|
|
238
|
+
|
|
239
|
+
if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
|
|
240
|
+
other_input = if include_other_option
|
|
241
|
+
"<div class=\"#{type} #{custom_class}\"> <label for=\"#{obj[:cid]}\">" \
|
|
242
|
+
"<input id=\"#{obj[:cid]}-other\" type=\"#{type}\" name=\"#{title.downcase}[]\" class=\"\">" \
|
|
243
|
+
'Other <input type="text" /></label></div>'
|
|
244
|
+
else
|
|
245
|
+
' '
|
|
246
|
+
end
|
|
142
247
|
else
|
|
143
|
-
html = "<select #{
|
|
248
|
+
html = "<select #{cf_attrs(obj[:custom_attrs])} name=\"#{f_name}\" class=\"#{custom_class}\">"
|
|
144
249
|
end
|
|
145
250
|
|
|
146
251
|
options.each do |op|
|
|
147
|
-
label = op[:label].translate
|
|
148
|
-
if type
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
252
|
+
label = op[:label].to_s.translate
|
|
253
|
+
if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
|
|
254
|
+
input_tag = "<input #{cf_attrs(obj[:custom_attrs])} type=\"#{type}\" " \
|
|
255
|
+
"#{'checked' if op[:checked].to_s.cama_true?} name=\"#{f_name}[]\" " \
|
|
256
|
+
"class=\"\" value=\"#{label.downcase}\">"
|
|
257
|
+
html += "<div class=\"#{type} #{custom_class}\">
|
|
258
|
+
<label for=\"#{obj[:cid]}\">
|
|
259
|
+
#{input_tag}
|
|
152
260
|
#{label}
|
|
153
261
|
</label>
|
|
154
262
|
</div>"
|
|
155
263
|
else
|
|
156
|
-
|
|
264
|
+
# A dropdown submits the label lowercased with spaces collapsed to underscores. Derived once
|
|
265
|
+
# so the emitted value and the `selected` comparison cannot drift apart -- but only here:
|
|
266
|
+
# radio and checkbox have always submitted the plain lowercased label, and unifying the two
|
|
267
|
+
# would silently change what every existing response row is compared against.
|
|
268
|
+
option_value = label.downcase.tr(' ', '_')
|
|
269
|
+
html += "<option value=\"#{option_value}\" " \
|
|
270
|
+
"#{'selected' if option_value == values[cid] || op[:checked].to_s.cama_true?} >#{label}</option>"
|
|
157
271
|
end
|
|
158
272
|
end
|
|
159
273
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
274
|
+
html += if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
|
|
275
|
+
other_input
|
|
276
|
+
else
|
|
277
|
+
' </select>'
|
|
278
|
+
end
|
|
165
279
|
end
|
|
166
280
|
end
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A contact form and, through `parent_id`, its stored response rows, scoped to a site.
|
|
1
4
|
class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
|
|
2
5
|
include Plugins::CamaContactForm::MainHelper
|
|
6
|
+
|
|
3
7
|
self.table_name = 'plugins_contact_forms'
|
|
4
|
-
belongs_to :site, class_name:
|
|
8
|
+
belongs_to :site, class_name: 'CamaleonCms::Site'
|
|
5
9
|
# attr_accessible :site_id, :name, :description, :count, :slug, :value, :settings, :parent_id
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
# A self-referential parent/child: a form and its stored response rows. There is no reciprocal
|
|
12
|
+
# `belongs_to :parent` to point `inverse_of` at, so the cop is disabled for this association only.
|
|
13
|
+
# rubocop:disable Rails/InverseOf
|
|
14
|
+
has_many :responses, class_name: 'Plugins::CamaContactForm::CamaContactForm', foreign_key: :parent_id,
|
|
15
|
+
dependent: :destroy
|
|
16
|
+
# rubocop:enable Rails/InverseOf
|
|
8
17
|
validates :name, presence: true
|
|
9
|
-
|
|
18
|
+
validates :slug, uniqueness: { scope: :site_id }
|
|
10
19
|
|
|
11
20
|
before_validation :before_validating
|
|
12
21
|
before_create :fix_save_settings
|
|
@@ -14,64 +23,90 @@ class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
|
|
|
14
23
|
|
|
15
24
|
default_scope { order(created_at: :desc) }
|
|
16
25
|
|
|
26
|
+
# Example of the stored `value` JSON, kept on one line for copy-paste:
|
|
27
|
+
# rubocop:disable Layout/LineLength
|
|
17
28
|
# [{"label":"Untitled","field_type":"text","required":true,"field_options":{"size":"large","field_class":"Default"},"cid":"c2"},{"label":"Untitled","field_type":"paragraph","required":true,"field_options":{"size":"large","field_class":"Default"},"cid":"c6"},{"label":"Untitled","field_type":"captcha","required":true,"field_options":{"field_class":"Default"},"cid":"c10"},{"label":"Untitled","field_type":"checkboxes","required":true,"field_options":{"options":[{"label":"Default","checked":false},{"label":"Default","checked":false}],"field_class":"Default","description":"description\n"},"cid":"c12"}]
|
|
29
|
+
# rubocop:enable Layout/LineLength
|
|
18
30
|
def fields
|
|
19
|
-
@_the_fields ||= JSON.parse(
|
|
20
|
-
@_the_fields[:fields]
|
|
31
|
+
@_the_fields ||= JSON.parse(value || '{"fields": []}').with_indifferent_access
|
|
32
|
+
Array(@_the_fields[:fields])
|
|
21
33
|
end
|
|
22
34
|
|
|
23
35
|
def the_settings
|
|
24
|
-
@
|
|
36
|
+
@the_settings ||= JSON.parse(settings || '{}').with_indifferent_access
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The three settings containers, always readable. A form that has never been saved carries
|
|
40
|
+
# `settings` of `{}`, and a partial update can leave any of them unset -- so every caller in the
|
|
41
|
+
# admin views guards its read with `rescue ''`, while the ones that do not (the public shortcode,
|
|
42
|
+
# the mailer) raised NoMethodError on every request until someone edited the database by hand.
|
|
43
|
+
def mail_settings
|
|
44
|
+
settings_hash(:railscf_mail)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def form_button_settings
|
|
48
|
+
settings_hash(:railscf_form_button)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def message_settings
|
|
52
|
+
settings_hash(:railscf_message)
|
|
25
53
|
end
|
|
26
54
|
|
|
27
55
|
def the_message(key, default)
|
|
28
|
-
r =
|
|
29
|
-
r.
|
|
56
|
+
r = message_settings[key].to_s.translate
|
|
57
|
+
r.presence || default
|
|
30
58
|
end
|
|
31
59
|
|
|
32
60
|
def self.field_template
|
|
33
61
|
"<div class='form-group'>\n\t <label>[label ci]</label>\n\t<p>[descr ci]</p>\n\t<div>[ci]</div> \n</div>"
|
|
34
62
|
end
|
|
35
|
-
|
|
63
|
+
|
|
36
64
|
# define recaptcha settings
|
|
37
65
|
def set_captcha_settings!
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
return unless recaptcha_enabled?
|
|
67
|
+
|
|
68
|
+
Recaptcha.configure do |config|
|
|
69
|
+
config.site_key = the_settings[:recaptcha_site_key]
|
|
70
|
+
config.secret_key = the_settings[:recaptcha_secret_key]
|
|
43
71
|
end
|
|
44
72
|
end
|
|
45
|
-
|
|
73
|
+
|
|
46
74
|
# verify if recaptcha was enabled for this form
|
|
47
|
-
# this method can be overwritten if recaptcha was already defined on initializers to return true as default
|
|
75
|
+
# this method can be overwritten if recaptcha was already defined on initializers to return true as default
|
|
48
76
|
def recaptcha_enabled?
|
|
49
77
|
the_settings[:recaptcha_site_key].present?
|
|
50
78
|
end
|
|
51
79
|
|
|
52
80
|
private
|
|
81
|
+
|
|
82
|
+
def settings_hash(key)
|
|
83
|
+
value = the_settings[key]
|
|
84
|
+
value.is_a?(Hash) ? value : {}.with_indifferent_access
|
|
85
|
+
end
|
|
86
|
+
|
|
53
87
|
def before_validating
|
|
54
88
|
slug = self.slug
|
|
55
|
-
slug =
|
|
89
|
+
slug = name if slug.blank?
|
|
56
90
|
self.slug = slug.to_s.parameterize
|
|
57
91
|
end
|
|
58
92
|
|
|
59
93
|
def fix_save_settings
|
|
60
|
-
self.value = {
|
|
61
|
-
self.settings = {}.to_json
|
|
94
|
+
self.value = { 'fields' => [] }.to_json if value.blank?
|
|
95
|
+
self.settings = {}.to_json if settings.blank?
|
|
62
96
|
end
|
|
63
97
|
|
|
64
98
|
def delete_uploaded_files
|
|
65
|
-
return if
|
|
66
|
-
|
|
99
|
+
return if parent_id.nil?
|
|
100
|
+
|
|
101
|
+
form = self.class.find_by id: parent_id
|
|
67
102
|
response_data = the_settings[:fields]
|
|
68
103
|
file_cids = form.fields
|
|
69
104
|
.select { |f| f[:field_type] == 'file' }
|
|
70
105
|
.map { |f| f[:cid].to_sym }
|
|
71
106
|
|
|
72
107
|
file_cids
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
.flat_map { |cid| response_data[cid] }
|
|
109
|
+
.map { |file| file.sub Rails.application.routes.url_helpers.cama_root_url, Rails.public_path.to_s }
|
|
110
|
+
.each { |file| FileUtils.rm_f file }
|
|
76
111
|
end
|
|
77
|
-
end
|
|
112
|
+
end
|
|
@@ -31,7 +31,14 @@
|
|
|
31
31
|
<%= text_area_tag "fields[#{cid}][field_options][description]", field[:field_options][:description], class: 'form-control translatable' %>
|
|
32
32
|
</div>
|
|
33
33
|
<% case field[:field_type] %>
|
|
34
|
-
|
|
34
|
+
<%# A refused save redraws this editor from the params the author submitted, which is the %>
|
|
35
|
+
<%# whole point -- but those params are exactly the ones that failed validation, so an %>
|
|
36
|
+
<%# option here may be a bare string or the list may be a hash. Coerced rather than %>
|
|
37
|
+
<%# assumed, or the refusal message is replaced by a 500. %>
|
|
38
|
+
<% when 'checkboxes', 'radio', 'select', 'dropdown'
|
|
39
|
+
raw_options = field[:field_options][:options]
|
|
40
|
+
raw_options = raw_options.values if raw_options.is_a?(Hash)
|
|
41
|
+
options = Array(raw_options).map { |o| o.is_a?(Hash) ? o : { label: o.to_s, checked: false } } %>
|
|
35
42
|
<label><%= t('.options', default: 'Options') %></label>
|
|
36
43
|
<ul class="options_sortable" data-options-count="<%= options.size %>">
|
|
37
44
|
<% options.unshift({label: '', checked: false}).each_with_index do |option, index| %>
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
@form = attributes['slug'] == '__first__' ? current_site.contact_forms.first : current_site.contact_forms.where({slug: attributes['slug']}).first
|
|
3
3
|
if @form.present?
|
|
4
4
|
@form.set_captcha_settings!
|
|
5
|
-
|
|
5
|
+
# Only the form the values were judged safe for -- see FrontController#save_form.
|
|
6
|
+
values_fields = (flash[:values].present? && flash[:values_form_id].to_s == @form.id.to_s) ? flash[:values].to_sym : {}
|
|
6
7
|
values = JSON.parse(@form.value).to_sym
|
|
7
|
-
|
|
8
|
+
# `mail_settings`/`form_button_settings`, not `the_settings[...]`: a form that has never been
|
|
9
|
+
# saved, or one saved without these containers, otherwise raised NoMethodError on every visit.
|
|
10
|
+
r = {form: @form, form_class: "railscf-form railscf-form-group", before_form: @form.mail_settings[:previous_html].to_s, after_form: @form.mail_settings[:after_html].to_s, submit: "<div class='form-group'><button class='submit_btn btn btn-default pull-right' type='submit'>[submit_label]</button></div>"}
|
|
8
11
|
hooks_run("contact_form_render", r)
|
|
9
12
|
%>
|
|
10
13
|
<section>
|
|
@@ -14,7 +17,8 @@
|
|
|
14
17
|
<%= render :partial => "camaleon_cms/flash_messages", locals:{ flash: (flash[:contact_form] || {}).with_indifferent_access } %>
|
|
15
18
|
<%= raw cama_form_element_bootstrap_object(r[:form], values[:fields], values_fields) %>
|
|
16
19
|
<% if values[:fields].present? && !values[:fields].delete_if{|field| field[:field_type] != 'submit' }.present? %>
|
|
17
|
-
|
|
20
|
+
<%# element content: rendered as markup, refused on save unless the author is trusted %>
|
|
21
|
+
<%= raw r[:submit].sub('[submit_label]') { @form.form_button_settings[:name_button].to_s } %>
|
|
18
22
|
<% end %>
|
|
19
23
|
<%= raw front_editor_link(edit_admin_plugins_cama_contact_form_admin_form_url(@form.id)) rescue "" %>
|
|
20
24
|
<% end %>
|