Skip to content

Have 3-state radiobuttons instead of a 2-state checkbox for your Boolean columns which can store NULLs

License

Notifications You must be signed in to change notification settings

sergeypedan/formtastic-tristate-radio

Repository files navigation

Formtastic tri-state radio

Gem Version Maintainability Test Coverage

What is “tri-state”?

— that which has 3 states.

By defenition Boolean values have 2 states: True & False.

However, if you store a Boolean value in a database column with no NOT NULL restriction, it aquires a 3d possible state: null.

Some may consider this practice questionable — I don’t think so. In real life you always have a case when the answer to your question may be only “yes” or “no”, but you don’t know the answer yet. Using a string type column, storing there "yes", "no" and "unset" + using a state machine + validations — feels overkill to me.

What the gem does

  1. Provides a custom Formtastic input type :tristate_radio which renders 3 radios (“Yes”, “No”, “Unset”) instead of a checkbox (only where you put it).
  2. Teaches Rails recognize "null" and "nil" param values as nil. See “How it works” ☟ section for technical details on this.
  3. Encourages you to add translations for ActiveAdmin “status tag” so that nil be correctly translated as “Unset” instead of “False”.

Usage

For a Boolean column with 3 possible states:

f.input :am_i_awake, as: :tristate_radio

You get (HTML is simplified, actually there are more classes etc.):

<fieldset>
  <legend>Am i awake?</legend>
  <input name="am_i_awake" type="radio" value="true">  <label>Yes</label>
  <input name="am_i_awake" type="radio" value="false"> <label>No</label>
  <input name="am_i_awake" type="radio" value="null">  <label>Unset</label>
</fieldset>

Installation

Gem

gem "formtastic_tristate_radio"

Translations

Add translation for the new “unset” option:

ru:
  formtastic:
    # :yes: Да       # <- these two fall back to translations
    # :no: Нет       #    in Formtastic gem but have only English
    null: Неизвестно # <- this you must provide youself

You can override individual translations like so:

f.input :attribute, as: :tristate_radio, null: "Your text"

ActiveAdmin translations

ActiveAdmin will automatically translate nil as “No”, so if you use ActiveAdmin, add translation like so:

ru:
  active_admin:
    status_tag:
      :yes: Да
      :no: Нет
      unset: Неизвестно

Notice that the key ActiveAdmin uses is “unset”, not “null”.

Configuration

It’s difficult to come up with a reasonable use case for that, but you can configure what will be used as inputs value:

# config/initializers/formtastic.rb
FormtasticTristateRadio.configure do |config|
  config.unset_key = "__unset" # default is :null
end

which will result in:

<input type="radio" name="am_i_awake" value="true">
<input type="radio" name="am_i_awake" value="false">
<input type="radio" name="am_i_awake" value="__unset">

Mind that for your custom value to work, you also need to configure ActiveModel to recognize that value as nil. Currently that is done like so.

Documentation

Low-level methods are properly documented in RubyDoc here.

Dependencies

Now the gem depends on Formtastic (naturally) and Rails. Frankly I am not sure whether I will have time to make it work with other frameworks.

How it works

In Ruby any String is cast to true:

!!""      #=> true
!!"false" #=> true
!!"nil"   #=> true
!!"no"    #=> true
!!"null"  #=> true

Web form params are passed as plain text and are interpreted as String by Rack.

So how are Boolean values transfered as strings if a "no" or "0" and even "" is truthy in Ruby?

Frameworks just have a list of string values to be recognized and mapped to Boolean values:

ActiveModel::Type::Boolean::FALSE_VALUES
#=> [
   0, "0", :"0",
  "f", :f, "F", :F,
  false, "false", :false, "FALSE", :FALSE,
  "off", :off, "OFF", :OFF,
]

so that

ActiveModel::Type::Boolean.new.cast("0")    #=> false
ActiveModel::Type::Boolean.new.cast("f")    #=> false
ActiveModel::Type::Boolean.new.cast(:FALSE) #=> false
ActiveModel::Type::Boolean.new.cast("off")  #=> false
# etc

So what I do in this gem is extend ActiveModel::Type::Boolean in a consistent way to teach it recognize null-ish values as nil:

module ActiveModel
  module Type
    class Boolean < Value

      NULL_VALUES = [nil, "", "null", :null, "nil", :nil].to_set.freeze

      private def cast_value(value)
        NULL_VALUES.include?(value) ? nil : !FALSE_VALUES.include?(value)
      end

    end
  end
end

And voila!

ActiveModel::Type::Boolean.new.cast("")     #=> nil
ActiveModel::Type::Boolean.new.cast("null") #=> nil
ActiveModel::Type::Boolean.new.cast(:null)  #=> nil
ActiveModel::Type::Boolean.new.cast("nil")  #=> nil
ActiveModel::Type::Boolean.new.cast(:nil)   #=> nil

Warning: as you might have noticed, default Rails behavior is changed. If you rely on Rails’ automatic conversion of strings with value "null" into true, this gem might not be for you (and you are definitely doing something weird).

Roadmap

  • Remove require_relative "../app/models/active_record/base" from main file
  • Make the gem configurable
  • Pull the key used for “unset” choice value into configuration
  • Add translations into most popular languages
  • Load translations from gem
  • Rgister :tristate_radio for Boolean columns with null
  • Decouple ActiveModel::Type::Boolean thing from Formtastic things, maybe into a separate gem
  • Decouple from Rails

License

The gem is available as open source under the terms of the MIT License.

About

Have 3-state radiobuttons instead of a 2-state checkbox for your Boolean columns which can store NULLs

Topics

Resources

License

Stars

Watchers

Forks

Languages