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,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 %>