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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<template>`-cloned rows need. External widgets bind through the public
Expand Down
85 changes: 85 additions & 0 deletions lib/forms/checkbox_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module Forms
# A batched checkbox group for an array-valued field (the tag/facet-picker
# shape). Renders a set of checkboxes sharing ONE array-valued field name
# (`user[tag_ids][]`), with a leading empty-array hidden field so an empty
# selection still submits, and derives each box's checked state from the
# resolved value: of its item against the model's current set.
#
# f.checkbox_group(:tag_ids, Tag.all, value: :id,
# label: ->(t) { t.name.presence || t.slug }, variant: :pill, size: :sm)
#
# value: method or proc -> the submitted value of each item (default :id)
# label: method or proc -> the visible text of each item (default :to_s)
# variant: :stack (default) | :inline | :pill β€” layout only, zero JS
# size: daisyUI checkbox size modifier (:xs :sm :md :lg :xl)
#
# The checked set is passed in pre-resolved by the builder (Field#checkbox_group
# matches the model's current value by each item's resolved value:), so the
# component itself stays presentation-only. Each checkbox's markup is delegated
# to DaisyUI::Checkbox so its size class is a literal, scanner-visible token.
class CheckboxGroup < Phlex::HTML
# variant -> the container class. The pill variant uses Tailwind's
# `has-[:checked]:` to style the active label with no JS.
VARIANT_CLASSES = {
stack: "flex flex-col gap-2",
inline: "flex flex-wrap gap-4",
pill: "flex flex-wrap gap-2"
}.freeze

def initialize(name:, id:, options:, variant: :stack, size: nil, error: false, **attributes)
@name = name # already the array name: "user[tag_ids][]"
@id = id
@options = options # [{ value:, label:, checked:, id: }, ...]
@variant = variant
@size = size
@error = error
@attributes = attributes
super()
end

def view_template
# Empty-array hidden field so an empty selection still submits (the same
# convention as collection_check_boxes).
input(type: "hidden", name: @name, value: "")

div(class: group_classes, role: "group", "aria-invalid": @error || nil) do
@options.each { |option| item(option) }
end
end

private

def item(option)
label(class: item_classes) do
render_checkbox(option)
span(class: item_label_classes) { option[:label].to_s }
end
end

# Delegate the checkbox markup to the daisyui gem so the size modifier
# resolves to a literal class (checkbox-sm, ...) the CSS scanner can see.
def render_checkbox(option)
render DaisyUI::Checkbox.new(
*checkbox_modifiers,
name: @name, id: option[:id], value: option[:value],
checked: option[:checked] || nil, class: @attributes[:class]
)
end

def checkbox_modifiers = @size ? [@size] : []

# --- styling seams (the Plain twin overrides these to bare/empty) ---

def group_classes = VARIANT_CLASSES.fetch(@variant, VARIANT_CLASSES[:stack])

def item_classes
return "label cursor-pointer gap-2 justify-start" unless @variant == :pill

"badge badge-lg cursor-pointer gap-2 has-[:checked]:badge-primary"
end

def item_label_classes = nil
end
end
28 changes: 28 additions & 0 deletions lib/forms/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ def tag_field(*modifiers, suggestions: [], **)
)
end

# A model-bound checkbox group over a collection. Shares one array-valued
# field name (`scope[name][]`) and derives the checked set from the model's
# current value, matched by each item's resolved value: (issue #9).
#
# field.checkbox_group(Tag.all, value: :id, label: ->(t) { t.name })
#
# value:/label: are a method name (Symbol) or a proc taking the item.
def checkbox_group(collection, value: :id, label: :to_s, **)
# The model's current value is already the raw values (e.g. record.tag_ids
# => [1, 3]), so compare against them directly β€” don't re-resolve value:.
selected = Array(field_value)
opts = Array(collection).map do |item|
item_value = resolve_item(item, value)
{
value: item_value,
label: resolve_item(item, label),
checked: selected.include?(item_value),
id: "#{field_id}_#{item_value}"
}
end
theme[:checkbox_group].new(name: "#{field_name}[]", id: field_id, options: opts, error: invalid?, **)
end

def label(text = nil, *modifiers, **, &block)
theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
end
Expand Down Expand Up @@ -229,6 +252,11 @@ def conditional?(validator)
validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
end

# value:/label: for checkbox_group: a Proc taking the item, or a method name.
def resolve_item(item, accessor)
accessor.respond_to?(:call) ? accessor.call(item) : item.public_send(accessor)
end

def field_attributes
{ name: field_name, id: field_id, value: field_value, error: invalid? }
end
Expand Down
24 changes: 23 additions & 1 deletion lib/forms/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions lib/forms/plain/checkbox_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Forms
module Plain
# Bare checkbox group. Inherits the whole binding contract from
# Forms::CheckboxGroup (the shared array name, the empty-array hidden field,
# the per-item checked state) and overrides only the rendering seams to ship
# zero daisyUI classes. The invalid state rides aria-invalid on the group,
# never a color class.
class CheckboxGroup < Forms::CheckboxGroup
private

# Bare <input type=checkbox>, no DaisyUI delegation, no styling classes.
def render_checkbox(option)
input(
type: "checkbox", name: @name, id: option[:id],
value: option[:value], class: @attributes[:class],
checked: option[:checked] || nil
)
end

def group_classes = @attributes[:class]
def item_classes = nil
def item_label_classes = nil
end
end
end
11 changes: 11 additions & 0 deletions lib/phlex_forms/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ def render_field_input(fo, name, as, modifiers, choices:, required:, **)
when :textarea then render fo.textarea(*modifiers, required:, **)
when :toggle then render fo.toggle(*modifiers, required:, **)
when :checkbox then render fo.checkbox(*modifiers, required:, **)
# required: doesn't apply to a group of checkboxes sharing one array name;
# validate the selection server-side instead.
when :checkbox_group then render_checkbox_group(fo, **)
when :file then render fo.file(*modifiers, required:, **)
when :hidden then render fo.hidden(**)
when :rich_textarea then render fo.rich_textarea(*modifiers, **)
Expand All @@ -177,6 +180,14 @@ def render_field_input(fo, name, as, modifiers, choices:, required:, **)
end
end

# `f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value:, label:`.
# collection: names the enumerable; the rest (value:/label:/variant:/size:)
# passes through to Field#checkbox_group. (choices:/required: are consumed by
# render_field_input's own signature, so they never reach here.)
def render_checkbox_group(fo, collection: [], **)
render fo.checkbox_group(collection, **)
end

def materialize_choices(choices)
choices.respond_to?(:call) ? choices.call : choices
end
Expand Down
2 changes: 2 additions & 0 deletions lib/phlex_forms/theme.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def daisy
input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
checkbox_group: Forms::CheckboxGroup,
file: Forms::FileInput, wrapped_input: Forms::WrappedInput,
control: Forms::FormControl, label: Forms::Label,
field_error: Forms::FieldError, field_hint: Forms::FieldHint,
Expand All @@ -72,6 +73,7 @@ def plain
input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
checkbox_group: Forms::Plain::CheckboxGroup,
file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
control: Forms::Plain::Control, label: Forms::Plain::Label,
field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
Expand Down
Loading
Loading