formblocks 0.1.0
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 +7 -0
- data/AGENTS.md +57 -0
- data/CHANGELOG.md +24 -0
- data/MIT-LICENSE +20 -0
- data/README.md +472 -0
- data/Rakefile +24 -0
- data/app/controllers/concerns/formblocks/request_context.rb +58 -0
- data/app/controllers/formblocks/application_controller.rb +16 -0
- data/app/controllers/formblocks/assets_controller.rb +27 -0
- data/app/controllers/formblocks/blocks_controller.rb +81 -0
- data/app/controllers/formblocks/dashboard_controller.rb +41 -0
- data/app/controllers/formblocks/forms_controller.rb +71 -0
- data/app/controllers/formblocks/pages_controller.rb +44 -0
- data/app/controllers/formblocks/public/forms_controller.rb +74 -0
- data/app/controllers/formblocks/responses_controller.rb +39 -0
- data/app/controllers/formblocks/settings_controller.rb +30 -0
- data/app/helpers/formblocks/application_helper.rb +93 -0
- data/app/models/concerns/formblocks/attachable.rb +43 -0
- data/app/models/formblocks/application_record.rb +7 -0
- data/app/models/formblocks/block.rb +177 -0
- data/app/models/formblocks/blocks/checkbox.rb +22 -0
- data/app/models/formblocks/blocks/email.rb +23 -0
- data/app/models/formblocks/blocks/heading.rb +13 -0
- data/app/models/formblocks/blocks/hidden.rb +25 -0
- data/app/models/formblocks/blocks/image.rb +14 -0
- data/app/models/formblocks/blocks/input.rb +59 -0
- data/app/models/formblocks/blocks/name.rb +9 -0
- data/app/models/formblocks/blocks/paragraph.rb +13 -0
- data/app/models/formblocks/blocks/phone.rb +13 -0
- data/app/models/formblocks/blocks/radio_group.rb +28 -0
- data/app/models/formblocks/blocks/text.rb +12 -0
- data/app/models/formblocks/blocks/textarea.rb +8 -0
- data/app/models/formblocks/blocks/url.rb +26 -0
- data/app/models/formblocks/csv_export.rb +47 -0
- data/app/models/formblocks/form.rb +197 -0
- data/app/models/formblocks/page.rb +79 -0
- data/app/models/formblocks/response.rb +48 -0
- data/app/models/formblocks/setting.rb +19 -0
- data/app/views/formblocks/blocks/_block.html.erb +102 -0
- data/app/views/formblocks/blocks/_palette.html.erb +16 -0
- data/app/views/formblocks/forms/_header.html.erb +19 -0
- data/app/views/formblocks/forms/edit.html.erb +38 -0
- data/app/views/formblocks/forms/index.html.erb +48 -0
- data/app/views/formblocks/forms/new.html.erb +26 -0
- data/app/views/formblocks/forms/published.html.erb +25 -0
- data/app/views/formblocks/forms/settings.html.erb +43 -0
- data/app/views/formblocks/pages/_page.html.erb +51 -0
- data/app/views/formblocks/public/blocks/_checkbox.html.erb +15 -0
- data/app/views/formblocks/public/blocks/_field.html.erb +13 -0
- data/app/views/formblocks/public/blocks/_heading.html.erb +1 -0
- data/app/views/formblocks/public/blocks/_hidden.html.erb +5 -0
- data/app/views/formblocks/public/blocks/_image.html.erb +3 -0
- data/app/views/formblocks/public/blocks/_paragraph.html.erb +1 -0
- data/app/views/formblocks/public/blocks/_radio_group.html.erb +17 -0
- data/app/views/formblocks/public/blocks/_text.html.erb +6 -0
- data/app/views/formblocks/public/blocks/_textarea.html.erb +5 -0
- data/app/views/formblocks/public/forms/not_found.html.erb +3 -0
- data/app/views/formblocks/public/forms/show.html.erb +37 -0
- data/app/views/formblocks/public/forms/thanks.html.erb +5 -0
- data/app/views/formblocks/responses/index.html.erb +45 -0
- data/app/views/formblocks/responses/show.html.erb +43 -0
- data/app/views/formblocks/settings/show.html.erb +19 -0
- data/app/views/formblocks/shared/_admin.html.erb +21 -0
- data/app/views/formblocks/shared/_brand_fields.html.erb +29 -0
- data/app/views/formblocks/shared/_errors.html.erb +7 -0
- data/app/views/layouts/formblocks/application.html.erb +18 -0
- data/app/views/layouts/formblocks/public.html.erb +20 -0
- data/config/locales/formblocks.en.yml +188 -0
- data/config/routes.rb +35 -0
- data/lib/formblocks/assets/admin.css +212 -0
- data/lib/formblocks/assets/admin.js +215 -0
- data/lib/formblocks/assets/public.css +66 -0
- data/lib/formblocks/assets/public.js +86 -0
- data/lib/formblocks/assets.rb +81 -0
- data/lib/formblocks/configuration.rb +93 -0
- data/lib/formblocks/engine.rb +33 -0
- data/lib/formblocks/seeds.rb +50 -0
- data/lib/formblocks/templates.rb +97 -0
- data/lib/formblocks/version.rb +5 -0
- data/lib/formblocks.rb +74 -0
- data/lib/generators/formblocks/install/install_generator.rb +43 -0
- data/lib/generators/formblocks/install/templates/create_formblocks_tables.rb.tt +65 -0
- data/lib/generators/formblocks/install/templates/initializer.rb.tt +69 -0
- data/lib/generators/formblocks/migration_helpers.rb +40 -0
- data/lib/tasks/formblocks_tasks.rake +10 -0
- metadata +199 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# A block on a page: a piece of content (heading, paragraph, image) or an
|
|
5
|
+
# input the visitor answers. Single-table inheritance; every block is a Ruby
|
|
6
|
+
# class under Formblocks::Blocks, and hosts can subclass and register their own:
|
|
7
|
+
#
|
|
8
|
+
# class Rating < Formblocks::Blocks::Input; end
|
|
9
|
+
# Formblocks::Block.register 'Rating'
|
|
10
|
+
class Block < ApplicationRecord
|
|
11
|
+
include Attachable
|
|
12
|
+
|
|
13
|
+
KEY_FORMAT = /\A[a-z0-9_]+\z/
|
|
14
|
+
|
|
15
|
+
belongs_to :page, inverse_of: :blocks, touch: true
|
|
16
|
+
attachable :image
|
|
17
|
+
|
|
18
|
+
# The palette, in the order the builder shows it. Class names as strings
|
|
19
|
+
# so they autoload on first use.
|
|
20
|
+
class_attribute :registered_types, instance_writer: false, default: %w[
|
|
21
|
+
Formblocks::Blocks::Heading
|
|
22
|
+
Formblocks::Blocks::Paragraph
|
|
23
|
+
Formblocks::Blocks::Image
|
|
24
|
+
Formblocks::Blocks::Name
|
|
25
|
+
Formblocks::Blocks::Email
|
|
26
|
+
Formblocks::Blocks::Phone
|
|
27
|
+
Formblocks::Blocks::Url
|
|
28
|
+
Formblocks::Blocks::Text
|
|
29
|
+
Formblocks::Blocks::Textarea
|
|
30
|
+
Formblocks::Blocks::Hidden
|
|
31
|
+
Formblocks::Blocks::Checkbox
|
|
32
|
+
Formblocks::Blocks::RadioGroup
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
normalizes :key, with: ->(key) { key.to_s.parameterize(separator: '_').presence }
|
|
36
|
+
|
|
37
|
+
before_validation :assign_key, if: :input?
|
|
38
|
+
before_create :assign_position
|
|
39
|
+
|
|
40
|
+
validates :type, inclusion: { in: ->(_block) { Block.registered_types } }
|
|
41
|
+
validates :label, presence: true, if: :label_required?
|
|
42
|
+
validates :label, :placeholder, length: { maximum: 200 }
|
|
43
|
+
validates :help_text, length: { maximum: 500 }
|
|
44
|
+
validates :content, length: { maximum: 10_000 }
|
|
45
|
+
validates :key, presence: true, length: { maximum: 60 }, format: { with: KEY_FORMAT }, if: :input?
|
|
46
|
+
validate :key_unique_within_form, if: :input?
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# "radio_group" for Formblocks::Blocks::RadioGroup — the identifier used
|
|
50
|
+
# in templates, the palette and partial names.
|
|
51
|
+
def kind
|
|
52
|
+
name.demodulize.underscore
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def registry
|
|
56
|
+
registered_types.map(&:constantize)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kinds
|
|
60
|
+
registry.map(&:kind)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def content_registry
|
|
64
|
+
registry.reject(&:input?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def input_registry
|
|
68
|
+
registry.select(&:input?)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def find_kind(kind)
|
|
72
|
+
registry.find { |klass| klass.kind == kind.to_s } ||
|
|
73
|
+
raise(ArgumentError, "Unknown block kind: #{kind.inspect}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def register(class_name)
|
|
77
|
+
Block.registered_types += [class_name.to_s] unless Block.registered_types.include?(class_name.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def display_name
|
|
81
|
+
I18n.t("formblocks.blocks.#{kind}.name", default: kind.humanize)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Capabilities — what the builder shows and the public page renders.
|
|
85
|
+
def input? = false
|
|
86
|
+
def content? = false
|
|
87
|
+
def options? = false
|
|
88
|
+
def placeholder? = false
|
|
89
|
+
def requirable? = false
|
|
90
|
+
def image? = false
|
|
91
|
+
|
|
92
|
+
# Attributes for a block freshly added from the palette.
|
|
93
|
+
def default_attributes
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# `base`, or `base_2`, `base_3`… until it is not in `taken`.
|
|
98
|
+
def unique_key(base, taken)
|
|
99
|
+
candidate = base
|
|
100
|
+
n = 2
|
|
101
|
+
while taken.include?(candidate)
|
|
102
|
+
candidate = "#{base}_#{n}"
|
|
103
|
+
n += 1
|
|
104
|
+
end
|
|
105
|
+
candidate
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
delegate :kind, :input?, :content?, :options?, :placeholder?, :requirable?, :image?, :display_name,
|
|
110
|
+
to: :class
|
|
111
|
+
|
|
112
|
+
# Rendered in the visible flow of the public form (hidden fields are not).
|
|
113
|
+
def visible?
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The partial the public page renders this block with. Subclasses that
|
|
118
|
+
# look alike share one (every single-line text input uses `text`).
|
|
119
|
+
def public_partial
|
|
120
|
+
"formblocks/public/blocks/#{kind}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Swap with the neighbouring block on the same page.
|
|
124
|
+
def move(direction)
|
|
125
|
+
list = page.blocks.reload.to_a
|
|
126
|
+
index = list.index { |block| block.id == id }
|
|
127
|
+
other = direction.to_s == 'up' ? index - 1 : index + 1
|
|
128
|
+
return if index.nil? || other.negative? || other >= list.size
|
|
129
|
+
|
|
130
|
+
list[index], list[other] = list[other], list[index]
|
|
131
|
+
transaction do
|
|
132
|
+
list.each_with_index { |block, i| block.update_column(:position, i + 1) }
|
|
133
|
+
end
|
|
134
|
+
page.blocks.reset
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def label_required?
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Options as the builder edits them: one per line.
|
|
142
|
+
def options_text
|
|
143
|
+
Array(options).join("\n")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def options_text=(text)
|
|
147
|
+
self.options = text.to_s.lines.map(&:strip).compact_blank
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The key an input gets when none was given: from the label ("Work email"
|
|
151
|
+
# → "work_email"), else the kind.
|
|
152
|
+
def default_key
|
|
153
|
+
(label.presence || kind).to_s.parameterize(separator: '_').presence || kind
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def assign_key
|
|
159
|
+
self.key = self.class.unique_key(default_key, sibling_keys) if key.blank?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sibling_keys
|
|
163
|
+
form = page&.form
|
|
164
|
+
return [] unless form&.persisted?
|
|
165
|
+
|
|
166
|
+
form.blocks.where.not(id: id).where.not(key: nil).pluck(:key)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def key_unique_within_form
|
|
170
|
+
errors.add(:key, :taken) if key.present? && sibling_keys.include?(key)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def assign_position
|
|
174
|
+
self.position = (page.blocks.maximum(:position) || 0) + 1 if position.to_i.zero? && page&.persisted?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
# A single yes/no checkbox. Required means it must be ticked.
|
|
6
|
+
class Checkbox < Input
|
|
7
|
+
def self.placeholder? = false
|
|
8
|
+
|
|
9
|
+
def normalize_answer(value)
|
|
10
|
+
ActiveModel::Type::Boolean.new.cast(value) ? true : false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate_answer(value, errors)
|
|
14
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.must_be_checked')) if required? && !value
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def display_answer(value)
|
|
18
|
+
I18n.t(value ? 'formblocks.blocks.defaults.yes' : 'formblocks.blocks.defaults.no')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
class Email < Text
|
|
6
|
+
FORMAT = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
7
|
+
|
|
8
|
+
def self.autocomplete = 'email'
|
|
9
|
+
|
|
10
|
+
def html_input_type
|
|
11
|
+
'email'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def normalize_answer(value)
|
|
15
|
+
super.downcase
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate_present_answer(value, errors)
|
|
19
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.invalid_email')) unless value.to_s.match?(FORMAT)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
# A field the visitor never sees. `key` is its name, `content` its default
|
|
6
|
+
# value; a query parameter with the same name on the public URL overrides
|
|
7
|
+
# the default, so ?utm_source=newsletter lands in the response.
|
|
8
|
+
class Hidden < Input
|
|
9
|
+
def self.placeholder? = false
|
|
10
|
+
def self.requirable? = false
|
|
11
|
+
|
|
12
|
+
def visible?
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def label_required?
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def default_value
|
|
21
|
+
content.to_s
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
# An uploaded image. `label` doubles as the alt text.
|
|
6
|
+
class Image < Block
|
|
7
|
+
def self.image? = true
|
|
8
|
+
|
|
9
|
+
def image_attached?
|
|
10
|
+
Formblocks.attachments? && image.attached?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
# Base of every block a visitor answers. Subclasses set the HTML input
|
|
6
|
+
# type, autocomplete hint and validation; the answer is stored in the
|
|
7
|
+
# response's `answers` hash under the block's `key`.
|
|
8
|
+
class Input < Block
|
|
9
|
+
MAX_ANSWER_LENGTH = 10_000
|
|
10
|
+
|
|
11
|
+
def self.input? = true
|
|
12
|
+
def self.placeholder? = true
|
|
13
|
+
def self.requirable? = true
|
|
14
|
+
|
|
15
|
+
def self.default_attributes
|
|
16
|
+
{ label: display_name }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The HTML autocomplete token, or nil.
|
|
20
|
+
def self.autocomplete
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def html_input_type
|
|
25
|
+
'text'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
delegate :autocomplete, to: :class
|
|
29
|
+
|
|
30
|
+
def label_required?
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The submitted value as it is stored.
|
|
35
|
+
def normalize_answer(value)
|
|
36
|
+
value.to_s.strip
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Adds errors to `errors` under this block's key.
|
|
40
|
+
def validate_answer(value, errors)
|
|
41
|
+
if value.blank?
|
|
42
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.required')) if required?
|
|
43
|
+
elsif value.to_s.length > MAX_ANSWER_LENGTH
|
|
44
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.too_long'))
|
|
45
|
+
else
|
|
46
|
+
validate_present_answer(value, errors)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Type-specific checks on a non-blank answer.
|
|
51
|
+
def validate_present_answer(_value, _errors); end
|
|
52
|
+
|
|
53
|
+
# The answer as the dashboard and CSV show it.
|
|
54
|
+
def display_answer(value)
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Blocks
|
|
5
|
+
# Pick one of several options.
|
|
6
|
+
class RadioGroup < Input
|
|
7
|
+
def self.options? = true
|
|
8
|
+
def self.placeholder? = false
|
|
9
|
+
|
|
10
|
+
def self.default_attributes
|
|
11
|
+
{ label: display_name,
|
|
12
|
+
options: (1..3).map { |n| I18n.t('formblocks.blocks.defaults.option', n:) } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
validate :options_present
|
|
16
|
+
|
|
17
|
+
def validate_present_answer(value, errors)
|
|
18
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.invalid_option')) unless Array(options).include?(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def options_present
|
|
24
|
+
errors.add(:options, :blank) if Array(options).compact_blank.empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Formblocks
|
|
6
|
+
module Blocks
|
|
7
|
+
class Url < Text
|
|
8
|
+
def self.autocomplete = 'url'
|
|
9
|
+
|
|
10
|
+
def html_input_type
|
|
11
|
+
'url'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate_present_answer(value, errors)
|
|
15
|
+
uri = begin
|
|
16
|
+
URI.parse(value.to_s)
|
|
17
|
+
rescue URI::InvalidURIError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
return if uri.is_a?(URI::HTTP) && uri.host.present?
|
|
21
|
+
|
|
22
|
+
errors.add(key.to_sym, I18n.t('formblocks.errors.invalid_url'))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
|
|
5
|
+
module Formblocks
|
|
6
|
+
# A form's responses as CSV: one column per input block (labelled), plus a
|
|
7
|
+
# column for any key a since-deleted block left behind, so nothing that
|
|
8
|
+
# was collected is lost.
|
|
9
|
+
class CsvExport
|
|
10
|
+
# Cells starting with these would be evaluated as formulas by
|
|
11
|
+
# spreadsheets; a leading apostrophe makes them text.
|
|
12
|
+
FORMULA_LEADERS = ['=', '+', '-', '@', "\t", "\r"].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :form, :responses
|
|
15
|
+
|
|
16
|
+
def initialize(form, responses = nil)
|
|
17
|
+
@form = form
|
|
18
|
+
@responses = (responses || form.responses.newest_first).to_a
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def filename
|
|
22
|
+
"#{form.slug}-responses-#{Date.current.iso8601}.csv"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def generate
|
|
26
|
+
blocks = form.input_blocks
|
|
27
|
+
known = blocks.map(&:key)
|
|
28
|
+
orphans = responses.flat_map { |r| r.answers.to_h.keys }.uniq - known
|
|
29
|
+
|
|
30
|
+
CSV.generate do |csv|
|
|
31
|
+
csv << [I18n.t('formblocks.responses.csv.submitted_at'), *blocks.map do |b|
|
|
32
|
+
b.label.presence || b.key
|
|
33
|
+
end, *orphans]
|
|
34
|
+
responses.each do |response|
|
|
35
|
+
cells = blocks.map { |b| b.display_answer(response.answer(b)) } +
|
|
36
|
+
orphans.map { |key| response.answers.to_h[key].to_s }
|
|
37
|
+
csv << [response.created_at.iso8601, *cells.map { |cell| self.class.safe(cell) }]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.safe(value)
|
|
43
|
+
value = value.to_s
|
|
44
|
+
FORMULA_LEADERS.any? { |leader| value.start_with?(leader) } ? "'#{value}" : value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|