formblocks 0.1.1 → 0.1.2
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 +4 -4
- data/AGENTS.md +6 -0
- data/CHANGELOG.md +16 -0
- data/README.md +41 -2
- data/app/controllers/formblocks/public/forms_controller.rb +2 -1
- data/app/models/formblocks/blocks/hidden.rb +3 -2
- data/app/models/formblocks/blocks/input.rb +8 -0
- data/app/models/formblocks/response.rb +13 -0
- data/app/views/formblocks/blocks/_block.html.erb +24 -17
- data/app/views/formblocks/public/blocks/_hidden.html.erb +2 -4
- data/config/locales/formblocks.en.yml +5 -1
- data/lib/formblocks/version.rb +1 -1
- data/lib/formblocks.rb +15 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58b538400d1acd9446a373f91ad583583492ac7c5d332c93c7f590ef05a11532
|
|
4
|
+
data.tar.gz: c8754c9251ef9da026d6ef89a4be9fdc7f849a17c363e17a84bffd0f01106564
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3945e16fda92efb229e3c1f202263c3d0e94660793b73298ec1638dc66428eccfaf3395d237459895356a7778b1ab236e67df55ef2923b1a925deb4042c48f6d
|
|
7
|
+
data.tar.gz: 242d28a57564446c31c3d485a7863a86254f5bd972b8a4f6f7327c3cb8f5cc7adb1092c403564caa2cdbcf52e98d2786affcb399042e900e377bad3bacc94759
|
data/AGENTS.md
CHANGED
|
@@ -26,6 +26,12 @@ Reach the admin at the mount path and published forms at
|
|
|
26
26
|
`formblocks_form_path(slug)`; the engine's own helpers are under the
|
|
27
27
|
`formblocks` route proxy (`formblocks.root_path`).
|
|
28
28
|
|
|
29
|
+
To link to a form with fields filled in advance, hidden or visible, pass the
|
|
30
|
+
answers by key through `Formblocks.prefill`:
|
|
31
|
+
`formblocks_form_path(slug, Formblocks.prefill(slug, user_id: user.id))`. The
|
|
32
|
+
URL then carries each field's opaque ID, not its key. Do not hard-code IDs
|
|
33
|
+
copied from the builder into host code — they differ per database.
|
|
34
|
+
|
|
29
35
|
## Working on the gem
|
|
30
36
|
|
|
31
37
|
- `bin/rails server` runs the dummy app in `test/dummy` (admin at
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 — 2026-09-18
|
|
4
|
+
|
|
5
|
+
- Prefill for every input, not only hidden fields: a query parameter on the
|
|
6
|
+
public URL fills a text, email, textarea, radio group or checkbox the same
|
|
7
|
+
way it fills a hidden field.
|
|
8
|
+
- Field IDs: every input has a short opaque ID (`Block#public_id`), and
|
|
9
|
+
`?<id>=value` fills it, so a link no longer has to show what its fields are
|
|
10
|
+
called. **Copy ID** on a block in the builder copies it. The plain key
|
|
11
|
+
(`?utm_source=newsletter`) keeps working.
|
|
12
|
+
- `Formblocks.prefill(slug, key: value)` turns keys into IDs for a link built
|
|
13
|
+
in a host view: `formblocks_form_path(slug, Formblocks.prefill(slug, …))`.
|
|
14
|
+
- The builder shows **Field name** (the key) on every input, not only on
|
|
15
|
+
hidden fields, and it can be renamed there.
|
|
16
|
+
- Fixed: a hidden field whose key matched a route parameter (`slug`) was
|
|
17
|
+
filled with that parameter. Only the query string is read now.
|
|
18
|
+
|
|
3
19
|
## 0.1.1 — 2026-09-15
|
|
4
20
|
|
|
5
21
|
- Responses: a URL answer that points at an image (an image file extension, or
|
data/README.md
CHANGED
|
@@ -77,6 +77,7 @@ any app that bundles it.
|
|
|
77
77
|
| **Multi-page** | Every form has at least one step and a thank-you page; add steps as you like |
|
|
78
78
|
| **Templates** | Blank, contact, lead capture, feedback — or your own hashes. Duplicate any form |
|
|
79
79
|
| **Public page** | One step at a time, browser validation, server validation with inline errors |
|
|
80
|
+
| **Prefill** | Fill any field from the link, hidden or visible, under opaque field IDs — the URL never says what a field is called |
|
|
80
81
|
| **Responses** | A dashboard per form, one response in full, CSV export, an `on_submit` hook |
|
|
81
82
|
| **Branding** | Logo, primary color, button text color — per form, inherited from global settings |
|
|
82
83
|
| **Deps** | Rails, `turbo-rails`, `stimulus-rails`. No asset pipeline, no bundler, no build step |
|
|
@@ -197,13 +198,14 @@ inheritance, so `Email < Text < Input < Block`:
|
|
|
197
198
|
| `image` | — | An upload; `label` is the alt text |
|
|
198
199
|
| `name`, `email`, `phone`, `url`, `text` | a string | Single-line inputs with the right `type` and `autocomplete`; email and URL are validated |
|
|
199
200
|
| `textarea` | a string | Multi-line |
|
|
200
|
-
| `hidden` | a string | `key` is the field name, `content` the default;
|
|
201
|
+
| `hidden` | a string | `key` is the field name, `content` the default; the link to the form can override it — see [Filling fields from the URL](#filling-fields-from-the-url) |
|
|
201
202
|
| `checkbox` | `true`/`false` | Required means it must be ticked |
|
|
202
203
|
| `radio_group` | one of `options` | One option per line in the builder |
|
|
203
204
|
|
|
204
205
|
Every input has a **key** — from its label when it is created ("Work email"
|
|
205
206
|
→ `work_email`), unique within the form, and stable afterwards even if the
|
|
206
|
-
label changes. Answers are stored
|
|
207
|
+
label changes; **Field name** in the builder renames it. Answers are stored
|
|
208
|
+
under it: `response.answers["work_email"]`.
|
|
207
209
|
|
|
208
210
|
<details>
|
|
209
211
|
<summary><b>Your own block</b></summary>
|
|
@@ -282,6 +284,43 @@ required fields, email and URL formats, radio options, required checkboxes —
|
|
|
282
284
|
and re-rendered with an error under each field. The slug is yours to set in
|
|
283
285
|
the form's settings; it is generated from the title otherwise.
|
|
284
286
|
|
|
287
|
+
### Filling fields from the URL
|
|
288
|
+
|
|
289
|
+
A link can carry answers with it: who is asking, which plan they are on, the
|
|
290
|
+
email you already know. Every input — hidden or visible — has an **ID**, a
|
|
291
|
+
short opaque string, and a query parameter with that name fills the field:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
/f/feedback?515e541fe571=7&d42b3cfb4220=ada%40example.com
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The visitor sees the values, not what they are stored as. **Copy ID** on a
|
|
298
|
+
block in the builder gives you its ID; it stays the same for the life of the
|
|
299
|
+
block, whatever its label and field name become.
|
|
300
|
+
|
|
301
|
+
From your own views, write the keys and let the gem look the IDs up:
|
|
302
|
+
|
|
303
|
+
```erb
|
|
304
|
+
<%= link_to "Request an integration",
|
|
305
|
+
formblocks_form_path("request-for-integration",
|
|
306
|
+
Formblocks.prefill("request-for-integration",
|
|
307
|
+
user_id: Current.user.id, account_id: Current.account.id,
|
|
308
|
+
role: Current.account.role, email: Current.user.email)),
|
|
309
|
+
target: "_blank" %>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`Formblocks.prefill(slug, answers)` is one query and returns `{ id => value }`.
|
|
313
|
+
A key the form does not have is left out, so the link survives a field being
|
|
314
|
+
renamed or removed. IDs belong to a database — the same form built in staging
|
|
315
|
+
and in production has different ones — which is the other reason to use the
|
|
316
|
+
helper rather than paste IDs into code.
|
|
317
|
+
|
|
318
|
+
A field also answers to its plain key, `?utm_source=newsletter`, for the
|
|
319
|
+
parameters whose names you do not choose. A radio group takes one of its
|
|
320
|
+
options, a checkbox `1` or `0`. Anything in a URL can be edited by the
|
|
321
|
+
visitor, so treat a prefilled answer like any other answer: it says what the
|
|
322
|
+
link said, not who the visitor is.
|
|
323
|
+
|
|
285
324
|
### Spam and rate limiting
|
|
286
325
|
|
|
287
326
|
Two defences, both on by default and neither visible to a person:
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Formblocks
|
|
4
4
|
module Blocks
|
|
5
5
|
# A field the visitor never sees. `key` is its name, `content` its default
|
|
6
|
-
# value;
|
|
7
|
-
#
|
|
6
|
+
# value; like any input it can be filled from the public URL's query
|
|
7
|
+
# string (see Response#prefill), so ?utm_source=newsletter lands in the
|
|
8
|
+
# response.
|
|
8
9
|
class Hidden < Input
|
|
9
10
|
def self.placeholder? = false
|
|
10
11
|
def self.requirable? = false
|
|
@@ -31,6 +31,14 @@ module Formblocks
|
|
|
31
31
|
true
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# The name this input answers to in the public URL's query string:
|
|
35
|
+
# opaque, so a link can fill the field in advance without showing what
|
|
36
|
+
# it is called (?3f9a1c2b7d4e=value). A digest of the id — stable for
|
|
37
|
+
# the life of the block, whatever its key and label become.
|
|
38
|
+
def public_id
|
|
39
|
+
Digest::SHA256.hexdigest("formblocks/block/#{id}").first(12) if persisted?
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
# The submitted value as it is stored.
|
|
35
43
|
def normalize_answer(value)
|
|
36
44
|
value.to_s.strip
|
|
@@ -27,6 +27,19 @@ module Formblocks
|
|
|
27
27
|
self
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Answers given in advance: the public URL's query parameters, named by
|
|
31
|
+
# a block's opaque id (?3f9a1c2b7d4e=ada@example.com) or by its key
|
|
32
|
+
# (?utm_source=newsletter). Only the form's own inputs and only the ones
|
|
33
|
+
# given, so a hidden block without one keeps its default.
|
|
34
|
+
def prefill(given)
|
|
35
|
+
given = given.to_h.stringify_keys
|
|
36
|
+
form.input_blocks.each do |block|
|
|
37
|
+
value = given.fetch(block.public_id) { given[block.key] }
|
|
38
|
+
answers[block.key] = block.normalize_answer(value) if value.is_a?(String)
|
|
39
|
+
end
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
30
43
|
def answer(block)
|
|
31
44
|
answers.to_h[block.key]
|
|
32
45
|
end
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
<% page = block.page %>
|
|
2
2
|
<% form = page.form %>
|
|
3
|
-
<article class="fb-block" id="<%= dom_id(block) %>" data-fb-sortable-target="item" data-id="<%= block.id %>"
|
|
3
|
+
<article class="fb-block" id="<%= dom_id(block) %>" data-fb-sortable-target="item" data-id="<%= block.id %>"
|
|
4
|
+
data-controller="fb-clipboard" data-fb-clipboard-copied-value="<%= t('formblocks.actions.copied') %>">
|
|
4
5
|
<header class="fb-block__header">
|
|
5
6
|
<button type="button" class="fb-block__grip" data-fb-sortable-handle title="<%= t('formblocks.forms.edit.drag') %>"
|
|
6
7
|
aria-label="<%= t('formblocks.forms.edit.drag') %>"><%= fb_icon(:grip) %></button>
|
|
7
8
|
<span class="fb-block__type"><%= block.display_name %></span>
|
|
8
9
|
<div class="fb-actions">
|
|
10
|
+
<% if block.input? %>
|
|
11
|
+
<button type="button" class="fb-btn fb-btn--sm fb-btn--ghost" data-action="fb-clipboard#copy"
|
|
12
|
+
title="<%= t('formblocks.actions.copy_id_hint') %>">
|
|
13
|
+
<%= fb_icon(:copy) %> <span data-fb-clipboard-target="label"><%= t('formblocks.actions.copy_id') %></span>
|
|
14
|
+
</button>
|
|
15
|
+
<% end %>
|
|
9
16
|
<%= button_to move_form_page_block_path(form, page, block, direction: 'up'), method: :patch, class: 'fb-iconbtn',
|
|
10
17
|
title: t('formblocks.actions.move_up'), 'aria-label': t('formblocks.actions.move_up'),
|
|
11
18
|
form: { data: { turbo_action: 'replace' } } do %><%= fb_icon(:up) %><% end %>
|
|
@@ -63,19 +70,25 @@
|
|
|
63
70
|
</div>
|
|
64
71
|
<% end %>
|
|
65
72
|
</div>
|
|
66
|
-
|
|
67
|
-
<div class="fb-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
<div class="fb-grid">
|
|
74
|
+
<div class="fb-field">
|
|
75
|
+
<%= f.label :key, t('formblocks.fields.key'), class: 'fb-label' %>
|
|
76
|
+
<%= f.text_field :key, class: 'fb-input fb-input--mono', autocomplete: 'off', spellcheck: false %>
|
|
77
|
+
<p class="fb-hint">
|
|
78
|
+
<%= t('formblocks.fields.key_hint') %>
|
|
79
|
+
<%= t('formblocks.fields.prefill_hint_html', id: tag.code(block.public_id, data: { fb_clipboard_target: 'source' })) %>
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="fb-field">
|
|
83
|
+
<% if block.visible? %>
|
|
84
|
+
<%= f.label :help_text, t('formblocks.fields.help_text'), class: 'fb-label' %>
|
|
85
|
+
<%= f.text_field :help_text, class: 'fb-input' %>
|
|
86
|
+
<% else %>
|
|
74
87
|
<%= f.label :content, t('formblocks.fields.value'), class: 'fb-label' %>
|
|
75
88
|
<%= f.text_field :content, class: 'fb-input' %>
|
|
76
|
-
|
|
89
|
+
<% end %>
|
|
77
90
|
</div>
|
|
78
|
-
|
|
91
|
+
</div>
|
|
79
92
|
<% if block.options? %>
|
|
80
93
|
<div class="fb-field">
|
|
81
94
|
<%= f.label :options_text, t('formblocks.fields.options'), class: 'fb-label' %>
|
|
@@ -83,12 +96,6 @@
|
|
|
83
96
|
<p class="fb-hint"><%= t('formblocks.fields.options_hint') %></p>
|
|
84
97
|
</div>
|
|
85
98
|
<% end %>
|
|
86
|
-
<% if block.visible? %>
|
|
87
|
-
<div class="fb-field">
|
|
88
|
-
<%= f.label :help_text, t('formblocks.fields.help_text'), class: 'fb-label' %>
|
|
89
|
-
<%= f.text_field :help_text, class: 'fb-input' %>
|
|
90
|
-
</div>
|
|
91
|
-
<% end %>
|
|
92
99
|
<% if block.requirable? %>
|
|
93
100
|
<label class="fb-check"><%= f.check_box :required %> <%= t('formblocks.fields.required') %></label>
|
|
94
101
|
<% end %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<%# The
|
|
2
|
-
<% value =
|
|
3
|
-
elsif params[block.key].is_a?(String) then params[block.key]
|
|
4
|
-
else block.default_value end %>
|
|
1
|
+
<%# The answer — submitted, or given in the URL's query string — else the block's default. %>
|
|
2
|
+
<% value = response&.answers&.key?(block.key) ? response.answer(block) : block.default_value %>
|
|
5
3
|
<%= hidden_field_tag "answers[#{block.key}]", value, id: nil %>
|
|
@@ -16,6 +16,9 @@ en:
|
|
|
16
16
|
delete_form: Delete this form
|
|
17
17
|
duplicate: Duplicate
|
|
18
18
|
back: Back
|
|
19
|
+
copy_id: Copy ID
|
|
20
|
+
copy_id_hint: The field's ID, for filling it in advance from the public URL
|
|
21
|
+
copied: Copied!
|
|
19
22
|
move_up: Move up
|
|
20
23
|
move_down: Move down
|
|
21
24
|
confirm_delete: Delete this? This cannot be undone.
|
|
@@ -102,7 +105,8 @@ en:
|
|
|
102
105
|
options: Options
|
|
103
106
|
options_hint: One option per line.
|
|
104
107
|
key: Field name
|
|
105
|
-
key_hint: The name the
|
|
108
|
+
key_hint: The name the answer is stored under.
|
|
109
|
+
prefill_hint_html: "To fill this field in advance, add ?%{id}=… to the public URL."
|
|
106
110
|
value: Default value
|
|
107
111
|
image: Image
|
|
108
112
|
alt_text: Alt text
|
data/lib/formblocks/version.rb
CHANGED
data/lib/formblocks.rb
CHANGED
|
@@ -48,6 +48,21 @@ module Formblocks
|
|
|
48
48
|
Form.for_tenant(tenant_key_for(record))
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Query parameters that fill a form's fields in advance, each under the
|
|
52
|
+
# field's opaque id rather than its key, so the URL does not show what the
|
|
53
|
+
# fields are called:
|
|
54
|
+
#
|
|
55
|
+
# formblocks_form_url('feedback', Formblocks.prefill('feedback', user_id: 7, email: 'ada@example.com'))
|
|
56
|
+
# # => /f/feedback?3f9a1c2b7d4e=7&b04e8a61c7d2=ada%40example.com
|
|
57
|
+
#
|
|
58
|
+
# Keys the form does not have are dropped, so a link keeps working when a
|
|
59
|
+
# field is renamed or removed in the builder.
|
|
60
|
+
def prefill(slug, answers)
|
|
61
|
+
answers = answers.to_h.stringify_keys
|
|
62
|
+
Block.joins(page: :form).where(Form.table_name => { slug: slug.to_s }, key: answers.keys)
|
|
63
|
+
.to_h { |block| [block.public_id, answers[block.key]] }
|
|
64
|
+
end
|
|
65
|
+
|
|
51
66
|
# The product name for titles and alt text: config.app_name, else the
|
|
52
67
|
# Rails application's module name, verbatim ("Nusii", "SupeRails").
|
|
53
68
|
def app_name
|