phlex-forms 0.5.0 → 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 +138 -38
- data/README.md +25 -0
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +41 -7
- data/lib/forms/validations/introspector.rb +17 -2
- data/lib/forms/validations/manual_rules.rb +19 -1
- data/lib/phlex_forms/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,7 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
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
|
|
11
26
|
|
|
12
27
|
### Upgrading to 0.5
|
|
13
28
|
|
|
@@ -72,7 +87,24 @@ read one immutable value, and so can Ractors when every setting can be shared
|
|
|
72
87
|
plain Ruby host got `NoMethodError` on its first form (and, with finalizing,
|
|
73
88
|
from `configure`). phlex-forms requires json before phlex-reactive.
|
|
74
89
|
|
|
75
|
-
## [0.4.1]
|
|
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
|
|
76
108
|
|
|
77
109
|
### Upgrading to 0.4
|
|
78
110
|
|
|
@@ -116,20 +148,10 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
116
148
|
longer raises `FeatureUnavailable`. The Plain theme is unchanged and still
|
|
117
149
|
emits zero daisy classes.
|
|
118
150
|
|
|
151
|
+
## [0.3.1] - 2026-09-09
|
|
152
|
+
|
|
119
153
|
### Fixed
|
|
120
154
|
|
|
121
|
-
- **Client-side validators honour the validator's `message:`** (issue #41).
|
|
122
|
-
`validates :code, format: { with: /\A[a-z]*\z/, message: "lowercase only" }`
|
|
123
|
-
used to show the generic "is invalid" in the browser and the specific text
|
|
124
|
-
only after a round trip. The introspector now emits
|
|
125
|
-
`data-validations--<validator>-message-value`: a String as is (`%{count}`
|
|
126
|
-
stays for the browser to fill), a Symbol resolved on the server through
|
|
127
|
-
ActiveModel's lookup chain in the current locale, a Proc skipped (it needs a
|
|
128
|
-
record). Length's `too_long:` / `too_short:` / `wrong_length:` win over
|
|
129
|
-
`message:`, as on the server. Every shipped controller prefers the value and
|
|
130
|
-
falls back to its bundled message; the submit coordinator collects it too. A
|
|
131
|
-
`validate: { presence: { message: "…" } }` / `confirmation: { message: "…" }`
|
|
132
|
-
rules hash now keeps its options (it used to collapse them to `true`).
|
|
133
155
|
- **`rich_textarea` forwards its block into `<lexxy-editor>`**: Lexxy configures
|
|
134
156
|
an editor through CHILD elements — `<lexxy-prompt>` for @mentions,
|
|
135
157
|
`<lexxy-code-language-picker>` for code blocks — but the component rendered
|
|
@@ -146,29 +168,59 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
146
168
|
pin uploads to whichever host rendered the page first), fully guarded — no
|
|
147
169
|
Rails dependency is introduced, and a caller-supplied value always wins.
|
|
148
170
|
|
|
171
|
+
## [0.3.0] - 2026-08-12
|
|
172
|
+
|
|
173
|
+
### Fixed
|
|
174
|
+
|
|
149
175
|
- **`FileInput` multiple uploads submit an array** (Rails `file_field` parity):
|
|
150
176
|
with `multiple:` set, the input name now gets `[]` appended (unless already
|
|
151
177
|
present), for the standalone component, the `Plain` variant, and the form
|
|
152
178
|
builder path. Without it the browser collapsed a multi-file selection into
|
|
153
179
|
ONE scalar param, which a host app's `params.expect(attr: [])` silently
|
|
154
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
|
|
155
221
|
|
|
156
222
|
### Added
|
|
157
223
|
|
|
158
|
-
- **`checkbox_group` — batched checkbox group for array-valued fields** (the
|
|
159
|
-
tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
|
|
160
|
-
label: :name, variant: :pill, size: :sm)`, or via field inference
|
|
161
|
-
(`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
|
|
162
|
-
Shares one array-valued field name with a leading empty-array hidden field,
|
|
163
|
-
derives the checked set from the model's current value, and renders under both
|
|
164
|
-
themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
|
|
165
|
-
- **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
|
|
166
|
-
now carry an accessible name/description. HTML/ARIA attributes pass straight
|
|
167
|
-
through to the group, so the bare verb is named with plain `aria:`
|
|
168
|
-
(`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
|
|
169
|
-
the Control's own visible `label:` / `hint:` get stable ids and the field wires
|
|
170
|
-
`aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
|
|
171
|
-
name, output is unchanged.
|
|
172
224
|
- **`checkbox_group` `item_label:`** — the per-item text accessor, so a
|
|
173
225
|
`f.field(:tags, as: :checkbox_group, label: "Tags", item_label: ->(t){…})`
|
|
174
226
|
renders a visible group heading (`label:`, via the Control) AND custom item
|
|
@@ -177,6 +229,30 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
177
229
|
item text; on the bare verb `label:` stays the item accessor and `item_label:`
|
|
178
230
|
is an alias. Absent it, behavior is unchanged.
|
|
179
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
|
+
|
|
180
256
|
### Changed
|
|
181
257
|
|
|
182
258
|
- **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
|
|
@@ -191,15 +267,6 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
191
267
|
|
|
192
268
|
### Fixed
|
|
193
269
|
|
|
194
|
-
- **A caller width class stacked with the `w-full` default instead of replacing
|
|
195
|
-
it**: `DelegatedField#width_class` joined `"w-full"` and the caller's
|
|
196
|
-
`class:` verbatim, so `Forms::Select(class: "w-36")` emitted
|
|
197
|
-
`class="... w-full w-36"` and stylesheet source order — not author intent —
|
|
198
|
-
picked the winner (in practice `w-full`, which blew filter selects up to full
|
|
199
|
-
width; zazu#2934). `ClassMerge` gained a width family (anchored `w-*`, so
|
|
200
|
-
`min-w-*`/`max-w-*` still compose) and `width_class` merges through it —
|
|
201
|
-
last width wins, matching the daisyui size/color family semantics.
|
|
202
|
-
|
|
203
270
|
- **`f.Radio` / `Field#radio` rendered the model's current value on every radio
|
|
204
271
|
instead of each radio's own value**: `field_attributes` carried `value:
|
|
205
272
|
field_value` and was splatted after the explicit positional value, clobbering
|
|
@@ -216,6 +283,37 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
216
283
|
instead of a single `scope[assoc][field]`. It is now treated as a single
|
|
217
284
|
nested scope; only genuine collections (Enumerable, not Hash) iterate.
|
|
218
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
|
+
|
|
219
317
|
- **`Forms::Base` declarative form classes**: subclass, declare fields in
|
|
220
318
|
`#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
|
|
221
319
|
render with `render UserForm.new(model: @user)`. Class-level `form_options`
|
|
@@ -284,7 +382,9 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
284
382
|
Pass `as:` explicitly or set `PhlexForms.config.infer_from_model = false`
|
|
285
383
|
to keep the old rendering.
|
|
286
384
|
|
|
287
|
-
|
|
385
|
+
## [0.1.0] - 2026-07-07
|
|
386
|
+
|
|
387
|
+
### Added
|
|
288
388
|
|
|
289
389
|
- Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
|
|
290
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
|
|
@@ -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
|
|
@@ -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
|
data/lib/phlex_forms/version.rb
CHANGED