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