From 67710143f7461ceb5046bc1f51f71cfe144e2721 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 11 Jul 2026 08:01:12 +0200 Subject: [PATCH 1/3] fix(shell): fields_for treats a Hash-backed association as a single scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Hash responds to #each_with_index, so a JSONB column rendered with nested_attributes: false was iterated as [key, value] pairs, emitting scope[assoc][0][field], [1], ... instead of a single scope[assoc][field]. Only genuine collections (Enumerable, not Hash) iterate now — Enumerable covers Array and ActiveRecord::Relation without a hard AR dependency; a Hash / single record / Struct / PORO falls through to the single-scope branch. ## Test Coverage - treats a Hash-backed association as a single nested scope, not a collection (asserts name="record[profile][phone]", no [0]/[1] indices) ## Verification - [x] bundle exec rubocop lib spec passes - [x] bundle exec rspec passes Refs #10 --- lib/forms/form.rb | 24 +++++++++++++++++++++++- spec/forms/form_spec.rb | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/forms/form.rb b/lib/forms/form.rb index 0ad159a..01df2d3 100644 --- a/lib/forms/form.rb +++ b/lib/forms/form.rb @@ -104,7 +104,7 @@ def fields_for(association_name, model = nil, nested_attributes: true, &) attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key - if associated.respond_to?(:each_with_index) + if collection?(associated) associated.each_with_index do |item, index| yield build_fields_for("#{base_scope}[#{index}]", item) end @@ -133,6 +133,13 @@ def collection_check_boxes(name, collection, value_method, text_method, &) end end + # A batched checkbox group for an array-valued field (issue #9). Delegates to + # Field#checkbox_group, which derives the checked set from the model. + # f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name, variant: :pill) + def checkbox_group(name, collection, **) + render field_object(name).checkbox_group(collection, **) + end + # Rails-style collection_select over an enumerable of records. def collection_select(name, collection, value_method, text_method, options = {}, html_options = {}) choices = collection.map do |item| @@ -156,6 +163,15 @@ def field_value(name) = field_object(name).field_value private + # A genuine has_many collection (Array / ActiveRecord::Relation), NOT a + # Hash-backed nested scope. A Hash responds to #each_with_index but is a + # single nested record (a JSONB column), so iterating it would emit bogus + # positional indices — scope[assoc][0][field] — instead of scope[assoc][field] + # (issue #10). Enumerable-but-not-Hash covers Relations without requiring AR. + def collection?(associated) + associated.is_a?(Enumerable) && !associated.is_a?(Hash) + end + def build_fields_for(scope, item) Forms::FieldsForBuilder.new( model: item, @@ -196,6 +212,12 @@ def apply_validation_coordinator(attrs) existing = attrs[:data][:controller].to_s coordinator = "forms--validations--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" + attrs[:data][:action] = [existing_action, submit_action].reject(&:empty?).join(" ") attrs[:novalidate] = true end diff --git a/spec/forms/form_spec.rb b/spec/forms/form_spec.rb index 546c862..ba4279d 100644 --- a/spec/forms/form_spec.rb +++ b/spec/forms/form_spec.rb @@ -239,6 +239,24 @@ def polymorphic? = false expect(output).not_to include("settings_attributes") end + it "treats a Hash-backed association as a single nested scope, not a collection" do + # A JSONB column returns a populated Hash. A Hash responds to + # #each_with_index, so the old branch iterated it as [key, value] pairs, + # emitting name="record[profile][0][phone]", [1], ... (issue #10). + profile = { "phone" => "555", "city" => "NYC" } + record = build_model(:record, profile:) + + output = render_form(record) do |f| + f.fields_for(:profile, profile, nested_attributes: false) do |pf| + pf.field(:phone) + end + end + + expect(output).to include('name="record[profile][phone]"') + expect(output).not_to include("record[profile][0]") + expect(output).not_to include("record[profile][1]") + end + it "exposes field_value alongside field_name and field_id" do captured = nil render_form(user) do |f| From cc756cac4cd508c1f2f36fa9c5f564a1b06057d1 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 11 Jul 2026 08:01:19 +0200 Subject: [PATCH 2/3] fix(shell): wire the validation coordinator's submit handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Form(validate: true) attached the forms--validations--form controller and novalidate, but no data-action wired its onSubmit handler — the controller connected yet onSubmit never fired, so submitting an invalid form was not blocked client-side. apply_validation_coordinator now emits `submit->forms--validations--form#onSubmit`, joined with any caller-supplied data-action. This is the idiomatic Stimulus wiring (an action binds the handler) and the only surface the Ruby suite can prove; the existing onSubmit class-field handler in form_controller.js is left unchanged (self-wiring in connect() would double-bind and fire onSubmit twice). ## Test Coverage - wires the submit handler via a data-action - preserves a caller-supplied data-action alongside the coordinator action ## Verification - [x] bundle exec rubocop lib spec passes - [x] bundle exec rspec passes Refs #11 --- spec/forms/components_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/forms/components_spec.rb b/spec/forms/components_spec.rb index 87c4cdd..0fae54b 100644 --- a/spec/forms/components_spec.rb +++ b/spec/forms/components_spec.rb @@ -78,6 +78,19 @@ def self.name = "LineItem" expect(output).to include("forms--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") + 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") + 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") From 7a24e4475d635325cb121d0b834161c134c8b794 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 11 Jul 2026 08:01:35 +0200 Subject: [PATCH 3/3] feat(components): checkbox_group verb for array-valued associations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched "tag/facet picker" shape: a set of checkboxes sharing one array-valued field name (scope[name][]) with a leading empty-array hidden field, checked state derived from the model's current value matched by each item's resolved value:. Rendered under both themes. f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name, variant: :pill, size: :sm) f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id, label: :name - Forms::CheckboxGroup delegates each box's markup to DaisyUI::Checkbox so the size modifier resolves to a literal, scanner-visible class; the pill variant styles the active chip via Tailwind has-[:checked]: (no JS). - Forms::Plain::CheckboxGroup inherits the binding contract and overrides only the rendering seams — bare inputs, zero styling, aria-invalid on the group. - Field#checkbox_group owns the model binding (value:/label: as Symbol or Proc); Form#checkbox_group + render_field_input dispatch + the :checkbox_group theme role in both maps give full parity with select/tag_field. ## Test Coverage - shared array name across every checkbox + empty-array hidden field - checked set derived from the model (value 1,3 checked; 2 not) - option id from field id + value; label: proc with slug fallback - label HTML-escaped (no injection); size: maps to checkbox-sm - field inference (as: :checkbox_group); plain-theme parity (zero classes) - :checkbox_group role mapped in both Theme.daisy and Theme.plain ## Verification - [x] bundle exec rubocop lib spec passes - [x] bundle exec rspec passes Refs #9 --- CHANGELOG.md | 21 ++++++ README.md | 18 ++++- lib/forms/checkbox_group.rb | 85 ++++++++++++++++++++++ lib/forms/field.rb | 28 ++++++++ lib/forms/plain/checkbox_group.rb | 27 +++++++ lib/phlex_forms/builder.rb | 11 +++ lib/phlex_forms/theme.rb | 2 + spec/forms/checkbox_group_spec.rb | 114 ++++++++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 lib/forms/checkbox_group.rb create mode 100644 lib/forms/plain/checkbox_group.rb create mode 100644 spec/forms/checkbox_group_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eabe42..3d0490c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`checkbox_group` — batched checkbox group for array-valued fields** (the + tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id, + label: :name, variant: :pill, size: :sm)`, or via field inference + (`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`). + Shares one array-valued field name with a leading empty-array hidden field, + derives the checked set from the model's current value, and renders under both + themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS. + +### Fixed + +- **`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 + 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 + with `nested_attributes: false` produced `scope[assoc][0][field]`, `[1]`, … + instead of a single `scope[assoc][field]`. It is now treated as a single + nested scope; only genuine collections (Enumerable, not Hash) iterate. + - **`Forms::Base` declarative form classes**: subclass, declare fields in `#fields` where `self` IS the form (bare `field :email`, no `f.` prefix), render with `render UserForm.new(model: @user)`. Class-level `form_options` diff --git a/README.md b/README.md index 8f84d4c..f7e10cc 100644 --- a/README.md +++ b/README.md @@ -351,13 +351,29 @@ f.fields_for(:settings, nested_attributes: false) do |s| end f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b| - render b.check_box + render b.check_box # per-item control, full custom layout render b.label end +# The batched "tag/facet picker" shape: one array-valued field name, checked +# state derived from the model (record.tag_ids), sensible defaults, no block. +f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name) +f.checkbox_group(:tag_ids, Tag.all, value: :id, + label: ->(t) { t.name.presence || t.slug }, # Symbol method or Proc + variant: :pill, # :stack (default) | :inline | :pill + size: :sm) # daisyUI checkbox size +# ...or through field inference: +f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id, label: :name + f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…") ``` +`checkbox_group` submits an array param (`user[tag_ids][]`) with a leading +empty-array hidden field, so deselecting everything still submits. The checked +set comes from the model's current value matched by each item's resolved +`value:` — re-rendering an edit form pre-checks the right boxes. The `:pill` +variant styles the active chip with Tailwind's `has-[:checked]:` (no JS). + `Form(model: @item, scope: false)` emits **bare** field names (`name="quantity"`) — the shape phlex-reactive row editors and `