Engineering

Requiring a Checkbox Group to Have at Least One Item Checked

Checkbox group with one item checked

Table of Contents

Share this article

One of the requirements we had in a recent project was to mark certain inputs as required and have the user shown a validation message in case the form was submitted without the required input filled in.

This wasn’t a huge problem, since almost all html input tags accept the required attribute and by setting that attribute, will automatically display a native validation message.

It even works with radio button groups too, where a radio button group can be setup to require one of the options to be selected before the form can be submittable.

We did run into some snags with checkbox groups however. MDN says that (and as we’ll show later) when you add the required attribute to a checkbox input tag, only that checkbox input tag becomes required.

That means that although we can mark individual checkboxes as required, we are not able to (at least with plain html) have a condition where at least one of the checkboxes in a group is required in order to submit the form.

<div>
	<form id="myForm">
    
    	<div>
            <input id="investigator" type="checkbox" value="Investigator" name="roles" required>
            <label for="investigator">Investigator</label>
        </div>
        
        <div>
            <input id="teamworker" type="checkbox" value="Teamworker" name="roles" required>
            <label for="teamworker">Teamworker</label>
        </div>
        
        <div>
            <input id="co-ordinator" type="checkbox" value="Co-ordinator" name="roles" required>
            <label for="co-ordinator">Co-ordinator</label>
        </div>
        
        <div><input type="submit" /></div>
        
	</form>
</div>

This is not what we want:

We want to first figure out our plan of action:

  • Get an array of checked items in a checkbox group.
  • If the checkbox group has no checked items, show an invalid validation message.
  • Otherwise allow the form to be submitted.
  • Verify this condition anytime the number of checked items changes.

We now have our first goal: be able to get an array of checked items in a checkbox group. Fortunately, there’s a browser API that will help us with this: the FormData interface.

Using the FormData API

FormData is an interface that represents a form field and its values as a set of key/value pairs. One way of using it would be to construct a new FormData object, append() some data onto an existing key, and then send() it all to an endpoint as multipart/form-data just as if an actual html form was submitted.

An alternative (and in this case the preferred) usage would be to construct a FormData object containing data from an existing html form and querying it.

What makes constructing the FormData this way useful is that when calling getAll() on the FormData, form fields with the same name are represented as an array of elements under a key with that name.

For example, if you have multiple form fields with the name “roles” and the checkbox “Investigator” and “Co-ordinator” were checked, then calling getAll("roles") will return something like:

['Investigator', 'Co-ordinator']

That means we can do something like:

const form = document.querySelector("#myForm")
const formData = new FormData(form)
const checkboxGroupName = "roles"
const checked = formData.getAll(checkboxGroupName)

If we instantiate a FormData object with the current form, then pass the getAll function the name of the checkbox group, we’re easily able to get the array of answers the user has checked.

Now all we need to do is count the number of elements in that array, and then allow the form to be submitted if there’s at least one element in the array; otherwise show an invalid validation message.

Stimulus JS

All we need to do now is to trigger the check and add some validation messages in case there isn’t at least one checked item in the checkbox group.

We’ll be using StimulusJS for this, due to its ease of use for progressively enhancing existing html, plus our project is using Rails so integrating this later would be simple.

We’ll need to also trigger the actual validation, so we’ll use a submit listener for that.

Here’s a prototype of this approach:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script type="module">
      import {
        Application,
        Controller,
      } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
      window.Stimulus = Application.start();

      Stimulus.register(
        "checkbox",
        class extends Controller {
          static values = {
            group: String,
          };
          static targets = ["input"];

          connect() {
            if (this.hasFormTarget) {
              this.formTarget.addEventListener(
                "submit",
                this.validateRequired.bind(this)
              );
            }
          }

          validateRequired(e) {
            if (this.hasInputTarget) {
              if (this.checkedItems().length < 1) {
                e.preventDefault();
                this.inputTarget.setCustomValidity("Please fill in this field");
                this.inputTarget.reportValidity();
              } else {
                this.inputTarget.setCustomValidity("");
                this.inputTarget.reportValidity();
              }
            }
          }

          checkedItems() {
            const formData = new FormData(this.inputTarget.form);
            const checked = formData
              .getAll(this.groupValue)
              .filter((str) => str !== "");

            return checked;
          }
        }
      );
    </script>
  </head>
  <body>
    <div>
      <form
        method="post"
        action="https://httpbin.org/post"
        data-controller="checkbox"
        data-checkbox-group-value="roles"
      >
        <div>
          <input
            id="investigator"
            type="checkbox"
            value="Investigator"
            name="roles"
            data-checkbox-target="input"
          />
          <label for="investigator">Investigator</label>
        </div>
        <div>
          <input
            id="teamworker"
            type="checkbox"
            value="Teamworker"
            name="roles"
          />
          <label for="teamworker">Teamworker</label>
        </div>
        <div>
          <input
            id="co-ordinator"
            type="checkbox"
            value="Co-ordinator"
            name="roles"
          />
          <label for="co-ordinator">Co-ordinator</label>
        </div>
        <div><input type="submit" /></div>
      </form>
    </div>
  </body>
