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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 541957d6f438fd71c7ab933fa6c69fdbfe8272c419de1809d37bff23be6bf0fc
|
|
4
|
+
data.tar.gz: cfe0f10f96076537705ea63160371959b456a0b9a71cf8f723b5acf24d05a3a7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 972771ede7be90bbccda2a0fc633e351582733a577b72b3e94781cfb0166f97708eefcdf1ac3e740000df18563705ab48370f8968dd5242a2c5be24a45789c0d
|
|
7
|
+
data.tar.gz: 4e061cf018881bce8a2bae93f8ec60332b5a3b45a0cae94b1442eef1981e44948ee839c937087d05305ffca29dd5da308d0c5934984e96680af7eeba49569a57
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Formblocks — notes for agents
|
|
2
|
+
|
|
3
|
+
This file is for an AI agent (or a person in a hurry) that is either
|
|
4
|
+
installing the gem into a Rails app or working on the gem itself. The
|
|
5
|
+
README has the full story; this is the short version.
|
|
6
|
+
|
|
7
|
+
## Installing into a host app
|
|
8
|
+
|
|
9
|
+
1. `gem "formblocks"` in the Gemfile, `bundle install`.
|
|
10
|
+
2. `bin/rails generate formblocks:install` — writes the initializer, one
|
|
11
|
+
migration (five `formblocks_*` tables, no Active Storage tables) and
|
|
12
|
+
`mount_formblocks at: "/forms", public_at: "/f"` in routes.rb.
|
|
13
|
+
3. `bin/rails db:migrate`.
|
|
14
|
+
4. Set `config.authorize_admin` in `config/initializers/formblocks.rb`; the
|
|
15
|
+
default allows the admin in development only.
|
|
16
|
+
5. Optional: `config.tenant` for multi-tenant apps, `config.on_submit` to
|
|
17
|
+
react to responses, `config.base_controller_class` or
|
|
18
|
+
`config.admin_layout` to put the admin inside an existing one.
|
|
19
|
+
|
|
20
|
+
Active Storage is optional. If the host has it, logos and image blocks work;
|
|
21
|
+
if not, those features are hidden. Never run `active_storage:install` on the
|
|
22
|
+
host's behalf without asking — most apps already have the tables.
|
|
23
|
+
|
|
24
|
+
Reach the admin at the mount path and published forms at
|
|
25
|
+
`#{public_path}/#{slug}`. The public URL helper in the host is
|
|
26
|
+
`formblocks_form_path(slug)`; the engine's own helpers are under the
|
|
27
|
+
`formblocks` route proxy (`formblocks.root_path`).
|
|
28
|
+
|
|
29
|
+
## Working on the gem
|
|
30
|
+
|
|
31
|
+
- `bin/rails server` runs the dummy app in `test/dummy` (admin at
|
|
32
|
+
http://localhost:3000/forms, allowed because it is development);
|
|
33
|
+
`bin/rails app:formblocks:seed_demo` fills it with demo forms (`app:`
|
|
34
|
+
because the gem's `bin/rails` drives the dummy app; a host runs
|
|
35
|
+
`bin/rails formblocks:seed_demo`).
|
|
36
|
+
- `bundle exec rake test` runs models, integration and generator tests;
|
|
37
|
+
`bundle exec rake test:system` runs the browser tests (headless Chrome);
|
|
38
|
+
`bundle exec rubocop` lints.
|
|
39
|
+
- Layout of the code:
|
|
40
|
+
- `lib/formblocks/configuration.rb` — every host option.
|
|
41
|
+
- `lib/formblocks/engine.rb` — `mount_formblocks`, which mounts the admin
|
|
42
|
+
engine and draws the public routes on the host's route set.
|
|
43
|
+
- `lib/formblocks/assets/` — the CSS and Stimulus controllers, served by
|
|
44
|
+
`Formblocks::AssetsController`; `lib/formblocks/assets.rb` fingerprints
|
|
45
|
+
them and fills the `{{turbo.js}}` / `{{stimulus.js}}` import tokens.
|
|
46
|
+
- `app/models/formblocks/block.rb` and `blocks/` — the STI hierarchy and
|
|
47
|
+
the registry of kinds the palette offers.
|
|
48
|
+
- `app/controllers/formblocks/dashboard_controller.rb` — the admin root,
|
|
49
|
+
inheriting from `config.base_controller_class`;
|
|
50
|
+
`public/forms_controller.rb` — the visitor-facing pages, always on
|
|
51
|
+
`ActionController::Base`.
|
|
52
|
+
- Admin views wrap themselves in `fb_admin_shell` so the assets and nav
|
|
53
|
+
survive a host layout; public views live under
|
|
54
|
+
`app/views/formblocks/public/`.
|
|
55
|
+
- Every user-facing string is in `config/locales/formblocks.en.yml`; views
|
|
56
|
+
use lazy `t('.key')` lookups.
|
|
57
|
+
- Keep the split: nothing public may inherit the host's base controller.
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Forms with pages of blocks, a builder with drag-and-drop, autosave and an
|
|
8
|
+
in-place editable submit button.
|
|
9
|
+
- Block kinds: heading, paragraph, image, name, email, phone, URL, short
|
|
10
|
+
text, long text, hidden, checkbox, radio group; a registry for host blocks.
|
|
11
|
+
- Templates (contact, lead capture, feedback) and form duplication.
|
|
12
|
+
- Public form pages with steps, browser and server validation, honeypot,
|
|
13
|
+
per-IP rate limiting, hidden-field prefill from the query string, a
|
|
14
|
+
thank-you page.
|
|
15
|
+
- Requires Rails 8.0 or newer and Ruby 3.2 or newer.
|
|
16
|
+
- Responses dashboard with CSV export and an `on_submit` hook.
|
|
17
|
+
- `bin/rails formblocks:seed_demo` for demo forms and responses.
|
|
18
|
+
- Per-form and global branding (logo, primary color, button text color).
|
|
19
|
+
- `config.storage_service`: store every upload on a named Active Storage
|
|
20
|
+
service from the host's `config/storage.yml` — a dedicated bucket or
|
|
21
|
+
folder, or a service entry with provider options such as Cloudinary's
|
|
22
|
+
`folder:`/`tags:` — instead of the environment default.
|
|
23
|
+
- Admin gate, host base controller / layout, multi-tenancy, configurable
|
|
24
|
+
admin and public paths, self-served assets, `config.app_name` for titles.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Michael Koper
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# formblocks
|
|
2
|
+
|
|
3
|
+
[](https://github.com/michaelkoper/formblocks/actions/workflows/ci.yml)
|
|
4
|
+
[](MIT-LICENSE)
|
|
5
|
+
|
|
6
|
+
**A form builder for Rails.** Multi-page forms built from blocks, published
|
|
7
|
+
at a public URL, responses in your own database. A self-hosted replacement
|
|
8
|
+
for a hosted form service, for the forms your app already needs: contact,
|
|
9
|
+
lead capture, feedback, signups.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "formblocks"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle install
|
|
22
|
+
bin/rails generate formblocks:install
|
|
23
|
+
bin/rails db:migrate
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The generator writes the initializer, the migration, and one line in
|
|
27
|
+
`config/routes.rb`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
mount_formblocks at: "/forms", public_at: "/f"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`at:` is the admin — the forms list, the builder, responses and settings.
|
|
34
|
+
`public_at:` is where published forms are served, as `/f/<slug>`. The two are
|
|
35
|
+
separate on purpose, so `/forms` can sit behind your admin while `/f/contact`
|
|
36
|
+
stays open to the world. Both are generator options too:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bin/rails generate formblocks:install --mount-path=/admin/forms --public-path=/forms
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Open `/forms` in development and build something. Optional demo data —
|
|
43
|
+
three forms from the built-in templates, two of them published with a few
|
|
44
|
+
responses:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bin/rails formblocks:seed_demo
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
> [!IMPORTANT]
|
|
51
|
+
> The admin defaults to **development only**. Set `authorize_admin` before
|
|
52
|
+
> you deploy — see [Configure](#configure). With Devise:
|
|
53
|
+
>
|
|
54
|
+
> ```ruby
|
|
55
|
+
> # config/initializers/formblocks.rb
|
|
56
|
+
> Formblocks.configure do |config|
|
|
57
|
+
> config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
58
|
+
> end
|
|
59
|
+
> ```
|
|
60
|
+
|
|
61
|
+
Ruby >= 3.2 · Rails >= 8.0 and < 9 · Active Storage only if you want logo and
|
|
62
|
+
image uploads — see [Uploads](#uploads-active-storage). The generator never
|
|
63
|
+
touches Active Storage tables: an app that has them gets uploads, an app
|
|
64
|
+
without them gets every other feature.
|
|
65
|
+
|
|
66
|
+
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
67
|
+
steps in the order an agent needs them, plus the things it should not do. It
|
|
68
|
+
ships inside the gem, so `cat "$(bundle show formblocks)/AGENTS.md"` works from
|
|
69
|
+
any app that bundles it.
|
|
70
|
+
|
|
71
|
+
## What you get
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
| ---------------- | --------------------------------------------------------------------------------- |
|
|
75
|
+
| **Builder** | Pages of blocks. Drag to reorder, autosave, click the button to rename it |
|
|
76
|
+
| **Blocks** | Heading, paragraph, image · name, email, phone, URL, text, textarea, hidden, checkbox, radio group |
|
|
77
|
+
| **Multi-page** | Every form has at least one step and a thank-you page; add steps as you like |
|
|
78
|
+
| **Templates** | Blank, contact, lead capture, feedback — or your own hashes. Duplicate any form |
|
|
79
|
+
| **Public page** | One step at a time, browser validation, server validation with inline errors |
|
|
80
|
+
| **Responses** | A dashboard per form, one response in full, CSV export, an `on_submit` hook |
|
|
81
|
+
| **Branding** | Logo, primary color, button text color — per form, inherited from global settings |
|
|
82
|
+
| **Deps** | Rails, `turbo-rails`, `stimulus-rails`. No asset pipeline, no bundler, no build step |
|
|
83
|
+
| **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, anything |
|
|
84
|
+
| **Turbo/CSP** | Turbo 8 morphing in the builder, nonce-based CSP on every page |
|
|
85
|
+
|
|
86
|
+
## Why self-host it
|
|
87
|
+
|
|
88
|
+
| | `formblocks` | A hosted form builder |
|
|
89
|
+
| -------------------------------- | ------------------------------------- | ------------------------------- |
|
|
90
|
+
| Cost | Free, MIT | Monthly subscription |
|
|
91
|
+
| Where responses live | Your database | The vendor's |
|
|
92
|
+
| Reacting to a submission | `config.on_submit`, a Ruby lambda | Webhooks and a Zapier plan |
|
|
93
|
+
| The public page | Your domain, your logo, no badge | Their domain or a CNAME add-on |
|
|
94
|
+
| Page weight | One stylesheet, one small module | Third-party bundle + tracking |
|
|
95
|
+
| If the vendor disappears | Nothing happens | You lose the forms and the data |
|
|
96
|
+
|
|
97
|
+
## The whole flow
|
|
98
|
+
|
|
99
|
+
| 1. Pick a template, or start blank | 2. Build it from blocks |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
|  |  |
|
|
102
|
+
| Every form starts with one step and a thank-you page. | Every change autosaves. The button at the bottom of a page is edited in place. |
|
|
103
|
+
| **3. Publish** | **4. Visitors fill it in, one step at a time** |
|
|
104
|
+
|  |  |
|
|
105
|
+
| Preview a draft any time; nobody else can see it until you publish. | Required fields are checked before the next step; the server checks again. |
|
|
106
|
+
| **5. Read the responses** | **6. Or download them** |
|
|
107
|
+
|  |  |
|
|
108
|
+
| The first three inputs as columns, newest first. | Every answer, plus where the visitor came from. CSV has one column per input. |
|
|
109
|
+
|
|
110
|
+
## Configure
|
|
111
|
+
|
|
112
|
+
Everything is optional — a fresh install works with zero config. In
|
|
113
|
+
`config/initializers/formblocks.rb`:
|
|
114
|
+
|
|
115
|
+
| Option | Default | What it does |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `app_name` | Rails app name | Shown in page titles ("Contact form · Nusii") and as the logo's alt text |
|
|
118
|
+
| `authorize_admin` | development only | **Who can build forms and read responses.** Override before deploying |
|
|
119
|
+
| `base_controller_class` | `ActionController::Base` | The controller the admin inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
120
|
+
| `admin_layout` | the gem's own | Just the shell, if you don't want the whole controller |
|
|
121
|
+
| `tenant` | `nil` | One set of forms per tenant — see [Multi-tenancy](#multi-tenancy) |
|
|
122
|
+
| `on_submit` | no-op | Runs after each saved response — email, Slack, CRM |
|
|
123
|
+
| `attachments` | `true` | Logo and image uploads (needs Active Storage) |
|
|
124
|
+
| `max_upload_size` | `5.megabytes` | Enforced server-side |
|
|
125
|
+
| `storage_service` | app default | Active Storage service for uploads (a `storage.yml` key) |
|
|
126
|
+
| `default_primary_color` | `"#111827"` | When neither the form nor the settings page set one |
|
|
127
|
+
| `default_button_text_color` | `"#ffffff"` | Same, for the text on the button |
|
|
128
|
+
| `templates` | contact, lead, feedback | The "New form" page — see [Templates](#templates-and-duplication) |
|
|
129
|
+
| `rate_limit` | `{ to: 10, within: 1.minute }` | Per-IP throttle on submit. `nil` disables |
|
|
130
|
+
| `mount_path` | `"/forms"` | Written by `mount_formblocks`; set only if you mount by hand |
|
|
131
|
+
| `public_path` | `"/f"` | Same, for the public pages |
|
|
132
|
+
|
|
133
|
+
A typical initializer, with Devise:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
Formblocks.configure do |config|
|
|
137
|
+
config.app_name = "Nusii"
|
|
138
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
139
|
+
config.on_submit = ->(response) { LeadMailer.new_response(response).deliver_later }
|
|
140
|
+
config.storage_service = :cloudinary_forms
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Gates receive the **raw request**, so they work with any auth:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# Devise / Warden
|
|
148
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
149
|
+
|
|
150
|
+
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
151
|
+
config.authorize_admin = lambda do |request|
|
|
152
|
+
token = request.cookies["session_id"]
|
|
153
|
+
Session.find_signed(token)&.user&.admin? if token
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Behind a network or a header
|
|
157
|
+
config.authorize_admin = ->(request) { request.headers["X-Admin-Token"] == Rails.application.credentials.admin_token }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
<details>
|
|
161
|
+
<summary><b>Inside your own admin</b></summary>
|
|
162
|
+
|
|
163
|
+
Two ways. The layout only:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
config.admin_layout = "admin/application"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The engine's stylesheet, script and navigation are declared by its views,
|
|
170
|
+
not its layout, so they survive the swap. Or the whole stack:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
config.base_controller_class = "Admin::BaseController"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The admin controllers then inherit from your class and pick up its
|
|
177
|
+
authentication, helpers, layout and whatever request context its
|
|
178
|
+
`before_action`s establish. `authorize_admin` still runs as a last gate, so
|
|
179
|
+
widen it (`->(_request) { true }`) if your base controller already does the
|
|
180
|
+
work. Only the admin inherits it; the public pages stay on the engine's own
|
|
181
|
+
controller, so a visitor filling in a form is never asked for a staff session.
|
|
182
|
+
|
|
183
|
+
Your layout already loads Turbo and Stimulus? The engine notices Turbo and
|
|
184
|
+
keeps yours. Its Stimulus controllers run on their own application with
|
|
185
|
+
`fb-` prefixed identifiers, so the two never register the same name.
|
|
186
|
+
|
|
187
|
+
</details>
|
|
188
|
+
|
|
189
|
+
## Blocks
|
|
190
|
+
|
|
191
|
+
Every block is a Ruby class under `Formblocks::Blocks`, single-table
|
|
192
|
+
inheritance, so `Email < Text < Input < Block`:
|
|
193
|
+
|
|
194
|
+
| Kind | Stores | Notes |
|
|
195
|
+
| --- | --- | --- |
|
|
196
|
+
| `heading`, `paragraph` | — | Content. Allowed on the thank-you page |
|
|
197
|
+
| `image` | — | An upload; `label` is the alt text |
|
|
198
|
+
| `name`, `email`, `phone`, `url`, `text` | a string | Single-line inputs with the right `type` and `autocomplete`; email and URL are validated |
|
|
199
|
+
| `textarea` | a string | Multi-line |
|
|
200
|
+
| `hidden` | a string | `key` is the field name, `content` the default; `?key=value` on the public URL overrides it |
|
|
201
|
+
| `checkbox` | `true`/`false` | Required means it must be ticked |
|
|
202
|
+
| `radio_group` | one of `options` | One option per line in the builder |
|
|
203
|
+
|
|
204
|
+
Every input has a **key** — from its label when it is created ("Work email"
|
|
205
|
+
→ `work_email`), unique within the form, and stable afterwards even if the
|
|
206
|
+
label changes. Answers are stored under it: `response.answers["work_email"]`.
|
|
207
|
+
|
|
208
|
+
<details>
|
|
209
|
+
<summary><b>Your own block</b></summary>
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# app/models/rating.rb
|
|
213
|
+
class Rating < Formblocks::Blocks::Input
|
|
214
|
+
def self.placeholder? = false
|
|
215
|
+
|
|
216
|
+
def validate_present_answer(value, errors)
|
|
217
|
+
errors.add(key.to_sym, "must be 1 to 5") unless (1..5).cover?(value.to_i)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# config/initializers/formblocks.rb
|
|
222
|
+
Rails.application.config.to_prepare do
|
|
223
|
+
Formblocks::Block.register "Rating"
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Then a public partial at
|
|
228
|
+
`app/views/formblocks/public/blocks/_rating.html.erb` (the built-in ones live
|
|
229
|
+
at the same path inside the gem) and a name under `formblocks.blocks.rating.name`
|
|
230
|
+
in your locale. Override `default_attributes` for what a freshly added block
|
|
231
|
+
looks like, `normalize_answer` for how a value is stored, `display_answer` for
|
|
232
|
+
how the dashboard and the CSV show it.
|
|
233
|
+
|
|
234
|
+
</details>
|
|
235
|
+
|
|
236
|
+
## Templates and duplication
|
|
237
|
+
|
|
238
|
+
The "New form" page offers a blank form and every entry in `config.templates`.
|
|
239
|
+
Three ship with the gem — contact, lead capture, feedback — as plain hashes,
|
|
240
|
+
so yours look the same:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
config.templates["webinar"] = {
|
|
244
|
+
title: "Webinar signup",
|
|
245
|
+
description: "Name and email, one page.",
|
|
246
|
+
pages: [
|
|
247
|
+
{ button_text: "Save my seat",
|
|
248
|
+
blocks: [
|
|
249
|
+
{ type: "heading", content: "Join us on Thursday" },
|
|
250
|
+
{ type: "name", label: "Name", required: true },
|
|
251
|
+
{ type: "email", label: "Email", required: true }
|
|
252
|
+
] }
|
|
253
|
+
],
|
|
254
|
+
thank_you: { blocks: [{ type: "heading", content: "See you there!" }] }
|
|
255
|
+
}
|
|
256
|
+
config.templates.delete("feedback")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`Formblocks::Form.from_template(definition, tenant:)` builds a form from one
|
|
260
|
+
in code. `form.duplicate` deep-copies any form — pages, blocks, logo, images —
|
|
261
|
+
into a new draft with a fresh slug, which is the other way to make a template:
|
|
262
|
+
build one form well and copy it.
|
|
263
|
+
|
|
264
|
+
## The public page
|
|
265
|
+
|
|
266
|
+
A form is served at `#{public_path}/#{slug}` once published. A draft answers
|
|
267
|
+
404 — unless an admin opens it with `?preview=1`, which is what the builder's
|
|
268
|
+
Preview button does.
|
|
269
|
+
|
|
270
|
+
<img src="docs/screenshots/public-step-2.png" alt="Step 2 of a lead capture form: a required radio group, a textarea, a checkbox, Back and Request demo buttons" width="620">
|
|
271
|
+
|
|
272
|
+
The page is standalone: its own layout, its own stylesheet, your logo, your
|
|
273
|
+
colors — as CSS custom properties in a nonced `<style>`, no inline style
|
|
274
|
+
attributes, so it works under a strict Content Security Policy. Steps are
|
|
275
|
+
`<fieldset>`s; a small Stimulus controller shows one at a time, validates it
|
|
276
|
+
with the browser's own constraint validation on **Next**, and opens the step a
|
|
277
|
+
server-side error belongs to. Without JavaScript every step is visible and the
|
|
278
|
+
form still submits as one.
|
|
279
|
+
|
|
280
|
+
Every submission is validated on the server against the form's blocks —
|
|
281
|
+
required fields, email and URL formats, radio options, required checkboxes —
|
|
282
|
+
and re-rendered with an error under each field. The slug is yours to set in
|
|
283
|
+
the form's settings; it is generated from the title otherwise.
|
|
284
|
+
|
|
285
|
+
### Spam and rate limiting
|
|
286
|
+
|
|
287
|
+
Two defences, both on by default and neither visible to a person:
|
|
288
|
+
|
|
289
|
+
- **A honeypot.** The form carries a text field no human sees. A bot that
|
|
290
|
+
fills it in gets the thank-you page and nothing is stored.
|
|
291
|
+
- **A per-IP rate limit** on the submit endpoint: 10 submissions a minute
|
|
292
|
+
from one address by default. Past that the visitor gets the form back with
|
|
293
|
+
their answers kept, a "too many submissions" message, and a 429 status. It
|
|
294
|
+
uses the rate limiter built into Rails, backed by `Rails.cache`, so the
|
|
295
|
+
counter needs a cache store shared across your processes (Solid Cache,
|
|
296
|
+
Redis, Memcached — not the per-process memory store) to count correctly.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
config.rate_limit = { to: 3, within: 10.minutes } # stricter
|
|
300
|
+
config.rate_limit = nil # off
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The value is read once, when the controller loads, so set it in the
|
|
304
|
+
initializer. Only the public submit is throttled; the admin never is.
|
|
305
|
+
|
|
306
|
+
## Responses
|
|
307
|
+
|
|
308
|
+
`Formblocks::Response` belongs to a form: `answers` (a hash keyed by block
|
|
309
|
+
key), `page_url` (where the visitor came from), `user_agent`, `locale`, and a
|
|
310
|
+
`tenant` copied from the form. `config.on_submit` receives each one right
|
|
311
|
+
after it is saved:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
config.on_submit = lambda do |response|
|
|
315
|
+
Lead.create!(email: response.answers["work_email"], name: response.answers["full_name"],
|
|
316
|
+
source: response.form.slug)
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The dashboard lists them fifty at a time, shows one in full, and **Download
|
|
321
|
+
CSV** exports every response with one column per input — plus a column for
|
|
322
|
+
any key a since-deleted block left behind, so nothing collected is lost. Cells
|
|
323
|
+
a spreadsheet would run as formulas are escaped.
|
|
324
|
+
|
|
325
|
+
## Branding
|
|
326
|
+
|
|
327
|
+
Each form has a logo, a primary color and a button text color. Leave any of
|
|
328
|
+
them blank and it inherits from the **Settings** page — one set of defaults
|
|
329
|
+
per tenant — and, failing that, from `default_primary_color` and
|
|
330
|
+
`default_button_text_color`. The builder previews the button in the resolved
|
|
331
|
+
colors.
|
|
332
|
+
|
|
333
|
+
<img src="docs/screenshots/settings.png" alt="The global settings: primary color, button text color and a logo upload" width="620">
|
|
334
|
+
|
|
335
|
+
## Uploads (Active Storage)
|
|
336
|
+
|
|
337
|
+
Three things are uploads: a form's logo, the global logo on the Settings
|
|
338
|
+
page, and the image block. All three are plain `has_one_attached`
|
|
339
|
+
attachments on the engine's own models, stored by the host's Active Storage
|
|
340
|
+
and served through the host's Active Storage routes — the engine adds no
|
|
341
|
+
storage of its own.
|
|
342
|
+
|
|
343
|
+
**It is optional.** The install generator never creates Active Storage
|
|
344
|
+
tables, because most apps already have them, and it never asks for them
|
|
345
|
+
either: `bin/rails db:migrate` after the install succeeds with or without
|
|
346
|
+
them. Without Active Storage — the gem not loaded, or
|
|
347
|
+
`config.attachments = false` — the logo fields and the image block's upload
|
|
348
|
+
disappear from the builder and the settings page, and everything else works
|
|
349
|
+
as before. To add it to an app that lacks it, run Rails' own installer:
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
bin/rails active_storage:install
|
|
353
|
+
bin/rails db:migrate
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The `formblocks_*` tables follow your app's `config.generators` primary key
|
|
357
|
+
type, the same way Rails' own Active Storage migration does — so on a
|
|
358
|
+
uuid-keyed app the tables are uuid-keyed too and attachments line up with
|
|
359
|
+
`active_storage_attachments.record_id`.
|
|
360
|
+
|
|
361
|
+
**Where files go.** By default, the environment's default service. To keep
|
|
362
|
+
form media apart from the rest of your library, name a service from
|
|
363
|
+
`config/storage.yml` — a dedicated bucket or folder, or an entry carrying
|
|
364
|
+
provider options such as Cloudinary's `folder:` and `tags:`:
|
|
365
|
+
|
|
366
|
+
```yaml
|
|
367
|
+
# config/storage.yml
|
|
368
|
+
cloudinary_forms:
|
|
369
|
+
service: Cloudinary
|
|
370
|
+
folder: Forms
|
|
371
|
+
tags: formblocks
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# config/initializers/formblocks.rb
|
|
376
|
+
config.storage_service = :cloudinary_forms
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The name is read when the models load, after your initializers, and a name
|
|
380
|
+
that is not in `storage.yml` fails at boot rather than at the first upload.
|
|
381
|
+
|
|
382
|
+
**What is checked.** An upload must be an image (`image/*`) and at most
|
|
383
|
+
`config.max_upload_size` (5 MB by default); anything else is refused with a
|
|
384
|
+
validation error and nothing is stored. Each attachment has a matching
|
|
385
|
+
"Remove" checkbox in the UI, which purges the file on save. Duplicating a
|
|
386
|
+
form copies its logo and images as new blobs rather than sharing them, so
|
|
387
|
+
purging one form's file never takes it away from the other.
|
|
388
|
+
|
|
389
|
+
| Option | Default | |
|
|
390
|
+
| --- | --- | --- |
|
|
391
|
+
| `attachments` | `true` | `false` hides every upload, even with Active Storage loaded |
|
|
392
|
+
| `max_upload_size` | `5.megabytes` | Per file, enforced server-side |
|
|
393
|
+
| `storage_service` | app default | A `config/storage.yml` key |
|
|
394
|
+
|
|
395
|
+
## Multi-tenancy
|
|
396
|
+
|
|
397
|
+
Scope forms, responses and settings to a tenant — each Account (or
|
|
398
|
+
Organization, Store, Site) with its own forms and its own dashboard — with
|
|
399
|
+
one resolver:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
config.tenant = ->(request) { Current.account&.to_gid&.to_s }
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Return an **opaque key**: a GlobalID, an id, a subdomain, a slug. The gem
|
|
406
|
+
never takes a foreign key into your models. It stamps the key on each form
|
|
407
|
+
and response and scopes every admin read and write, so an admin only ever
|
|
408
|
+
sees their tenant's forms. `nil` — the default — is a single global
|
|
409
|
+
collection, so single-tenant apps need none of this.
|
|
410
|
+
`Formblocks.for(account)` returns that record's forms.
|
|
411
|
+
|
|
412
|
+
Slugs stay global: a public URL has no tenant in it, so two tenants cannot
|
|
413
|
+
both own `/f/contact`. The second gets `contact-2` and can pick another slug
|
|
414
|
+
in the form's settings.
|
|
415
|
+
|
|
416
|
+
## Assets, Turbo and Stimulus
|
|
417
|
+
|
|
418
|
+
The engine serves its own stylesheet and JavaScript from
|
|
419
|
+
`#{mount_path}/assets/` with content fingerprints in the URLs and a year of
|
|
420
|
+
caching. Turbo and Stimulus are served the same way, straight from the
|
|
421
|
+
`turbo-rails` and `stimulus-rails` gems the engine depends on — so your
|
|
422
|
+
bundle decides their versions and nothing goes through the host's asset
|
|
423
|
+
pipeline, importmap or bundler. A host with Sprockets, Propshaft, esbuild,
|
|
424
|
+
importmap, or no JavaScript setup at all all get the same builder.
|
|
425
|
+
|
|
426
|
+
## Who's using it
|
|
427
|
+
|
|
428
|
+
- [Nusii](https://nusii.com) — proposal software
|
|
429
|
+
|
|
430
|
+
Shipping it? [Open a PR](https://github.com/michaelkoper/formblocks/pulls) and
|
|
431
|
+
add yourself.
|
|
432
|
+
|
|
433
|
+
## Development
|
|
434
|
+
|
|
435
|
+
The gem carries a small Rails app in `test/dummy` — the same app the tests
|
|
436
|
+
run against — and `bin/rails` at the gem root drives it, so you can try the
|
|
437
|
+
builder without installing the gem anywhere:
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
bin/rails db:migrate # the dummy app's database (SQLite, in test/dummy/storage)
|
|
441
|
+
bin/rails app:formblocks:seed_demo # optional: three forms and a few responses
|
|
442
|
+
bin/rails server # http://localhost:3000/forms — no login, it is development
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Inside the gem, the app's own rake tasks sit under `app:` — that is why the
|
|
446
|
+
seed task is `app:formblocks:seed_demo` here and plain `formblocks:seed_demo`
|
|
447
|
+
in a host. The dummy app has Active Storage, so logos and image blocks work,
|
|
448
|
+
and a strict nonce-based Content Security Policy, so anything that needed an
|
|
449
|
+
inline style or script would show up right away.
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
bundle exec rake test # models, requests, generator
|
|
453
|
+
bundle exec rake test:system # the builder and the public form in headless Chrome
|
|
454
|
+
bundle exec rubocop
|
|
455
|
+
node --check lib/formblocks/assets/admin.js lib/formblocks/assets/public.js
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The default suite covers every admin and public request, the models and the
|
|
459
|
+
generator. The system task drives a real headless Chrome through the palette,
|
|
460
|
+
autosave, publishing and a two-step submission. CI runs Rails 8.0 and 8.1
|
|
461
|
+
against Ruby 3.2 through 4.0.
|
|
462
|
+
|
|
463
|
+
Bug reports and pull requests welcome.
|
|
464
|
+
|
|
465
|
+
## Credits
|
|
466
|
+
|
|
467
|
+
Highly inspired by [testimonials](https://github.com/yshmarov/testimonials)
|
|
468
|
+
by [Yaroslav Shmarov](https://github.com/yshmarov).
|
|
469
|
+
|
|
470
|
+
## License
|
|
471
|
+
|
|
472
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
|
|
6
|
+
load 'rails/tasks/engine.rake'
|
|
7
|
+
|
|
8
|
+
require 'bundler/gem_tasks'
|
|
9
|
+
require 'rake/testtask'
|
|
10
|
+
|
|
11
|
+
Rake::TestTask.new(:test) do |t|
|
|
12
|
+
t.libs << 'test'
|
|
13
|
+
t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/dummy/**/*')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
namespace :test do
|
|
17
|
+
desc 'Run browser (system) tests'
|
|
18
|
+
Rake::TestTask.new(:system) do |task|
|
|
19
|
+
task.libs << 'test'
|
|
20
|
+
task.test_files = FileList['test/system/**/*_test.rb']
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
task default: :test
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Formblocks
|
|
6
|
+
# Which tenant is asking and whether they may administer forms. A concern
|
|
7
|
+
# rather than inherited behaviour because the engine has two controller
|
|
8
|
+
# roots: the public pages hang off ActionController::Base, and the admin
|
|
9
|
+
# hangs off whatever the host set as `base_controller_class`.
|
|
10
|
+
module RequestContext
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def formblocks_admin_layout
|
|
16
|
+
Formblocks.config.admin_layout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The tenant for this request (nil = the single global collection). Every
|
|
20
|
+
# admin read and write scopes to it.
|
|
21
|
+
def current_tenant
|
|
22
|
+
return @current_tenant if defined?(@current_tenant)
|
|
23
|
+
|
|
24
|
+
@current_tenant = Formblocks.tenant(request)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tenant_forms
|
|
28
|
+
Form.for_tenant(current_tenant)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Server-side gate for the admin. Default: development only.
|
|
32
|
+
def require_admin
|
|
33
|
+
return if Formblocks.admin?(request)
|
|
34
|
+
|
|
35
|
+
render plain: I18n.t('formblocks.forbidden'), status: :forbidden
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Browser referrers can carry password-reset tokens, signed ids and
|
|
39
|
+
# campaign details in their query or fragment. Keep only a plain HTTP(S)
|
|
40
|
+
# origin and path; anything else becomes nil before it is stored.
|
|
41
|
+
def clean_page_url(value)
|
|
42
|
+
uri = URI.parse(value.to_s)
|
|
43
|
+
return unless uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
|
|
44
|
+
|
|
45
|
+
uri.query = nil
|
|
46
|
+
uri.fragment = nil
|
|
47
|
+
uri.to_s.first(500)
|
|
48
|
+
rescue URI::InvalidURIError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Turbo submits forms with a turbo-stream Accept header; a plain browser
|
|
53
|
+
# submit (no JavaScript) does not. Autosave answers the former with 204.
|
|
54
|
+
def turbo_request?
|
|
55
|
+
request.format.turbo_stream?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|