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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Root of the engine's PUBLIC surface: the assets and the form pages. These
5
+ # stay on a plain ActionController::Base deliberately — a visitor filling
6
+ # in a form must not be routed through a host's admin controller.
7
+ #
8
+ # The admin's root is DashboardController, where
9
+ # `config.base_controller_class` applies.
10
+ class ApplicationController < ActionController::Base
11
+ include RequestContext
12
+
13
+ helper Formblocks::ApplicationHelper
14
+ protect_from_forgery with: :exception
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Serves the engine's CSS and JavaScript as plain same-origin files (see
5
+ # Formblocks::Assets). Same-origin matters: under a `script-src 'self'`
6
+ # CSP an external script from the app's own host is always allowed.
7
+ class AssetsController < ApplicationController
8
+ # Static, no user data; without this Rails' cross-origin JavaScript
9
+ # guard refuses to serve a script to a plain <script src> request.
10
+ skip_forgery_protection
11
+
12
+ # The URLs carry a content fingerprint (?v=<md5>), so stale code is
13
+ # structurally impossible: new code means a new URL. The canonical
14
+ # fingerprinted URL gets long-lived caching; anything else only
15
+ # ETag-revalidates.
16
+ def show
17
+ source, content_type = Assets.lookup(params[:name])
18
+ return head :not_found unless source
19
+
20
+ fingerprint = Assets.fingerprint(params[:name])
21
+ expires_in 1.year, public: true if params[:v] == fingerprint
22
+ return unless stale?(etag: [Formblocks::VERSION, fingerprint])
23
+
24
+ render plain: source, content_type:
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class BlocksController < DashboardController
5
+ before_action :set_form, :set_page
6
+ before_action :set_block, only: %i[update destroy move]
7
+
8
+ # Adds a block of `params[:kind]` from the palette, with its defaults.
9
+ def create
10
+ klass = Block.find_kind(params[:kind])
11
+ return head :unprocessable_content unless @page.allowed_block_classes.include?(klass)
12
+
13
+ block = @page.blocks.create!(klass.default_attributes.merge(type: klass.name))
14
+ redirect_to builder_path(dom_id(block)), status: :see_other
15
+ rescue ArgumentError
16
+ head :bad_request
17
+ end
18
+
19
+ # Autosaved from the builder. A text edit answers 204 so the field keeps
20
+ # focus; an upload re-renders the card so the new image shows.
21
+ def update
22
+ if @block.update(block_params)
23
+ if !turbo_request?
24
+ redirect_to builder_path(dom_id(@block)), status: :see_other
25
+ elsif rerender_after_update?
26
+ render turbo_stream: replace_block
27
+ else
28
+ head :no_content
29
+ end
30
+ elsif turbo_request?
31
+ render turbo_stream: replace_block, status: :unprocessable_content
32
+ else
33
+ redirect_to builder_path(dom_id(@block)), alert: @block.errors.full_messages.to_sentence, status: :see_other
34
+ end
35
+ end
36
+
37
+ def destroy
38
+ @block.destroy!
39
+ respond_to do |format|
40
+ format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@block)) }
41
+ format.html { redirect_to builder_path(dom_id(@page)), status: :see_other }
42
+ end
43
+ end
44
+
45
+ def move
46
+ @block.move(params[:direction])
47
+ redirect_to builder_path(dom_id(@block)), status: :see_other
48
+ end
49
+
50
+ # Drag-and-drop order for the whole page: `ids` in their new order.
51
+ def reorder
52
+ @page.reorder_blocks(params[:ids])
53
+ head :no_content
54
+ end
55
+
56
+ private
57
+
58
+ def set_page
59
+ @page = @form.pages.find(params.expect(:page_id))
60
+ end
61
+
62
+ def set_block
63
+ @block = @page.blocks.find(params.expect(:id))
64
+ end
65
+
66
+ def block_params
67
+ permitted = %i[label placeholder help_text content required options_text key]
68
+ permitted += %i[image remove_image] if Formblocks.attachments?
69
+ params.expect(block: [*permitted])
70
+ end
71
+
72
+ def rerender_after_update?
73
+ Formblocks.attachments? && @block.image? &&
74
+ (params.dig(:block, :image).present? || params.dig(:block, :remove_image).present?)
75
+ end
76
+
77
+ def replace_block
78
+ turbo_stream.replace(dom_id(@block), partial: 'formblocks/blocks/block', locals: { block: @block })
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # Root of the ADMIN surface: the forms list, the builder, responses and
5
+ # settings.
6
+ #
7
+ # Inherits from `config.base_controller_class` — by default a plain
8
+ # ActionController::Base, which is why `authorize_admin` exists. Point it at
9
+ # the controller your own admin already inherits from and the builder picks
10
+ # up that stack wholesale: your layout, helpers, authentication, and
11
+ # whatever request context your before_actions establish.
12
+ class DashboardController < Formblocks.base_controller
13
+ include RequestContext
14
+ include ActionView::RecordIdentifier
15
+
16
+ helper Formblocks::ApplicationHelper
17
+
18
+ # A host base controller brings its own layout, and declaring one here
19
+ # would override it. So the gem only claims the layout when it owns the
20
+ # decision: no host base controller, or a host that named an
21
+ # `admin_layout` explicitly.
22
+ layout :formblocks_admin_layout unless superclass != ActionController::Base &&
23
+ Formblocks.config.admin_layout == Configuration::DEFAULT_ADMIN_LAYOUT
24
+
25
+ before_action :require_admin
26
+
27
+ # A host base controller has configured CSRF already; declaring it twice
28
+ # would run the check twice.
29
+ protect_from_forgery with: :exception if superclass == ActionController::Base
30
+
31
+ private
32
+
33
+ def set_form
34
+ @form = tenant_forms.find(params[:form_id] || params[:id])
35
+ end
36
+
37
+ def builder_path(anchor = nil)
38
+ edit_form_path(@form, anchor:)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class FormsController < DashboardController
5
+ before_action :set_form, except: %i[index new create]
6
+
7
+ def index
8
+ @forms = tenant_forms.newest_first
9
+ end
10
+
11
+ def new
12
+ @templates = Formblocks.config.templates
13
+ end
14
+
15
+ # The builder.
16
+ def edit
17
+ @pages = @form.pages.includes(:blocks)
18
+ end
19
+
20
+ def create
21
+ template = Formblocks.config.templates[params[:template].to_s] if params[:template].present?
22
+ @form = if template
23
+ Form.from_template(template, tenant: current_tenant)
24
+ else
25
+ Form.new(title: t('formblocks.forms.new.untitled'), tenant: current_tenant)
26
+ end
27
+ @form.save!
28
+ redirect_to edit_form_path(@form), notice: t('formblocks.flash.form_created')
29
+ end
30
+
31
+ def settings; end
32
+
33
+ def update
34
+ if @form.update(form_params)
35
+ redirect_to settings_form_path(@form), notice: t('formblocks.flash.form_updated'), status: :see_other
36
+ else
37
+ render :settings, status: :unprocessable_content
38
+ end
39
+ end
40
+
41
+ def destroy
42
+ @form.destroy!
43
+ redirect_to forms_path, notice: t('formblocks.flash.form_deleted'), status: :see_other
44
+ end
45
+
46
+ def duplicate
47
+ copy = @form.duplicate
48
+ redirect_to edit_form_path(copy), notice: t('formblocks.flash.form_duplicated'), status: :see_other
49
+ end
50
+
51
+ def publish
52
+ @form.publish!
53
+ redirect_to published_form_path(@form), status: :see_other
54
+ end
55
+
56
+ def unpublish
57
+ @form.unpublish!
58
+ redirect_to edit_form_path(@form), notice: t('formblocks.flash.form_unpublished'), status: :see_other
59
+ end
60
+
61
+ def published; end
62
+
63
+ private
64
+
65
+ def form_params
66
+ permitted = %i[title slug primary_color button_text_color]
67
+ permitted += %i[logo remove_logo] if Formblocks.attachments?
68
+ params.expect(form: [*permitted])
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class PagesController < DashboardController
5
+ before_action :set_form
6
+ before_action :set_page, only: %i[update destroy move]
7
+
8
+ def create
9
+ page = @form.pages.create!(kind: 'step')
10
+ @form.resequence_pages!
11
+ redirect_to builder_path(dom_id(page)), status: :see_other
12
+ end
13
+
14
+ # The page's button text, autosaved from the builder.
15
+ def update
16
+ if @page.update(page_params)
17
+ turbo_request? ? head(:no_content) : redirect_to(builder_path(dom_id(@page)), status: :see_other)
18
+ else
19
+ head :unprocessable_content
20
+ end
21
+ end
22
+
23
+ def destroy
24
+ @page.destroy! if @page.step? && @form.steps.size > 1
25
+ @form.resequence_pages!
26
+ redirect_to builder_path, status: :see_other
27
+ end
28
+
29
+ def move
30
+ @page.move(params[:direction])
31
+ redirect_to builder_path(dom_id(@page)), status: :see_other
32
+ end
33
+
34
+ private
35
+
36
+ def set_page
37
+ @page = @form.pages.find(params.expect(:id))
38
+ end
39
+
40
+ def page_params
41
+ params.expect(page: [:button_text])
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module Public
5
+ # The visitor-facing pages: the form, its submission, and the thank-you
6
+ # page. Routed by the host app (see `mount_formblocks`), so URL helpers go
7
+ # through `main_app`.
8
+ class FormsController < Formblocks::ApplicationController
9
+ layout 'formblocks/public'
10
+
11
+ before_action :set_form
12
+
13
+ # Throttle submissions per IP so one visitor or bot cannot flood the
14
+ # responses. Rails' own limiter, backed by Rails.cache. Tune or disable
15
+ # via config.rate_limit — read once when the controller loads, so set
16
+ # it in an initializer.
17
+ if Formblocks.config.rate_limit
18
+ rate_limit(**Formblocks.config.rate_limit, only: :create, with: -> { render_rate_limited })
19
+ end
20
+
21
+ def show
22
+ @response = @form.responses.new
23
+ @referrer = clean_page_url(request.referer)
24
+ end
25
+
26
+ def create
27
+ @referrer = clean_page_url(params[:fb_referrer])
28
+ # The honeypot: a field no human sees. Bots fill it; we pretend it
29
+ # worked and save nothing.
30
+ return redirect_to(thanks_path, status: :see_other) if params[:fb_website].present?
31
+
32
+ @response = @form.responses.new(page_url: @referrer, user_agent: request.user_agent.to_s.first(500),
33
+ locale: I18n.locale.to_s)
34
+ @response.fill(params.fetch(:answers, {}).to_unsafe_h)
35
+
36
+ if @response.save
37
+ redirect_to thanks_path, status: :see_other
38
+ else
39
+ render :show, status: :unprocessable_content
40
+ end
41
+ end
42
+
43
+ def thanks
44
+ @page = @form.thank_you_page
45
+ end
46
+
47
+ private
48
+
49
+ # Drafts are visible only to admins previewing them (?preview=1).
50
+ def set_form
51
+ @form = Form.includes(pages: :blocks).find_by(slug: params[:slug])
52
+ return if @form && (@form.published? || preview?)
53
+
54
+ render :not_found, status: :not_found
55
+ end
56
+
57
+ def preview?
58
+ params[:preview].present? && Formblocks.admin?(request)
59
+ end
60
+
61
+ def thanks_path
62
+ main_app.formblocks_form_thanks_path(@form.slug, preview: params[:preview].presence)
63
+ end
64
+
65
+ # The form again, answers kept, with the message where the errors go.
66
+ def render_rate_limited
67
+ @referrer = clean_page_url(params[:fb_referrer])
68
+ @response = @form.responses.new.fill(params.fetch(:answers, {}).to_unsafe_h)
69
+ @rate_limited = true
70
+ render :show, status: :too_many_requests
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class ResponsesController < DashboardController
5
+ PER_PAGE = 50
6
+
7
+ before_action :set_form
8
+ before_action :set_response, only: %i[show destroy]
9
+
10
+ def index
11
+ respond_to do |format|
12
+ format.html do
13
+ @page = [params[:page].to_i, 1].max
14
+ @responses = @form.responses.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
15
+ @more = @responses.size > PER_PAGE
16
+ @responses = @responses.first(PER_PAGE)
17
+ @columns = @form.input_blocks.select(&:visible?).first(3)
18
+ end
19
+ format.csv do
20
+ export = CsvExport.new(@form)
21
+ send_data export.generate, filename: export.filename, type: 'text/csv'
22
+ end
23
+ end
24
+ end
25
+
26
+ def show; end
27
+
28
+ def destroy
29
+ @response.destroy!
30
+ redirect_to form_responses_path(@form), notice: t('formblocks.flash.response_deleted'), status: :see_other
31
+ end
32
+
33
+ private
34
+
35
+ def set_response
36
+ @response = @form.responses.find(params.expect(:id))
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # The tenant's brand defaults, one page.
5
+ class SettingsController < DashboardController
6
+ before_action :set_setting
7
+
8
+ def show; end
9
+
10
+ def update
11
+ if @setting.update(setting_params)
12
+ redirect_to settings_path, notice: t('formblocks.flash.settings_updated'), status: :see_other
13
+ else
14
+ render :show, status: :unprocessable_content
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def set_setting
21
+ @setting = Setting.for_tenant(current_tenant)
22
+ end
23
+
24
+ def setting_params
25
+ permitted = %i[primary_color button_text_color]
26
+ permitted += %i[logo remove_logo] if Formblocks.attachments?
27
+ params.expect(setting: [*permitted])
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ module ApplicationHelper
5
+ ICONS = {
6
+ plus: '<path d="M12 5v14M5 12h14"/>',
7
+ trash: '<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6"/>',
8
+ up: '<path d="M12 19V5M5 12l7-7 7 7"/>',
9
+ down: '<path d="M12 5v14M19 12l-7 7-7-7"/>',
10
+ grip: '<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/>' \
11
+ '<circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>',
12
+ check: '<circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/>',
13
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
14
+ external: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
15
+ back: '<path d="M19 12H5M12 19l-7-7 7-7"/>',
16
+ download: '<path d="M12 3v12M6 11l6 6 6-6M4 21h16"/>',
17
+ duplicate: '<rect x="8" y="8" width="12" height="12" rx="2"/>' \
18
+ '<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>'
19
+ }.freeze
20
+
21
+ # Wraps an admin view in the engine's shell (assets, nav, flash). The block
22
+ # is captured here, in the view's own context, so its lazy `t('.key')`
23
+ # lookups resolve against the view — inside `render layout:` they would
24
+ # resolve against the shell partial instead.
25
+ def fb_admin_shell(&)
26
+ render 'formblocks/shared/admin', content: capture(&)
27
+ end
28
+
29
+ # True for a stored attachment — not one that was just assigned and then
30
+ # refused by validation, which has no URL yet.
31
+ def fb_stored?(attachment)
32
+ Formblocks.attachments? && attachment.attached? && attachment.blob&.persisted?
33
+ end
34
+
35
+ # Stylesheet and module script for a bundle (:admin or :public), with
36
+ # fingerprinted same-origin URLs served by the engine itself.
37
+ def formblocks_assets(bundle)
38
+ stylesheet = stylesheet_link_tag(formblocks_asset_url("#{bundle}.css"), 'data-turbo-track': 'reload')
39
+ script = javascript_include_tag(formblocks_asset_url("#{bundle}.js"), type: 'module',
40
+ 'data-turbo-track': 'reload', nonce: true)
41
+ safe_join([stylesheet, script], "\n")
42
+ end
43
+
44
+ def formblocks_asset_url(name)
45
+ formblocks.engine_asset_path(name, v: Formblocks::Assets.fingerprint(name))
46
+ end
47
+
48
+ # A nonced <style> setting the brand custom properties, so no element
49
+ # needs an inline style attribute (which a strict style-src refuses).
50
+ # The colors are validated on save; this re-checks them on the way into
51
+ # a <style> tag, so nothing but a hex color can ever land there.
52
+ def formblocks_brand_style(form)
53
+ primary = fb_hex_color(form.effective_primary_color, Formblocks::Configuration.new.default_primary_color)
54
+ text = fb_hex_color(form.effective_button_text_color, Formblocks::Configuration.new.default_button_text_color)
55
+ css = ".fb-brand { --fb-primary: #{primary}; --fb-primary-text: #{text}; }"
56
+ content_tag(:style, css.html_safe, nonce: content_security_policy_nonce) # rubocop:disable Rails/OutputSafety
57
+ end
58
+
59
+ def fb_hex_color(value, fallback)
60
+ value.to_s.match?(Formblocks::Form::COLOR_FORMAT) ? value.to_s : fallback
61
+ end
62
+
63
+ def fb_icon(name, **options)
64
+ body = ICONS.fetch(name)
65
+ options[:class] = ['fb-icon', options[:class]].compact.join(' ')
66
+ tag.svg(body.html_safe, # rubocop:disable Rails/OutputSafety
67
+ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
68
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', **options)
69
+ end
70
+
71
+ def fb_status_badge(form)
72
+ tag.span(t("formblocks.status.#{form.status}"), class: "fb-badge fb-badge--#{form.status}")
73
+ end
74
+
75
+ # Where visitors reach the form.
76
+ def fb_public_url(form, **)
77
+ main_app.formblocks_form_url(form.slug, **)
78
+ end
79
+
80
+ def fb_public_base_url
81
+ "#{request.base_url}#{Formblocks.config.public_path.to_s.chomp('/')}"
82
+ end
83
+
84
+ # Attachments are served by the host's Active Storage routes.
85
+ def fb_attachment_url(attachment)
86
+ main_app.url_for(attachment)
87
+ end
88
+
89
+ def fb_time(time)
90
+ tag.time(l(time, format: :short), datetime: time.iso8601, title: l(time, format: :long))
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ # `attachable :logo` — an Active Storage image attachment with a
5
+ # `remove_logo` flag and size/type validation. A no-op when the host has no
6
+ # Active Storage (or turned attachments off), so the models still load.
7
+ module Attachable
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ def attachable(name)
12
+ return unless Formblocks.attachments?
13
+
14
+ has_one_attached name, service: Formblocks.config.storage_service
15
+ attribute :"remove_#{name}", :boolean, default: false
16
+
17
+ validate { validate_attachable(name) }
18
+ after_save do
19
+ attachment = public_send(name)
20
+ attachment.purge if public_send(:"remove_#{name}") && attachment.attached?
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Only a freshly assigned upload is checked; what is already stored was
28
+ # checked when it came in.
29
+ def validate_attachable(name)
30
+ attachment = public_send(name)
31
+ return unless attachment.attached? && attachment.blob&.new_record?
32
+
33
+ unless attachment.content_type.to_s.start_with?('image/')
34
+ errors.add(name,
35
+ I18n.t('formblocks.errors.not_an_image'))
36
+ end
37
+ return if attachment.byte_size.to_i <= Formblocks.config.max_upload_size
38
+
39
+ errors.add(name,
40
+ I18n.t('formblocks.errors.file_too_large', size: Formblocks.config.max_upload_size / (1024 * 1024)))
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formblocks
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end