</html>

For now we are using StimulusJS as a JavaScript Module as a quick way to include the StimulusJS library in our html page without having to sacrifice portability, as modern browsers already support native import statements just like how it is done in ES6.

In the html, we mark the form with some data attributes that allows StimulusJS to run a controller on the form element, mark the form as that controller’s target, as well as the input field that would eventually receive the validation message.

In this quick prototype the controller attaches an event listener to the form’s submit event so that when that event is fired, the number of checked items is calculated via the checkedItems() function and then either the form is prevented from completing the submission and a validation message is added, or the submission is allowed.

We are able to get a reference to the form because an input element retains a reference to its parent form.

Here’s an example result:

A Catch-22

At first glance, everything seems to be working. The happy-path of checking at least one item allows the form to be submitted. Trying to submit the form without checking at least one of the items triggers a validation message.

Problems arise however when triggering the validation message, then trying to rectify the situation by checking an item. You’d expect that trying to resubmit the form should pass since at least one of the items in the checkbox group has been checked, but that doesn’t seem to be the case here.

This can be very confusing, but the MDN documentation on the submit event can shed some light:

Note: Trying to submit a form that does not pass validation triggers an invalid event. In this case, the validation prevents form submission, and thus there is no submit event.

This means that marking an input field as invalid will make it so clicking the submit button does not actually fire a submit event. However, since our code to add or remove validation messages are only triggered on the submit event, there is no way to remove the validation message once an input field on the form is marked as invalid.

We’ll need to attach the validation logic differently; instead of only checking the items once the form is submitted, we can instead attach the logic on each of the checkboxes whenever they change. That way, we can clear the validation messages when at least one item in the checkbox group is checked.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script type="module">
      import {
        Application,
        Controller,
      } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
      window.Stimulus = Application.start();

      Stimulus.register(
        "checkbox",
        class extends Controller {
          static values = {
            group: String,
          };
          static targets = ["input"];

          connect() {
            if (this.hasInputTarget) {
              this.inputTargets.forEach((e) => {
                e.addEventListener("change", this.validateRequired.bind(this));
              });

              this.validateRequired();
            }
          }

          validateRequired(e) {
            console.log(this.checkedItems().length);
            if (this.checkedItems().length < 1) {
              this.inputTarget.setCustomValidity("Please fill in this field");
            } else {
              this.inputTargets.forEach((element) => {
                element.setCustomValidity("");
                element.reportValidity();
              });
            }
          }

          checkedItems() {
            const formData = new FormData(this.inputTarget.form);
            const checked = formData
              .getAll(this.groupValue)
              .filter((str) => str !== "");

            return checked;
          }
        }
      );
    </script>
  </head>
  <body>
    <div>
      <form
        method="post"
        action="https://httpbin.org/post"
        data-controller="checkbox"
        data-checkbox-target="form"
        data-checkbox-group-value="roles"
      >
        <div>
          <input
            id="investigator"
            type="checkbox"
            value="Investigator"
            name="roles"
            data-checkbox-target="input"
          />
          <label for="investigator">Investigator</label>
        </div>
        <div>
          <input
            id="teamworker"
            type="checkbox"
            value="Teamworker"
            name="roles"
            data-checkbox-target="input"
          />
          <label for="teamworker">Teamworker</label>
        </div>
        <div>
          <input
            id="co-ordinator"
            type="checkbox"
            value="Co-ordinator"
            name="roles"
            data-checkbox-target="input"
          />
          <label for="co-ordinator">Co-ordinator</label>
        </div>
        <div><input type="submit" /></div>
      </form>
    </div>
  </body>
</html>

The biggest difference is that we now attach the validateRequired() event handler to all of the checkbox input elements and let the handler fire on change events. This also means we calculate the validation condition every time a checkbox is checked (or unchecked).

Although the documentation example shows you have to call reportValidity() after you setCustomValidity(), we won’t do that here because we only want to show the validation message on actual submission, not when the user changes the checked items.

