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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2b04f8207b4d1d2916bf97d98dc73087aac5362d5062595e8db6825f7be7e7e
4
- data.tar.gz: 0b865be55ddc3b825e4a514fac4978d7f097c6ec92aa93802578cc07ea08a5e1
3
+ metadata.gz: 58b538400d1acd9446a373f91ad583583492ac7c5d332c93c7f590ef05a11532
4
+ data.tar.gz: c8754c9251ef9da026d6ef89a4be9fdc7f849a17c363e17a84bffd0f01106564
5
5
  SHA512:
6
- metadata.gz: f4fd405a7e97b459806ad8ee1dea28ad19ef55775bac6b12d46516caa1c745b6577a7b774f894cc5de5ea0853b1dec6b0fd0fe7c7e24e4a4a93de7cb4eb923aa
7
- data.tar.gz: dfe1386a1db2f2b62271c0ba003366cc8a9c8e700528deb619c979c1703b2068c235e90240f63fd039c8afe38f69f58e72777b53afab016549968333060b96f8
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; `?key=value` on the public URL overrides it |
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 under it: `response.answers["work_email"]`.
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:
@@ -19,7 +19,8 @@ module Formblocks
19
19
  end
20
20
 
21
21
  def show
22
- @response = @form.responses.new
22
+ # The query string only — never the path's own :slug.
23
+ @response = @form.responses.new.prefill(request.query_parameters)
23
24
  @referrer = clean_page_url(request.referer)
24
25
  end
25
26
 
@@ -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; a query parameter with the same name on the public URL overrides
7
- # the default, so ?utm_source=newsletter lands in the response.
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
- <% unless block.visible? %>
67
- <div class="fb-grid">
68
- <div class="fb-field">
69
- <%= f.label :key, t('formblocks.fields.key'), class: 'fb-label' %>
70
- <%= f.text_field :key, class: 'fb-input fb-input--mono', autocomplete: 'off', spellcheck: false %>
71
- <p class="fb-hint"><%= t('formblocks.fields.key_hint') %></p>
72
- </div>
73
- <div class="fb-field">
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
- </div>
89
+ <% end %>
77
90
  </div>
78
- <% end %>
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 stored answer on a re-render, else the URL's query parameter, else the block's default. %>
2
- <% value = if response&.answers&.key?(block.key) then response.answer(block)
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 value is stored under. A matching query parameter on the public URL fills it (for example ?utm_source=…).
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Formblocks
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.2'
5
5
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: formblocks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Koper