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,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# A form: a title, a slug for its public URL, brand colors, a logo, and its
|
|
5
|
+
# pages. Every form has at least one step page and exactly one thank-you
|
|
6
|
+
# page, which is always last.
|
|
7
|
+
class Form < ApplicationRecord
|
|
8
|
+
include Attachable
|
|
9
|
+
|
|
10
|
+
COLOR_FORMAT = /\A#(?:[0-9a-f]{3}){1,2}\z/i
|
|
11
|
+
SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
12
|
+
|
|
13
|
+
has_many :pages, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :form
|
|
14
|
+
has_many :blocks, through: :pages
|
|
15
|
+
has_many :responses, dependent: :destroy, inverse_of: :form
|
|
16
|
+
attachable :logo
|
|
17
|
+
|
|
18
|
+
normalizes :slug, with: ->(slug) { slug.to_s.strip.downcase.presence }
|
|
19
|
+
normalizes :primary_color, :button_text_color, with: ->(color) { color.to_s.strip.downcase.presence }
|
|
20
|
+
|
|
21
|
+
before_validation :assign_slug
|
|
22
|
+
after_create :ensure_pages
|
|
23
|
+
|
|
24
|
+
validates :title, presence: true, length: { maximum: 200 }
|
|
25
|
+
validates :slug, presence: true, length: { maximum: 100 }, uniqueness: true,
|
|
26
|
+
format: { with: SLUG_FORMAT }
|
|
27
|
+
validates :primary_color, :button_text_color, format: { with: COLOR_FORMAT }, allow_nil: true
|
|
28
|
+
|
|
29
|
+
scope :for_tenant, ->(tenant) { where(tenant: tenant.presence&.to_s) }
|
|
30
|
+
scope :newest_first, -> { order(updated_at: :desc, id: :desc) }
|
|
31
|
+
scope :published, -> { where.not(published_at: nil) }
|
|
32
|
+
|
|
33
|
+
def published?
|
|
34
|
+
published_at.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def status
|
|
38
|
+
published? ? 'published' : 'draft'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def publish!
|
|
42
|
+
update!(published_at: published_at || Time.current)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unpublish!
|
|
46
|
+
update!(published_at: nil)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The pages a visitor fills in, in order. The thank-you page is separate.
|
|
50
|
+
def steps
|
|
51
|
+
pages.select(&:step?)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def thank_you_page
|
|
55
|
+
pages.find(&:thank_you?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Every input a visitor can answer, in page order. Answers and CSV columns
|
|
59
|
+
# follow this order.
|
|
60
|
+
def input_blocks
|
|
61
|
+
steps.flat_map(&:blocks).select(&:input?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The tenant's global settings — the fallback for colors and logo.
|
|
65
|
+
def settings
|
|
66
|
+
@settings ||= Setting.for_tenant(tenant)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def effective_primary_color
|
|
70
|
+
primary_color.presence || settings.primary_color.presence || Formblocks.config.default_primary_color
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def effective_button_text_color
|
|
74
|
+
button_text_color.presence || settings.button_text_color.presence ||
|
|
75
|
+
Formblocks.config.default_button_text_color
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The form's own logo, else the global one, else nil.
|
|
79
|
+
def effective_logo
|
|
80
|
+
return unless Formblocks.attachments?
|
|
81
|
+
return logo if logo.attached?
|
|
82
|
+
|
|
83
|
+
settings.logo if settings.logo.attached?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Puts the thank-you page last and numbers the pages 1..n. Called after
|
|
87
|
+
# anything that adds, removes or moves a page.
|
|
88
|
+
def resequence_pages!
|
|
89
|
+
steps, rest = pages.reload.partition(&:step?)
|
|
90
|
+
(steps + rest).each_with_index do |page, index|
|
|
91
|
+
page.update_column(:position, index + 1) unless page.position == index + 1
|
|
92
|
+
end
|
|
93
|
+
pages.reset
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A deep copy — pages, blocks, logo and images — as a new draft with a
|
|
97
|
+
# fresh slug. Responses are not copied.
|
|
98
|
+
def duplicate
|
|
99
|
+
copy = dup
|
|
100
|
+
copy.assign_attributes(title: "#{title} (copy)", slug: nil, published_at: nil, responses_count: 0)
|
|
101
|
+
attachments = [[self, copy, :logo]]
|
|
102
|
+
|
|
103
|
+
pages.each do |page|
|
|
104
|
+
page_copy = page.dup
|
|
105
|
+
copy.pages << page_copy
|
|
106
|
+
page.blocks.each do |block|
|
|
107
|
+
block_copy = block.dup
|
|
108
|
+
page_copy.blocks << block_copy
|
|
109
|
+
attachments << [block, block_copy, :image]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
transaction do
|
|
114
|
+
copy.save!
|
|
115
|
+
attachments.each { |from, to, name| self.class.copy_attachment(from, to, name) }
|
|
116
|
+
end
|
|
117
|
+
copy
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class << self
|
|
121
|
+
# Builds (does not save) a form from a template definition — see
|
|
122
|
+
# Formblocks::Templates for the shape.
|
|
123
|
+
def from_template(definition, tenant: nil, title: nil)
|
|
124
|
+
definition = definition.to_h.deep_symbolize_keys
|
|
125
|
+
form = new(title: title.presence || definition[:title], tenant:)
|
|
126
|
+
keys = []
|
|
127
|
+
|
|
128
|
+
Array(definition[:pages]).each_with_index do |page_def, index|
|
|
129
|
+
page = form.pages.build(kind: 'step', position: index + 1, button_text: page_def[:button_text])
|
|
130
|
+
build_blocks(page, page_def[:blocks], keys)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
thank_you = form.pages.build(kind: 'thank_you', position: form.pages.size + 1)
|
|
134
|
+
build_blocks(thank_you, definition.dig(:thank_you, :blocks), keys)
|
|
135
|
+
form
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Attachments are copied blob-for-blob rather than shared: purging a
|
|
139
|
+
# shared blob from one record would take it away from the other.
|
|
140
|
+
def copy_attachment(from, to, name)
|
|
141
|
+
return unless Formblocks.attachments?
|
|
142
|
+
|
|
143
|
+
source = from.public_send(name)
|
|
144
|
+
return unless source.attached?
|
|
145
|
+
|
|
146
|
+
blob = source.blob
|
|
147
|
+
to.public_send(name).attach(io: StringIO.new(blob.download), filename: blob.filename.to_s,
|
|
148
|
+
content_type: blob.content_type)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def build_blocks(page, definitions, keys)
|
|
154
|
+
Array(definitions).each_with_index do |attrs, index|
|
|
155
|
+
attrs = attrs.to_h.symbolize_keys
|
|
156
|
+
klass = Block.find_kind(attrs.delete(:type))
|
|
157
|
+
block = page.blocks.build(attrs.merge(type: klass.name, position: index + 1))
|
|
158
|
+
next unless block.input?
|
|
159
|
+
|
|
160
|
+
block.key = Block.unique_key(block.key.presence || block.default_key, keys)
|
|
161
|
+
keys << block.key
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# From the title on first save, or whenever the slug was blanked in the
|
|
169
|
+
# settings. A taken slug gets a numeric suffix; a slug the user typed
|
|
170
|
+
# themselves is validated instead, so they see why it was refused.
|
|
171
|
+
def assign_slug
|
|
172
|
+
return if slug.present?
|
|
173
|
+
|
|
174
|
+
base = title.to_s.parameterize.presence || 'form'
|
|
175
|
+
base = base.first(90)
|
|
176
|
+
candidate = base
|
|
177
|
+
n = 2
|
|
178
|
+
while self.class.where.not(id: id).exists?(slug: candidate)
|
|
179
|
+
candidate = "#{base}-#{n}"
|
|
180
|
+
n += 1
|
|
181
|
+
end
|
|
182
|
+
self.slug = candidate
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ensure_pages
|
|
186
|
+
pages.create!(kind: 'step') if pages.none?(&:step?)
|
|
187
|
+
if pages.none?(&:thank_you?)
|
|
188
|
+
thank_you = pages.create!(kind: 'thank_you')
|
|
189
|
+
thank_you.blocks.create!(type: 'Formblocks::Blocks::Heading',
|
|
190
|
+
content: I18n.t('formblocks.public.thanks_title'))
|
|
191
|
+
thank_you.blocks.create!(type: 'Formblocks::Blocks::Paragraph',
|
|
192
|
+
content: I18n.t('formblocks.public.thanks_message'))
|
|
193
|
+
end
|
|
194
|
+
resequence_pages!
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# One screen of a form. `step` pages hold the blocks a visitor fills in and
|
|
5
|
+
# end in a button; the single `thank_you` page is shown after submission
|
|
6
|
+
# and takes content blocks only.
|
|
7
|
+
class Page < ApplicationRecord
|
|
8
|
+
KINDS = %w[step thank_you].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :form, inverse_of: :pages, touch: true
|
|
11
|
+
has_many :blocks, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :page
|
|
12
|
+
|
|
13
|
+
validates :kind, inclusion: { in: KINDS }
|
|
14
|
+
validates :button_text, length: { maximum: 60 }
|
|
15
|
+
|
|
16
|
+
before_create :assign_position
|
|
17
|
+
|
|
18
|
+
def step?
|
|
19
|
+
kind == 'step'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def thank_you?
|
|
23
|
+
kind == 'thank_you'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 1-based position among the step pages.
|
|
27
|
+
def number
|
|
28
|
+
form.steps.index { |page| page.id == id }.to_i + 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def last_step?
|
|
32
|
+
form.steps.last&.id == id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The visitor-facing button: the custom text, else "Next" or "Submit".
|
|
36
|
+
def button_label
|
|
37
|
+
button_text.presence || I18n.t(last_step? ? 'formblocks.public.submit' : 'formblocks.public.next')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Which block classes the builder may add here.
|
|
41
|
+
def allowed_block_classes
|
|
42
|
+
thank_you? ? Block.content_registry : Block.registry
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Swap with the neighbouring step. The thank-you page never moves.
|
|
46
|
+
def move(direction)
|
|
47
|
+
return unless step?
|
|
48
|
+
|
|
49
|
+
list = form.steps
|
|
50
|
+
index = list.index { |page| page.id == id }
|
|
51
|
+
other = direction.to_s == 'up' ? index - 1 : index + 1
|
|
52
|
+
return if index.nil? || other.negative? || other >= list.size
|
|
53
|
+
|
|
54
|
+
list[index], list[other] = list[other], list[index]
|
|
55
|
+
transaction do
|
|
56
|
+
list.each_with_index { |page, i| page.update_column(:position, i + 1) }
|
|
57
|
+
form.thank_you_page&.update_column(:position, list.size + 1)
|
|
58
|
+
end
|
|
59
|
+
form.pages.reset
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reorder the blocks to match `ids`; ids that are not on this page are
|
|
63
|
+
# ignored, blocks missing from `ids` keep their relative order at the end.
|
|
64
|
+
def reorder_blocks(ids)
|
|
65
|
+
ids = Array(ids).map(&:to_s)
|
|
66
|
+
ordered = blocks.reload.sort_by.with_index { |block, i| [ids.index(block.id.to_s) || ids.size, i] }
|
|
67
|
+
transaction do
|
|
68
|
+
ordered.each_with_index { |block, i| block.update_column(:position, i + 1) }
|
|
69
|
+
end
|
|
70
|
+
blocks.reset
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def assign_position
|
|
76
|
+
self.position = (form.pages.maximum(:position) || 0) + 1 if position.to_i.zero? && form&.persisted?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# One submission of a form. `answers` is a hash keyed by block key
|
|
5
|
+
# ("email" => "ada@example.com"); validation runs every input block's rules
|
|
6
|
+
# against it, so the public page can show an error under each field.
|
|
7
|
+
class Response < ApplicationRecord
|
|
8
|
+
belongs_to :form, inverse_of: :responses, counter_cache: true
|
|
9
|
+
|
|
10
|
+
attribute :answers, default: -> { {} }
|
|
11
|
+
|
|
12
|
+
before_validation { self.tenant = form&.tenant }
|
|
13
|
+
validate :validate_answers
|
|
14
|
+
|
|
15
|
+
after_create_commit :notify_host
|
|
16
|
+
|
|
17
|
+
scope :for_tenant, ->(tenant) { where(tenant: tenant.presence&.to_s) }
|
|
18
|
+
scope :newest_first, -> { order(created_at: :desc, id: :desc) }
|
|
19
|
+
|
|
20
|
+
# Takes the submitted values (a hash keyed by block key) and keeps only the
|
|
21
|
+
# form's own inputs, each normalized by its block.
|
|
22
|
+
def fill(submitted)
|
|
23
|
+
submitted = submitted.to_h.stringify_keys
|
|
24
|
+
self.answers = form.input_blocks.to_h do |block|
|
|
25
|
+
[block.key, block.normalize_answer(submitted[block.key])]
|
|
26
|
+
end
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def answer(block)
|
|
31
|
+
answers.to_h[block.key]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def error_for(block)
|
|
35
|
+
errors[block.key].first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_answers
|
|
41
|
+
form.input_blocks.each { |block| block.validate_answer(answer(block), errors) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notify_host
|
|
45
|
+
Formblocks.config.on_submit.call(self)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# Per-tenant brand defaults: colors and a logo every form inherits unless
|
|
5
|
+
# it sets its own. One row per tenant, created on first save.
|
|
6
|
+
class Setting < ApplicationRecord
|
|
7
|
+
include Attachable
|
|
8
|
+
|
|
9
|
+
attachable :logo
|
|
10
|
+
|
|
11
|
+
normalizes :primary_color, :button_text_color, with: ->(color) { color.to_s.strip.downcase.presence }
|
|
12
|
+
|
|
13
|
+
validates :primary_color, :button_text_color, format: { with: Form::COLOR_FORMAT }, allow_nil: true
|
|
14
|
+
|
|
15
|
+
def self.for_tenant(tenant)
|
|
16
|
+
find_or_initialize_by(tenant: tenant.presence&.to_s)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<% page = block.page %>
|
|
2
|
+
<% form = page.form %>
|
|
3
|
+
<article class="fb-block" id="<%= dom_id(block) %>" data-fb-sortable-target="item" data-id="<%= block.id %>">
|
|
4
|
+
<header class="fb-block__header">
|
|
5
|
+
<button type="button" class="fb-block__grip" data-fb-sortable-handle title="<%= t('formblocks.forms.edit.drag') %>"
|
|
6
|
+
aria-label="<%= t('formblocks.forms.edit.drag') %>"><%= fb_icon(:grip) %></button>
|
|
7
|
+
<span class="fb-block__type"><%= block.display_name %></span>
|
|
8
|
+
<div class="fb-actions">
|
|
9
|
+
<%= button_to move_form_page_block_path(form, page, block, direction: 'up'), method: :patch, class: 'fb-iconbtn',
|
|
10
|
+
title: t('formblocks.actions.move_up'), 'aria-label': t('formblocks.actions.move_up'),
|
|
11
|
+
form: { data: { turbo_action: 'replace' } } do %><%= fb_icon(:up) %><% end %>
|
|
12
|
+
<%= button_to move_form_page_block_path(form, page, block, direction: 'down'), method: :patch, class: 'fb-iconbtn',
|
|
13
|
+
title: t('formblocks.actions.move_down'), 'aria-label': t('formblocks.actions.move_down'),
|
|
14
|
+
form: { data: { turbo_action: 'replace' } } do %><%= fb_icon(:down) %><% end %>
|
|
15
|
+
<%= button_to form_page_block_path(form, page, block), method: :delete, class: 'fb-iconbtn fb-iconbtn--danger',
|
|
16
|
+
title: t('formblocks.actions.delete'), 'aria-label': t('formblocks.actions.delete'),
|
|
17
|
+
form: { data: { turbo_confirm: t('formblocks.actions.confirm_delete_block') } } do %><%= fb_icon(:trash) %><% end %>
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<%= form_with model: block, scope: :block, url: form_page_block_path(form, page, block), method: :patch, id: dom_id(block, :form),
|
|
22
|
+
class: 'fb-block__form',
|
|
23
|
+
data: { controller: 'fb-autosave',
|
|
24
|
+
action: 'input->fb-autosave#queue change->fb-autosave#save turbo:submit-end->fb-autosave#finished' } do |f| %>
|
|
25
|
+
<%= render 'formblocks/shared/errors', record: block %>
|
|
26
|
+
|
|
27
|
+
<% if block.content? %>
|
|
28
|
+
<div class="fb-field">
|
|
29
|
+
<%= f.label :content, t('formblocks.fields.content'), class: 'fb-label' %>
|
|
30
|
+
<%= f.text_area :content, rows: block.kind == 'heading' ? 1 : 3, class: 'fb-input' %>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if block.image? %>
|
|
35
|
+
<% if fb_stored?(block.image) %>
|
|
36
|
+
<figure class="fb-block__image"><%= image_tag fb_attachment_url(block.image), alt: block.label.to_s %></figure>
|
|
37
|
+
<label class="fb-check"><%= f.check_box :remove_image %> <%= t('formblocks.fields.remove_image') %></label>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% if Formblocks.attachments? %>
|
|
40
|
+
<div class="fb-field">
|
|
41
|
+
<%= f.label :image, t('formblocks.fields.image'), class: 'fb-label' %>
|
|
42
|
+
<%= f.file_field :image, accept: 'image/*', class: 'fb-input fb-input--file' %>
|
|
43
|
+
</div>
|
|
44
|
+
<% else %>
|
|
45
|
+
<p class="fb-hint"><%= t('formblocks.fields.attachments_off') %></p>
|
|
46
|
+
<% end %>
|
|
47
|
+
<div class="fb-field">
|
|
48
|
+
<%= f.label :label, t('formblocks.fields.alt_text'), class: 'fb-label' %>
|
|
49
|
+
<%= f.text_field :label, class: 'fb-input' %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
|
|
53
|
+
<% if block.input? %>
|
|
54
|
+
<div class="fb-grid">
|
|
55
|
+
<div class="fb-field">
|
|
56
|
+
<%= f.label :label, t('formblocks.fields.label'), class: 'fb-label' %>
|
|
57
|
+
<%= f.text_field :label, class: 'fb-input' %>
|
|
58
|
+
</div>
|
|
59
|
+
<% if block.placeholder? %>
|
|
60
|
+
<div class="fb-field">
|
|
61
|
+
<%= f.label :placeholder, t('formblocks.fields.placeholder'), class: 'fb-label' %>
|
|
62
|
+
<%= f.text_field :placeholder, class: 'fb-input' %>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
<% unless block.visible? %>
|
|
67
|
+
<div class="fb-grid">
|
|
68
|
+
<div class="fb-field">
|
|
69
|
+
<%= f.label :key, t('formblocks.fields.key'), class: 'fb-label' %>
|
|
70
|
+
<%= f.text_field :key, class: 'fb-input fb-input--mono', autocomplete: 'off', spellcheck: false %>
|
|
71
|
+
<p class="fb-hint"><%= t('formblocks.fields.key_hint') %></p>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="fb-field">
|
|
74
|
+
<%= f.label :content, t('formblocks.fields.value'), class: 'fb-label' %>
|
|
75
|
+
<%= f.text_field :content, class: 'fb-input' %>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
<% if block.options? %>
|
|
80
|
+
<div class="fb-field">
|
|
81
|
+
<%= f.label :options_text, t('formblocks.fields.options'), class: 'fb-label' %>
|
|
82
|
+
<%= f.text_area :options_text, rows: 4, class: 'fb-input' %>
|
|
83
|
+
<p class="fb-hint"><%= t('formblocks.fields.options_hint') %></p>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
<% if block.visible? %>
|
|
87
|
+
<div class="fb-field">
|
|
88
|
+
<%= f.label :help_text, t('formblocks.fields.help_text'), class: 'fb-label' %>
|
|
89
|
+
<%= f.text_field :help_text, class: 'fb-input' %>
|
|
90
|
+
</div>
|
|
91
|
+
<% end %>
|
|
92
|
+
<% if block.requirable? %>
|
|
93
|
+
<label class="fb-check"><%= f.check_box :required %> <%= t('formblocks.fields.required') %></label>
|
|
94
|
+
<% end %>
|
|
95
|
+
<% end %>
|
|
96
|
+
|
|
97
|
+
<div class="fb-block__footer">
|
|
98
|
+
<span class="fb-autosave-status" data-fb-autosave-target="status" aria-live="polite"></span>
|
|
99
|
+
<button class="fb-btn fb-btn--sm fb-nojs" type="submit"><%= t('formblocks.actions.save') %></button>
|
|
100
|
+
</div>
|
|
101
|
+
<% end %>
|
|
102
|
+
</article>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<details class="fb-palette">
|
|
2
|
+
<summary class="fb-btn fb-btn--ghost fb-palette__toggle"><%= fb_icon(:plus) %> <%= t('formblocks.forms.edit.add_block') %></summary>
|
|
3
|
+
<div class="fb-palette__menu">
|
|
4
|
+
<% classes = page.allowed_block_classes %>
|
|
5
|
+
<% { content: classes.reject(&:input?), input: classes.select(&:input?) }.each do |group, group_classes| %>
|
|
6
|
+
<% next if group_classes.empty? %>
|
|
7
|
+
<div class="fb-palette__group">
|
|
8
|
+
<p class="fb-palette__title"><%= t("formblocks.forms.edit.#{group}_blocks") %></p>
|
|
9
|
+
<% group_classes.each do |klass| %>
|
|
10
|
+
<%= button_to klass.display_name, form_page_blocks_path(form, page, kind: klass.kind),
|
|
11
|
+
class: 'fb-palette__item', form: { data: { turbo_action: 'replace' } } %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
</details>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<header class="fb-form-header">
|
|
2
|
+
<%= link_to forms_path, class: 'fb-backlink' do %><%= fb_icon(:back) %> <%= t('formblocks.nav.forms') %><% end %>
|
|
3
|
+
<div class="fb-form-header__main">
|
|
4
|
+
<h1 class="fb-h1"><%= form.title %></h1>
|
|
5
|
+
<%= fb_status_badge(form) %>
|
|
6
|
+
<% if form.published? %>
|
|
7
|
+
<%= link_to fb_public_url(form), fb_public_url(form), class: 'fb-link fb-small fb-mono', target: '_blank', rel: 'noopener' %>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
10
|
+
<nav class="fb-tabs">
|
|
11
|
+
<%= link_to t('formblocks.forms.tabs.builder'), edit_form_path(form),
|
|
12
|
+
class: class_names('fb-tab', 'is-active': tab == :builder) %>
|
|
13
|
+
<%= link_to t('formblocks.forms.tabs.settings'), settings_form_path(form),
|
|
14
|
+
class: class_names('fb-tab', 'is-active': tab == :settings) %>
|
|
15
|
+
<%= link_to form_responses_path(form), class: class_names('fb-tab', 'is-active': tab == :responses) do %>
|
|
16
|
+
<%= t('formblocks.forms.tabs.responses') %> <span class="fb-count"><%= form.responses_count %></span>
|
|
17
|
+
<% end %>
|
|
18
|
+
</nav>
|
|
19
|
+
</header>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% content_for :title, @form.title %>
|
|
2
|
+
<%= fb_admin_shell do %>
|
|
3
|
+
<%= render 'formblocks/forms/header', form: @form, tab: :builder %>
|
|
4
|
+
<%= formblocks_brand_style(@form) %>
|
|
5
|
+
|
|
6
|
+
<div class="fb-builder fb-brand">
|
|
7
|
+
<% @form.pages.each do |page| %>
|
|
8
|
+
<% if page.thank_you? %>
|
|
9
|
+
<div class="fb-add-page">
|
|
10
|
+
<%= button_to form_pages_path(@form), class: 'fb-btn fb-btn--ghost', form: { data: { turbo_action: 'replace' } } do %>
|
|
11
|
+
<%= fb_icon(:plus) %> <%= t('.add_page') %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
<%= render 'formblocks/pages/page', page: page, form: @form %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<footer class="fb-builder-footer">
|
|
20
|
+
<p class="fb-muted fb-small">
|
|
21
|
+
<% if @form.published? %>
|
|
22
|
+
<%= t('.published_hint', url: fb_public_url(@form)) %>
|
|
23
|
+
<% else %>
|
|
24
|
+
<%= t('.draft_hint') %>
|
|
25
|
+
<% end %>
|
|
26
|
+
</p>
|
|
27
|
+
<div class="fb-builder-footer__actions">
|
|
28
|
+
<%= link_to fb_public_url(@form, preview: 1), class: 'fb-btn', target: '_blank', rel: 'noopener' do %>
|
|
29
|
+
<%= fb_icon(:external) %> <%= t('.preview') %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if @form.published? %>
|
|
32
|
+
<%= button_to t('.unpublish'), unpublish_form_path(@form), class: 'fb-btn' %>
|
|
33
|
+
<% else %>
|
|
34
|
+
<%= button_to t('.publish'), publish_form_path(@form), class: 'fb-btn fb-btn--primary' %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
</footer>
|
|
38
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<% content_for :title, t('.title') %>
|
|
2
|
+
<%= fb_admin_shell do %>
|
|
3
|
+
<header class="fb-header">
|
|
4
|
+
<h1 class="fb-h1"><%= t('.title') %></h1>
|
|
5
|
+
<%= link_to new_form_path, class: 'fb-btn fb-btn--primary' do %><%= fb_icon(:plus) %> <%= t('.new') %><% end %>
|
|
6
|
+
</header>
|
|
7
|
+
|
|
8
|
+
<% if @forms.empty? %>
|
|
9
|
+
<div class="fb-card fb-empty">
|
|
10
|
+
<p><%= t('.empty') %></p>
|
|
11
|
+
<%= link_to t('.new'), new_form_path, class: 'fb-btn fb-btn--primary' %>
|
|
12
|
+
</div>
|
|
13
|
+
<% else %>
|
|
14
|
+
<div class="fb-card fb-card--flush">
|
|
15
|
+
<table class="fb-table">
|
|
16
|
+
<thead>
|
|
17
|
+
<tr>
|
|
18
|
+
<th><%= t('.columns.title') %></th>
|
|
19
|
+
<th><%= t('.columns.status') %></th>
|
|
20
|
+
<th><%= t('.columns.responses') %></th>
|
|
21
|
+
<th><%= t('.columns.updated') %></th>
|
|
22
|
+
<th><span class="fb-sr-only"><%= t('formblocks.actions.actions') %></span></th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% @forms.each do |form| %>
|
|
27
|
+
<tr id="<%= dom_id(form) %>">
|
|
28
|
+
<td>
|
|
29
|
+
<%= link_to form.title, edit_form_path(form), class: 'fb-link fb-link--strong' %>
|
|
30
|
+
<div class="fb-muted fb-small fb-mono">/<%= form.slug %></div>
|
|
31
|
+
</td>
|
|
32
|
+
<td><%= fb_status_badge(form) %></td>
|
|
33
|
+
<td><%= link_to form.responses_count, form_responses_path(form), class: 'fb-link' %></td>
|
|
34
|
+
<td class="fb-muted"><%= fb_time(form.updated_at) %></td>
|
|
35
|
+
<td class="fb-table__actions">
|
|
36
|
+
<%= button_to duplicate_form_path(form), class: 'fb-iconbtn', title: t('formblocks.actions.duplicate'),
|
|
37
|
+
'aria-label': t('formblocks.actions.duplicate') do %><%= fb_icon(:duplicate) %><% end %>
|
|
38
|
+
<%= button_to form_path(form), method: :delete, class: 'fb-iconbtn fb-iconbtn--danger',
|
|
39
|
+
title: t('formblocks.actions.delete'), 'aria-label': t('formblocks.actions.delete'),
|
|
40
|
+
form: { data: { turbo_confirm: t('formblocks.actions.confirm_delete_form') } } do %><%= fb_icon(:trash) %><% end %>
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<% end %>
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<% content_for :title, t('.title') %>
|
|
2
|
+
<%= fb_admin_shell do %>
|
|
3
|
+
<header class="fb-header">
|
|
4
|
+
<h1 class="fb-h1"><%= t('.title') %></h1>
|
|
5
|
+
<%= link_to t('formblocks.actions.cancel'), forms_path, class: 'fb-btn fb-btn--ghost' %>
|
|
6
|
+
</header>
|
|
7
|
+
|
|
8
|
+
<div class="fb-templates">
|
|
9
|
+
<div class="fb-card fb-template">
|
|
10
|
+
<h2 class="fb-h3"><%= t('.blank') %></h2>
|
|
11
|
+
<p class="fb-muted"><%= t('.blank_description') %></p>
|
|
12
|
+
<%= button_to t('.start_blank'), forms_path, class: 'fb-btn fb-btn--primary' %>
|
|
13
|
+
</div>
|
|
14
|
+
<% @templates.each do |key, template| %>
|
|
15
|
+
<% template = template.to_h.deep_symbolize_keys %>
|
|
16
|
+
<% pages = Array(template[:pages]) %>
|
|
17
|
+
<% fields = pages.sum { |page| Array(page[:blocks]).count { |block| Formblocks::Block.find_kind(block[:type]).input? } } %>
|
|
18
|
+
<div class="fb-card fb-template">
|
|
19
|
+
<h2 class="fb-h3"><%= template[:title] %></h2>
|
|
20
|
+
<p class="fb-muted"><%= template[:description] %></p>
|
|
21
|
+
<p class="fb-small fb-muted"><%= t('.summary', pages: pages.size, fields: fields) %></p>
|
|
22
|
+
<%= button_to t('.use_template'), forms_path, params: { template: key }, class: 'fb-btn' %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% content_for :title, t('.title') %>
|
|
2
|
+
<%= fb_admin_shell do %>
|
|
3
|
+
<div class="fb-card fb-published" data-controller="fb-clipboard" data-fb-clipboard-copied-value="<%= t('.copied') %>">
|
|
4
|
+
<div class="fb-published__icon"><%= fb_icon(:check) %></div>
|
|
5
|
+
<h1 class="fb-h1"><%= t('.title') %></h1>
|
|
6
|
+
<p class="fb-muted"><%= t('.message') %></p>
|
|
7
|
+
|
|
8
|
+
<label class="fb-label" for="fb-public-url"><%= t('.url_label') %></label>
|
|
9
|
+
<div class="fb-input-group">
|
|
10
|
+
<input id="fb-public-url" class="fb-input fb-input--mono" type="text" readonly value="<%= fb_public_url(@form) %>"
|
|
11
|
+
data-fb-clipboard-target="source">
|
|
12
|
+
<button type="button" class="fb-btn" data-action="fb-clipboard#copy">
|
|
13
|
+
<%= fb_icon(:copy) %> <span data-fb-clipboard-target="label"><%= t('.copy') %></span>
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="fb-published__actions">
|
|
18
|
+
<%= link_to fb_public_url(@form), class: 'fb-btn fb-btn--primary', target: '_blank', rel: 'noopener' do %>
|
|
19
|
+
<%= fb_icon(:external) %> <%= t('.open') %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<%= link_to t('.view_responses'), form_responses_path(@form), class: 'fb-btn' %>
|
|
22
|
+
<%= link_to t('.back_to_builder'), edit_form_path(@form), class: 'fb-btn fb-btn--ghost' %>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<% content_for :title, @form.title %>
|
|
2
|
+
<%= fb_admin_shell do %>
|
|
3
|
+
<%= render 'formblocks/forms/header', form: @form, tab: :settings %>
|
|
4
|
+
|
|
5
|
+
<%= form_with model: @form, url: form_path(@form), method: :patch, class: 'fb-stack' do |f| %>
|
|
6
|
+
<%= render 'formblocks/shared/errors', record: @form %>
|
|
7
|
+
|
|
8
|
+
<section class="fb-card">
|
|
9
|
+
<h2 class="fb-h2"><%= t('.general') %></h2>
|
|
10
|
+
<div class="fb-field">
|
|
11
|
+
<%= f.label :title, t('formblocks.fields.title'), class: 'fb-label' %>
|
|
12
|
+
<%= f.text_field :title, class: 'fb-input', required: true %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="fb-field">
|
|
15
|
+
<%= f.label :slug, t('formblocks.fields.slug'), class: 'fb-label' %>
|
|
16
|
+
<div class="fb-input-group">
|
|
17
|
+
<span class="fb-input-group__prefix fb-mono"><%= fb_public_base_url %>/</span>
|
|
18
|
+
<%= f.text_field :slug, class: 'fb-input fb-input--mono', autocomplete: 'off', spellcheck: false %>
|
|
19
|
+
</div>
|
|
20
|
+
<p class="fb-hint"><%= t('.slug_hint') %></p>
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<section class="fb-card">
|
|
25
|
+
<h2 class="fb-h2"><%= t('.branding') %></h2>
|
|
26
|
+
<p class="fb-muted"><%= t('.branding_hint') %></p>
|
|
27
|
+
<%= render 'formblocks/shared/brand_fields', f: f, record: @form, logo_hint: nil,
|
|
28
|
+
fallbacks: { primary_color: @form.settings.primary_color.presence || Formblocks.config.default_primary_color,
|
|
29
|
+
button_text_color: @form.settings.button_text_color.presence || Formblocks.config.default_button_text_color } %>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<div class="fb-form-actions">
|
|
33
|
+
<%= f.submit t('formblocks.actions.save'), class: 'fb-btn fb-btn--primary' %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<section class="fb-card fb-card--danger">
|
|
38
|
+
<h2 class="fb-h2"><%= t('.danger') %></h2>
|
|
39
|
+
<p class="fb-muted"><%= t('.delete_hint') %></p>
|
|
40
|
+
<%= button_to t('formblocks.actions.delete_form'), form_path(@form), method: :delete, class: 'fb-btn fb-btn--danger',
|
|
41
|
+
form: { data: { turbo_confirm: t('formblocks.actions.confirm_delete_form') } } %>
|
|
42
|
+
</section>
|
|
43
|
+
<% end %>
|