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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +57 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +472 -0
  6. data/Rakefile +24 -0
  7. data/app/controllers/concerns/formblocks/request_context.rb +58 -0
  8. data/app/controllers/formblocks/application_controller.rb +16 -0
  9. data/app/controllers/formblocks/assets_controller.rb +27 -0
  10. data/app/controllers/formblocks/blocks_controller.rb +81 -0
  11. data/app/controllers/formblocks/dashboard_controller.rb +41 -0
  12. data/app/controllers/formblocks/forms_controller.rb +71 -0
  13. data/app/controllers/formblocks/pages_controller.rb +44 -0
  14. data/app/controllers/formblocks/public/forms_controller.rb +74 -0
  15. data/app/controllers/formblocks/responses_controller.rb +39 -0
  16. data/app/controllers/formblocks/settings_controller.rb +30 -0
  17. data/app/helpers/formblocks/application_helper.rb +93 -0
  18. data/app/models/concerns/formblocks/attachable.rb +43 -0
  19. data/app/models/formblocks/application_record.rb +7 -0
  20. data/app/models/formblocks/block.rb +177 -0
  21. data/app/models/formblocks/blocks/checkbox.rb +22 -0
  22. data/app/models/formblocks/blocks/email.rb +23 -0
  23. data/app/models/formblocks/blocks/heading.rb +13 -0
  24. data/app/models/formblocks/blocks/hidden.rb +25 -0
  25. data/app/models/formblocks/blocks/image.rb +14 -0
  26. data/app/models/formblocks/blocks/input.rb +59 -0
  27. data/app/models/formblocks/blocks/name.rb +9 -0
  28. data/app/models/formblocks/blocks/paragraph.rb +13 -0
  29. data/app/models/formblocks/blocks/phone.rb +13 -0
  30. data/app/models/formblocks/blocks/radio_group.rb +28 -0
  31. data/app/models/formblocks/blocks/text.rb +12 -0
  32. data/app/models/formblocks/blocks/textarea.rb +8 -0
  33. data/app/models/formblocks/blocks/url.rb +26 -0
  34. data/app/models/formblocks/csv_export.rb +47 -0
  35. data/app/models/formblocks/form.rb +197 -0
  36. data/app/models/formblocks/page.rb +79 -0
  37. data/app/models/formblocks/response.rb +48 -0
  38. data/app/models/formblocks/setting.rb +19 -0
  39. data/app/views/formblocks/blocks/_block.html.erb +102 -0
  40. data/app/views/formblocks/blocks/_palette.html.erb +16 -0
  41. data/app/views/formblocks/forms/_header.html.erb +19 -0
  42. data/app/views/formblocks/forms/edit.html.erb +38 -0
  43. data/app/views/formblocks/forms/index.html.erb +48 -0
  44. data/app/views/formblocks/forms/new.html.erb +26 -0
  45. data/app/views/formblocks/forms/published.html.erb +25 -0
  46. data/app/views/formblocks/forms/settings.html.erb +43 -0
  47. data/app/views/formblocks/pages/_page.html.erb +51 -0
  48. data/app/views/formblocks/public/blocks/_checkbox.html.erb +15 -0
  49. data/app/views/formblocks/public/blocks/_field.html.erb +13 -0
  50. data/app/views/formblocks/public/blocks/_heading.html.erb +1 -0
  51. data/app/views/formblocks/public/blocks/_hidden.html.erb +5 -0
  52. data/app/views/formblocks/public/blocks/_image.html.erb +3 -0
  53. data/app/views/formblocks/public/blocks/_paragraph.html.erb +1 -0
  54. data/app/views/formblocks/public/blocks/_radio_group.html.erb +17 -0
  55. data/app/views/formblocks/public/blocks/_text.html.erb +6 -0
  56. data/app/views/formblocks/public/blocks/_textarea.html.erb +5 -0
  57. data/app/views/formblocks/public/forms/not_found.html.erb +3 -0
  58. data/app/views/formblocks/public/forms/show.html.erb +37 -0
  59. data/app/views/formblocks/public/forms/thanks.html.erb +5 -0
  60. data/app/views/formblocks/responses/index.html.erb +45 -0
  61. data/app/views/formblocks/responses/show.html.erb +43 -0
  62. data/app/views/formblocks/settings/show.html.erb +19 -0
  63. data/app/views/formblocks/shared/_admin.html.erb +21 -0
  64. data/app/views/formblocks/shared/_brand_fields.html.erb +29 -0
  65. data/app/views/formblocks/shared/_errors.html.erb +7 -0
  66. data/app/views/layouts/formblocks/application.html.erb +18 -0
  67. data/app/views/layouts/formblocks/public.html.erb +20 -0
  68. data/config/locales/formblocks.en.yml +188 -0
  69. data/config/routes.rb +35 -0
  70. data/lib/formblocks/assets/admin.css +212 -0
  71. data/lib/formblocks/assets/admin.js +215 -0
  72. data/lib/formblocks/assets/public.css +66 -0
  73. data/lib/formblocks/assets/public.js +86 -0
  74. data/lib/formblocks/assets.rb +81 -0
  75. data/lib/formblocks/configuration.rb +93 -0
  76. data/lib/formblocks/engine.rb +33 -0
  77. data/lib/formblocks/seeds.rb +50 -0
  78. data/lib/formblocks/templates.rb +97 -0
  79. data/lib/formblocks/version.rb +5 -0
  80. data/lib/formblocks.rb +74 -0
  81. data/lib/generators/formblocks/install/install_generator.rb +43 -0
  82. data/lib/generators/formblocks/install/templates/create_formblocks_tables.rb.tt +65 -0
  83. data/lib/generators/formblocks/install/templates/initializer.rb.tt +69 -0
  84. data/lib/generators/formblocks/migration_helpers.rb +40 -0
  85. data/lib/tasks/formblocks_tasks.rake +10 -0
  86. 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ class Heading < Block
6
+ def self.content? = true
7
+
8
+ def self.default_attributes
9
+ { content: I18n.t('formblocks.blocks.defaults.heading') }
10
+ end
11
+ end
12
+ end
13
+ 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ class Name < Text
6
+ def self.autocomplete = 'name'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ class Paragraph < Block
6
+ def self.content? = true
7
+
8
+ def self.default_attributes
9
+ { content: I18n.t('formblocks.blocks.defaults.paragraph') }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ class Phone < Text
6
+ def self.autocomplete = 'tel'
7
+
8
+ def html_input_type
9
+ 'tel'
10
+ end
11
+ end
12
+ end
13
+ 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ # A single-line text input. Name, Email, Phone and Url specialise it.
6
+ class Text < Input
7
+ def public_partial
8
+ 'formblocks/public/blocks/text'
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Blocks
5
+ class Textarea < Input
6
+ end
7
+ end
8
+ 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