Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
derives the checked set from the model's current value, and renders under both
themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.

### Changed

- **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
the bundled controllers now emit `validations--presence`, `validations--length`,
… (and the `validations--form` coordinator) so
`lazyLoadControllersFrom("phlex_forms/controllers")` resolves them to their
shipped path `phlex_forms/controllers/validations/*_controller` β€” previously
`forms--validations--*` derived `.../forms/validations/*`, which 404'd and the
controllers never connected (issue #12). The `data-validations--*` binding
attributes and the `invalidate:validations` event changed to match. Hosts that
registered `forms--validations--*` explicitly must update the identifier.

### Fixed

- **`f.Radio` / `Field#radio` rendered the model's current value on every radio
instead of each radio's own value**: `field_attributes` carried `value:
field_value` and was splatted after the explicit positional value, clobbering
it β€” a new record lost the value entirely, an edit form gave every radio the
same value. `radio` now drops `field_attributes`' `value` (issue #13).
- **`Form(validate: true)` never fired client-side validation on submit**: the
coordinator controller was attached but no `data-action` wired its `onSubmit`
handler, so submitting an invalid form was not blocked. `apply_validation_coordinator`
now emits `submit->forms--validations--form#onSubmit` (joined with any
now emits `submit->validations--form#onSubmit` (joined with any
caller-supplied `data-action`).
- **`fields_for` iterated a Hash-backed association (JSONB), emitting bogus
indices**: a Hash responds to `#each_with_index`, so a JSONB column rendered
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
lazyLoadControllersFrom("phlex_forms/controllers", application)
```

The emitted identifiers are `validations--presence`, `validations--length`, … (and
the form-level `validations--form` coordinator), which `lazyLoadControllersFrom`
resolves to `phlex_forms/controllers/validations/*_controller` β€” the path the gem
ships them at.

Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.

## Nested attributes, collections & escape valves
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Controller } from "@hotwired/stimulus"
// reads from data attributes; the base class only knows about the
// `allowBlank` / `allowNil` short-circuits.
export class FieldValidatorController extends Controller {
// `error` is opt-in: callers that pre-render a `<p data-forms--validations--error-target="error">`
// `error` is opt-in: callers that pre-render a `<p data-validations--<validator>-target="error">`
// get a stable slot the controller toggles. Inputs without an
// explicit target still work β€” the controller lazily creates one
// adjacent to the input below.
Expand All @@ -24,12 +24,12 @@ export class FieldValidatorController extends Controller {
// directly to <input>, <textarea>, <select> via the form builder.
connect() {
this.element.addEventListener("blur", this.onBlur)
this.element.addEventListener("invalidate:forms--validations", this.onValidate)
this.element.addEventListener("invalidate:validations", this.onValidate)
}

disconnect() {
this.element.removeEventListener("blur", this.onBlur)
this.element.removeEventListener("invalidate:forms--validations", this.onValidate)
this.element.removeEventListener("invalidate:validations", this.onValidate)
}

onBlur = () => {
Expand Down Expand Up @@ -150,7 +150,7 @@ export class FieldValidatorController extends Controller {
// adjacent to the input. Keeps the framework usable on plain
// forms that haven't opted into the static-target convention.
const id = this.element.id || this.element.name
const selector = `[data-forms--validations--error="${id}"]`
const selector = `[data-validations--error="${id}"]`
const existing = this.element.closest("form")?.querySelector(selector)
if (existing) return existing
if (!create) return null
Expand All @@ -159,7 +159,7 @@ export class FieldValidatorController extends Controller {
container.className = "text-error text-sm mt-1"
// `dataset` rejects keys with `--`, so we set the attribute
// directly. The CSS selector still matches.
container.setAttribute("data-forms--validations--error", id)
container.setAttribute("data-validations--error", id)
this.element.insertAdjacentElement("afterend", container)
return container
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
// Form-level coordinator for the validation framework. Sits on the
// <form> element and intercepts `submit` to broadcast a synchronous
// validation event to every field. Each field controller listens
// for `invalidate:forms--validations`, runs its check, and (on
// for `invalidate:validations`, runs its check, and (on
// failure) appends its error to the event's `detail.errors` array.
// If anything ended up in that array, we cancel the submit and
// focus the first invalid field.
Expand All @@ -23,11 +23,11 @@ export default class extends Controller {
fields.forEach((field) => {
const validators = (field.dataset.controller || "")
.split(/\s+/)
.filter((c) => c.startsWith("forms--validations--") && c !== "forms--validations--form")
.filter((c) => c.startsWith("validations--") && c !== "validations--form")
if (validators.length === 0) return

field.dispatchEvent(
new CustomEvent("invalidate:forms--validations", {
new CustomEvent("invalidate:validations", {
detail: { errors },
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default class extends FieldValidatorController {
// Stimulus walks the prototype chain to accumulate `static values`
// and `static targets`, so we only declare the validator-specific
// ones here. `counter` is opt-in: callers that pre-render
// `<span data-forms--validations--length-target="counter">` get a
// `<span data-validations--length-target="counter">` get a
// stable slot the controller updates. Inputs without an explicit
// target still get a lazily-injected one (see counterElement).
static targets = ["counter"]
Expand Down Expand Up @@ -80,15 +80,15 @@ export default class extends FieldValidatorController {

// Fallback: cache-by-id lookup or lazy injection for plain forms.
const id = this.element.id || this.element.name
const selector = `[data-forms--validations--counter="${id}"]`
const selector = `[data-validations--counter="${id}"]`
const existing = this.element.closest("form")?.querySelector(selector)
if (existing) return existing
if (!create) return null

const counter = document.createElement("span")
counter.className = "text-xs text-base-content/60 ml-auto"
// `dataset` rejects keys containing `--`; use the raw attribute API.
counter.setAttribute("data-forms--validations--counter", id)
counter.setAttribute("data-validations--counter", id)
this.element.insertAdjacentElement("afterend", counter)
return counter
}
Expand Down
2 changes: 1 addition & 1 deletion docs/app/views/docs/pages/client_validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def turning_it_on
DocsUI::Section("Turning it on") do
md <<~'MD'
`validate: true` introspects the model's validators and emits
`data-forms--validations--*` bindings per field, plus a form-level
`data-validations--*` bindings per field, plus a form-level
submit coordinator. The form gets `novalidate` β€” the shipped Stimulus
controllers own error display, so you never see the inconsistent native
browser bubbles.
Expand Down
6 changes: 5 additions & 1 deletion lib/forms/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,15 @@ def toggle(*modifiers, **)
end

def radio(value, *modifiers, **options)
# field_attributes carries value: field_value (the model's CURRENT value).
# Drop it here so it can't clobber this radio's own positional value β€”
# otherwise every radio in the group renders the model's value (issue #13).
attrs = field_attributes.except(:value).merge(options)
theme[:radio].new(
*modifiers,
value:,
checked: field_value == value,
**field_attributes.merge(options).merge(id: "#{field_id}_#{value}")
**attrs.merge(id: "#{field_id}_#{value}")
)
end
alias radio_button radio
Expand Down
6 changes: 4 additions & 2 deletions lib/forms/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,15 @@ def form_attributes
# UI (novalidate) β€” the Stimulus layer owns error display.
def apply_validation_coordinator(attrs)
existing = attrs[:data][:controller].to_s
coordinator = "forms--validations--form"
# Derive the coordinator identifier from the introspector's prefix so the
# form-level and field-level controllers can never drift (issue #12).
coordinator = "#{Forms::Validations::Introspector::CONTROLLER_PREFIX}--form"
attrs[:data][:controller] = [existing, coordinator].reject(&:empty?).join(" ")
# Wire the coordinator's submit handler. Without this data-action the
# controller connects but onSubmit never fires, so an invalid form is not
# blocked client-side (issue #11). Joined with any caller-supplied action.
existing_action = attrs[:data][:action].to_s
submit_action = "submit->forms--validations--form#onSubmit"
submit_action = "submit->#{coordinator}#onSubmit"
attrs[:data][:action] = [existing_action, submit_action].reject(&:empty?).join(" ")
attrs[:novalidate] = true
end
Expand Down
14 changes: 9 additions & 5 deletions lib/forms/validations/introspector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ module Validations
# validation remains authoritative β€” the client side just
# shortens the loop for the common cases.
class Introspector
CONTROLLER_PREFIX = "forms--validations"
# The Stimulus identifier prefix. Kept in sync with the file path the
# controllers ship at (phlex_forms/controllers/validations/*_controller),
# so lazyLoadControllersFrom("phlex_forms/controllers") resolves
# `validations--length` β†’ .../validations/length_controller (issue #12).
CONTROLLER_PREFIX = "validations"

# Validators we know how to mirror. Keys are the short class
# name (without namespace), values are the controller suffix
Expand Down Expand Up @@ -59,9 +63,9 @@ def initialize(model_class)

# Returns a hash of the shape:
# {
# controller: "forms--validations--presence forms--validations--length",
# forms__validations__presence_required_value: "true",
# forms__validations__length_maximum_value: "60",
# controller: "validations--presence validations--length",
# validations__presence_required_value: "true",
# validations__length_maximum_value: "60",
# }
#
# Returns {} when no supported validators are present.
Expand Down Expand Up @@ -95,7 +99,7 @@ def data_attributes_for(attribute)

# Phlex turns underscores in `data:` hash keys into hyphens
# in the rendered HTML. To produce a key like
# `data-forms--validations--length-maximum-value` from a
# `data-validations--length-maximum-value` from a
# Ruby symbol we need every "-" represented as "__" in the
# symbol. That's what this method builds.
def data_key(suffix, key)
Expand Down
50 changes: 44 additions & 6 deletions spec/forms/components_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,44 @@ def render_form_via(model, **args, &block)
end
end

describe "Radio (issue #13)" do
it "keeps each radio's own value instead of the model's current value" do
# field_attributes carries value: field_value; splatted after the explicit
# radio value it used to clobber it, so every radio submitted the model's
# value (or, for a new record, nothing).
user = build_model(:user, role: nil)

output = render_form(user) do |f|
f.Radio(:role, "manager")
f.Radio(:role, "member")
end

expect(output).to include('value="manager"')
expect(output).to include('value="member"')
expect(output).to include('name="user[role]"')
end

it "checks the radio whose value matches the model, not all of them" do
user = build_model(:user, role: "manager")

output = render_form(user) do |f|
f.Radio(:role, "manager")
f.Radio(:role, "member")
end

expect(output).to match(/value="manager"[^>]*checked/)
expect(output).not_to match(/value="member"[^>]*checked/)
end

it "still lets an explicit value: option win" do
user = build_model(:user, role: nil)

output = render_form(user) { |f| f.Radio(:role, "manager", value: "override") }

expect(output).to include('value="override"')
end
end

describe "fields_for (has_many nested attributes)" do
it "renders indexed nested attribute names" do
child = Class.new do
Expand Down Expand Up @@ -75,31 +113,31 @@ def self.name = "LineItem"
it "attaches the coordinator + novalidate when validate: true" do
output = render_form(partner, validate: true, &:submit)
expect(output).to include("novalidate")
expect(output).to include("forms--validations--form")
expect(output).to include("validations--form")
end

it "wires the submit handler via a data-action (issue #11)" do
# Without this, the coordinator connects but onSubmit is never invoked, so
# submitting an invalid form is not blocked client-side.
output = render_form(partner, validate: true, &:submit)
expect(output).to include("submit->forms--validations--form#onSubmit")
expect(output).to include("submit->validations--form#onSubmit")
end

it "preserves a caller-supplied data-action alongside the coordinator action" do
output = render_form(partner, validate: true, data: { action: "click->thing#go" }, &:submit)
expect(output).to include("click->thing#go")
expect(output).to include("submit->forms--validations--form#onSubmit")
expect(output).to include("submit->validations--form#onSubmit")
end

it "wires per-field validator controllers from the model" do
output = render_form(partner, validate: true) { |f| f.field(:title) }
expect(output).to include("forms--validations--presence forms--validations--length")
expect(output).to include('data-forms--validations--length-maximum-value="60"')
expect(output).to include("validations--presence validations--length")
expect(output).to include('data-validations--length-maximum-value="60"')
end

it "opts a field out with validate: false" do
output = render_form(partner, validate: true) { |f| f.field(:title, validate: false) }
expect(output).not_to include("forms--validations--presence")
expect(output).not_to include("validations--presence")
end
end
end
28 changes: 14 additions & 14 deletions spec/forms/validations/introspector_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,41 +25,41 @@ def data_for(attribute, &validations)

it "emits the presence controller and required value" do
data = data_for(:title) { validates :title, presence: true }
expect(data[:controller]).to eq("forms--validations--presence")
expect(data[:forms__validations__presence_required_value]).to eq("true")
expect(data[:controller]).to eq("validations--presence")
expect(data[:validations__presence_required_value]).to eq("true")
end

it "emits length min/max values" do
data = data_for(:title) { validates :title, length: { minimum: 3, maximum: 60 } }
expect(data[:forms__validations__length_minimum_value]).to eq("3")
expect(data[:forms__validations__length_maximum_value]).to eq("60")
expect(data[:validations__length_minimum_value]).to eq("3")
expect(data[:validations__length_maximum_value]).to eq("60")
end

it "consolidates multiple validators into one space-joined controller list" do
data = data_for(:title) { validates :title, presence: true, length: { maximum: 60 } }
expect(data[:controller]).to eq("forms--validations--presence forms--validations--length")
expect(data[:controller]).to eq("validations--presence validations--length")
end

it "converts a Ruby regex to a JS-compatible pattern (\\A/\\z -> ^/$)" do
data = data_for(:title) { validates :title, format: { with: /\A[a-z]+\z/i } }
expect(data[:forms__validations__format_pattern_value]).to eq("^[a-z]+$")
expect(data[:forms__validations__format_flags_value]).to eq("i")
expect(data[:validations__format_pattern_value]).to eq("^[a-z]+$")
expect(data[:validations__format_flags_value]).to eq("i")
end

it "emits numericality bounds" do
data = data_for(:count) { validates :count, numericality: { greater_than_or_equal_to: 0, only_integer: true } }
expect(data[:forms__validations__numericality_greater_than_or_equal_to_value]).to eq("0")
expect(data[:forms__validations__numericality_only_integer_value]).to eq("true")
expect(data[:validations__numericality_greater_than_or_equal_to_value]).to eq("0")
expect(data[:validations__numericality_only_integer_value]).to eq("true")
end

it "emits inclusion list as JSON" do
data = data_for(:kind) { validates :kind, inclusion: { in: %w[a b c] } }
expect(data[:forms__validations__inclusion_in_value]).to eq('["a","b","c"]')
expect(data[:validations__inclusion_in_value]).to eq('["a","b","c"]')
end

it "emits the confirmation match attribute" do
data = data_for(:password) { validates :password, confirmation: true }
expect(data[:forms__validations__confirmation_match_value]).to eq("password_confirmation")
expect(data[:validations__confirmation_match_value]).to eq("password_confirmation")
end

it "skips validators with :if / :unless / :on (need server context)" do
Expand All @@ -74,9 +74,9 @@ def data_for(attribute, &validations)
describe "paired with ManualRules" do
it "produces the same data shape from an inline rules hash" do
data = Forms::Validations::ManualRules.new(length: { maximum: 30 }, presence: true).data_attributes
expect(data[:controller]).to include("forms--validations--length")
expect(data[:controller]).to include("forms--validations--presence")
expect(data[:forms__validations__length_maximum_value]).to eq("30")
expect(data[:controller]).to include("validations--length")
expect(data[:controller]).to include("validations--presence")
expect(data[:validations__length_maximum_value]).to eq("30")
end
end
end
Loading