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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# Host-tunable settings. Everything has a safe default, so a fresh install
|
|
5
|
+
# works with zero configuration; the hooks below let an app decide who may
|
|
6
|
+
# build forms, which tenant they belong to, and what happens on submit.
|
|
7
|
+
class Configuration
|
|
8
|
+
# The gem's own admin layout. Compared against, so DashboardController can
|
|
9
|
+
# tell "the host left this alone" from "the host chose this".
|
|
10
|
+
DEFAULT_ADMIN_LAYOUT = 'formblocks/application'
|
|
11
|
+
|
|
12
|
+
# Your product's name, shown in page titles ("Contact form · Nusii") and
|
|
13
|
+
# as the alt text of a logo. nil resolves to the Rails application name.
|
|
14
|
+
attr_accessor :app_name
|
|
15
|
+
|
|
16
|
+
# Per-request gate for the builder, responses and settings. Defaults to
|
|
17
|
+
# development only — override it before deploying, e.g. with an admin check.
|
|
18
|
+
attr_accessor :authorize_admin
|
|
19
|
+
|
|
20
|
+
# Layout used by the admin pages. Override this to render the builder
|
|
21
|
+
# inside your app's admin shell, e.g. "admin/application".
|
|
22
|
+
attr_accessor :admin_layout
|
|
23
|
+
|
|
24
|
+
# The controller the ADMIN inherits from, as a String so it resolves
|
|
25
|
+
# lazily. Default: a plain 'ActionController::Base', where
|
|
26
|
+
# `authorize_admin` is the only gate. Name the controller your own admin
|
|
27
|
+
# already inherits from and the builder adopts that whole stack — layout,
|
|
28
|
+
# helpers, authentication, request context.
|
|
29
|
+
#
|
|
30
|
+
# Only the admin uses it. The public form pages stay on the engine's own
|
|
31
|
+
# controller, so an admin base controller here can never demand a staff
|
|
32
|
+
# session from a visitor filling in a form.
|
|
33
|
+
attr_accessor :base_controller_class
|
|
34
|
+
|
|
35
|
+
# Resolve the current tenant (optional, for multi-tenant apps). Return an
|
|
36
|
+
# opaque key — a GlobalID, an id, a subdomain, a slug — or nil. Receives
|
|
37
|
+
# the request. nil (the default) is a single, global collection. Forms,
|
|
38
|
+
# responses and the settings all scope to whatever this returns.
|
|
39
|
+
attr_accessor :tenant
|
|
40
|
+
|
|
41
|
+
# Called with each saved Formblocks::Response — notify Slack, send an
|
|
42
|
+
# email, create a lead. Runs inline after save; keep it fast or hand off
|
|
43
|
+
# to a job.
|
|
44
|
+
attr_accessor :on_submit
|
|
45
|
+
|
|
46
|
+
# Logo and image-block uploads. Requires Active Storage in the host; set
|
|
47
|
+
# false to hide every upload even when Active Storage is loaded.
|
|
48
|
+
attr_accessor :attachments
|
|
49
|
+
attr_accessor :max_upload_size
|
|
50
|
+
|
|
51
|
+
# The Active Storage service that stores uploads — form logos, the global
|
|
52
|
+
# logo, image blocks — as a service name from the host's
|
|
53
|
+
# config/storage.yml (e.g. a dedicated bucket or folder, or an entry with
|
|
54
|
+
# provider options like Cloudinary's folder/tags). nil, the default, uses
|
|
55
|
+
# the environment's default service. Read when the models load, so set it
|
|
56
|
+
# in an initializer.
|
|
57
|
+
attr_accessor :storage_service
|
|
58
|
+
|
|
59
|
+
# Per-IP throttle for the public submit endpoint, as keyword arguments for
|
|
60
|
+
# Rails' rate limiter. Read once when the controller loads — set it in an
|
|
61
|
+
# initializer. nil disables throttling.
|
|
62
|
+
attr_accessor :rate_limit
|
|
63
|
+
|
|
64
|
+
# Brand defaults when neither the form nor the settings page set a color.
|
|
65
|
+
attr_accessor :default_primary_color, :default_button_text_color
|
|
66
|
+
|
|
67
|
+
# Where the admin engine is mounted, and where public forms live. Both are
|
|
68
|
+
# written by `mount_formblocks`; set them here only if you mount manually.
|
|
69
|
+
attr_accessor :mount_path, :public_path
|
|
70
|
+
|
|
71
|
+
# Form templates offered on the "New form" page, keyed by an identifier.
|
|
72
|
+
# See Formblocks::Templates for the built-in ones and the definition shape.
|
|
73
|
+
attr_reader :templates
|
|
74
|
+
|
|
75
|
+
def initialize
|
|
76
|
+
@app_name = nil
|
|
77
|
+
@authorize_admin = ->(_request) { Rails.env.development? }
|
|
78
|
+
@admin_layout = DEFAULT_ADMIN_LAYOUT
|
|
79
|
+
@base_controller_class = 'ActionController::Base'
|
|
80
|
+
@tenant = ->(_request) {}
|
|
81
|
+
@on_submit = ->(_response) {}
|
|
82
|
+
@attachments = true
|
|
83
|
+
@max_upload_size = 5 * 1024 * 1024
|
|
84
|
+
@storage_service = nil
|
|
85
|
+
@rate_limit = { to: 10, within: 60 }
|
|
86
|
+
@default_primary_color = '#111827'
|
|
87
|
+
@default_button_text_color = '#ffffff'
|
|
88
|
+
@mount_path = '/forms'
|
|
89
|
+
@public_path = '/f'
|
|
90
|
+
@templates = Templates.builtin
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Formblocks
|
|
6
|
+
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path('../tasks/formblocks_tasks.rake', __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The public form pages are drawn on the HOST's route set, not the
|
|
12
|
+
# engine's, so they can live at a path of the host's choosing that is
|
|
13
|
+
# separate from the admin mount. `mount_formblocks` does both in one line.
|
|
14
|
+
#
|
|
15
|
+
# The mount is named `formblocks` on purpose: the engine's views reach
|
|
16
|
+
# their own assets through that proxy even when a request came in through
|
|
17
|
+
# a host route (the public pages) or a host layout.
|
|
18
|
+
initializer 'formblocks.routing' do
|
|
19
|
+
ActionDispatch::Routing::Mapper.include(Module.new do
|
|
20
|
+
def mount_formblocks(at: Formblocks.config.mount_path, public_at: Formblocks.config.public_path, **options)
|
|
21
|
+
Formblocks.config.mount_path = at
|
|
22
|
+
Formblocks.config.public_path = public_at
|
|
23
|
+
mount Formblocks::Engine, at:, as: :formblocks, **options
|
|
24
|
+
|
|
25
|
+
public_at = public_at.to_s.chomp('/')
|
|
26
|
+
get "#{public_at}/:slug", to: 'formblocks/public/forms#show', as: :formblocks_form
|
|
27
|
+
post "#{public_at}/:slug", to: 'formblocks/public/forms#create'
|
|
28
|
+
get "#{public_at}/:slug/thanks", to: 'formblocks/public/forms#thanks', as: :formblocks_form_thanks
|
|
29
|
+
end
|
|
30
|
+
end)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# Demo data for trying the gem out: three forms built from the built-in
|
|
5
|
+
# templates — two published, with a few responses each, and one draft.
|
|
6
|
+
# Idempotent: running it again refreshes the same forms (found by slug)
|
|
7
|
+
# instead of duplicating them.
|
|
8
|
+
module Seeds
|
|
9
|
+
SLUGS = { 'contact' => 'demo-contact', 'lead' => 'demo-lead-capture', 'feedback' => 'demo-feedback' }.freeze
|
|
10
|
+
|
|
11
|
+
RESPONSES = {
|
|
12
|
+
'demo-contact' => [
|
|
13
|
+
{ 'your_name' => 'Ada Lovelace', 'email_address' => 'ada@example.com',
|
|
14
|
+
'message' => 'Could you send over the enterprise pricing?' },
|
|
15
|
+
{ 'your_name' => 'Grace Hopper', 'email_address' => 'grace@example.com',
|
|
16
|
+
'message' => "Loved the demo.\nWhen does the API ship?" }
|
|
17
|
+
],
|
|
18
|
+
'demo-lead-capture' => [
|
|
19
|
+
{ 'full_name' => 'Linus Torvalds', 'work_email' => 'linus@example.com', 'team_size' => 'Just me',
|
|
20
|
+
'send_me_product_updates_by_email' => '1' },
|
|
21
|
+
{ 'full_name' => 'Margaret Hamilton', 'work_email' => 'margaret@example.com',
|
|
22
|
+
'phone_number' => '+1 617 555 0100', 'company_website' => 'https://apollo.example', 'team_size' => '51+',
|
|
23
|
+
'what_are_you_hoping_to_solve' => 'Onboarding for a distributed team.' },
|
|
24
|
+
{ 'full_name' => 'Ken Thompson', 'work_email' => 'ken@example.com', 'team_size' => '2–10' }
|
|
25
|
+
]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.load!(tenant: nil)
|
|
29
|
+
forms = SLUGS.to_h do |template, slug|
|
|
30
|
+
definition = Templates.builtin.fetch(template)
|
|
31
|
+
form = Form.find_by(slug:) || Form.from_template(definition, tenant:).tap { |f| f.slug = slug }
|
|
32
|
+
form.tenant = tenant
|
|
33
|
+
form.save!
|
|
34
|
+
template == 'feedback' ? form.unpublish! : form.publish!
|
|
35
|
+
[slug, form]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
responses = RESPONSES.flat_map do |slug, answers_list|
|
|
39
|
+
form = forms.fetch(slug)
|
|
40
|
+
form.responses.destroy_all
|
|
41
|
+
answers_list.map do |answers|
|
|
42
|
+
form.responses.new(page_url: 'https://example.com/pricing', locale: 'en',
|
|
43
|
+
user_agent: 'Mozilla/5.0 (demo)').fill(answers).tap(&:save!)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{ forms: forms.values, responses: }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
# Form templates offered on the "New form" page. A template is a plain hash:
|
|
5
|
+
#
|
|
6
|
+
# {
|
|
7
|
+
# title: 'Contact form',
|
|
8
|
+
# description: 'Name, email and a message.',
|
|
9
|
+
# pages: [
|
|
10
|
+
# { button_text: 'Send',
|
|
11
|
+
# blocks: [
|
|
12
|
+
# { type: 'heading', content: 'Get in touch' },
|
|
13
|
+
# { type: 'email', label: 'Email', required: true }
|
|
14
|
+
# ] }
|
|
15
|
+
# ],
|
|
16
|
+
# thank_you: { blocks: [{ type: 'heading', content: 'Thanks!' }] }
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# `type` is a block kind (see Formblocks::Block.kinds); the other keys are
|
|
20
|
+
# block attributes. Hosts add their own with
|
|
21
|
+
# `Formblocks.config.templates['webinar'] = { ... }` and remove a built-in
|
|
22
|
+
# with `Formblocks.config.templates.delete('contact')`.
|
|
23
|
+
module Templates
|
|
24
|
+
def self.builtin
|
|
25
|
+
{
|
|
26
|
+
'contact' => {
|
|
27
|
+
title: 'Contact form',
|
|
28
|
+
description: 'Name, email and a message. The classic.',
|
|
29
|
+
pages: [
|
|
30
|
+
{ button_text: 'Send message',
|
|
31
|
+
blocks: [
|
|
32
|
+
{ type: 'heading', content: 'Get in touch' },
|
|
33
|
+
{ type: 'paragraph', content: 'Leave us a message and we will get back to you within a day.' },
|
|
34
|
+
{ type: 'name', label: 'Your name', required: true },
|
|
35
|
+
{ type: 'email', label: 'Email address', required: true },
|
|
36
|
+
{ type: 'textarea', label: 'Message', required: true, placeholder: 'How can we help?' }
|
|
37
|
+
] }
|
|
38
|
+
],
|
|
39
|
+
thank_you: {
|
|
40
|
+
blocks: [
|
|
41
|
+
{ type: 'heading', content: 'Thanks for reaching out!' },
|
|
42
|
+
{ type: 'paragraph', content: 'We have received your message and will reply soon.' }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
'lead' => {
|
|
47
|
+
title: 'Lead capture',
|
|
48
|
+
description: 'Two steps: contact details first, then a few qualifying questions.',
|
|
49
|
+
pages: [
|
|
50
|
+
{ button_text: 'Continue',
|
|
51
|
+
blocks: [
|
|
52
|
+
{ type: 'heading', content: 'Request a demo' },
|
|
53
|
+
{ type: 'name', label: 'Full name', required: true },
|
|
54
|
+
{ type: 'email', label: 'Work email', required: true },
|
|
55
|
+
{ type: 'phone', label: 'Phone number' },
|
|
56
|
+
{ type: 'url', label: 'Company website' }
|
|
57
|
+
] },
|
|
58
|
+
{ button_text: 'Request demo',
|
|
59
|
+
blocks: [
|
|
60
|
+
{ type: 'heading', content: 'A bit about your team' },
|
|
61
|
+
{ type: 'radio_group', label: 'Team size', required: true,
|
|
62
|
+
options: ['Just me', '2–10', '11–50', '51+'] },
|
|
63
|
+
{ type: 'textarea', label: 'What are you hoping to solve?' },
|
|
64
|
+
{ type: 'checkbox', label: 'Send me product updates by email' }
|
|
65
|
+
] }
|
|
66
|
+
],
|
|
67
|
+
thank_you: {
|
|
68
|
+
blocks: [
|
|
69
|
+
{ type: 'heading', content: 'Thank you!' },
|
|
70
|
+
{ type: 'paragraph', content: 'We will be in touch shortly to schedule your demo.' }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
'feedback' => {
|
|
75
|
+
title: 'Feedback survey',
|
|
76
|
+
description: 'A quick rating and an open comment.',
|
|
77
|
+
pages: [
|
|
78
|
+
{ button_text: 'Send feedback',
|
|
79
|
+
blocks: [
|
|
80
|
+
{ type: 'heading', content: 'How are we doing?' },
|
|
81
|
+
{ type: 'radio_group', label: 'How satisfied are you?', required: true,
|
|
82
|
+
options: ['Very satisfied', 'Satisfied', 'Neutral', 'Unsatisfied', 'Very unsatisfied'] },
|
|
83
|
+
{ type: 'textarea', label: 'What could we do better?' },
|
|
84
|
+
{ type: 'email', label: 'Email, if you would like a reply' }
|
|
85
|
+
] }
|
|
86
|
+
],
|
|
87
|
+
thank_you: {
|
|
88
|
+
blocks: [
|
|
89
|
+
{ type: 'heading', content: 'Thanks for your feedback!' },
|
|
90
|
+
{ type: 'paragraph', content: 'Every answer helps us improve.' }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/formblocks.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'turbo-rails'
|
|
4
|
+
require 'stimulus-rails'
|
|
5
|
+
|
|
6
|
+
require 'formblocks/version'
|
|
7
|
+
require 'formblocks/configuration'
|
|
8
|
+
require 'formblocks/assets'
|
|
9
|
+
require 'formblocks/templates'
|
|
10
|
+
require 'formblocks/seeds'
|
|
11
|
+
require 'formblocks/engine'
|
|
12
|
+
|
|
13
|
+
# Formblocks: a block-based form builder for Rails. Forms are built from
|
|
14
|
+
# blocks (content and inputs) spread over pages, published at a public URL,
|
|
15
|
+
# and their responses land in your own database with a dashboard and CSV export.
|
|
16
|
+
module Formblocks
|
|
17
|
+
class << self
|
|
18
|
+
def config
|
|
19
|
+
@config ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The class the admin controllers inherit from. Resolved on every call
|
|
27
|
+
# rather than memoized, so a host that reassigns base_controller_class in
|
|
28
|
+
# a reloadable initializer is not pinned to a stale, unloaded constant.
|
|
29
|
+
def base_controller
|
|
30
|
+
config.base_controller_class.to_s.constantize
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Can this request use the builder and read responses? Checked by every
|
|
34
|
+
# admin action, and by the public page to allow previews of drafts.
|
|
35
|
+
def admin?(request)
|
|
36
|
+
!!config.authorize_admin.call(request)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The tenant key for this request, or nil for the single global collection.
|
|
40
|
+
# Normalized to a string so `where(tenant:)` is consistent whether the
|
|
41
|
+
# resolver returns a GlobalID, an integer id, or a slug.
|
|
42
|
+
def tenant(request)
|
|
43
|
+
config.tenant.call(request).presence&.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Forms belonging to a host record, keyed by its GlobalID.
|
|
47
|
+
def for(record)
|
|
48
|
+
Form.for_tenant(tenant_key_for(record))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The product name for titles and alt text: config.app_name, else the
|
|
52
|
+
# Rails application's module name, verbatim ("Nusii", "SupeRails").
|
|
53
|
+
def app_name
|
|
54
|
+
config.app_name.presence || rails_app_name
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Logo and image uploads need Active Storage loaded in the host.
|
|
58
|
+
def attachments?
|
|
59
|
+
config.attachments && defined?(::ActiveStorage) ? true : false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def rails_app_name
|
|
65
|
+
Rails.application.class.module_parent_name
|
|
66
|
+
rescue StandardError
|
|
67
|
+
'this app'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def tenant_key_for(record)
|
|
71
|
+
record.respond_to?(:to_gid) ? record.to_gid.to_s : record.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
require_relative '../migration_helpers'
|
|
6
|
+
|
|
7
|
+
module Formblocks
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationHelpers
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path('templates', __dir__)
|
|
14
|
+
|
|
15
|
+
desc 'Installs formblocks: config initializer, migration, and routes.'
|
|
16
|
+
|
|
17
|
+
class_option :mount_path, type: :string, default: '/forms',
|
|
18
|
+
desc: 'Where the admin (builder, responses, settings) is mounted'
|
|
19
|
+
class_option :public_path, type: :string, default: '/f',
|
|
20
|
+
desc: 'Where published forms are served'
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template 'initializer.rb.tt', 'config/initializers/formblocks.rb'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_migration_file
|
|
27
|
+
migration_template 'create_formblocks_tables.rb.tt',
|
|
28
|
+
'db/migrate/create_formblocks_tables.rb'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mount_engine
|
|
32
|
+
route %(mount_formblocks at: "#{options[:mount_path]}", public_at: "#{options[:public_path]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def post_install
|
|
36
|
+
say "\nformblocks installed. Run `bin/rails db:migrate`.", :green
|
|
37
|
+
say "Build forms at #{options[:mount_path]} (development only until you set config.authorize_admin)."
|
|
38
|
+
say "Published forms are served at #{options[:public_path]}/<slug>."
|
|
39
|
+
say "Logo and image uploads need Active Storage: `bin/rails active_storage:install` if you have not.\n"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateFormblocksTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :formblocks_settings<%= primary_key_type_option %> do |t|
|
|
6
|
+
t.string :tenant # opaque per-tenant key; nil = single global collection
|
|
7
|
+
t.string :primary_color
|
|
8
|
+
t.string :button_text_color
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
add_index :formblocks_settings, :tenant, unique: true
|
|
13
|
+
|
|
14
|
+
create_table :formblocks_forms<%= primary_key_type_option %> do |t|
|
|
15
|
+
t.string :title, null: false
|
|
16
|
+
t.string :slug, null: false
|
|
17
|
+
t.datetime :published_at
|
|
18
|
+
t.string :primary_color
|
|
19
|
+
t.string :button_text_color
|
|
20
|
+
t.string :tenant
|
|
21
|
+
t.integer :responses_count, null: false, default: 0
|
|
22
|
+
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
add_index :formblocks_forms, :slug, unique: true
|
|
26
|
+
add_index :formblocks_forms, :tenant
|
|
27
|
+
|
|
28
|
+
create_table :formblocks_pages<%= primary_key_type_option %> do |t|
|
|
29
|
+
t.references :form, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_forms }
|
|
30
|
+
t.string :kind, null: false, default: 'step'
|
|
31
|
+
t.integer :position, null: false, default: 0
|
|
32
|
+
t.string :button_text
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create_table :formblocks_blocks<%= primary_key_type_option %> do |t|
|
|
38
|
+
t.references :page, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_pages }
|
|
39
|
+
t.string :type, null: false
|
|
40
|
+
t.integer :position, null: false, default: 0
|
|
41
|
+
t.string :key
|
|
42
|
+
t.string :label
|
|
43
|
+
t.string :placeholder
|
|
44
|
+
t.text :help_text
|
|
45
|
+
t.text :content
|
|
46
|
+
t.boolean :required, null: false, default: false
|
|
47
|
+
t.json :options
|
|
48
|
+
t.json :settings
|
|
49
|
+
|
|
50
|
+
t.timestamps
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
create_table :formblocks_responses<%= primary_key_type_option %> do |t|
|
|
54
|
+
t.references :form, null: false<%= foreign_key_type_option %>, foreign_key: { to_table: :formblocks_forms }
|
|
55
|
+
t.string :tenant
|
|
56
|
+
t.json :answers
|
|
57
|
+
t.string :page_url
|
|
58
|
+
t.string :user_agent
|
|
59
|
+
t.string :locale
|
|
60
|
+
|
|
61
|
+
t.timestamps
|
|
62
|
+
end
|
|
63
|
+
add_index :formblocks_responses, %i[tenant created_at]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Formblocks.configure do |config|
|
|
4
|
+
# Your product's name, shown in page titles ("Contact form · Nusii") and
|
|
5
|
+
# as the alt text of your logo. Defaults to your Rails application name.
|
|
6
|
+
# config.app_name = "My App"
|
|
7
|
+
|
|
8
|
+
# Who can build forms, read responses and change settings at the mount
|
|
9
|
+
# path. Receives the raw request. Defaults to development only — the admin
|
|
10
|
+
# answers 403 everywhere else until you override this.
|
|
11
|
+
#
|
|
12
|
+
# Devise / Warden:
|
|
13
|
+
# config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
14
|
+
#
|
|
15
|
+
# Rails 8 built-in auth (bin/rails generate authentication):
|
|
16
|
+
# config.authorize_admin = lambda do |request|
|
|
17
|
+
# token = request.cookies["session_id"]
|
|
18
|
+
# Session.find_signed(token)&.user&.admin? if token
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Two ways to put the builder inside an admin you already have.
|
|
22
|
+
#
|
|
23
|
+
# The layout only — the gem's controllers, your shell:
|
|
24
|
+
# config.admin_layout = "admin/application"
|
|
25
|
+
#
|
|
26
|
+
# Or the whole stack. Name the controller your own admin inherits from and
|
|
27
|
+
# the builder picks up its layout, helpers, authentication and any request
|
|
28
|
+
# context its before_actions set up. Only the admin inherits it; the public
|
|
29
|
+
# form pages stay on the engine's own controller.
|
|
30
|
+
# config.base_controller_class = "Admin::BaseController"
|
|
31
|
+
|
|
32
|
+
# Multi-tenancy (optional). Scope forms, responses and settings to a
|
|
33
|
+
# tenant — each Account/Organization its own set of forms. Return an
|
|
34
|
+
# opaque key (nil = one global collection, the default). A GlobalID is the
|
|
35
|
+
# recommended key; an id, subdomain or slug work too.
|
|
36
|
+
# config.tenant = ->(request) { Current.account&.to_gid&.to_s }
|
|
37
|
+
|
|
38
|
+
# Called with each saved Formblocks::Response — notify Slack, send an
|
|
39
|
+
# email, create a lead. `response.answers` is a hash keyed by block key
|
|
40
|
+
# ("email", "name", ...); `response.form` is the form it came from.
|
|
41
|
+
# config.on_submit = ->(response) {}
|
|
42
|
+
|
|
43
|
+
# Logo and image uploads (requires Active Storage).
|
|
44
|
+
# config.attachments = true
|
|
45
|
+
# config.max_upload_size = 5.megabytes
|
|
46
|
+
|
|
47
|
+
# Store uploads (logos, image blocks) on a specific Active Storage service
|
|
48
|
+
# from config/storage.yml — e.g. a dedicated bucket or folder, or a service
|
|
49
|
+
# entry carrying provider options like Cloudinary's `folder:` / `tags:` —
|
|
50
|
+
# to keep form media apart from the rest of your library. Default: your
|
|
51
|
+
# environment's default service.
|
|
52
|
+
# config.storage_service = :formblocks
|
|
53
|
+
|
|
54
|
+
# Brand defaults, used when neither the form nor the settings page set one.
|
|
55
|
+
# config.default_primary_color = "#111827"
|
|
56
|
+
# config.default_button_text_color = "#ffffff"
|
|
57
|
+
|
|
58
|
+
# Form templates on the "New form" page. Add your own or drop a built-in.
|
|
59
|
+
# config.templates["webinar"] = { title: "Webinar signup", pages: [...] }
|
|
60
|
+
# config.templates.delete("feedback")
|
|
61
|
+
|
|
62
|
+
# Per-IP throttle for the public submit endpoint. nil disables it.
|
|
63
|
+
# config.rate_limit = { to: 10, within: 1.minute }
|
|
64
|
+
|
|
65
|
+
# Paths, written by `mount_formblocks` in routes.rb. Set these only if you
|
|
66
|
+
# mount the engine manually.
|
|
67
|
+
# config.mount_path = "<%= options[:mount_path] %>"
|
|
68
|
+
# config.public_path = "<%= options[:public_path] %>"
|
|
69
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formblocks
|
|
4
|
+
module Generators
|
|
5
|
+
# Shared bits every migration-writing generator needs.
|
|
6
|
+
module MigrationHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def migration_version
|
|
10
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Follow the host's own key type instead of forcing bigint. An app that
|
|
14
|
+
# keys its models with uuids sets this, and its
|
|
15
|
+
# `active_storage_attachments.record_id` is then a uuid column — bigint
|
|
16
|
+
# tables here could never take a logo or image attachment.
|
|
17
|
+
#
|
|
18
|
+
# Same lookup Rails' own Active Storage, Action Text and Action Mailbox
|
|
19
|
+
# migrations do, so a host that set it once gets consistent tables from
|
|
20
|
+
# all of them. Rendered as a `create_table` option rather than a bare
|
|
21
|
+
# value, because a template is expanded at generate time.
|
|
22
|
+
def primary_key_type_option
|
|
23
|
+
type = primary_key_type
|
|
24
|
+
type ? ", id: :#{type}" : ''
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The matching option for `t.references`, so foreign keys point at the
|
|
28
|
+
# right column type.
|
|
29
|
+
def foreign_key_type_option
|
|
30
|
+
type = primary_key_type
|
|
31
|
+
type ? ", type: :#{type}" : ''
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def primary_key_type
|
|
35
|
+
config = Rails.configuration.generators
|
|
36
|
+
config.options[config.orm][:primary_key_type]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :formblocks do
|
|
4
|
+
desc 'Create or refresh formblocks demo data: three template forms and a few responses'
|
|
5
|
+
task seed_demo: :environment do
|
|
6
|
+
result = Formblocks::Seeds.load!
|
|
7
|
+
puts "Seeded #{result[:forms].size} forms and #{result[:responses].size} responses."
|
|
8
|
+
result[:forms].each { |form| puts " #{form.status.ljust(9)} #{form.title} (#{form.slug})" }
|
|
9
|
+
end
|
|
10
|
+
end
|