phlex-forms 0.1.0 → 0.2.1
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/CHANGELOG.md +70 -0
- data/README.md +229 -52
- data/lib/forms/base.rb +100 -0
- data/lib/forms/field.rb +39 -21
- data/lib/forms/fields_for_builder.rb +19 -6
- data/lib/forms/form.rb +38 -10
- data/lib/forms/group.rb +23 -0
- data/lib/forms/live/field.rb +22 -0
- data/lib/forms/live.rb +179 -0
- data/lib/forms/plain/checkbox.rb +17 -0
- data/lib/forms/plain/control.rb +23 -0
- data/lib/forms/plain/field_error.rb +15 -0
- data/lib/forms/plain/field_hint.rb +14 -0
- data/lib/forms/plain/file_input.rb +14 -0
- data/lib/forms/plain/group.rb +15 -0
- data/lib/forms/plain/input.rb +13 -0
- data/lib/forms/plain/label.rb +19 -0
- data/lib/forms/plain/radio.rb +14 -0
- data/lib/forms/plain/row.rb +13 -0
- data/lib/forms/plain/select.rb +22 -0
- data/lib/forms/plain/submit.rb +15 -0
- data/lib/forms/plain/textarea.rb +14 -0
- data/lib/forms/plain/wrapped_input.rb +34 -0
- data/lib/forms/row.rb +34 -0
- data/lib/phlex_forms/builder.rb +51 -18
- data/lib/phlex_forms/configuration.rb +28 -0
- data/lib/phlex_forms/delegated_field.rb +17 -0
- data/lib/phlex_forms/engine.rb +7 -0
- data/lib/phlex_forms/inference.rb +238 -0
- data/lib/phlex_forms/theme.rb +80 -0
- data/lib/phlex_forms/version.rb +1 -1
- data/lib/phlex_forms.rb +26 -1
- metadata +37 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07e978bd7062d93d342a6048d51a54559abecf20098ae39eda81c10497856578
|
|
4
|
+
data.tar.gz: 30c5714c27d7a892da6eb591a4c3e98cae860d0d971d47932e7eed2f127fe1da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ab143d433e88c413810036f0b53cc3f70c3eb74c75b0d5fb4f319b94c44ba9b62b003156274a95d67aa048623edeeafab45fc6e0bcc17875817c980f956126d
|
|
7
|
+
data.tar.gz: 502f58b8543f1b31ba0700a24da4588875945b38f54bf26ac411fa13b88c304f9d2250d822357e327a689ec58863b2d3651d16bb09ed20f7c486c24193259888
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- **`Forms::Base` declarative form classes**: subclass, declare fields in
|
|
13
|
+
`#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
|
|
14
|
+
render with `render UserForm.new(model: @user)`. Class-level `form_options`
|
|
15
|
+
defaults are inherited and merged down the subclass chain; a render-time
|
|
16
|
+
block appends after the declared fields.
|
|
17
|
+
- **`live` server-truth validation** (`Forms::Base` only, phlex-reactive soft
|
|
18
|
+
dependency): `live model: User` runs the real ActiveModel validators
|
|
19
|
+
server-side on blur/debounced input — uniqueness, `:if`/`:unless`,
|
|
20
|
+
confirmation, `:on` context — and morphs errors back in with focus
|
|
21
|
+
preserved. Touched-field tracking rides the signed reactive state; nothing
|
|
22
|
+
is persisted. Inline `Form(live: true)` raises with guidance.
|
|
23
|
+
- **Model-driven type inference** in `field`: boolean column → toggle, AR enum
|
|
24
|
+
→ humanized select, non-polymorphic `belongs_to` → association select (name
|
|
25
|
+
rewritten to the foreign key, errors still surface from the association
|
|
26
|
+
name), text column → textarea, date/datetime/time/numeric columns → matching
|
|
27
|
+
inputs (+`step`), `has_rich_text` → rich textarea, attachments → file
|
|
28
|
+
(`multiple` + `name[]` for `has_many_attached`), length/numericality
|
|
29
|
+
validators → `maxlength`/`min`/`max`. Explicit `as:`/`choices:`/positional
|
|
30
|
+
type modifiers always win; POROs and untyped models behave exactly as
|
|
31
|
+
before. Kill-switch: `PhlexForms.config.infer_from_model = false`.
|
|
32
|
+
- `choices:` without `as: :select` now implies a select (previously silently
|
|
33
|
+
dropped).
|
|
34
|
+
- **Themes**: component roles resolve through `PhlexForms::Theme`. The daisy
|
|
35
|
+
theme (default when the daisyui gem is loaded) is unchanged; the new Plain
|
|
36
|
+
theme renders bare semantic HTML — same binding contract, variants accepted
|
|
37
|
+
and ignored, `aria-invalid` + `role="alert"` + `data-field-error/hint`
|
|
38
|
+
hooks, zero styling classes — so the same form class renders in non-daisyui
|
|
39
|
+
projects. Select per form (`theme:`), per class (`form_options theme:`), or
|
|
40
|
+
globally; override single roles with `Theme#with`.
|
|
41
|
+
- **Form-level variant defaults**: `field_variants: [:primary, :sm]` on a
|
|
42
|
+
form / `form_options` / `PhlexForms.config.field_variants` prepends daisyui
|
|
43
|
+
variants to every `field`'s inner input (call-site modifiers stack last).
|
|
44
|
+
- Layout helpers: `f.row(columns:)` (responsive grid) and `f.group(legend:)`
|
|
45
|
+
(fieldset), available on inline forms and `fields_for` builders.
|
|
46
|
+
- Adoption enablers: `Form(scope: false)` emits bare field names (reactive row
|
|
47
|
+
editors, `<template>` rows); `fields_for(..., nested_attributes: false)`
|
|
48
|
+
skips the `_attributes` suffix (JSONB columns); public `Form#field_value`
|
|
49
|
+
joins `field_name`/`field_id` for external widgets.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **Minimum Ruby is now 3.4** (was 3.2). The optional `live` validation runs on
|
|
54
|
+
phlex-reactive, which has required Ruby >= 3.4 since its 0.9 line; aligning the
|
|
55
|
+
floor keeps the whole gem installable wherever the live integration is.
|
|
56
|
+
- **`daisyui` is now a soft dependency** (removed from the gemspec). With it
|
|
57
|
+
loaded nothing changes; without it the Plain theme is the default and the
|
|
58
|
+
gem renders unstyled semantic HTML.
|
|
59
|
+
- **Live validation is compatible with phlex-reactive >= 0.11's default-ON
|
|
60
|
+
`verify_authorized` (#168).** The `:validate` action declares
|
|
61
|
+
`skip_verify_authorized` — it's a no-persist, read-only validation pass, so it
|
|
62
|
+
needs no authorization call and won't raise `AuthorizationNotVerified`. No host
|
|
63
|
+
action required.
|
|
64
|
+
- Model-driven inference changes rendered output for ActiveRecord-backed
|
|
65
|
+
forms that previously fell through to a text input. Before/after:
|
|
66
|
+
|
|
67
|
+
| field on an AR model | before | after |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| boolean column | `<input type="text">` | toggle (checkbox + hidden pair) |
|
|
70
|
+
| text column | `<input type="text">` | `<textarea>` |
|
|
71
|
+
| AR enum | text input | `<select>` with humanized options |
|
|
72
|
+
| `belongs_to` (`:country`/`:country_id`) | text input | `<select>` over the association, `name="…[country_id]"` |
|
|
73
|
+
| date/datetime/time column | text input (unless name-mapped) | matching input type |
|
|
74
|
+
| integer/decimal/float column | text input | `<input type="number" step=…>` |
|
|
75
|
+
| attachment / rich text | text input | file input / rich textarea |
|
|
76
|
+
|
|
77
|
+
Pass `as:` explicitly or set `PhlexForms.config.infer_from_model = false`
|
|
78
|
+
to keep the old rendering.
|
|
79
|
+
|
|
80
|
+
### Initial extraction
|
|
81
|
+
|
|
12
82
|
- Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
|
|
13
83
|
- Control-first builder API: `f.field :email, label:, hint:, as:, choices:`
|
|
14
84
|
renders label + input + error/hint in one call, inferring input type and the
|
data/README.md
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
# phlex-forms
|
|
2
2
|
|
|
3
|
-
A model-bound
|
|
3
|
+
A model-bound form builder for [Phlex](https://www.phlex.fun) — forms as
|
|
4
|
+
first-class Phlex classes, types inferred from your model, DaisyUI styling by
|
|
5
|
+
default (and a plain-HTML theme when you want none), and optional
|
|
6
|
+
**server-truth live validation** over
|
|
7
|
+
[phlex-reactive](https://github.com/mhenrixon/phlex-reactive).
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
```ruby
|
|
10
|
+
class UserForm < Forms::Base
|
|
11
|
+
live model: User # real validators, live, focus preserved
|
|
12
|
+
|
|
13
|
+
def fields # self IS the form — no f. prefix
|
|
14
|
+
field :email # name → type=email, required inferred
|
|
15
|
+
field :role # AR enum → select, humanized
|
|
16
|
+
field :country # belongs_to → select over the association
|
|
17
|
+
field :notify # boolean column → toggle
|
|
18
|
+
field :bio # text column → textarea
|
|
19
|
+
row do
|
|
20
|
+
field :first_name
|
|
21
|
+
field :last_name
|
|
22
|
+
end
|
|
23
|
+
submit :primary
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
render UserForm.new(model: @user)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For one-offs, the inline builder does the same with a yielded form:
|
|
11
31
|
|
|
12
32
|
```ruby
|
|
13
|
-
Form(
|
|
14
|
-
f.field :email, hint: t("users.email_hint")
|
|
15
|
-
f.field :
|
|
16
|
-
f.field :bio, as: :textarea, rows: 6
|
|
17
|
-
f.field :notify, as: :toggle
|
|
33
|
+
Form(model: @user) do |f|
|
|
34
|
+
f.field :email, hint: t("users.email_hint")
|
|
35
|
+
f.field :bio
|
|
18
36
|
f.submit :primary
|
|
19
37
|
end
|
|
20
38
|
```
|
|
@@ -24,6 +42,8 @@ end
|
|
|
24
42
|
```ruby
|
|
25
43
|
# Gemfile
|
|
26
44
|
gem "phlex-forms"
|
|
45
|
+
gem "daisyui" # optional — the daisy theme; omit for plain semantic HTML
|
|
46
|
+
gem "phlex-reactive" # optional — the `live` server-truth validation
|
|
27
47
|
```
|
|
28
48
|
|
|
29
49
|
phlex-forms exposes its components under the `Forms::` namespace as a
|
|
@@ -36,36 +56,109 @@ class ApplicationComponent < Phlex::HTML
|
|
|
36
56
|
end
|
|
37
57
|
```
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
## Form classes (`Forms::Base`)
|
|
60
|
+
|
|
61
|
+
Subclass `Forms::Base`, declare the fields in `#fields`, render. The whole
|
|
62
|
+
builder surface (`field`, `row`, `group`, `Input`, `submit`, `fields_for`, …)
|
|
63
|
+
is available as bare calls — the form is an object you can reuse, subclass,
|
|
64
|
+
and test in isolation.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class ApplicationForm < Forms::Base
|
|
68
|
+
form_options :spaced, field_variants: [:primary] # inherited defaults
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class UserForm < ApplicationForm
|
|
72
|
+
form_options url: "/signup" # merged over the parent's
|
|
73
|
+
|
|
74
|
+
def fields
|
|
75
|
+
field :email
|
|
76
|
+
submit :primary
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
render UserForm.new(model: @user) # instance args win
|
|
81
|
+
render UserForm.new(model: @user) { |f| f.Hidden(:token) } # appends after fields
|
|
82
|
+
```
|
|
41
83
|
|
|
42
84
|
## The `field` API
|
|
43
85
|
|
|
44
|
-
`f.field(name, *modifiers, **options)` is the primary verb
|
|
45
|
-
|
|
86
|
+
`f.field(name, *modifiers, **options)` is the primary verb: one call renders a
|
|
87
|
+
control wrapping the label, the input, and an error (or hint).
|
|
46
88
|
|
|
47
89
|
| Option | Effect |
|
|
48
90
|
| --- | --- |
|
|
49
91
|
| `label:` | Label text. Defaults to the model's humanized attribute name. `label: false` omits it. |
|
|
50
92
|
| `hint:` | Help text shown when there is no error. |
|
|
51
|
-
| `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, or any text-like type. |
|
|
93
|
+
| `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, `:rich_textarea`, or any text-like type. |
|
|
52
94
|
| `required:` | Force the required flag. Otherwise inferred from the model's presence validators. |
|
|
53
|
-
| `choices:` | Choices for `as: :select
|
|
95
|
+
| `choices:` | Choices for a select (implies `as: :select`). |
|
|
54
96
|
| positional modifiers | daisyui variants — `:primary`, `:lg`, `:ghost`, … — stacked onto the input. |
|
|
55
97
|
|
|
56
|
-
|
|
57
|
-
|
|
98
|
+
### Model-driven inference
|
|
99
|
+
|
|
100
|
+
`field :x` interrogates the bound model, so `as:` is an override, not a
|
|
101
|
+
requirement. Precedence (first hit wins):
|
|
102
|
+
|
|
103
|
+
1. Explicit `as:`
|
|
104
|
+
2. A positional type modifier (`field :price, :number`)
|
|
105
|
+
3. `choices:` → select
|
|
106
|
+
4. Model structure — `has_rich_text` → rich textarea; ActiveStorage attachment
|
|
107
|
+
→ file input (`multiple` for `has_many_attached`); ActiveRecord enum →
|
|
108
|
+
select over humanized keys; non-polymorphic `belongs_to` (as `:country` or
|
|
109
|
+
`:country_id`) → select over the association (`name="user[country_id]"`,
|
|
110
|
+
label and required flag from the association, errors surface from
|
|
111
|
+
`:country`)
|
|
112
|
+
5. Non-string column type — boolean → toggle, text → textarea,
|
|
113
|
+
date/datetime/time → matching inputs, integer/decimal/float → number with
|
|
114
|
+
a sensible `step`
|
|
115
|
+
6. The attribute-name map (`email` → `type=email`, `password` →
|
|
116
|
+
`type=password`, `phone` → `type=tel`, …)
|
|
117
|
+
7. `type=text`
|
|
118
|
+
|
|
119
|
+
Length and numericality validators also emit `maxlength`/`min`/`max`
|
|
120
|
+
(conditional validators are skipped; caller options always win). Everything is
|
|
121
|
+
duck-typed behind `respond_to?` guards — plain objects, Structs, and form
|
|
122
|
+
objects fall through to the name map, exactly as before. Opt out entirely
|
|
123
|
+
with:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
PhlexForms.configure { |c| c.infer_from_model = false }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Association selects load `klass.all` per render — pass `choices:` to scope,
|
|
130
|
+
order, or cache.
|
|
58
131
|
|
|
59
|
-
###
|
|
132
|
+
### Variants
|
|
60
133
|
|
|
61
|
-
|
|
62
|
-
lower-level component methods are always available with stable signatures:
|
|
134
|
+
daisyui variants stack positionally, per field or as defaults:
|
|
63
135
|
|
|
64
136
|
```ruby
|
|
65
|
-
f.
|
|
66
|
-
|
|
137
|
+
f.field :email, :primary, :sm # this field
|
|
138
|
+
Form(model: @user, field_variants: [:primary]) # every field in this form
|
|
139
|
+
form_options field_variants: [:primary] # every field in this class
|
|
140
|
+
PhlexForms.configure { |c| c.field_variants = [:sm] } # everywhere
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Defaults stack first, call-site modifiers last — the most local wins.
|
|
144
|
+
|
|
145
|
+
### Layout
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
row { field :first_name; field :last_name } # responsive grid, columns: 2|3|4
|
|
149
|
+
group(legend: "Address") do # <fieldset> + legend
|
|
150
|
+
field :street
|
|
151
|
+
field :city
|
|
67
152
|
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Both work on inline forms (`f.row { … }`) and inside `fields_for` builders.
|
|
156
|
+
|
|
157
|
+
## Escape hatches & custom widgets
|
|
68
158
|
|
|
159
|
+
The lower-level component methods are always available with stable signatures:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
69
162
|
f.Input(:name, :primary, :lg) # bare input, variants stacked
|
|
70
163
|
f.Select(:role, choices: roles) # native <select>
|
|
71
164
|
f.Textarea(:bio, :ghost)
|
|
@@ -73,51 +166,109 @@ f.Checkbox(:terms) ; f.Toggle(:notify) ; f.FileInput(:avatar)
|
|
|
73
166
|
f.Label(:email) ; f.Hidden(:token) ; f.submit("Save", :primary)
|
|
74
167
|
```
|
|
75
168
|
|
|
76
|
-
|
|
169
|
+
For a bespoke widget (date picker, tag field, remote select), wrap it in
|
|
170
|
+
`f.Control` and bind through the public helpers — this is the supported path,
|
|
171
|
+
not a fork reason:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
f.Control(:starts_at, label: "Starts") do
|
|
175
|
+
render MyDatePicker.new(
|
|
176
|
+
name: f.field_name(:starts_at),
|
|
177
|
+
id: f.field_id(:starts_at),
|
|
178
|
+
value: f.field_value(:starts_at)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
```
|
|
77
182
|
|
|
78
|
-
|
|
183
|
+
Icons inside a field (daisyui v5 `<label class="input">` pattern):
|
|
79
184
|
|
|
80
185
|
```ruby
|
|
81
186
|
f.field(:search).wrapped_input(:primary) do
|
|
82
|
-
LucideIcon("search", class: "opacity-50")
|
|
187
|
+
LucideIcon("search", class: "opacity-50")
|
|
83
188
|
end
|
|
84
189
|
```
|
|
85
190
|
|
|
86
|
-
##
|
|
191
|
+
## Themes — using phlex-forms without daisyui
|
|
192
|
+
|
|
193
|
+
Every component resolves through a theme (a role → component-class map). With
|
|
194
|
+
the daisyui gem loaded, the daisy theme is the default. Without it — or on
|
|
195
|
+
demand — the **Plain theme** renders bare semantic HTML: the same binding
|
|
196
|
+
(names, ids, values, required, errors), variants accepted and ignored, no
|
|
197
|
+
styling classes, and stable hooks (`aria-invalid`, `role="alert"`,
|
|
198
|
+
`data-field-error`, `data-field-hint`, `data-form-row`) for your own CSS.
|
|
87
199
|
|
|
88
200
|
```ruby
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
201
|
+
render UserForm.new(model: @user, theme: :plain) # per render
|
|
202
|
+
form_options theme: :plain # per class
|
|
203
|
+
PhlexForms.configure { |c| c.theme = :plain } # global
|
|
92
204
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
205
|
+
# override single roles:
|
|
206
|
+
PhlexForms.configure do |c|
|
|
207
|
+
c.theme = PhlexForms::Theme.resolve(:plain).with(input: MyInput)
|
|
96
208
|
end
|
|
209
|
+
```
|
|
97
210
|
|
|
98
|
-
|
|
211
|
+
The same `UserForm` class renders under either theme — write the form once,
|
|
212
|
+
style it per project. (Plain degradations: `searchable:` selects fall back to
|
|
213
|
+
the native select; `rich_textarea` falls back to a plain textarea.)
|
|
214
|
+
|
|
215
|
+
## Live validation (server-truth, via phlex-reactive)
|
|
216
|
+
|
|
217
|
+
The `live` macro turns the whole form into one reactive component. Blurring a
|
|
218
|
+
field (or typing, debounced) POSTs **all** form fields to a single signed
|
|
219
|
+
`:validate` action; the server assigns a whitelisted slice to the model, runs
|
|
220
|
+
the **real** ActiveModel validators, and morphs the errors back in — the
|
|
221
|
+
focused input and caret survive.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class UserForm < Forms::Base
|
|
225
|
+
live model: User, debounce: 300
|
|
226
|
+
|
|
227
|
+
def fields
|
|
228
|
+
field :email # uniqueness validates against the real DB
|
|
229
|
+
field :password
|
|
230
|
+
field :password_confirmation # cross-field confirmation just works
|
|
231
|
+
submit :primary
|
|
232
|
+
end
|
|
233
|
+
end
|
|
99
234
|
```
|
|
100
235
|
|
|
101
|
-
|
|
236
|
+
What you get over any client-side mirror:
|
|
102
237
|
|
|
103
|
-
|
|
104
|
-
validators
|
|
238
|
+
- **One source of truth** — uniqueness, `:if`/`:unless`, `:on` contexts,
|
|
239
|
+
cross-field and custom validators all run, because it *is* your model.
|
|
240
|
+
- **i18n is plain Rails i18n** — no duplicated message catalogs.
|
|
241
|
+
- **No premature errors** — a field's error first appears on blur (`touched`
|
|
242
|
+
tracking rides the signed token; zero client-side bookkeeping), while fixing
|
|
243
|
+
a field live-updates errors of fields you already touched.
|
|
244
|
+
- **Progressive enhancement** — nothing is ever persisted by `:validate`;
|
|
245
|
+
native submit and your controller stay authoritative. A failed-submit 422
|
|
246
|
+
re-render shows all errors as usual.
|
|
247
|
+
|
|
248
|
+
Constraints: `live` needs a `Forms::Base` subclass (the endpoint rebuilds the
|
|
249
|
+
form from its class — an inline block cannot be serialized; `Form(live: true)`
|
|
250
|
+
raises and says so). Collection controls (`collection_check_boxes`,
|
|
251
|
+
multi-selects) are excluded from live assignment in v1. Use
|
|
252
|
+
`live_permit`/`live_deny` to adjust the assignable attributes; setters run on
|
|
253
|
+
an in-memory model only.
|
|
254
|
+
|
|
255
|
+
### Client-side fallback (`validate: true`)
|
|
256
|
+
|
|
257
|
+
Without phlex-reactive, the bundled Stimulus framework mirrors your validators
|
|
258
|
+
client-side:
|
|
105
259
|
|
|
106
260
|
```ruby
|
|
107
261
|
Form(model: @partner, validate: true) do |f|
|
|
108
262
|
f.field :title # every validator on :title
|
|
109
|
-
f.field :slug, validate: false
|
|
263
|
+
f.field :slug, validate: false # opt this field out
|
|
110
264
|
f.field :note, validate: { length: { maximum: 30 } } # explicit rules
|
|
111
265
|
end
|
|
112
266
|
```
|
|
113
267
|
|
|
114
|
-
When `validate: true`, the form gets `novalidate` and a submit coordinator; each
|
|
115
|
-
field emits `data-forms--validations--*` bindings introspected from the model.
|
|
116
268
|
Supported: presence, length (with a live counter), format, numericality,
|
|
117
|
-
inclusion, exclusion, confirmation, acceptance. Validators with
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
Register the controllers in your Stimulus setup:
|
|
269
|
+
inclusion, exclusion, confirmation, acceptance. Validators with
|
|
270
|
+
`:if`/`:unless`/`:on` are skipped; uniqueness can't be checked client-side —
|
|
271
|
+
the server stays authoritative. Register the controllers:
|
|
121
272
|
|
|
122
273
|
```js
|
|
123
274
|
// app/javascript/controllers/index.js
|
|
@@ -125,13 +276,36 @@ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
|
|
|
125
276
|
lazyLoadControllersFrom("phlex_forms/controllers", application)
|
|
126
277
|
```
|
|
127
278
|
|
|
128
|
-
|
|
129
|
-
|
|
279
|
+
Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.
|
|
280
|
+
|
|
281
|
+
## Nested attributes, collections & escape valves
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
f.fields_for(:line_items) do |item| # single assoc or has_many
|
|
285
|
+
item.field :description
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
f.fields_for(:settings, nested_attributes: false) do |s|
|
|
289
|
+
s.field :locale # user[settings][locale] — JSONB/hash columns
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
|
|
293
|
+
render b.check_box
|
|
294
|
+
render b.label
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`Form(model: @item, scope: false)` emits **bare** field names
|
|
301
|
+
(`name="quantity"`) — the shape phlex-reactive row editors and
|
|
302
|
+
`<template>`-cloned rows need. External widgets bind through the public
|
|
303
|
+
`f.field_name` / `f.field_id` / `f.field_value` helpers.
|
|
130
304
|
|
|
131
305
|
## Icons
|
|
132
306
|
|
|
133
307
|
Icons default to a bundled inline SVG so the gem is self-contained. To use
|
|
134
|
-
[`glyphs`](https://rubygems.org/gems/glyphs)
|
|
308
|
+
[`glyphs`](https://rubygems.org/gems/glyphs):
|
|
135
309
|
|
|
136
310
|
```ruby
|
|
137
311
|
PhlexForms.configure do |c|
|
|
@@ -155,10 +329,13 @@ inherit_gem:
|
|
|
155
329
|
- `PhlexForms/LegacyFormMethod` — use `form.field(...)` / the PascalCase methods
|
|
156
330
|
over Rails-style `text_field` / `select` / etc.
|
|
157
331
|
|
|
158
|
-
##
|
|
332
|
+
## Dependencies
|
|
159
333
|
|
|
160
|
-
-
|
|
161
|
-
- `
|
|
334
|
+
- Hard: `phlex` (~> 2.0), `activesupport`, `zeitwerk`, `glyphs`.
|
|
335
|
+
- Soft: `daisyui` (the daisy theme — without it the Plain theme is the
|
|
336
|
+
default), `phlex-reactive` (the `live` macro).
|
|
337
|
+
- JS peers: `@hotwired/stimulus` (client validation fallback), `choices.js`
|
|
338
|
+
(only for `searchable: true` selects).
|
|
162
339
|
|
|
163
340
|
## Companion
|
|
164
341
|
|
data/lib/forms/base.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Declarative form classes: subclass, declare the fields in #fields, render.
|
|
5
|
+
# Inside #fields, `self` IS the form — the whole builder surface (field, row,
|
|
6
|
+
# group, Input, submit, fields_for, ...) is available as bare calls.
|
|
7
|
+
#
|
|
8
|
+
# class UserForm < Forms::Base
|
|
9
|
+
# form_options :spaced
|
|
10
|
+
#
|
|
11
|
+
# def fields
|
|
12
|
+
# field :email
|
|
13
|
+
# row { field :first_name; field :last_name }
|
|
14
|
+
# submit :primary
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# render UserForm.new(model: @user)
|
|
19
|
+
#
|
|
20
|
+
# A render-time block appends after the declared fields:
|
|
21
|
+
#
|
|
22
|
+
# render UserForm.new(model: @user) { |f| f.Hidden(:token) }
|
|
23
|
+
class Base < Form
|
|
24
|
+
class << self
|
|
25
|
+
# Class-level defaults, inherited and merged down the subclass chain
|
|
26
|
+
# (instance args beat class defaults; subclass defaults beat parents'):
|
|
27
|
+
#
|
|
28
|
+
# form_options :spaced, url: "/signup", validate: true
|
|
29
|
+
def form_options(*modifiers, **defaults)
|
|
30
|
+
@form_modifiers = modifiers
|
|
31
|
+
@form_defaults = defaults
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def form_modifiers
|
|
35
|
+
inherited = superclass.respond_to?(:form_modifiers) ? superclass.form_modifiers : []
|
|
36
|
+
(inherited + (@form_modifiers || [])).uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def form_defaults
|
|
40
|
+
inherited = superclass.respond_to?(:form_defaults) ? superclass.form_defaults : {}
|
|
41
|
+
inherited.merge(@form_defaults || {})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Server-truth live validation via phlex-reactive (a soft dependency):
|
|
45
|
+
#
|
|
46
|
+
# class UserForm < Forms::Base
|
|
47
|
+
# live model: User
|
|
48
|
+
# def fields = field(:email)
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# Blur/debounced input POST every field to a :validate action that runs
|
|
52
|
+
# the REAL model validators and morphs the errors back in, focus intact.
|
|
53
|
+
# Only Forms::Base subclasses can be live — the endpoint rebuilds the
|
|
54
|
+
# form from its class; an inline block cannot be serialized.
|
|
55
|
+
def live(model:, scope: nil, debounce: 300)
|
|
56
|
+
unless reactive_available?
|
|
57
|
+
raise PhlexForms::FeatureUnavailable,
|
|
58
|
+
"#{name || 'this form'} declares `live` but the phlex-reactive gem is not " \
|
|
59
|
+
"installed. Add `gem \"phlex-reactive\"` to your Gemfile, or use " \
|
|
60
|
+
"`validate: true` for the client-side Stimulus mirror instead."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
include Forms::Live
|
|
64
|
+
|
|
65
|
+
setup_live(model_class: model, scope: scope || derive_live_scope(model), debounce:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Extracted so specs can exercise the FeatureUnavailable guard.
|
|
69
|
+
def reactive_available?
|
|
70
|
+
defined?(Phlex::Reactive::Component) ? true : false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def derive_live_scope(model)
|
|
76
|
+
model.respond_to?(:model_name) ? model.model_name.param_key : model.name.underscore.tr("/", "_")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(*modifiers, **options)
|
|
81
|
+
super(*(self.class.form_modifiers + modifiers).uniq, **self.class.form_defaults.merge(options))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Renders Form's chrome (action/method/CSRF/enctype), then the declared
|
|
85
|
+
# fields. `yield`/`block_given?` inside the inner block bind to
|
|
86
|
+
# view_template's own block — the optional render-time block.
|
|
87
|
+
def view_template
|
|
88
|
+
super do
|
|
89
|
+
fields
|
|
90
|
+
yield self if block_given?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The hook name `fields` is verified free against Phlex::HTML (phlex 2.4.x)
|
|
95
|
+
# and this gem (Form defines fields_for/field_object, never fields).
|
|
96
|
+
def fields
|
|
97
|
+
raise NotImplementedError, "#{self.class} must define #fields"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|