Skip to content

fix: JSONB fields_for + validate submit wiring; feat: checkbox_group (#10 #11 #9) - #14

Merged
mhenrixon merged 3 commits into
mainfrom
fix-issues-10-11-9
Jul 11, 2026
Merged

fix: JSONB fields_for + validate submit wiring; feat: checkbox_group (#10 #11 #9)#14
mhenrixon merged 3 commits into
mainfrom
fix-issues-10-11-9

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

Closes three open issues in one branch (three commits) — bundled because all
three touch lib/forms/form.rb, so separate branches would just conflict there.

Commit Issue Type
fix(shell): fields_for treats a Hash-backed association as a single scope #10 🐛 Bug
fix(shell): wire the validation coordinator's submit handler #11 🐛 Bug
feat(components): checkbox_group verb for array-valued associations #9 ✨ Feature

#10fields_for iterated a Hash (JSONB) association

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].
Now only genuine collections iterate — Enumerable && !Hash covers Array and
ActiveRecord::Relation without a hard ActiveRecord dependency.

#11Form(validate: true) never blocked submit

The coordinator controller and novalidate were attached, but no data-action
wired its onSubmit handler — the controller connected yet onSubmit never
fired. apply_validation_coordinator now emits
submit->forms--validations--form#onSubmit, joined with any caller-supplied
data-action.

#9checkbox_group verb (the tag/facet-picker shape)

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

One array-valued field name (user[tag_ids][]) + a leading empty-array hidden
field, checked state derived from the model's current value, rendered under both
themes. The daisy leaf delegates each box to DaisyUI::Checkbox (literal size
class); the Plain twin ships zero styling. variant: is layout-only, no JS.

Test Coverage

Full suite: 141 examples, 0 failures · rubocop lib spec clean.

Deviations & judgment calls

  • Bug: Form(validate: true) coordinator never wires its submit handler #11 fix location: the issue recommended option 2 (self-wire in the JS
    controller's connect()). I chose option 1 (emit the data-action from
    Ruby) — it's the only surface the existing test suite can prove (no JS test
    harness), and it's idiomatic Stimulus. Doing both would double-bind
    onSubmit (fire twice), so form_controller.js is left unchanged; its
    existing onSubmit class-field handler is reachable via the emitted action.
  • Bug: fields_for iterates a Hash-backed association (JSONB), emitting bogus indices #10 predicate: the issue suggested Array || ActiveRecord::Relation. I
    used Enumerable && !Hash — covers AR::Relation (includes Enumerable)
    without a hard ActiveRecord dependency (the gem must boot without AR), and
    still excludes the Hash-backed JSONB scope. A single record / Struct / PORO is
    not Enumerable → single scope, unchanged.
  • Feature: a checkbox-group builder verb for array-valued associations #9 checked-set matching: the model's current value (record.tag_ids) is
    already the raw values, so the checked set compares against them directly —
    value: is not re-applied to them. This matches existing collection_check_boxes
    behavior (no type coercion; a string/integer id mismatch is a known, gem-wide
    v1 limitation).
  • Feature: a checkbox-group builder verb for array-valued associations #9 size class: hand-emitting "checkbox-#{size}" would break the
    no-interpolated-class rule, so the daisy leaf delegates the checkbox markup to
    DaisyUI::Checkbox with a modifier symbol → literal registered class. The
    Plain twin overrides render_checkbox to a bare <input>.
  • Feature: a checkbox-group builder verb for array-valued associations #9 required:: a group sharing one array name can't satisfy the browser's
    required on any single box, so render_field_input drops it for
    :checkbox_group (validate server-side) — same posture as :tags.

…cope

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
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant