formblocks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +57 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +472 -0
  6. data/Rakefile +24 -0
  7. data/app/controllers/concerns/formblocks/request_context.rb +58 -0
  8. data/app/controllers/formblocks/application_controller.rb +16 -0
  9. data/app/controllers/formblocks/assets_controller.rb +27 -0
  10. data/app/controllers/formblocks/blocks_controller.rb +81 -0
  11. data/app/controllers/formblocks/dashboard_controller.rb +41 -0
  12. data/app/controllers/formblocks/forms_controller.rb +71 -0
  13. data/app/controllers/formblocks/pages_controller.rb +44 -0
  14. data/app/controllers/formblocks/public/forms_controller.rb +74 -0
  15. data/app/controllers/formblocks/responses_controller.rb +39 -0
  16. data/app/controllers/formblocks/settings_controller.rb +30 -0
  17. data/app/helpers/formblocks/application_helper.rb +93 -0
  18. data/app/models/concerns/formblocks/attachable.rb +43 -0
  19. data/app/models/formblocks/application_record.rb +7 -0
  20. data/app/models/formblocks/block.rb +177 -0
  21. data/app/models/formblocks/blocks/checkbox.rb +22 -0
  22. data/app/models/formblocks/blocks/email.rb +23 -0
  23. data/app/models/formblocks/blocks/heading.rb +13 -0
  24. data/app/models/formblocks/blocks/hidden.rb +25 -0
  25. data/app/models/formblocks/blocks/image.rb +14 -0
  26. data/app/models/formblocks/blocks/input.rb +59 -0
  27. data/app/models/formblocks/blocks/name.rb +9 -0
  28. data/app/models/formblocks/blocks/paragraph.rb +13 -0
  29. data/app/models/formblocks/blocks/phone.rb +13 -0
  30. data/app/models/formblocks/blocks/radio_group.rb +28 -0
  31. data/app/models/formblocks/blocks/text.rb +12 -0
  32. data/app/models/formblocks/blocks/textarea.rb +8 -0
  33. data/app/models/formblocks/blocks/url.rb +26 -0
  34. data/app/models/formblocks/csv_export.rb +47 -0
  35. data/app/models/formblocks/form.rb +197 -0
  36. data/app/models/formblocks/page.rb +79 -0
  37. data/app/models/formblocks/response.rb +48 -0
  38. data/app/models/formblocks/setting.rb +19 -0
  39. data/app/views/formblocks/blocks/_block.html.erb +102 -0
  40. data/app/views/formblocks/blocks/_palette.html.erb +16 -0
  41. data/app/views/formblocks/forms/_header.html.erb +19 -0
  42. data/app/views/formblocks/forms/edit.html.erb +38 -0
  43. data/app/views/formblocks/forms/index.html.erb +48 -0
  44. data/app/views/formblocks/forms/new.html.erb +26 -0
  45. data/app/views/formblocks/forms/published.html.erb +25 -0
  46. data/app/views/formblocks/forms/settings.html.erb +43 -0
  47. data/app/views/formblocks/pages/_page.html.erb +51 -0
  48. data/app/views/formblocks/public/blocks/_checkbox.html.erb +15 -0
  49. data/app/views/formblocks/public/blocks/_field.html.erb +13 -0
  50. data/app/views/formblocks/public/blocks/_heading.html.erb +1 -0
  51. data/app/views/formblocks/public/blocks/_hidden.html.erb +5 -0
  52. data/app/views/formblocks/public/blocks/_image.html.erb +3 -0
  53. data/app/views/formblocks/public/blocks/_paragraph.html.erb +1 -0
  54. data/app/views/formblocks/public/blocks/_radio_group.html.erb +17 -0
  55. data/app/views/formblocks/public/blocks/_text.html.erb +6 -0
  56. data/app/views/formblocks/public/blocks/_textarea.html.erb +5 -0
  57. data/app/views/formblocks/public/forms/not_found.html.erb +3 -0
  58. data/app/views/formblocks/public/forms/show.html.erb +37 -0
  59. data/app/views/formblocks/public/forms/thanks.html.erb +5 -0
  60. data/app/views/formblocks/responses/index.html.erb +45 -0
  61. data/app/views/formblocks/responses/show.html.erb +43 -0
  62. data/app/views/formblocks/settings/show.html.erb +19 -0
  63. data/app/views/formblocks/shared/_admin.html.erb +21 -0
  64. data/app/views/formblocks/shared/_brand_fields.html.erb +29 -0
  65. data/app/views/formblocks/shared/_errors.html.erb +7 -0
  66. data/app/views/layouts/formblocks/application.html.erb +18 -0
  67. data/app/views/layouts/formblocks/public.html.erb +20 -0
  68. data/config/locales/formblocks.en.yml +188 -0
  69. data/config/routes.rb +35 -0
  70. data/lib/formblocks/assets/admin.css +212 -0
  71. data/lib/formblocks/assets/admin.js +215 -0
  72. data/lib/formblocks/assets/public.css +66 -0
  73. data/lib/formblocks/assets/public.js +86 -0
  74. data/lib/formblocks/assets.rb +81 -0
  75. data/lib/formblocks/configuration.rb +93 -0
  76. data/lib/formblocks/engine.rb +33 -0
  77. data/lib/formblocks/seeds.rb +50 -0
  78. data/lib/formblocks/templates.rb +97 -0
  79. data/lib/formblocks/version.rb +5 -0
  80. data/lib/formblocks.rb +74 -0
  81. data/lib/generators/formblocks/install/install_generator.rb +43 -0
  82. data/lib/generators/formblocks/install/templates/create_formblocks_tables.rb.tt +65 -0
  83. data/lib/generators/formblocks/install/templates/initializer.rb.tt +69 -0
  84. data/lib/generators/formblocks/migration_helpers.rb +40 -0
  85. data/lib/tasks/formblocks_tasks.rake +10 -0
  86. metadata +199 -0
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
+ [![CI](https://github.com/michaelkoper/formblocks/actions/workflows/ci.yml/badge.svg)](https://github.com/michaelkoper/formblocks/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![The builder: a lead capture form with its pages, blocks and the publish bar](docs/screenshots/builder.png)
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
+ | ![The forms index](docs/screenshots/forms-index.png) | ![Blocks in the builder: a URL input and a radio group](docs/screenshots/builder-blocks.png) |
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
+ | ![The published page with the public URL](docs/screenshots/published.png) | ![The public form, step 1 of 2](docs/screenshots/public-step-1.png) |
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
+ | ![The responses list](docs/screenshots/responses-index.png) | ![One response in full](docs/screenshots/response-show.png) |
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