phlex-forms 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +203 -36
- data/README.md +52 -0
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +41 -7
- data/lib/forms/base.rb +8 -2
- data/lib/forms/live.rb +17 -5
- data/lib/forms/rich_textarea.rb +5 -4
- data/lib/forms/validations/introspector.rb +17 -2
- data/lib/forms/validations/manual_rules.rb +19 -1
- data/lib/phlex_forms/configuration.rb +59 -11
- data/lib/phlex_forms/engine.rb +4 -0
- data/lib/phlex_forms/shareable.rb +33 -0
- data/lib/phlex_forms/theme.rb +39 -32
- data/lib/phlex_forms/version.rb +1 -1
- data/lib/phlex_forms.rb +35 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cbde20cd42869a04e1114e08575a532ea441763b6317893fef4aa9e28bd3902
|
|
4
|
+
data.tar.gz: ef6910db2fdcc82f01b5b61b50b6433c4e41bee9e72ab0acaae7cc3a12ed7446
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d4b972c08db3453e7ea345bfa8bacacc18416106a12af4dbdac730a6ebd4a634d26dd3d11cff431c383c41e59c1b352c76e008442095a59bd6e144b1d7cd6d3
|
|
7
|
+
data.tar.gz: bff7f0a358de04a2d41fa4f387025428033591e4d3f94fa4834a6aaa25427a8f0c509b9352d1d04d9752f42b2e887f2f05d9d1b8737041cec57ab100c2a391f5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-11-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **An advisory `recommended:` length on the length rule** (#51):
|
|
15
|
+
`validate: { length: { maximum: 50, recommended: 32 } }`, or the same
|
|
16
|
+
options on a model's `validates … length:`, emits
|
|
17
|
+
`data-validations--length-recommended-value`. The counter reads
|
|
18
|
+
`n / recommended` up to it, then `n / maximum` with `text-warning`, and
|
|
19
|
+
`text-error` past the maximum as before. It never adds an error and never
|
|
20
|
+
becomes `maxlength`; the server ignores it. A counter now renders for
|
|
21
|
+
`recommended:` alone, and an inline rule may give `recommended:` without a
|
|
22
|
+
bound. A value that is not a positive Integer below the maximum, or one
|
|
23
|
+
next to an exact `is:`, is ignored.
|
|
24
|
+
|
|
25
|
+
## [0.5.0] - 2026-10-15
|
|
26
|
+
|
|
27
|
+
### Upgrading to 0.5
|
|
28
|
+
|
|
29
|
+
The configuration is frozen once the app has booted, so threads and fibers
|
|
30
|
+
read one immutable value, and so can Ractors when every setting can be shared
|
|
31
|
+
(#42, conformance-kit; see the unshareable-value note below).
|
|
32
|
+
|
|
33
|
+
- **It finalizes on its own.** Under Rails the engine calls
|
|
34
|
+
`PhlexForms.finalize!` in `after_initialize`, after every initializer has
|
|
35
|
+
run, with no app code. Without Rails, `PhlexForms.configure` finalizes when
|
|
36
|
+
its block returns; a host that never calls `configure` (or loads Rails
|
|
37
|
+
without booting an app) calls `PhlexForms.finalize!` at boot.
|
|
38
|
+
- **A direct write after boot raises `FrozenError`.** `PhlexForms.config.infer_from_model = false`
|
|
39
|
+
in a Rails initializer still works. After boot (a test's `before` block, or
|
|
40
|
+
after `configure` outside Rails), write through `PhlexForms.configure`
|
|
41
|
+
instead: it edits a copy and swaps it in.
|
|
42
|
+
- **Finalizing never raises over a value it cannot share.** The value is kept
|
|
43
|
+
as given and works on the main Ractor only: an icon renderer lambda at the
|
|
44
|
+
top level of an initializer (its `self` is `main`, and it may close over a
|
|
45
|
+
logger) is one. Define it in a class or module body (or with
|
|
46
|
+
`Ractor.shareable_lambda` on Ruby 4.0) to keep the whole configuration
|
|
47
|
+
shareable. A renderer that is not a Proc (an object with `#call`, a
|
|
48
|
+
`Method`) is never copied or frozen, so one that keeps a cache still works.
|
|
49
|
+
- **`form_options` and the `live` macros keep frozen copies** of what they are
|
|
50
|
+
given, so the values nested in `form_defaults` and the live settings are
|
|
51
|
+
frozen. Your own arrays and hashes are copied, never frozen.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **`PhlexForms.finalize!`**: freezes the configuration and stores
|
|
56
|
+
Ractor-shareable copies of its settings. Idempotent; returns the
|
|
57
|
+
configuration. `PhlexForms::Theme::DAISY` / `PLAIN` are the built-in themes,
|
|
58
|
+
built once when the theme loads (`Theme.daisy` / `Theme.plain` return them),
|
|
59
|
+
and every `Theme` is frozen.
|
|
60
|
+
- **Ractor support for the non-rendering paths.** On Ruby 4.0, field
|
|
61
|
+
inference, option resolution, theme resolution, the icon renderer and the
|
|
62
|
+
`form_options` / `live` settings work inside a non-main Ractor. Rendering
|
|
63
|
+
there waits on Phlex
|
|
64
|
+
([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
|
|
65
|
+
- **conformance-kit 0.4 gates in CI**: the thread-safety RuboCop cops, thread
|
|
66
|
+
and fiber contention specs, a Ractor canary and a strict
|
|
67
|
+
`conformance-audit` of `PhlexForms` and `Forms` with nothing allowlisted.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **The configuration is frozen after boot** (see Upgrading to 0.5).
|
|
72
|
+
`Configuration` sets every default in `initialize`; no reader memoizes. The
|
|
73
|
+
default icon renderer and `Configuration.glyphs_renderer` are shareable.
|
|
74
|
+
- `rich_textarea` renders `<lexxy-editor>` through Phlex's `tag` instead of
|
|
75
|
+
`register_element`; the markup is the same.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **The themes build without phlex-reactive.** `Theme.daisy` named
|
|
80
|
+
`Forms::TagField` before checking for the soft dependency, so a host without
|
|
81
|
+
phlex-reactive got `NameError: uninitialized constant Forms::TagField` on its
|
|
82
|
+
first form.
|
|
83
|
+
- **`Theme.daisy` / `Theme.plain` are built once.** Two threads resolving a
|
|
84
|
+
theme for the first time could each build their own.
|
|
85
|
+
- **phlex-forms loads without Rails with phlex-reactive bundled.** phlex-reactive's
|
|
86
|
+
`Sortable` calls `Hash#to_json` while it loads without requiring json, so a
|
|
87
|
+
plain Ruby host got `NoMethodError` on its first form (and, with finalizing,
|
|
88
|
+
from `configure`). phlex-forms requires json before phlex-reactive.
|
|
89
|
+
|
|
90
|
+
## [0.4.1] - 2026-10-14
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **Client-side validators honour the validator's `message:`** (issue #41).
|
|
95
|
+
`validates :code, format: { with: /\A[a-z]*\z/, message: "lowercase only" }`
|
|
96
|
+
used to show the generic "is invalid" in the browser and the specific text
|
|
97
|
+
only after a round trip. The introspector now emits
|
|
98
|
+
`data-validations--<validator>-message-value`: a String as is (`%{count}`
|
|
99
|
+
stays for the browser to fill), a Symbol resolved on the server through
|
|
100
|
+
ActiveModel's lookup chain in the current locale, a Proc skipped (it needs a
|
|
101
|
+
record). Length's `too_long:` / `too_short:` / `wrong_length:` win over
|
|
102
|
+
`message:`, as on the server. Every shipped controller prefers the value and
|
|
103
|
+
falls back to its bundled message; the submit coordinator collects it too. A
|
|
104
|
+
`validate: { presence: { message: "…" } }` / `confirmation: { message: "…" }`
|
|
105
|
+
rules hash now keeps its options (it used to collapse them to `true`).
|
|
106
|
+
|
|
107
|
+
## [0.4.0] - 2026-09-30
|
|
108
|
+
|
|
10
109
|
### Upgrading to 0.4
|
|
11
110
|
|
|
12
111
|
phlex-forms now depends on **daisyui `~> 2.0`** and merges every default +
|
|
@@ -49,20 +148,10 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
49
148
|
longer raises `FeatureUnavailable`. The Plain theme is unchanged and still
|
|
50
149
|
emits zero daisy classes.
|
|
51
150
|
|
|
151
|
+
## [0.3.1] - 2026-09-09
|
|
152
|
+
|
|
52
153
|
### Fixed
|
|
53
154
|
|
|
54
|
-
- **Client-side validators honour the validator's `message:`** (issue #41).
|
|
55
|
-
`validates :code, format: { with: /\A[a-z]*\z/, message: "lowercase only" }`
|
|
56
|
-
used to show the generic "is invalid" in the browser and the specific text
|
|
57
|
-
only after a round trip. The introspector now emits
|
|
58
|
-
`data-validations--<validator>-message-value`: a String as is (`%{count}`
|
|
59
|
-
stays for the browser to fill), a Symbol resolved on the server through
|
|
60
|
-
ActiveModel's lookup chain in the current locale, a Proc skipped (it needs a
|
|
61
|
-
record). Length's `too_long:` / `too_short:` / `wrong_length:` win over
|
|
62
|
-
`message:`, as on the server. Every shipped controller prefers the value and
|
|
63
|
-
falls back to its bundled message; the submit coordinator collects it too. A
|
|
64
|
-
`validate: { presence: { message: "…" } }` / `confirmation: { message: "…" }`
|
|
65
|
-
rules hash now keeps its options (it used to collapse them to `true`).
|
|
66
155
|
- **`rich_textarea` forwards its block into `<lexxy-editor>`**: Lexxy configures
|
|
67
156
|
an editor through CHILD elements — `<lexxy-prompt>` for @mentions,
|
|
68
157
|
`<lexxy-code-language-picker>` for code blocks — but the component rendered
|
|
@@ -79,29 +168,59 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
79
168
|
pin uploads to whichever host rendered the page first), fully guarded — no
|
|
80
169
|
Rails dependency is introduced, and a caller-supplied value always wins.
|
|
81
170
|
|
|
171
|
+
## [0.3.0] - 2026-08-12
|
|
172
|
+
|
|
173
|
+
### Fixed
|
|
174
|
+
|
|
82
175
|
- **`FileInput` multiple uploads submit an array** (Rails `file_field` parity):
|
|
83
176
|
with `multiple:` set, the input name now gets `[]` appended (unless already
|
|
84
177
|
present), for the standalone component, the `Plain` variant, and the form
|
|
85
178
|
builder path. Without it the browser collapsed a multi-file selection into
|
|
86
179
|
ONE scalar param, which a host app's `params.expect(attr: [])` silently
|
|
87
180
|
discarded — uploads no-opped with a success response.
|
|
181
|
+
- **Hidden fields render as bare inputs** (#22): `f.Hidden`, `f.hidden_field`,
|
|
182
|
+
`f.field(..., as: :hidden)` and `f.Input(..., type: :hidden)` went through
|
|
183
|
+
the `:input` theme role, so under the daisy theme a hidden field carried
|
|
184
|
+
`class="input w-full"`, and daisyUI's `display: inline-flex` beat Safari's
|
|
185
|
+
non-`!important` `display: none`: an empty box and a phantom tab stop. They
|
|
186
|
+
now render the new `Forms::Hidden` leaf through a new `:hidden` theme role,
|
|
187
|
+
with no styling classes and no `error:`.
|
|
188
|
+
|
|
189
|
+
## [0.2.9] - 2026-07-16
|
|
190
|
+
|
|
191
|
+
### Fixed
|
|
192
|
+
|
|
193
|
+
- **A caller width class stacked with the `w-full` default instead of replacing
|
|
194
|
+
it**: `DelegatedField#width_class` joined `"w-full"` and the caller's
|
|
195
|
+
`class:` verbatim, so `Forms::Select(class: "w-36")` emitted
|
|
196
|
+
`class="... w-full w-36"` and stylesheet source order — not author intent —
|
|
197
|
+
picked the winner (in practice `w-full`, which blew filter selects up to full
|
|
198
|
+
width; zazu#2934). `ClassMerge` gained a width family (anchored `w-*`, so
|
|
199
|
+
`min-w-*`/`max-w-*` still compose) and `width_class` merges through it —
|
|
200
|
+
last width wins, matching the daisyui size/color family semantics.
|
|
201
|
+
|
|
202
|
+
## [0.2.8] - 2026-07-11
|
|
203
|
+
|
|
204
|
+
### Added
|
|
205
|
+
|
|
206
|
+
- **`hidden_field`** (#21): a snake_case alias of `f.Hidden` for Rails
|
|
207
|
+
`FormBuilder` migrations, on inline forms and `fields_for` builders. An
|
|
208
|
+
explicit `value:` still wins over the model's value.
|
|
209
|
+
|
|
210
|
+
## [0.2.7] - 2026-07-11
|
|
211
|
+
|
|
212
|
+
### Changed
|
|
213
|
+
|
|
214
|
+
- **`tag_field` uses phlex-reactive 0.12.2's escape-hatch sugar**
|
|
215
|
+
(`reactive_tags` / `reactive_filter`) (#20): the wire name is validated at
|
|
216
|
+
render, and the client-side type-ahead filter runs again (under
|
|
217
|
+
phlex-reactive 0.12.x only one of its two selectors was emitted, so it never
|
|
218
|
+
ran).
|
|
219
|
+
|
|
220
|
+
## [0.2.6] - 2026-07-11
|
|
88
221
|
|
|
89
222
|
### Added
|
|
90
223
|
|
|
91
|
-
- **`checkbox_group` — batched checkbox group for array-valued fields** (the
|
|
92
|
-
tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
|
|
93
|
-
label: :name, variant: :pill, size: :sm)`, or via field inference
|
|
94
|
-
(`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
|
|
95
|
-
Shares one array-valued field name with a leading empty-array hidden field,
|
|
96
|
-
derives the checked set from the model's current value, and renders under both
|
|
97
|
-
themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
|
|
98
|
-
- **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
|
|
99
|
-
now carry an accessible name/description. HTML/ARIA attributes pass straight
|
|
100
|
-
through to the group, so the bare verb is named with plain `aria:`
|
|
101
|
-
(`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
|
|
102
|
-
the Control's own visible `label:` / `hint:` get stable ids and the field wires
|
|
103
|
-
`aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
|
|
104
|
-
name, output is unchanged.
|
|
105
224
|
- **`checkbox_group` `item_label:`** — the per-item text accessor, so a
|
|
106
225
|
`f.field(:tags, as: :checkbox_group, label: "Tags", item_label: ->(t){…})`
|
|
107
226
|
renders a visible group heading (`label:`, via the Control) AND custom item
|
|
@@ -110,6 +229,30 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
110
229
|
item text; on the bare verb `label:` stays the item accessor and `item_label:`
|
|
111
230
|
is an alias. Absent it, behavior is unchanged.
|
|
112
231
|
|
|
232
|
+
## [0.2.5] - 2026-07-11
|
|
233
|
+
|
|
234
|
+
### Added
|
|
235
|
+
|
|
236
|
+
- **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
|
|
237
|
+
now carry an accessible name/description. HTML/ARIA attributes pass straight
|
|
238
|
+
through to the group, so the bare verb is named with plain `aria:`
|
|
239
|
+
(`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
|
|
240
|
+
the Control's own visible `label:` / `hint:` get stable ids and the field wires
|
|
241
|
+
`aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
|
|
242
|
+
name, output is unchanged.
|
|
243
|
+
|
|
244
|
+
## [0.2.4] - 2026-07-11
|
|
245
|
+
|
|
246
|
+
### Added
|
|
247
|
+
|
|
248
|
+
- **`checkbox_group` — batched checkbox group for array-valued fields** (the
|
|
249
|
+
tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
|
|
250
|
+
label: :name, variant: :pill, size: :sm)`, or via field inference
|
|
251
|
+
(`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
|
|
252
|
+
Shares one array-valued field name with a leading empty-array hidden field,
|
|
253
|
+
derives the checked set from the model's current value, and renders under both
|
|
254
|
+
themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
|
|
255
|
+
|
|
113
256
|
### Changed
|
|
114
257
|
|
|
115
258
|
- **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
|
|
@@ -124,15 +267,6 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
124
267
|
|
|
125
268
|
### Fixed
|
|
126
269
|
|
|
127
|
-
- **A caller width class stacked with the `w-full` default instead of replacing
|
|
128
|
-
it**: `DelegatedField#width_class` joined `"w-full"` and the caller's
|
|
129
|
-
`class:` verbatim, so `Forms::Select(class: "w-36")` emitted
|
|
130
|
-
`class="... w-full w-36"` and stylesheet source order — not author intent —
|
|
131
|
-
picked the winner (in practice `w-full`, which blew filter selects up to full
|
|
132
|
-
width; zazu#2934). `ClassMerge` gained a width family (anchored `w-*`, so
|
|
133
|
-
`min-w-*`/`max-w-*` still compose) and `width_class` merges through it —
|
|
134
|
-
last width wins, matching the daisyui size/color family semantics.
|
|
135
|
-
|
|
136
270
|
- **`f.Radio` / `Field#radio` rendered the model's current value on every radio
|
|
137
271
|
instead of each radio's own value**: `field_attributes` carried `value:
|
|
138
272
|
field_value` and was splatted after the explicit positional value, clobbering
|
|
@@ -149,6 +283,37 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
149
283
|
instead of a single `scope[assoc][field]`. It is now treated as a single
|
|
150
284
|
nested scope; only genuine collections (Enumerable, not Hash) iterate.
|
|
151
285
|
|
|
286
|
+
## [0.2.3] - 2026-07-08
|
|
287
|
+
|
|
288
|
+
### Added
|
|
289
|
+
|
|
290
|
+
- **`tag_field`** (#7): `f.field :tags, as: :tags, suggestions: [...]`, a
|
|
291
|
+
model-bound tag/chip control composed from phlex-reactive's client-only tag
|
|
292
|
+
primitives. It submits one comma-joined param; `live_tags` validates it
|
|
293
|
+
inside a `live` form.
|
|
294
|
+
|
|
295
|
+
## [0.2.2] - 2026-07-08
|
|
296
|
+
|
|
297
|
+
### Fixed
|
|
298
|
+
|
|
299
|
+
- **`Forms::TimeZoneSelect` raised at render time** (#5): `NoMethodError` for
|
|
300
|
+
`time_zone_options_for_select` (called on the `DaisyUI::Select` receiver)
|
|
301
|
+
and `Phlex::ArgumentError: Expected a String` (an HTML-safe buffer passed to
|
|
302
|
+
`safe`). It was broken in 0.2.0 and 0.2.1.
|
|
303
|
+
|
|
304
|
+
## [0.2.1] - 2026-07-08
|
|
305
|
+
|
|
306
|
+
### Added
|
|
307
|
+
|
|
308
|
+
- **Rails form-builder compat aliases** (#4): `Forms::Form#object` (`model`),
|
|
309
|
+
`#object_name` (`scope`) and `#[]` (`field_object`), and
|
|
310
|
+
`Forms::FieldsForBuilder#object` / `#[]`, for call sites migrating off an
|
|
311
|
+
ActionView `FormBuilder`.
|
|
312
|
+
|
|
313
|
+
## [0.2.0] - 2026-07-08
|
|
314
|
+
|
|
315
|
+
### Added
|
|
316
|
+
|
|
152
317
|
- **`Forms::Base` declarative form classes**: subclass, declare fields in
|
|
153
318
|
`#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
|
|
154
319
|
render with `render UserForm.new(model: @user)`. Class-level `form_options`
|
|
@@ -217,7 +382,9 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
217
382
|
Pass `as:` explicitly or set `PhlexForms.config.infer_from_model = false`
|
|
218
383
|
to keep the old rendering.
|
|
219
384
|
|
|
220
|
-
|
|
385
|
+
## [0.1.0] - 2026-07-07
|
|
386
|
+
|
|
387
|
+
### Added
|
|
221
388
|
|
|
222
389
|
- Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
|
|
223
390
|
- Control-first builder API: `f.field :email, label:, hint:, as:, choices:`
|
data/README.md
CHANGED
|
@@ -359,6 +359,31 @@ f.field :note, validate: { length: { maximum: 30, too_long: "keep it under %{cou
|
|
|
359
359
|
Without a `message:`, the bundled messages apply: they ship for `en` / `fr` /
|
|
360
360
|
`af`; override them page-wide via `window.PhlexForms.messages`.
|
|
361
361
|
|
|
362
|
+
The length counter can count against an advisory `recommended:` length below
|
|
363
|
+
the hard `maximum:`. It never adds an error, never becomes `maxlength`, and the
|
|
364
|
+
server ignores it (ActiveModel keeps the unknown option without reading it):
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
f.field :title, as: :textarea, validate: { length: { maximum: 50, recommended: 32 } }
|
|
368
|
+
validates :title, length: { maximum: 50, recommended: 32 } # the same, from the model
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
| Length | Counter | Class |
|
|
372
|
+
|---|---|---|
|
|
373
|
+
| up to `recommended` | `n / recommended` | none |
|
|
374
|
+
| past `recommended`, up to `maximum` | `n / maximum` | `text-warning` |
|
|
375
|
+
| past `maximum` | `n / maximum` | `text-error` (and the `too_long` message) |
|
|
376
|
+
|
|
377
|
+
Without a `maximum:`, the counter reads `n / recommended` and turns
|
|
378
|
+
`text-warning` past it. An inline `validate: { length: { recommended: 32 } }`
|
|
379
|
+
needs no bound; a model-level `validates … length:` still needs one, because
|
|
380
|
+
ActiveModel requires it there. A `recommended:` that is not a positive Integer
|
|
381
|
+
below the maximum, or one next to an exact `is:`, is ignored. The counter's
|
|
382
|
+
`text-warning` / `text-error` classes are toggled from the gem's JavaScript, so
|
|
383
|
+
your Tailwind build must include them. The counter comes from these client-side
|
|
384
|
+
rules only: live validation's server round trip (`Forms::Live`) adds none, and
|
|
385
|
+
a live field shows one only when it also carries client-side length rules.
|
|
386
|
+
|
|
362
387
|
## Nested attributes, collections & escape valves
|
|
363
388
|
|
|
364
389
|
```ruby
|
|
@@ -437,6 +462,33 @@ PhlexForms.configure do |c|
|
|
|
437
462
|
end
|
|
438
463
|
```
|
|
439
464
|
|
|
465
|
+
## Configuration is finalized at boot
|
|
466
|
+
|
|
467
|
+
`PhlexForms.configure` writes the configuration; finalizing freezes it and
|
|
468
|
+
stores Ractor-shareable copies of its settings, so threads and fibers read one
|
|
469
|
+
immutable value, and so can Ractors when every setting can be shared (see
|
|
470
|
+
below). It happens on its own:
|
|
471
|
+
|
|
472
|
+
- **Under Rails** the engine finalizes in `after_initialize`, once every
|
|
473
|
+
initializer has run, so initializers can still write (through `configure`
|
|
474
|
+
or directly).
|
|
475
|
+
- **Without Rails** (no `Rails::Engine` when phlex-forms loads), `configure`
|
|
476
|
+
finalizes when its block returns. A host that never calls `configure`, or
|
|
477
|
+
loads Rails without booting an app, calls `PhlexForms.finalize!` at boot.
|
|
478
|
+
|
|
479
|
+
After that, write through `configure` again (it edits a copy and swaps it in);
|
|
480
|
+
a direct `PhlexForms.config.theme = :plain` raises `FrozenError`. Your own
|
|
481
|
+
arrays and hashes are copied, never frozen.
|
|
482
|
+
|
|
483
|
+
Finalizing never raises over a value it cannot share: that value is kept as
|
|
484
|
+
given and works on the main Ractor only. An icon renderer lambda at the top
|
|
485
|
+
level of an initializer (its `self` is `main`, and it may close over a logger)
|
|
486
|
+
is one; define it in a class or module body (or with `Ractor.shareable_lambda`
|
|
487
|
+
on Ruby 4.0) to keep the whole configuration shareable. A renderer that is not
|
|
488
|
+
a Proc (an object with `#call`, a `Method`) is never copied or frozen, so one
|
|
489
|
+
that keeps a cache still works. `PhlexForms.reset_configuration!` restores the
|
|
490
|
+
open defaults (for test suites).
|
|
491
|
+
|
|
440
492
|
## RuboCop cops
|
|
441
493
|
|
|
442
494
|
Two cops nudge call sites toward the phlex-forms API. Enable them in your
|
|
@@ -3,12 +3,14 @@ import { interpolate } from "phlex_forms/i18n"
|
|
|
3
3
|
|
|
4
4
|
// Mirrors ActiveModel::Validations::LengthValidator. Reads:
|
|
5
5
|
// - maximum / minimum / is
|
|
6
|
+
// - recommended (advisory: the counter's target, never an error)
|
|
6
7
|
// - too-long / too-short / wrong-length messages (win over `message`)
|
|
7
8
|
// - allow-blank / allow-nil
|
|
8
9
|
//
|
|
9
|
-
// Counter is rendered next to the input when `maximum`
|
|
10
|
-
// the user gets a live "12 / 60" indicator without having to
|
|
11
|
-
// for blur.
|
|
10
|
+
// Counter is rendered next to the input when `maximum` or `recommended`
|
|
11
|
+
// is set, so the user gets a live "12 / 60" indicator without having to
|
|
12
|
+
// wait for blur. Past `recommended` it counts against `maximum` and turns
|
|
13
|
+
// `text-warning`; past `maximum` it turns `text-error`.
|
|
12
14
|
export default class extends FieldValidatorController {
|
|
13
15
|
// Stimulus walks the prototype chain to accumulate `static values`
|
|
14
16
|
// and `static targets`, so we only declare the validator-specific
|
|
@@ -21,6 +23,7 @@ export default class extends FieldValidatorController {
|
|
|
21
23
|
maximum: Number,
|
|
22
24
|
minimum: Number,
|
|
23
25
|
is: Number,
|
|
26
|
+
recommended: Number,
|
|
24
27
|
tooLongMessage: String,
|
|
25
28
|
tooShortMessage: String,
|
|
26
29
|
wrongLengthMessage: String,
|
|
@@ -28,7 +31,7 @@ export default class extends FieldValidatorController {
|
|
|
28
31
|
|
|
29
32
|
connect() {
|
|
30
33
|
super.connect()
|
|
31
|
-
if (this.
|
|
34
|
+
if (this.counts) {
|
|
32
35
|
this.renderCounter()
|
|
33
36
|
this.element.addEventListener("input", this.updateCounter)
|
|
34
37
|
}
|
|
@@ -36,11 +39,15 @@ export default class extends FieldValidatorController {
|
|
|
36
39
|
|
|
37
40
|
disconnect() {
|
|
38
41
|
super.disconnect()
|
|
39
|
-
if (this.
|
|
42
|
+
if (this.counts) {
|
|
40
43
|
this.element.removeEventListener("input", this.updateCounter)
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
get counts() {
|
|
48
|
+
return this.hasMaximumValue || this.recommendedLength(null) !== null
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
check(value) {
|
|
45
52
|
const length = this.codepointLength(value)
|
|
46
53
|
|
|
@@ -67,8 +74,35 @@ export default class extends FieldValidatorController {
|
|
|
67
74
|
const counter = this.counterElement()
|
|
68
75
|
if (!counter) return
|
|
69
76
|
const length = this.codepointLength(this.element.value)
|
|
70
|
-
|
|
71
|
-
counter.
|
|
77
|
+
const { limit, state, advisory } = this.counterState(length)
|
|
78
|
+
counter.textContent = `${length} / ${limit}`
|
|
79
|
+
// Without a recommended length the counter is exactly 0.5.0's: a caller's
|
|
80
|
+
// own `text-warning` on a pre-rendered counter target is left alone.
|
|
81
|
+
if (advisory) counter.classList.toggle("text-warning", state === "warning")
|
|
82
|
+
counter.classList.toggle("text-error", state === "error")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The counter's denominator and state. `recommended` applies only below
|
|
86
|
+
// `maximum` (the server drops it otherwise; this guards a hand-written
|
|
87
|
+
// attribute): up to it the counter reads "n / recommended", past it
|
|
88
|
+
// "n / maximum" in a warning colour, past `maximum` an error.
|
|
89
|
+
counterState(length) {
|
|
90
|
+
const maximum = this.hasMaximumValue ? this.maximumValue : null
|
|
91
|
+
const recommended = this.recommendedLength(maximum)
|
|
92
|
+
|
|
93
|
+
const advisory = recommended !== null
|
|
94
|
+
|
|
95
|
+
if (maximum !== null && length > maximum) return { limit: maximum, state: "error", advisory }
|
|
96
|
+
if (!advisory) return { limit: maximum, state: null, advisory }
|
|
97
|
+
if (length <= recommended) return { limit: recommended, state: null, advisory }
|
|
98
|
+
return { limit: maximum ?? recommended, state: "warning", advisory }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
recommendedLength(maximum) {
|
|
102
|
+
if (!this.hasRecommendedValue) return null
|
|
103
|
+
const recommended = this.recommendedValue
|
|
104
|
+
if (!(recommended > 0) || (maximum !== null && recommended >= maximum)) return null
|
|
105
|
+
return recommended
|
|
72
106
|
}
|
|
73
107
|
|
|
74
108
|
// Ruby's String#length counts Unicode codepoints; JS's String#length
|
data/lib/forms/base.rb
CHANGED
|
@@ -26,9 +26,14 @@ module Forms
|
|
|
26
26
|
# (instance args beat class defaults; subclass defaults beat parents'):
|
|
27
27
|
#
|
|
28
28
|
# form_options :spaced, url: "/signup", validate: true
|
|
29
|
+
#
|
|
30
|
+
# A class macro: it runs while the form class body loads (at boot, on the
|
|
31
|
+
# main Ractor) and stores Ractor-shareable copies of its arguments
|
|
32
|
+
# (PhlexForms::Shareable.copy), so the readers work in any Ractor.
|
|
33
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
29
34
|
def form_options(*modifiers, **defaults)
|
|
30
|
-
@form_modifiers = modifiers
|
|
31
|
-
@form_defaults = defaults
|
|
35
|
+
@form_modifiers = PhlexForms::Shareable.copy(modifiers)
|
|
36
|
+
@form_defaults = PhlexForms::Shareable.copy(defaults)
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def form_modifiers
|
|
@@ -40,6 +45,7 @@ module Forms
|
|
|
40
45
|
inherited = superclass.respond_to?(:form_defaults) ? superclass.form_defaults : {}
|
|
41
46
|
inherited.merge(@form_defaults || {})
|
|
42
47
|
end
|
|
48
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
43
49
|
|
|
44
50
|
# Server-truth live validation via phlex-reactive (a soft dependency):
|
|
45
51
|
#
|
data/lib/forms/live.rb
CHANGED
|
@@ -42,10 +42,15 @@ module Forms
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
class_methods do
|
|
45
|
+
# live (setup_live), live_permit, live_deny and live_tags are class macros:
|
|
46
|
+
# they run while the form class body loads (at boot, on the main Ractor)
|
|
47
|
+
# and store Ractor-shareable values (PhlexForms::Shareable.copy), so the
|
|
48
|
+
# readers work in any Ractor.
|
|
49
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
45
50
|
def setup_live(model_class:, scope:, debounce:)
|
|
46
51
|
@live_model_class = model_class
|
|
47
|
-
@live_scope = scope.to_s
|
|
48
|
-
@live_debounce = debounce
|
|
52
|
+
@live_scope = PhlexForms::Shareable.copy(scope.to_s)
|
|
53
|
+
@live_debounce = PhlexForms::Shareable.copy(debounce)
|
|
49
54
|
reactive_state :model_gid, :touched
|
|
50
55
|
action :validate, params: { scope.to_sym => :form_attributes, _touch: :string }
|
|
51
56
|
# phlex-reactive >= 0.11 defaults verify_authorized ON: an action that
|
|
@@ -64,8 +69,8 @@ module Forms
|
|
|
64
69
|
# Narrow (live_permit) or trim (live_deny) the attributes the :validate
|
|
65
70
|
# action may assign. Default: the model's column/attribute names plus
|
|
66
71
|
# every validated attribute (and its _confirmation twin).
|
|
67
|
-
def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
|
|
68
|
-
def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
|
|
72
|
+
def live_permit(*attrs) = @live_permit = PhlexForms::Shareable.copy(attrs.map(&:to_s))
|
|
73
|
+
def live_deny(*attrs) = @live_deny = PhlexForms::Shareable.copy(attrs.map(&:to_s))
|
|
69
74
|
|
|
70
75
|
# Lift a tag field onto the form's reactive root so live :validate sees its
|
|
71
76
|
# comma-joined value. The widget renders ROOTLESS (no nested reactive root),
|
|
@@ -90,7 +95,7 @@ module Forms
|
|
|
90
95
|
"non-live)."
|
|
91
96
|
end
|
|
92
97
|
|
|
93
|
-
@live_tags = { name: name.to_sym, suggestions: }
|
|
98
|
+
@live_tags = PhlexForms::Shareable.copy({ name: name.to_sym, suggestions: })
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
def live_tags_declaration = @live_tags || inherited_live(:live_tags_declaration)
|
|
@@ -101,6 +106,7 @@ module Forms
|
|
|
101
106
|
permitted |= [tag[:name].to_s] if tag
|
|
102
107
|
permitted - (@live_deny || [])
|
|
103
108
|
end
|
|
109
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
104
110
|
|
|
105
111
|
private
|
|
106
112
|
|
|
@@ -219,5 +225,11 @@ module Forms
|
|
|
219
225
|
@model.public_send(setter, value) if @model.respond_to?(setter)
|
|
220
226
|
end
|
|
221
227
|
end
|
|
228
|
+
|
|
229
|
+
# ActiveSupport::Concern's own list of the concerns this one includes
|
|
230
|
+
# (Phlex::Reactive::Component): filled while this module body loads and
|
|
231
|
+
# only read after, when a form class includes Forms::Live. Frozen, so the
|
|
232
|
+
# module holds no mutable state.
|
|
233
|
+
@_dependencies.freeze
|
|
222
234
|
end
|
|
223
235
|
end
|
data/lib/forms/rich_textarea.rb
CHANGED
|
@@ -6,8 +6,6 @@ module Forms
|
|
|
6
6
|
# Renders an editor bound to the field; when the value is an ActionText::RichText
|
|
7
7
|
# its HTML body is used as the initial content.
|
|
8
8
|
class RichTextarea < Phlex::HTML
|
|
9
|
-
register_element :lexxy_editor
|
|
10
|
-
|
|
11
9
|
def initialize(*modifiers, name:, id: nil, value: nil, **options)
|
|
12
10
|
@modifiers = modifiers
|
|
13
11
|
@name = name
|
|
@@ -20,9 +18,12 @@ module Forms
|
|
|
20
18
|
|
|
21
19
|
# Lexxy configures the editor through CHILD elements — `<lexxy-prompt>` for
|
|
22
20
|
# @mentions, `<lexxy-code-language-picker>` for code blocks — so the block
|
|
23
|
-
# has to reach the element, not be dropped.
|
|
21
|
+
# has to reach the element, not be dropped. Rendered through `tag`, not a
|
|
22
|
+
# `register_element` method, so the class keeps no element registry of its
|
|
23
|
+
# own.
|
|
24
24
|
def view_template(&)
|
|
25
|
-
|
|
25
|
+
tag(
|
|
26
|
+
:lexxy_editor,
|
|
26
27
|
name: @name,
|
|
27
28
|
id: @id,
|
|
28
29
|
value: formatted_value,
|
|
@@ -166,17 +166,32 @@ module Forms
|
|
|
166
166
|
attrs[key] = opts[key].to_s if opts.key?(key)
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
range = opts[:in] || opts[:within]
|
|
170
|
+
if range
|
|
171
171
|
attrs[:minimum] = range.min.to_s
|
|
172
172
|
attrs[:maximum] = range.max.to_s
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
+
attrs[:recommended] = opts[:recommended].to_s if recommended?(opts, range ? range.max : opts[:maximum])
|
|
175
176
|
attrs[:allow_blank] = "true" if opts[:allow_blank]
|
|
176
177
|
attrs[:allow_nil] = "true" if opts[:allow_nil]
|
|
177
178
|
attrs
|
|
178
179
|
end
|
|
179
180
|
|
|
181
|
+
# `recommended:` is advisory (the counter's target, never an error), so
|
|
182
|
+
# it is kept only when it can sit below the hard limit: a positive
|
|
183
|
+
# Integer under a numeric maximum, or any positive Integer when there is
|
|
184
|
+
# no maximum. An exact `is:` length leaves nothing to recommend. Anything
|
|
185
|
+
# else is dropped without raising.
|
|
186
|
+
def recommended?(opts, maximum)
|
|
187
|
+
recommended = opts[:recommended]
|
|
188
|
+
return false unless recommended.is_a?(Integer) && recommended.positive?
|
|
189
|
+
return false if opts.key?(:is)
|
|
190
|
+
return true if maximum.nil?
|
|
191
|
+
|
|
192
|
+
maximum.is_a?(Numeric) && recommended < maximum
|
|
193
|
+
end
|
|
194
|
+
|
|
180
195
|
def format_attrs(validator)
|
|
181
196
|
opts = validator.options
|
|
182
197
|
regex = opts[:with]
|
|
@@ -21,6 +21,20 @@ module Forms
|
|
|
21
21
|
# acceptance: true,
|
|
22
22
|
# )
|
|
23
23
|
class ManualRules
|
|
24
|
+
# ActiveModel's LengthValidator demands a bound, so `length: { recommended: 32 }`
|
|
25
|
+
# alone (an advisory counter with no limit) can't go through `validates`.
|
|
26
|
+
# This no-op validator carries the options instead; the Introspector matches
|
|
27
|
+
# it by its short name, LengthValidator, and it never adds an error.
|
|
28
|
+
module Advisory
|
|
29
|
+
class LengthValidator < ActiveModel::EachValidator
|
|
30
|
+
def validate_each(_record, _attribute, _value); end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The only keys a bound-less advisory rule may carry. Anything else (a
|
|
35
|
+
# misspelled `maxium:`) goes to ActiveModel, which raises for the missing bound.
|
|
36
|
+
ADVISORY_LENGTH_KEYS = %i[recommended allow_blank allow_nil].freeze
|
|
37
|
+
|
|
24
38
|
def initialize(rules)
|
|
25
39
|
@rules = rules
|
|
26
40
|
end
|
|
@@ -51,7 +65,11 @@ module Forms
|
|
|
51
65
|
when :presence
|
|
52
66
|
validates :value, presence: opts.is_a?(Hash) ? opts : true if opts
|
|
53
67
|
when :length
|
|
54
|
-
|
|
68
|
+
if opts.is_a?(Hash) && opts.key?(:recommended) && (opts.keys - ADVISORY_LENGTH_KEYS).empty?
|
|
69
|
+
validates_with Advisory::LengthValidator, opts.merge(attributes: [:value])
|
|
70
|
+
else
|
|
71
|
+
validates :value, length: opts
|
|
72
|
+
end
|
|
55
73
|
when :format
|
|
56
74
|
validates :value, format: opts
|
|
57
75
|
when :numericality
|
|
@@ -22,7 +22,23 @@ module PhlexForms
|
|
|
22
22
|
# glyphs is intentionally NOT the default: rails_icons reads SVGs from the host
|
|
23
23
|
# app's asset paths, which a minimal host (or the gem's own test suite) has not
|
|
24
24
|
# set up — so defaulting to glyphs would raise Icons::IconNotFound.
|
|
25
|
+
#
|
|
26
|
+
# Finalized at boot: `configure` finalizes when the Rails engine is absent, the
|
|
27
|
+
# engine's after_initialize does under Rails (or call `PhlexForms.finalize!`).
|
|
28
|
+
# Finalizing freezes the configuration and stores Ractor-shareable copies of
|
|
29
|
+
# its settings, so any Ractor can read it. A renderer that cannot be shared (a
|
|
30
|
+
# lambda written at the top level of an initializer has `main` as its self) is
|
|
31
|
+
# kept as given and still works on the main Ractor; build it in a class or
|
|
32
|
+
# module body, or with `Ractor.shareable_lambda` on Ruby 4.0, to keep the
|
|
33
|
+
# whole configuration shareable. A renderer that is not a Proc (an object with
|
|
34
|
+
# #call, a Method) is never copied or frozen: it is shared only if it already
|
|
35
|
+
# is. After finalizing, write through `configure`, which edits a copy and
|
|
36
|
+
# swaps it in.
|
|
25
37
|
class Configuration
|
|
38
|
+
# The bundled inline SVGs. Built in the class body (self is the class and the
|
|
39
|
+
# lambda captures nothing), so it can be made shareable once, here.
|
|
40
|
+
DEFAULT_ICON_RENDERER = Shareable.copy(->(name, **opts) { InlineIcons.render(name, **opts) })
|
|
41
|
+
|
|
26
42
|
# A ready-made renderer that delegates to glyphs' LucideIcon. Raises a clear
|
|
27
43
|
# error if glyphs is not loaded.
|
|
28
44
|
def self.glyphs_renderer
|
|
@@ -31,7 +47,7 @@ module PhlexForms
|
|
|
31
47
|
"PhlexForms glyphs_renderer requires the `glyphs` gem to be loaded"
|
|
32
48
|
end
|
|
33
49
|
|
|
34
|
-
->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) }
|
|
50
|
+
Shareable.copy(->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) })
|
|
35
51
|
end
|
|
36
52
|
|
|
37
53
|
attr_writer :icon_renderer
|
|
@@ -43,29 +59,48 @@ module PhlexForms
|
|
|
43
59
|
|
|
44
60
|
# Global daisyui variants prepended to every `field`'s inner input, e.g.
|
|
45
61
|
# `c.field_variants = [:primary]`. Form-level `field_variants:` and
|
|
46
|
-
# call-site modifiers stack after (later wins).
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@field_variants ||= []
|
|
62
|
+
# call-site modifiers stack after (later wins). nil means none, stored as an
|
|
63
|
+
# empty list so `c.field_variants << :sm` keeps working.
|
|
64
|
+
def field_variants=(variants)
|
|
65
|
+
@field_variants = variants || []
|
|
51
66
|
end
|
|
52
67
|
|
|
53
68
|
# The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
|
|
54
69
|
# Defaults to daisy.
|
|
55
70
|
attr_writer :theme
|
|
56
71
|
|
|
72
|
+
# Every default is set here, never memoized by a reader, so concurrent
|
|
73
|
+
# readers never race to build one. The theme stays nil until set or
|
|
74
|
+
# finalized (the reader falls back to Theme.daisy), so building a
|
|
75
|
+
# configuration loads no component.
|
|
76
|
+
def initialize
|
|
77
|
+
@theme = nil
|
|
78
|
+
@infer_from_model = true
|
|
79
|
+
@field_variants = []
|
|
80
|
+
@icon_renderer = DEFAULT_ICON_RENDERER
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `configure` on a finalized configuration edits a copy: give the copy its
|
|
84
|
+
# own variants list, so `c.field_variants << :sm` works on it.
|
|
85
|
+
def initialize_copy(source)
|
|
86
|
+
super
|
|
87
|
+
@field_variants = @field_variants.dup
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def field_variants
|
|
91
|
+
@field_variants || []
|
|
92
|
+
end
|
|
93
|
+
|
|
57
94
|
def theme
|
|
58
|
-
@theme
|
|
95
|
+
@theme || Theme.daisy
|
|
59
96
|
end
|
|
60
97
|
|
|
61
98
|
def infer_from_model
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
true
|
|
99
|
+
@infer_from_model.nil? || @infer_from_model
|
|
65
100
|
end
|
|
66
101
|
|
|
67
102
|
def icon_renderer
|
|
68
|
-
@icon_renderer
|
|
103
|
+
@icon_renderer || DEFAULT_ICON_RENDERER
|
|
69
104
|
end
|
|
70
105
|
|
|
71
106
|
# Returns a renderable for the named icon. Components pass the result to Phlex
|
|
@@ -73,5 +108,18 @@ module PhlexForms
|
|
|
73
108
|
def render_icon(name, **)
|
|
74
109
|
icon_renderer.call(name.to_s, **)
|
|
75
110
|
end
|
|
111
|
+
|
|
112
|
+
# Stores Ractor-shareable copies of every setting and freezes the
|
|
113
|
+
# configuration. Resolves the default theme, which builds both built-in
|
|
114
|
+
# themes at boot rather than on the first render. Idempotent; returns self.
|
|
115
|
+
def finalize!
|
|
116
|
+
return self if frozen?
|
|
117
|
+
|
|
118
|
+
@theme = Shareable.copy(theme)
|
|
119
|
+
@infer_from_model = Shareable.copy(@infer_from_model)
|
|
120
|
+
@field_variants = Shareable.copy(@field_variants)
|
|
121
|
+
@icon_renderer = Shareable.callable(@icon_renderer)
|
|
122
|
+
freeze
|
|
123
|
+
end
|
|
76
124
|
end
|
|
77
125
|
end
|
data/lib/phlex_forms/engine.rb
CHANGED
|
@@ -32,6 +32,10 @@ module PhlexForms
|
|
|
32
32
|
app.config.i18n.load_path.unshift(*locales)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# PhlexForms.configure leaves the configuration open under Rails; freeze it
|
|
36
|
+
# (with Ractor-shareable settings) once every initializer has run.
|
|
37
|
+
config.after_initialize { PhlexForms.finalize! }
|
|
38
|
+
|
|
35
39
|
# The raw-hash param type Forms::Live's :validate action schema declares.
|
|
36
40
|
# Registered from an initializer because phlex-reactive freezes its
|
|
37
41
|
# param-type registry after boot.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PhlexForms
|
|
4
|
+
# Ractor-shareable copies of boot-time settings (the configuration, the
|
|
5
|
+
# form_options / live class macros), so any Ractor can read them after boot.
|
|
6
|
+
module Shareable
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# A deep-frozen, Ractor-shareable copy of value; value itself is left as the
|
|
10
|
+
# caller has it. A Proc cannot be copied, so it is shared in place, which
|
|
11
|
+
# works only when its self and everything it captures are already shareable
|
|
12
|
+
# (it never freezes them). A value that cannot be shared (a Proc whose self
|
|
13
|
+
# is not shareable, an IO, ...) comes back unchanged: it still works on the
|
|
14
|
+
# main Ractor. Engines without Ractor get the value back as is.
|
|
15
|
+
def copy(value)
|
|
16
|
+
return value unless defined?(::Ractor)
|
|
17
|
+
return value if ::Ractor.shareable?(value)
|
|
18
|
+
return ::Ractor.make_shareable(value) if value.is_a?(::Proc)
|
|
19
|
+
|
|
20
|
+
::Ractor.make_shareable(value, copy: true)
|
|
21
|
+
rescue ::Ractor::Error, ::TypeError
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A callable (the icon renderer) shared only where that leaves it working: a
|
|
26
|
+
# Proc as in copy, any other object kept as given. A frozen copy of an
|
|
27
|
+
# object would break one that keeps state of its own (a cache, a buffer)
|
|
28
|
+
# on its first call, and a Method cannot be copied.
|
|
29
|
+
def callable(value)
|
|
30
|
+
value.is_a?(::Proc) ? copy(value) : value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/phlex_forms/theme.rb
CHANGED
|
@@ -19,8 +19,10 @@ module PhlexForms
|
|
|
19
19
|
#
|
|
20
20
|
# PhlexForms::Theme.resolve(:plain).with(input: MyInput)
|
|
21
21
|
class Theme
|
|
22
|
+
# Frozen, so a theme of component classes is Ractor-shareable.
|
|
22
23
|
def initialize(components)
|
|
23
24
|
@components = components.freeze
|
|
25
|
+
freeze
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def [](role)
|
|
@@ -45,48 +47,53 @@ module PhlexForms
|
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
def daisy
|
|
49
|
-
@daisy ||= new({
|
|
50
|
-
input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
|
|
51
|
-
textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
|
|
52
|
-
checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
|
|
53
|
-
checkbox_group: Forms::CheckboxGroup,
|
|
54
|
-
file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
|
|
55
|
-
control: Forms::FormControl, label: Forms::Label,
|
|
56
|
-
field_error: Forms::FieldError, field_hint: Forms::FieldHint,
|
|
57
|
-
submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
|
|
58
|
-
# tag_field is phlex-reactive-gated (ClientBindings); only registered
|
|
59
|
-
# when the soft dep is present, else :tag_field raises a clear KeyError.
|
|
60
|
-
**reactive_roles(Forms::TagField, Forms::RootlessTagField)
|
|
61
|
-
})
|
|
62
|
-
end
|
|
50
|
+
def daisy = DAISY
|
|
63
51
|
|
|
64
|
-
|
|
65
|
-
# rich_textarea to a plain textarea; toggle renders as a checkbox.
|
|
66
|
-
def plain
|
|
67
|
-
@plain ||= new({
|
|
68
|
-
input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
|
|
69
|
-
textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
|
|
70
|
-
checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
|
|
71
|
-
checkbox_group: Forms::Plain::CheckboxGroup,
|
|
72
|
-
file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
|
|
73
|
-
control: Forms::Plain::Control, label: Forms::Plain::Label,
|
|
74
|
-
field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
|
|
75
|
-
submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
|
|
76
|
-
**reactive_roles(Forms::Plain::TagField, Forms::Plain::RootlessTagField)
|
|
77
|
-
})
|
|
78
|
-
end
|
|
52
|
+
def plain = PLAIN
|
|
79
53
|
|
|
80
54
|
private
|
|
81
55
|
|
|
82
56
|
# phlex-reactive-gated roles: mapped only when the soft dep is present. When
|
|
83
57
|
# absent, the leaf classes aren't autoloaded, so these roles are simply not
|
|
84
58
|
# registered (fetching one raises the theme's own KeyError with the list).
|
|
85
|
-
|
|
59
|
+
# Takes the namespace, not the classes: naming Forms::TagField without
|
|
60
|
+
# phlex-reactive would raise NameError before the guard ran.
|
|
61
|
+
def reactive_roles(namespace)
|
|
86
62
|
return {} unless defined?(Phlex::Reactive)
|
|
87
63
|
|
|
88
|
-
{ tag_field:
|
|
64
|
+
{ tag_field: namespace::TagField, rootless_tag_field: namespace::RootlessTagField }
|
|
89
65
|
end
|
|
90
66
|
end
|
|
67
|
+
|
|
68
|
+
# Both built-ins are built once, when this file loads (never memoized by a
|
|
69
|
+
# reader, so no two threads race to build one; PhlexForms.finalize! loads
|
|
70
|
+
# it at boot), and the phlex-reactive probe is evaluated once, here.
|
|
71
|
+
DAISY = new({
|
|
72
|
+
input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
|
|
73
|
+
textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
|
|
74
|
+
checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
|
|
75
|
+
checkbox_group: Forms::CheckboxGroup,
|
|
76
|
+
file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
|
|
77
|
+
control: Forms::FormControl, label: Forms::Label,
|
|
78
|
+
field_error: Forms::FieldError, field_hint: Forms::FieldHint,
|
|
79
|
+
submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
|
|
80
|
+
# tag_field is phlex-reactive-gated (ClientBindings); only registered
|
|
81
|
+
# when the soft dep is present, else :tag_field raises a clear KeyError.
|
|
82
|
+
**reactive_roles(Forms)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# choices_select degrades to the native plain select (no choices.js) and
|
|
86
|
+
# rich_textarea to a plain textarea; toggle renders as a checkbox.
|
|
87
|
+
PLAIN = new({
|
|
88
|
+
input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
|
|
89
|
+
textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
|
|
90
|
+
checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
|
|
91
|
+
checkbox_group: Forms::Plain::CheckboxGroup,
|
|
92
|
+
file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
|
|
93
|
+
control: Forms::Plain::Control, label: Forms::Plain::Label,
|
|
94
|
+
field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
|
|
95
|
+
submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
|
|
96
|
+
**reactive_roles(Forms::Plain)
|
|
97
|
+
})
|
|
91
98
|
end
|
|
92
99
|
end
|
data/lib/phlex_forms/version.rb
CHANGED
data/lib/phlex_forms.rb
CHANGED
|
@@ -10,6 +10,11 @@ require "zeitwerk"
|
|
|
10
10
|
require "daisy_ui"
|
|
11
11
|
|
|
12
12
|
begin
|
|
13
|
+
# phlex-reactive's Sortable calls Hash#to_json while it loads without
|
|
14
|
+
# requiring json (a Rails host has it loaded already). Finalizing builds the
|
|
15
|
+
# themes, which load the tag-field leaves and with them Sortable, so without
|
|
16
|
+
# Rails `configure` would raise NoMethodError.
|
|
17
|
+
require "json"
|
|
13
18
|
require "phlex/reactive"
|
|
14
19
|
rescue LoadError
|
|
15
20
|
# phlex-reactive is a soft dependency: without it the `live` server-truth
|
|
@@ -32,17 +37,38 @@ module PhlexForms
|
|
|
32
37
|
# used but its backing dependency is not available.
|
|
33
38
|
class FeatureUnavailable < Error; end
|
|
34
39
|
|
|
40
|
+
# @configuration is built when this file loads (see the bottom) and replaced
|
|
41
|
+
# only by configure and reset_configuration!, on the main Ractor at boot or
|
|
42
|
+
# between tests (a non-main Ractor cannot write a module ivar). Once finalized
|
|
43
|
+
# it is frozen and Ractor-shareable, so any Ractor may read it.
|
|
44
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
35
45
|
class << self
|
|
36
|
-
|
|
37
|
-
@configuration ||= Configuration.new
|
|
38
|
-
end
|
|
46
|
+
attr_reader :configuration
|
|
39
47
|
alias config configuration
|
|
40
48
|
|
|
49
|
+
# Yields the configuration, then finalizes it unless the Rails engine is
|
|
50
|
+
# loaded (its after_initialize finalizes once every initializer has run). A
|
|
51
|
+
# finalized configuration is never edited in place: the block gets a copy,
|
|
52
|
+
# which is finalized and swapped in whole. Returns the block's value.
|
|
41
53
|
def configure
|
|
42
|
-
|
|
54
|
+
if @configuration.frozen?
|
|
55
|
+
draft = @configuration.dup
|
|
56
|
+
result = yield(draft)
|
|
57
|
+
@configuration = draft.finalize!
|
|
58
|
+
else
|
|
59
|
+
result = yield(@configuration)
|
|
60
|
+
finalize! unless defined?(PhlexForms::Engine)
|
|
61
|
+
end
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Freezes the configuration with Ractor-shareable settings (see
|
|
66
|
+
# Configuration#finalize!). Idempotent; returns the configuration.
|
|
67
|
+
def finalize!
|
|
68
|
+
@configuration.finalize!
|
|
43
69
|
end
|
|
44
70
|
|
|
45
|
-
# Reset configuration to defaults. Intended for test isolation.
|
|
71
|
+
# Reset configuration to defaults, open again. Intended for test isolation.
|
|
46
72
|
def reset_configuration!
|
|
47
73
|
@configuration = Configuration.new
|
|
48
74
|
end
|
|
@@ -97,6 +123,10 @@ end
|
|
|
97
123
|
|
|
98
124
|
loader.setup
|
|
99
125
|
|
|
126
|
+
# The default configuration, built at load (never lazily, so no two readers
|
|
127
|
+
# race to create it).
|
|
128
|
+
PhlexForms.reset_configuration!
|
|
129
|
+
|
|
100
130
|
# Optional Rails integration (Stimulus controllers via importmap/assets, and
|
|
101
131
|
# the gem's default locale files). Loaded only under Rails; the gem stays pure
|
|
102
132
|
# Phlex otherwise.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-forms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -189,6 +189,7 @@ files:
|
|
|
189
189
|
- lib/phlex_forms/inference.rb
|
|
190
190
|
- lib/phlex_forms/inline_icons.rb
|
|
191
191
|
- lib/phlex_forms/rubocop.rb
|
|
192
|
+
- lib/phlex_forms/shareable.rb
|
|
192
193
|
- lib/phlex_forms/theme.rb
|
|
193
194
|
- lib/phlex_forms/version.rb
|
|
194
195
|
- lib/rubocop/phlex_forms/cop/legacy_form_method.rb
|