The call to reportValidity() when there are no checked items detected is thus removed, because calling it this early will cause the validation message to show even before the form is submitted (which is not what we want; we want the validation message to only fire on submit in order to reduce visual clutter).

We will however immediately call reportValidity() as soon as we clear the custom validation message because we want that message to disappear as soon as the user checks at least one item in the checkbox group.

Rails integration

At this point, we have a working proof of concept. Now we just need to integrate our solution with the actual project.

The first issue we faced was getting the form object in order to construct a FormData from it. It’s easy to get the form object in the proof of concept code because we can just add the Stimulus controller there, but our project is setup differently.

We have a somewhat wizard-like setup where there is a form view that renders various partials depending on the type of input field required (text_field, radio, checkbox, etc.) and a form section can have many input fields.

This means that if we put the checkbox controller on the form, there would be many form sections that won’t make use of this Stimulus controller; the current form already has various Stimulus controllers on it and we don’t really want to add another one that only acts on a specific partial; it can be quite difficult to maintain and debug especially if there are errors regarding execution dependencies (e.g. one controller modifies data that another controller was expecting to still be the same).

Fortunately, there is a way to get an input field’s parent form object. This way, we can attach the Stimulus controller on the actual checkbox input field, and instantiate the FormData object from one of those checkboxes.

The other issue we faced was generating the input field’s name dynamically. It was easy to hard-code the name in the proof of concept, but in our project we deal with input fields whose name change depending on the model it will be submitted to.

This is probably one of the many instances where we’re grateful that Rails is open source; we were able to trace how Rails itself generates an input field’s name from the documentation and use that.

rails g scaffold user name:string
rails g model role name:string
rails g model role_user role:references user:references
rails db:prepare db:seed

user.rb

class User < ApplicationRecord
  has_many :role_users
  has_many :roles, through: :role_users
end

role.rb

class Role < ApplicationRecord
  has_many :role_users
  has_many :users, through: :role_users
end

role_user.rb

class RoleUser < ApplicationRecord
  belongs_to :role
  belongs_to :user
end

seeds.rb

User.create(name: "test user")
[
  "Resource Investigator",
  "Teamworker",
  "Co-ordinator",
  "Plant",
  "Monitor Evaluator",
  "Specialist",
  "Shaper",
  "Implementer",
  "Complete Finisher"
].each { |role| Role.create(name: role) }
# team roles taken from https://www.belbin.com/about/belbin-team-roles

_form.html.erb

<%= form_with(model: user, class: "contents") do |form| %>
.
.
.
  <div
    class="my-5"
    data-controller="checkbox"
    data-checkbox-group-value='<%= form.field_name("role_ids", multiple: true) %>'
  >
    <%= form.collection_check_boxes(:role_ids, Role.all, :id, :name) do |cb| %>
      <div>
        <%= cb.label(class: "inline-flex items-center") do %>
          <%= cb.check_box(class: "form-checkbox", data: { "checkbox-target": "input" }) %>
          <span class="ml-2"><%= cb.text %></span>
        <% end %>
      </div>
    <% end %>
  </div>

  <div class="inline">
    <%= form.submit %>
  </div>
<% end %>

checkbox_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = {
    group: String,
  };
  static targets = ["input"];

  connect() {
    if (this.hasInputTarget) {
      this.inputTargets.forEach((e) => {
        e.addEventListener("change", this.validateRequired.bind(this));
      });

      this.validateRequired();
    }
  }

  validateRequired() {
    console.log(this.checkedItems().length);
    if (this.checkedItems().length < 1) {
      this.inputTarget.setCustomValidity("Please fill in this field");
    } else {
      this.inputTargets.forEach((element) => {
        element.setCustomValidity("");
        element.reportValidity();
      });
    }
  }

  checkedItems() {
    const formData = new FormData(this.inputTarget.form);
    const checked = formData
      .getAll(this.groupValue)
      .filter((str) => str !== "");

    return checked;
  }
}

Lessons Learned

  • A form with an input field that has a custom validity message is considered invalid, and forms with invalid input fields won’t trigger the submit event.
  • A FormData object can be instantiated from an existing html form, and we input fields with the same name are stored as an array.
  • We can generate the same field name that Rails uses for a form field via the field_name method on the form object.

Your vision deserves a great dev team.

We're not about tech jargon or over-promising. Instead, we focus on clear communication, transparency in our process, and delivering results that speak for themselves.

Awards won by Hivekind