phlex-forms 0.4.0 → 0.5.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 +79 -0
- data/README.md +40 -1
- data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +2 -3
- data/app/javascript/phlex_forms/controllers/validations/base_controller.js +12 -1
- data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +1 -2
- data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +1 -2
- data/app/javascript/phlex_forms/controllers/validations/format_controller.js +1 -2
- data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +1 -2
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +15 -4
- data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +10 -11
- data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +1 -2
- data/app/javascript/phlex_forms/i18n.js +1 -1
- 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 +24 -1
- data/lib/forms/validations/manual_rules.rb +3 -3
- data/lib/forms/validations/message.rb +77 -0
- 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 +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b43b29cfcccda75c6a0af1c888d3edd50b824f3c393c9938890355143d787231
|
|
4
|
+
data.tar.gz: 8b231701048287d17a7e44b004f6389f5d760e2b5e36750c90f2416dcbfdca89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 04f12370599fbae631ac6e88345a7e412eb8f0a143eaa20e76978d8e584aeab6d9657b182e8ac7a424b9624b9df7c362a4008a922c586d4669652fa3da67fa60
|
|
7
|
+
data.tar.gz: 802635cc20851fbade99760b38c05d6e3e8fb1a67433fedbcb59ff5318e2ef21fb66d202c7e6f0534c530005dfb468c9e4dc67a01fce5b69b3e5a6c8929db18d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0]
|
|
11
|
+
|
|
12
|
+
### Upgrading to 0.5
|
|
13
|
+
|
|
14
|
+
The configuration is frozen once the app has booted, so threads and fibers
|
|
15
|
+
read one immutable value, and so can Ractors when every setting can be shared
|
|
16
|
+
(#42, conformance-kit; see the unshareable-value note below).
|
|
17
|
+
|
|
18
|
+
- **It finalizes on its own.** Under Rails the engine calls
|
|
19
|
+
`PhlexForms.finalize!` in `after_initialize`, after every initializer has
|
|
20
|
+
run, with no app code. Without Rails, `PhlexForms.configure` finalizes when
|
|
21
|
+
its block returns; a host that never calls `configure` (or loads Rails
|
|
22
|
+
without booting an app) calls `PhlexForms.finalize!` at boot.
|
|
23
|
+
- **A direct write after boot raises `FrozenError`.** `PhlexForms.config.infer_from_model = false`
|
|
24
|
+
in a Rails initializer still works. After boot (a test's `before` block, or
|
|
25
|
+
after `configure` outside Rails), write through `PhlexForms.configure`
|
|
26
|
+
instead: it edits a copy and swaps it in.
|
|
27
|
+
- **Finalizing never raises over a value it cannot share.** The value is kept
|
|
28
|
+
as given and works on the main Ractor only: an icon renderer lambda at the
|
|
29
|
+
top level of an initializer (its `self` is `main`, and it may close over a
|
|
30
|
+
logger) is one. Define it in a class or module body (or with
|
|
31
|
+
`Ractor.shareable_lambda` on Ruby 4.0) to keep the whole configuration
|
|
32
|
+
shareable. A renderer that is not a Proc (an object with `#call`, a
|
|
33
|
+
`Method`) is never copied or frozen, so one that keeps a cache still works.
|
|
34
|
+
- **`form_options` and the `live` macros keep frozen copies** of what they are
|
|
35
|
+
given, so the values nested in `form_defaults` and the live settings are
|
|
36
|
+
frozen. Your own arrays and hashes are copied, never frozen.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`PhlexForms.finalize!`**: freezes the configuration and stores
|
|
41
|
+
Ractor-shareable copies of its settings. Idempotent; returns the
|
|
42
|
+
configuration. `PhlexForms::Theme::DAISY` / `PLAIN` are the built-in themes,
|
|
43
|
+
built once when the theme loads (`Theme.daisy` / `Theme.plain` return them),
|
|
44
|
+
and every `Theme` is frozen.
|
|
45
|
+
- **Ractor support for the non-rendering paths.** On Ruby 4.0, field
|
|
46
|
+
inference, option resolution, theme resolution, the icon renderer and the
|
|
47
|
+
`form_options` / `live` settings work inside a non-main Ractor. Rendering
|
|
48
|
+
there waits on Phlex
|
|
49
|
+
([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
|
|
50
|
+
- **conformance-kit 0.4 gates in CI**: the thread-safety RuboCop cops, thread
|
|
51
|
+
and fiber contention specs, a Ractor canary and a strict
|
|
52
|
+
`conformance-audit` of `PhlexForms` and `Forms` with nothing allowlisted.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **The configuration is frozen after boot** (see Upgrading to 0.5).
|
|
57
|
+
`Configuration` sets every default in `initialize`; no reader memoizes. The
|
|
58
|
+
default icon renderer and `Configuration.glyphs_renderer` are shareable.
|
|
59
|
+
- `rich_textarea` renders `<lexxy-editor>` through Phlex's `tag` instead of
|
|
60
|
+
`register_element`; the markup is the same.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- **The themes build without phlex-reactive.** `Theme.daisy` named
|
|
65
|
+
`Forms::TagField` before checking for the soft dependency, so a host without
|
|
66
|
+
phlex-reactive got `NameError: uninitialized constant Forms::TagField` on its
|
|
67
|
+
first form.
|
|
68
|
+
- **`Theme.daisy` / `Theme.plain` are built once.** Two threads resolving a
|
|
69
|
+
theme for the first time could each build their own.
|
|
70
|
+
- **phlex-forms loads without Rails with phlex-reactive bundled.** phlex-reactive's
|
|
71
|
+
`Sortable` calls `Hash#to_json` while it loads without requiring json, so a
|
|
72
|
+
plain Ruby host got `NoMethodError` on its first form (and, with finalizing,
|
|
73
|
+
from `configure`). phlex-forms requires json before phlex-reactive.
|
|
74
|
+
|
|
75
|
+
## [0.4.1] and earlier
|
|
76
|
+
|
|
10
77
|
### Upgrading to 0.4
|
|
11
78
|
|
|
12
79
|
phlex-forms now depends on **daisyui `~> 2.0`** and merges every default +
|
|
@@ -51,6 +118,18 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
|
|
|
51
118
|
|
|
52
119
|
### Fixed
|
|
53
120
|
|
|
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`).
|
|
54
133
|
- **`rich_textarea` forwards its block into `<lexxy-editor>`**: Lexxy configures
|
|
55
134
|
an editor through CHILD elements — `<lexxy-prompt>` for @mentions,
|
|
56
135
|
`<lexxy-code-language-picker>` for code blocks — but the component rendered
|
data/README.md
CHANGED
|
@@ -345,7 +345,19 @@ the form-level `validations--form` coordinator), which `lazyLoadControllersFrom`
|
|
|
345
345
|
resolves to `phlex_forms/controllers/validations/*_controller` — the path the gem
|
|
346
346
|
ships them at.
|
|
347
347
|
|
|
348
|
-
|
|
348
|
+
A validator's own `message:` reaches the browser too, so the client shows what
|
|
349
|
+
the server would: a String as is (`%{value}` is filled in, and `%{count}` for
|
|
350
|
+
rules that have one: length and the numericality bounds), a Symbol resolved on
|
|
351
|
+
the server through your app's i18n, a Proc skipped (it needs a record).
|
|
352
|
+
Length's `too_long:` / `too_short:` / `wrong_length:` win over `message:`.
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
validates :code, format: { with: /\A[a-z]*\z/, message: "may only contain lowercase letters" }
|
|
356
|
+
f.field :note, validate: { length: { maximum: 30, too_long: "keep it under %{count}" } }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Without a `message:`, the bundled messages apply: they ship for `en` / `fr` /
|
|
360
|
+
`af`; override them page-wide via `window.PhlexForms.messages`.
|
|
349
361
|
|
|
350
362
|
## Nested attributes, collections & escape valves
|
|
351
363
|
|
|
@@ -425,6 +437,33 @@ PhlexForms.configure do |c|
|
|
|
425
437
|
end
|
|
426
438
|
```
|
|
427
439
|
|
|
440
|
+
## Configuration is finalized at boot
|
|
441
|
+
|
|
442
|
+
`PhlexForms.configure` writes the configuration; finalizing freezes it and
|
|
443
|
+
stores Ractor-shareable copies of its settings, so threads and fibers read one
|
|
444
|
+
immutable value, and so can Ractors when every setting can be shared (see
|
|
445
|
+
below). It happens on its own:
|
|
446
|
+
|
|
447
|
+
- **Under Rails** the engine finalizes in `after_initialize`, once every
|
|
448
|
+
initializer has run, so initializers can still write (through `configure`
|
|
449
|
+
or directly).
|
|
450
|
+
- **Without Rails** (no `Rails::Engine` when phlex-forms loads), `configure`
|
|
451
|
+
finalizes when its block returns. A host that never calls `configure`, or
|
|
452
|
+
loads Rails without booting an app, calls `PhlexForms.finalize!` at boot.
|
|
453
|
+
|
|
454
|
+
After that, write through `configure` again (it edits a copy and swaps it in);
|
|
455
|
+
a direct `PhlexForms.config.theme = :plain` raises `FrozenError`. Your own
|
|
456
|
+
arrays and hashes are copied, never frozen.
|
|
457
|
+
|
|
458
|
+
Finalizing never raises over a value it cannot share: that value is kept as
|
|
459
|
+
given and works on the main Ractor only. An icon renderer lambda at the top
|
|
460
|
+
level of an initializer (its `self` is `main`, and it may close over a logger)
|
|
461
|
+
is one; define it in a class or module body (or with `Ractor.shareable_lambda`
|
|
462
|
+
on Ruby 4.0) to keep the whole configuration shareable. A renderer that is not
|
|
463
|
+
a Proc (an object with `#call`, a `Method`) is never copied or frozen, so one
|
|
464
|
+
that keeps a cache still works. `PhlexForms.reset_configuration!` restores the
|
|
465
|
+
open defaults (for test suites).
|
|
466
|
+
|
|
428
467
|
## RuboCop cops
|
|
429
468
|
|
|
430
469
|
Two cops nudge call sites toward the phlex-forms API. Enable them in your
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::AcceptanceValidator. For
|
|
5
4
|
// checkboxes, asserts `.checked`. For other inputs, asserts the
|
|
@@ -11,11 +10,11 @@ export default class extends FieldValidatorController {
|
|
|
11
10
|
|
|
12
11
|
check(value) {
|
|
13
12
|
if (this.element.type === "checkbox") {
|
|
14
|
-
return this.element.checked ? null :
|
|
13
|
+
return this.element.checked ? null : this.messageOr("js.forms.validations.acceptance.accepted")
|
|
15
14
|
}
|
|
16
15
|
try {
|
|
17
16
|
const list = JSON.parse(this.acceptValue)
|
|
18
|
-
return list.map(String).includes(String(value ?? "")) ? null :
|
|
17
|
+
return list.map(String).includes(String(value ?? "")) ? null : this.messageOr("js.forms.validations.acceptance.accepted")
|
|
19
18
|
} catch (_e) {
|
|
20
19
|
return null
|
|
21
20
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { interpolate, t } from "phlex_forms/i18n"
|
|
2
3
|
|
|
3
4
|
// Shared base for every field-level validator. Subclasses implement
|
|
4
5
|
// `check(value)` and return either `null` (valid) or a translation
|
|
@@ -8,7 +9,8 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
8
9
|
//
|
|
9
10
|
// Each subclass declares its own `static values` for the rules it
|
|
10
11
|
// reads from data attributes; the base class only knows about the
|
|
11
|
-
// `allowBlank` / `allowNil` short-circuits
|
|
12
|
+
// `allowBlank` / `allowNil` short-circuits and the `message` value,
|
|
13
|
+
// which carries the validator's own `message:` option (issue #41).
|
|
12
14
|
export class FieldValidatorController extends Controller {
|
|
13
15
|
// `error` is opt-in: callers that pre-render a `<p data-validations--<validator>-target="error">`
|
|
14
16
|
// get a stable slot the controller toggles. Inputs without an
|
|
@@ -18,6 +20,7 @@ export class FieldValidatorController extends Controller {
|
|
|
18
20
|
static values = {
|
|
19
21
|
allowBlank: { type: Boolean, default: false },
|
|
20
22
|
allowNil: { type: Boolean, default: false },
|
|
23
|
+
message: String,
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Element must be the input itself — these controllers are attached
|
|
@@ -64,6 +67,14 @@ export class FieldValidatorController extends Controller {
|
|
|
64
67
|
return null
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
// The validator's own `message:` when the server rendered one, else the
|
|
71
|
+
// bundled translation for `key`. Either way `%{count}` & co. are filled in;
|
|
72
|
+
// a custom message may also carry `%{value}`, as on the server.
|
|
73
|
+
messageOr(key, vars = {}) {
|
|
74
|
+
if (!(this.hasMessageValue && this.messageValue)) return t(key, vars)
|
|
75
|
+
return interpolate(this.messageValue, { value: this.fieldValue(), ...vars })
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
shouldSkip(value) {
|
|
68
79
|
if (this.allowNilValue && (value === null || value === undefined)) return true
|
|
69
80
|
if (this.allowBlankValue && this.isBlank(value)) return true
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::ConfirmationValidator. Looks
|
|
5
4
|
// for the `<name>_confirmation` field by name within the same form
|
|
@@ -15,7 +14,7 @@ export default class extends FieldValidatorController {
|
|
|
15
14
|
if (!form) return null
|
|
16
15
|
const partner = form.elements[this.matchValue] || this.partnerByAttribute(form)
|
|
17
16
|
if (!partner) return null
|
|
18
|
-
return String(partner.value) === String(value ?? "") ? null :
|
|
17
|
+
return String(partner.value) === String(value ?? "") ? null : this.messageOr("js.forms.validations.confirmation.confirmation")
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
partnerByAttribute(form) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::ExclusionValidator.
|
|
5
4
|
export default class extends FieldValidatorController {
|
|
@@ -11,7 +10,7 @@ export default class extends FieldValidatorController {
|
|
|
11
10
|
if (!this.hasInValue) return null
|
|
12
11
|
try {
|
|
13
12
|
const list = JSON.parse(this.inValue)
|
|
14
|
-
return list.map(String).includes(String(value ?? "")) ?
|
|
13
|
+
return list.map(String).includes(String(value ?? "")) ? this.messageOr("js.forms.validations.exclusion.exclusion") : null
|
|
15
14
|
} catch (_e) {
|
|
16
15
|
return null
|
|
17
16
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::FormatValidator.
|
|
5
4
|
export default class extends FieldValidatorController {
|
|
@@ -12,7 +11,7 @@ export default class extends FieldValidatorController {
|
|
|
12
11
|
if (!this.hasPatternValue) return null
|
|
13
12
|
try {
|
|
14
13
|
const regex = new RegExp(this.patternValue, this.flagsValue)
|
|
15
|
-
return regex.test(String(value ?? "")) ? null :
|
|
14
|
+
return regex.test(String(value ?? "")) ? null : this.messageOr("js.forms.validations.format.invalid")
|
|
16
15
|
} catch (_e) {
|
|
17
16
|
// If Rails' regex doesn't translate cleanly to JS, fall back to
|
|
18
17
|
// letting the server be the authority — don't block submit on a
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::InclusionValidator.
|
|
5
4
|
export default class extends FieldValidatorController {
|
|
@@ -11,7 +10,7 @@ export default class extends FieldValidatorController {
|
|
|
11
10
|
if (!this.hasInValue) return null
|
|
12
11
|
try {
|
|
13
12
|
const list = JSON.parse(this.inValue)
|
|
14
|
-
return list.map(String).includes(String(value ?? "")) ? null :
|
|
13
|
+
return list.map(String).includes(String(value ?? "")) ? null : this.messageOr("js.forms.validations.inclusion.inclusion")
|
|
15
14
|
} catch (_e) {
|
|
16
15
|
return null
|
|
17
16
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import {
|
|
2
|
+
import { interpolate } from "phlex_forms/i18n"
|
|
3
3
|
|
|
4
4
|
// Mirrors ActiveModel::Validations::LengthValidator. Reads:
|
|
5
5
|
// - maximum / minimum / is
|
|
6
|
+
// - too-long / too-short / wrong-length messages (win over `message`)
|
|
6
7
|
// - allow-blank / allow-nil
|
|
7
8
|
//
|
|
8
9
|
// Counter is rendered next to the input when `maximum` is set, so
|
|
@@ -20,6 +21,9 @@ export default class extends FieldValidatorController {
|
|
|
20
21
|
maximum: Number,
|
|
21
22
|
minimum: Number,
|
|
22
23
|
is: Number,
|
|
24
|
+
tooLongMessage: String,
|
|
25
|
+
tooShortMessage: String,
|
|
26
|
+
wrongLengthMessage: String,
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
connect() {
|
|
@@ -41,17 +45,24 @@ export default class extends FieldValidatorController {
|
|
|
41
45
|
const length = this.codepointLength(value)
|
|
42
46
|
|
|
43
47
|
if (this.hasIsValue && length !== this.isValue) {
|
|
44
|
-
return
|
|
48
|
+
return this.lengthMessage(this.wrongLengthMessageValue, "wrong_length", this.isValue)
|
|
45
49
|
}
|
|
46
50
|
if (this.hasMaximumValue && length > this.maximumValue) {
|
|
47
|
-
return
|
|
51
|
+
return this.lengthMessage(this.tooLongMessageValue, "too_long", this.maximumValue)
|
|
48
52
|
}
|
|
49
53
|
if (this.hasMinimumValue && length < this.minimumValue) {
|
|
50
|
-
return
|
|
54
|
+
return this.lengthMessage(this.tooShortMessageValue, "too_short", this.minimumValue)
|
|
51
55
|
}
|
|
52
56
|
return null
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// A per-case message (`too_long:` & co.) wins over `message:`, as on the
|
|
60
|
+
// server; the bundled translation is the final fallback.
|
|
61
|
+
lengthMessage(caseMessage, key, count) {
|
|
62
|
+
if (caseMessage) return interpolate(caseMessage, { value: this.fieldValue(), count })
|
|
63
|
+
return this.messageOr(`js.forms.validations.length.${key}`, { count })
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
updateCounter = () => {
|
|
56
67
|
const counter = this.counterElement()
|
|
57
68
|
if (!counter) return
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::NumericalityValidator.
|
|
5
4
|
export default class extends FieldValidatorController {
|
|
@@ -20,40 +19,40 @@ export default class extends FieldValidatorController {
|
|
|
20
19
|
if (raw === "") return null
|
|
21
20
|
|
|
22
21
|
const number = Number(raw)
|
|
23
|
-
if (Number.isNaN(number)) return
|
|
22
|
+
if (Number.isNaN(number)) return this.messageOr("js.forms.validations.numericality.not_a_number")
|
|
24
23
|
|
|
25
24
|
if (this.onlyIntegerValue && !Number.isInteger(number)) {
|
|
26
|
-
return
|
|
25
|
+
return this.messageOr("js.forms.validations.numericality.not_an_integer")
|
|
27
26
|
}
|
|
28
27
|
if (this.hasGreaterThanValue && !(number > this.greaterThanValue)) {
|
|
29
|
-
return
|
|
28
|
+
return this.messageOr("js.forms.validations.numericality.greater_than", { count: this.greaterThanValue })
|
|
30
29
|
}
|
|
31
30
|
if (this.hasGreaterThanOrEqualToValue && !(number >= this.greaterThanOrEqualToValue)) {
|
|
32
|
-
return
|
|
31
|
+
return this.messageOr("js.forms.validations.numericality.greater_than_or_equal_to", {
|
|
33
32
|
count: this.greaterThanOrEqualToValue,
|
|
34
33
|
})
|
|
35
34
|
}
|
|
36
35
|
if (this.hasLessThanValue && !(number < this.lessThanValue)) {
|
|
37
|
-
return
|
|
36
|
+
return this.messageOr("js.forms.validations.numericality.less_than", { count: this.lessThanValue })
|
|
38
37
|
}
|
|
39
38
|
if (this.hasLessThanOrEqualToValue && !(number <= this.lessThanOrEqualToValue)) {
|
|
40
|
-
return
|
|
39
|
+
return this.messageOr("js.forms.validations.numericality.less_than_or_equal_to", {
|
|
41
40
|
count: this.lessThanOrEqualToValue,
|
|
42
41
|
})
|
|
43
42
|
}
|
|
44
43
|
if (this.hasEqualToValue && number !== this.equalToValue) {
|
|
45
|
-
return
|
|
44
|
+
return this.messageOr("js.forms.validations.numericality.equal_to", { count: this.equalToValue })
|
|
46
45
|
}
|
|
47
46
|
if (this.hasOtherThanValue && number === this.otherThanValue) {
|
|
48
|
-
return
|
|
47
|
+
return this.messageOr("js.forms.validations.numericality.other_than", { count: this.otherThanValue })
|
|
49
48
|
}
|
|
50
49
|
// Rails' NumericalityValidator coerces with `.to_i` before
|
|
51
50
|
// checking parity, so `2.5` is treated as `2` (even). JS `%` on
|
|
52
51
|
// a float diverges from that — truncate first to keep client and
|
|
53
52
|
// server in agreement.
|
|
54
53
|
const truncated = Math.trunc(number)
|
|
55
|
-
if (this.oddValue && truncated % 2 === 0) return
|
|
56
|
-
if (this.evenValue && truncated % 2 !== 0) return
|
|
54
|
+
if (this.oddValue && truncated % 2 === 0) return this.messageOr("js.forms.validations.numericality.odd")
|
|
55
|
+
if (this.evenValue && truncated % 2 !== 0) return this.messageOr("js.forms.validations.numericality.even")
|
|
57
56
|
return null
|
|
58
57
|
}
|
|
59
58
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
|
|
2
|
-
import { t } from "phlex_forms/i18n"
|
|
3
2
|
|
|
4
3
|
// Mirrors ActiveModel::Validations::PresenceValidator.
|
|
5
4
|
export default class extends FieldValidatorController {
|
|
@@ -9,6 +8,6 @@ export default class extends FieldValidatorController {
|
|
|
9
8
|
|
|
10
9
|
check(value) {
|
|
11
10
|
if (!this.requiredValue) return null
|
|
12
|
-
return this.isBlank(value) ?
|
|
11
|
+
return this.isBlank(value) ? this.messageOr("js.forms.validations.presence.blank") : null
|
|
13
12
|
}
|
|
14
13
|
}
|
|
@@ -26,7 +26,7 @@ function lookup(scope, locale) {
|
|
|
26
26
|
return scope.split(".").reduce((node, key) => (node == null ? undefined : node[key]), table)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function interpolate(string, vars) {
|
|
29
|
+
export function interpolate(string, vars = {}) {
|
|
30
30
|
return string.replace(/%\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `%{${key}}`))
|
|
31
31
|
}
|
|
32
32
|
|
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,
|
|
@@ -46,6 +46,11 @@ module Forms
|
|
|
46
46
|
only_integer odd even
|
|
47
47
|
].freeze
|
|
48
48
|
|
|
49
|
+
# LengthValidator's per-case messages; each wins over `message:` on the
|
|
50
|
+
# server, so the length controller checks them first. Each maps to the
|
|
51
|
+
# bound it reports, so a Symbol resolves with that count.
|
|
52
|
+
LENGTH_MESSAGE_KEYS = { too_long: :maximum, too_short: :minimum, wrong_length: :is }.freeze
|
|
53
|
+
|
|
49
54
|
# Convenience: returns a null introspector for nil models so
|
|
50
55
|
# callers don't need to guard.
|
|
51
56
|
def self.for(model_or_class)
|
|
@@ -83,7 +88,8 @@ module Forms
|
|
|
83
88
|
next unless suffix
|
|
84
89
|
|
|
85
90
|
controllers << "#{CONTROLLER_PREFIX}--#{suffix}"
|
|
86
|
-
value_attributes(suffix, validator, attribute).
|
|
91
|
+
value_attributes(suffix, validator, attribute).merge(message_attrs(suffix, validator, attribute))
|
|
92
|
+
.each do |key, value|
|
|
87
93
|
attrs[data_key(suffix, key)] = value
|
|
88
94
|
end
|
|
89
95
|
end
|
|
@@ -131,6 +137,23 @@ module Forms
|
|
|
131
137
|
end
|
|
132
138
|
end
|
|
133
139
|
|
|
140
|
+
# `message` (and length's `too_long_message` & co.) values, for the
|
|
141
|
+
# validator's own `message:` options. Omitted when there is none, or when
|
|
142
|
+
# it can't be resolved without a record, so the generic message stays.
|
|
143
|
+
def message_attrs(suffix, validator, attribute)
|
|
144
|
+
counts = suffix == "length" ? length_message_counts(validator) : {}
|
|
145
|
+
options = counts.keys + [:message]
|
|
146
|
+
options.each_with_object({}) do |option, attrs|
|
|
147
|
+
text = Message.resolve(validator.options[option], model_class:, attribute:, count: counts[option])
|
|
148
|
+
attrs[option == :message ? :message : :"#{option}_message"] = text if text
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def length_message_counts(validator)
|
|
153
|
+
bounds = length_attrs(validator)
|
|
154
|
+
LENGTH_MESSAGE_KEYS.to_h { |option, bound| [option, bounds[bound]&.to_i] }
|
|
155
|
+
end
|
|
156
|
+
|
|
134
157
|
def presence_attrs
|
|
135
158
|
{ required: "true" }
|
|
136
159
|
end
|
|
@@ -13,7 +13,7 @@ module Forms
|
|
|
13
13
|
# ManualRules.new(
|
|
14
14
|
# length: { maximum: 60, minimum: 3 },
|
|
15
15
|
# presence: true,
|
|
16
|
-
# format: { with: /\A[a-z]+\z/i },
|
|
16
|
+
# format: { with: /\A[a-z]+\z/i, message: "letters only" },
|
|
17
17
|
# numericality: { greater_than_or_equal_to: 0 },
|
|
18
18
|
# inclusion: { in: %w[a b c] },
|
|
19
19
|
# exclusion: { in: %w[admin] },
|
|
@@ -49,7 +49,7 @@ module Forms
|
|
|
49
49
|
captured_rules.each do |key, opts|
|
|
50
50
|
case key
|
|
51
51
|
when :presence
|
|
52
|
-
validates :value, presence: true if opts
|
|
52
|
+
validates :value, presence: opts.is_a?(Hash) ? opts : true if opts
|
|
53
53
|
when :length
|
|
54
54
|
validates :value, length: opts
|
|
55
55
|
when :format
|
|
@@ -61,7 +61,7 @@ module Forms
|
|
|
61
61
|
when :exclusion
|
|
62
62
|
validates :value, exclusion: opts
|
|
63
63
|
when :confirmation
|
|
64
|
-
validates :value, confirmation: true if opts
|
|
64
|
+
validates :value, confirmation: opts.is_a?(Hash) ? opts : true if opts
|
|
65
65
|
when :acceptance
|
|
66
66
|
validates :value, acceptance: opts.is_a?(Hash) ? opts : { accept: %w[1 true] }
|
|
67
67
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Validations
|
|
5
|
+
# Turns a validator's `message:` option into the text the browser should
|
|
6
|
+
# show, so the client mirror says what the server would (issue #41).
|
|
7
|
+
#
|
|
8
|
+
# - a String is emitted as is; `%{count}`-style placeholders stay in place
|
|
9
|
+
# for the Stimulus controller to fill, as it does for its own messages.
|
|
10
|
+
# A blank String resolves to nil: the browser reads "" as valid.
|
|
11
|
+
# - a Symbol is resolved on the server through ActiveModel's lookup chain
|
|
12
|
+
# (`<scope>.errors.models.<model>.attributes.<attr>.<key>` ... then
|
|
13
|
+
# `errors.messages.<key>`), in the current locale. With a known `count:`
|
|
14
|
+
# (length's per-case messages) it is passed to I18n, so a pluralised
|
|
15
|
+
# translation picks the right form; otherwise `%{count}` stays a
|
|
16
|
+
# placeholder and the plural `:other` form is used.
|
|
17
|
+
# - anything else (a Proc needs a record) resolves to nil, so the
|
|
18
|
+
# controller keeps its generic message.
|
|
19
|
+
#
|
|
20
|
+
# Mirrors `ActiveModel::Error.generate_message` against the class alone: the
|
|
21
|
+
# introspector never instantiates the model.
|
|
22
|
+
class Message
|
|
23
|
+
# Interpolations the browser fills in, kept as literal placeholders.
|
|
24
|
+
PLACEHOLDERS = { count: "%{count}", value: "%{value}" }.freeze
|
|
25
|
+
|
|
26
|
+
def self.resolve(message, model_class:, attribute:, count: nil)
|
|
27
|
+
case message
|
|
28
|
+
when String then message unless message.empty?
|
|
29
|
+
when Symbol then new(model_class, attribute).translate(message, count:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(model_class, attribute)
|
|
34
|
+
@model_class = model_class
|
|
35
|
+
@attribute = attribute.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def translate(key, count: nil)
|
|
39
|
+
return unless defined?(I18n)
|
|
40
|
+
|
|
41
|
+
first, *rest = lookup_keys(key)
|
|
42
|
+
vars = interpolations
|
|
43
|
+
vars[:count] = count unless count.nil?
|
|
44
|
+
I18n.translate(first, **vars, default: rest, raise: true)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :model_class, :attribute
|
|
52
|
+
|
|
53
|
+
def lookup_keys(key)
|
|
54
|
+
model_keys(key) + [:"errors.attributes.#{attribute}.#{key}", :"errors.messages.#{key}"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def model_keys(key)
|
|
58
|
+
return [] unless model_class.respond_to?(:i18n_scope) && model_class.respond_to?(:lookup_ancestors)
|
|
59
|
+
|
|
60
|
+
scope = model_class.i18n_scope
|
|
61
|
+
keys = model_class.lookup_ancestors.flat_map do |klass|
|
|
62
|
+
i18n_key = klass.model_name.i18n_key
|
|
63
|
+
[:"#{scope}.errors.models.#{i18n_key}.attributes.#{attribute}.#{key}",
|
|
64
|
+
:"#{scope}.errors.models.#{i18n_key}.#{key}"]
|
|
65
|
+
end
|
|
66
|
+
keys << :"#{scope}.errors.messages.#{key}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def interpolations
|
|
70
|
+
names = {}
|
|
71
|
+
names[:model] = model_class.model_name.human if model_class.respond_to?(:model_name)
|
|
72
|
+
names[:attribute] = model_class.human_attribute_name(attribute) if model_class.respond_to?(:human_attribute_name)
|
|
73
|
+
PLACEHOLDERS.merge(names)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -178,6 +178,7 @@ files:
|
|
|
178
178
|
- lib/forms/toggle.rb
|
|
179
179
|
- lib/forms/validations/introspector.rb
|
|
180
180
|
- lib/forms/validations/manual_rules.rb
|
|
181
|
+
- lib/forms/validations/message.rb
|
|
181
182
|
- lib/forms/wrapped_input.rb
|
|
182
183
|
- lib/phlex-forms.rb
|
|
183
184
|
- lib/phlex_forms.rb
|
|
@@ -188,6 +189,7 @@ files:
|
|
|
188
189
|
- lib/phlex_forms/inference.rb
|
|
189
190
|
- lib/phlex_forms/inline_icons.rb
|
|
190
191
|
- lib/phlex_forms/rubocop.rb
|
|
192
|
+
- lib/phlex_forms/shareable.rb
|
|
191
193
|
- lib/phlex_forms/theme.rb
|
|
192
194
|
- lib/phlex_forms/version.rb
|
|
193
195
|
- lib/rubocop/phlex_forms/cop/legacy_form_method.rb
|