formtastic_tristate_radio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3c29b06aadeb4938a06be81d4b33c23c904d7033011aec8346d43b765f54d262
4
+ data.tar.gz: 471d66686e0aa7db2526642214602aa49ede7ab6183100877e9bda5954bab37d
5
+ SHA512:
6
+ metadata.gz: 7292702606d47a360a308d89c322bb3dda27adc62866c4bc0fa45e6939ebb841a30760684b8b299b7312838b83ebc800d1d5b83624a6d73b37d2f21f2ea33cc5
7
+ data.tar.gz: 97251242621b60cc2d1c9f381f29a58f408eddd44c202ba9f57ddf6c7075c54212733c70c9b7ef83c317e8fde8991b7aaaa9f79439e7d060f8f111fc6d0f6c11
data/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # Formtastic tri-state radio
2
+
3
+ ## What is “tri-state”?
4
+
5
+ — that which has 3 states.
6
+
7
+ By defenition Boolean values have 2 states: True | False.
8
+
9
+ However, if you store a Boolean value in a database column with no `NOT FULL` restriction, it aquires a 3d possible state: `null`.
10
+
11
+ Some may say this is a questionable practice — 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.
12
+
13
+
14
+ ## What the gem does
15
+
16
+ 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).
17
+ 1. Teaches Rails recognize `"null"` and `"nil"` param values as `nil`. See “[How it works](#how-it-works)” ☟ section for technical details on this.
18
+ 1. Encourages you to add translations for ActiveAdmin “status tag” so that `nil` be correctly translated as “Unset” instead of “False”.
19
+
20
+
21
+ ## Usage
22
+
23
+ For a Boolean column with 3 possible states:
24
+
25
+ ```ruby
26
+ f.input :column_name, as: :tristate_radio
27
+ ```
28
+
29
+ You get (HTML is simplified):
30
+
31
+ ```html
32
+ <fieldset>
33
+ <input name="column_name" type="radio" value="true"> <label>Yes</label>
34
+ <input name="column_name" type="radio" value="false"> <label>No</label>
35
+ <input name="column_name" type="radio" value="null"> <label>Unset</label>
36
+ </fieldset>
37
+ ```
38
+
39
+ In the future `:tristate_radio` will be registered for Boolean columns with `null` by default. Until then you have to assign it manually.
40
+
41
+
42
+ ## Installation
43
+
44
+ ```ruby
45
+ gem "formtastic_tristate_radio"
46
+ ```
47
+
48
+ Add translation for the new “unset” option:
49
+
50
+ ```yaml
51
+ ru:
52
+ formtastic:
53
+ # :yes: Да # <- these two fall back to translations
54
+ # :no: Нет # in Formtastic gem but have only English
55
+ null: Неизвестно # <- this you must provide youself
56
+ ```
57
+
58
+ ActiveAdmin will automatically translate `nil` as “No”, so if you use ActiveAdmin, add translation like so:
59
+
60
+ ```yaml
61
+ ru:
62
+ active_admin:
63
+ status_tag:
64
+ :yes: Да
65
+ :no: Нет
66
+ null: Неизвестно
67
+ ```
68
+
69
+
70
+ ## Configuration
71
+
72
+ Nothing is configurable yet. I think of making configurable which values are regognized as `nil`.
73
+
74
+
75
+ ## Dependencies
76
+
77
+ Now the gem depends on [Formtastic](https://github.com/formtastic/formtastic) (naturally) and Rails. Frankly I am not sure whether I will have time to make it work with other frameworks.
78
+
79
+
80
+ ## How it works
81
+
82
+ In Ruby any String is cast to `true`:
83
+
84
+ ```ruby
85
+ !!"" #=> true
86
+ !!"false" #=> true
87
+ !!"nil" #=> true
88
+ !!"no" #=> true
89
+ !!"null" #=> true
90
+ ```
91
+
92
+ Web form params are passed as plain text and are interpreted as String by Rack.
93
+
94
+ So how Boolean values are transfered as strings if a `"no"` or `"0"` and even `""` is truthy in Ruby?
95
+
96
+ Frameworks just have a list of string values to be recognized and mapped to Boolean values:
97
+
98
+ ```ruby
99
+ ActiveModel::Type::Boolean::FALSE_VALUES
100
+ #=> [
101
+ 0, "0", :"0",
102
+ "f", :f, "F", :F,
103
+ false, "false", :false, "FALSE", :FALSE,
104
+ "off", :off, "OFF", :OFF,
105
+ ]
106
+ ```
107
+
108
+ so that
109
+
110
+ ```ruby
111
+ ActiveModel::Type::Boolean.new.cast("0") #=> false
112
+ ActiveModel::Type::Boolean.new.cast("f") #=> false
113
+ ActiveModel::Type::Boolean.new.cast(:FALSE) #=> false
114
+ ActiveModel::Type::Boolean.new.cast("off") #=> false
115
+ # etc
116
+ ```
117
+
118
+ So what [I do in this gem](https://github.com/sergeypedan/formtastic_tristate_radio/blob/master/config/initializers/activemodel_type_boolean.rb) is extend `ActiveModel::Type::Boolean` in a consistent way to teach it recognize null-ish values as `nil`:
119
+
120
+ ```ruby
121
+ module ActiveModel
122
+ module Type
123
+ class Boolean < Value
124
+
125
+ NULL_VALUES = [nil, "", "null", :null, "nil", :nil].to_set.freeze
126
+
127
+ private def cast_value(value)
128
+ NULL_VALUES.include?(value) ? nil : !FALSE_VALUES.include?(value)
129
+ end
130
+
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ And voila!
137
+
138
+ ```ruby
139
+ ActiveModel::Type::Boolean.new.cast("") #=> nil
140
+ ActiveModel::Type::Boolean.new.cast("null") #=> nil
141
+ ActiveModel::Type::Boolean.new.cast(:null) #=> nil
142
+ ActiveModel::Type::Boolean.new.cast("nil") #=> nil
143
+ ActiveModel::Type::Boolean.new.cast(:nil) #=> nil
144
+ ```
145
+
146
+ **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).
147
+
148
+
149
+ ## License
150
+
151
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ # APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+ load "rails/tasks/statistics.rake"
8
+
9
+ require "bundler/gem_tasks"
10
+ require "rspec/core/rake_task"
11
+ RSpec::Core::RakeTask.new(:spec)
12
+ task default: :spec
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "formtastic"
4
+
5
+ # It may also be appropriate to put this file in `app/inputs`
6
+ class TristateRadioInput
7
+
8
+ include Formtastic::Inputs::Base
9
+ include Formtastic::Inputs::Base::Collections
10
+ include Formtastic::Inputs::Base::Choices
11
+
12
+
13
+ # UNSET_KEY = :null
14
+ UNSET_KEY = ActiveModel::Type::Boolean::NULL_VALUES.reject(&:blank?).first
15
+ #
16
+ # Mind ActiveAdmin status resolving logic:
17
+ # https://github.com/activeadmin/activeadmin/blob/master/lib/active_admin/views/components/status_tag.rb#L51
18
+ # In status tag builder the value is lowercased before casting into boolean, and the keyword for nil is "unset", so
19
+ # if we have lowercase "unset", translations from `ru.formtastic.unset` will be overriden by `ru.active_admin.status_tag.unset`.
20
+
21
+ MISSING_TRANSLATION_ERROR_MSG = <<~HEREDOC
22
+
23
+
24
+ For ActiveAdmin status tags in index & view tables:
25
+ ru:
26
+ active_admin:
27
+ status_tag:
28
+ :yes: Да
29
+ :no: Нет
30
+ :#{UNSET_KEY}: Неизвестно
31
+
32
+ For radiobutton labels in forms:
33
+ ru:
34
+ formtastic:
35
+ :yes: Да
36
+ :no: Нет
37
+ :#{UNSET_KEY}: Неизвестно
38
+
39
+ Note: “yes”, “no”, “null” and some other words are reserved, converted into Boolean values in YAML, so you need to quote or symbolize them.
40
+ HEREDOC
41
+
42
+
43
+ # template => an instance of ActionView::Base
44
+ #
45
+ # @param choice [Array], ["Completed", "completed"]
46
+ #
47
+ def choice_html(choice)
48
+ template.content_tag(:label, tag_content(choice), tag_options(choice))
49
+ end
50
+
51
+
52
+ # collection => [["Completed", "completed"], ["In progress", "in_progress"], ["Unknown", "unset"]]
53
+ #
54
+ # @return [Array]
55
+ #
56
+ def collection_with_unset
57
+ collection + [[unset_label_translation, UNSET_KEY]]
58
+ end
59
+
60
+
61
+ # Override to remove the for attribute since this isn't associated with any element, as it's nested inside the legend
62
+ # @return [Hash]
63
+ #
64
+ # @example
65
+ # { for: nil, class: ["label"] }
66
+ #
67
+ def label_html_options
68
+ super.merge({ for: nil })
69
+ end
70
+
71
+
72
+ # choice_value(choice) => true | false | UNSET_KEY <- in our version
73
+ # choice_value(choice) => true | false | ? <- in regular radio-buttons version
74
+ # method => :status
75
+ # object => ActiveRecord::Base model subclass, `User`
76
+ #
77
+ # @param choice [Array], ["Completed", "completed"]
78
+ #
79
+ # For this to work, ActiveModel::Type::Boolean must be patched to resolve `UNSET_KEY` as nil
80
+ #
81
+ def selected?(choice)
82
+ ActiveModel::Type::Boolean.new.cast(choice_value(choice)) == object.public_send(method)
83
+ end
84
+
85
+
86
+ # @returns [String]
87
+ # "<input ...> Text..."
88
+ #
89
+ # @param choice [Array], ["Completed", "completed"]
90
+ #
91
+ # input_html_options => { id: "task_status", required: false, autofocus: false, readonly: false}
92
+ #
93
+ # input_html_options.merge(choice_html_options(choice)).merge({ required: false })
94
+ # => { id: "task_status_completed", required: false, autofocus: false, readonly: false }
95
+ #
96
+ # builder => an instance of ActiveAdmin::FormBuilder
97
+ # choice_label(choice) => "Completed"
98
+ # choice_html_options(choice) => { id: "task_status_completed" }
99
+ # choice_value(choice) => "completed"
100
+ # input_name => :status
101
+ #
102
+ def tag_content(choice)
103
+ builder.radio_button(
104
+ input_name,
105
+ choice_value(choice),
106
+ input_html_options.merge(choice_html_options(choice)).merge({ required: false, checked: selected?(choice) })
107
+ ) << choice_label(choice)
108
+ end
109
+
110
+
111
+ # choice_input_dom_id(choice) => "task_status_completed"
112
+ # label_html_options => { for: nil, class: ["label"] }
113
+ #
114
+ # @param choice [Array], ["Completed", "completed"]
115
+ #
116
+ def tag_options(choice)
117
+ label_html_options.merge({ for: choice_input_dom_id(choice), class: nil })
118
+ end
119
+
120
+
121
+ # choice_wrapping_html_options(choice) #=> { class: "choice" }
122
+ #
123
+ # legend_html => "<legend class="label">
124
+ # <label>Status</label>
125
+ # </legend>"
126
+ #
127
+ # choice_html(choice) => "<label for="task_status_completed">
128
+ # <input type="radio" value="completed" name="task[status]" /> Completed
129
+ # </label>"
130
+ #
131
+ # collection.map do |choice|
132
+ # choice_wrapping({ class: "choice" }) do
133
+ # choice_html(choice)
134
+ # end
135
+ # end
136
+ # => ["<li class="choice">
137
+ # <label for="task_status_completed">
138
+ # <input type="radio" value="completed" name="task[status]" /> Completed
139
+ # </label>
140
+ # </li>",
141
+ # "<li class="choice">
142
+ # ...
143
+ # ]
144
+ #
145
+ # This method relies on ActiveAdmin
146
+ #
147
+ def to_html
148
+ choices = collection_with_unset #=> [["Completed", "completed"], ["In progress", "in_progress"], ["Unknown", "unset"]]
149
+
150
+ input_wrapping do
151
+ choices_wrapping do
152
+ legend_html << choices_group_wrapping do
153
+ choices.map { |choice|
154
+ choice_wrapping(choice_wrapping_html_options(choice)) do
155
+ choice_html(choice)
156
+ end
157
+ }.join("\n").html_safe
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+
164
+ # @return [String] Label of the radio that stands for the unknown choice
165
+ #
166
+ def unset_label_translation
167
+ Formtastic::I18n.t(UNSET_KEY).presence or fail StandardError.new(MISSING_TRANSLATION_ERROR_MSG)
168
+ end
169
+
170
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecord::Base
4
+
5
+ # @return [Array] of symbols — names of Boolean columns, which can be `NULL`
6
+ def self.tristate_column_names
7
+ columns.select { |col| col.type == :boolean && col.null }.map(&:name)
8
+ end
9
+
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModel
4
+ module Type
5
+ class Boolean < Value
6
+
7
+ NULL_VALUES = [nil, "", "null", :null, "nil", :nil].to_set.freeze
8
+
9
+ private def cast_value(value)
10
+ NULL_VALUES.include?(value) ? nil : !FALSE_VALUES.include?(value)
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ ---
2
+ en:
3
+ active_admin:
4
+ status_tag:
5
+ unset: Unset
@@ -0,0 +1,7 @@
1
+ ---
2
+ ru:
3
+ active_admin:
4
+ status_tag:
5
+ :no: Нет
6
+ :yes: Да
7
+ unset: Неизвестно
@@ -0,0 +1,4 @@
1
+ ---
2
+ en:
3
+ formtastic:
4
+ :null: Unset
@@ -0,0 +1,6 @@
1
+ ---
2
+ ru:
3
+ formtastic:
4
+ :no: Нет
5
+ :null: Неизвестно
6
+ :yes: Да
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,4 @@
1
+ module FormtasticTristateRadio
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,3 @@
1
+ module FormtasticTristateRadio
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,8 @@
1
+ require "formtastic_tristate_radio/version"
2
+ require "formtastic_tristate_radio/engine"
3
+
4
+ require_relative "../app/models/active_record/base"
5
+ # require_relative "../app/inputs/tristate_radio_input"
6
+
7
+ module FormtasticTristateRadio
8
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :formtastic_tristate_radio do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: formtastic_tristate_radio
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergey Pedan
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-11-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: formtastic
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '3'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rails
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '7'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '4'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '7'
53
+ - !ruby/object:Gem::Dependency
54
+ name: coveralls
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.2'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '0.2'
67
+ - !ruby/object:Gem::Dependency
68
+ name: pry
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '0.14'
74
+ type: :development
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.14'
81
+ - !ruby/object:Gem::Dependency
82
+ name: rake
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '13'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '13'
95
+ - !ruby/object:Gem::Dependency
96
+ name: yard
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '0.9'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '0.9'
109
+ description: 'Have 3-state radiobuttons instead of a 2-state checkbox for your Boolean
110
+ columns which can store NULL. Does not change controls, you need to turn it on via
111
+ `as: :tristate_radio` option.'
112
+ email:
113
+ - sergey.pedan@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files:
117
+ - README.md
118
+ files:
119
+ - README.md
120
+ - Rakefile
121
+ - app/inputs/tristate_radio_input.rb
122
+ - app/models/active_record/base.rb
123
+ - config/initializers/activemodel_type_boolean.rb
124
+ - config/locales/active_admin.en.yml
125
+ - config/locales/active_admin.ru.yml
126
+ - config/locales/formtastic.en.yml
127
+ - config/locales/formtastic.ru.yml
128
+ - config/routes.rb
129
+ - lib/formtastic_tristate_radio.rb
130
+ - lib/formtastic_tristate_radio/engine.rb
131
+ - lib/formtastic_tristate_radio/version.rb
132
+ - lib/tasks/formtastic_tristate_radio_tasks.rake
133
+ homepage: https://github.com/sergeypedan/formtastic-tristate-radio
134
+ licenses:
135
+ - MIT
136
+ metadata:
137
+ changelog_uri: https://github.com/sergeypedan/formtastic-tristate-radio/blob/master/Changelog.md
138
+ documentation_uri: https://github.com/sergeypedan/formtastic-tristate-radio#usage
139
+ homepage_uri: https://github.com/sergeypedan/formtastic-tristate-radio
140
+ source_code_uri: https://github.com/sergeypedan/formtastic-tristate-radio
141
+ post_install_message:
142
+ rdoc_options:
143
+ - "--charset=UTF-8"
144
+ require_paths:
145
+ - lib
146
+ - app/inputs
147
+ - app/models/active_record
148
+ - config/initializers
149
+ - config/locales
150
+ required_ruby_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: 2.4.0
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ requirements: []
161
+ rubygems_version: 3.2.15
162
+ signing_key:
163
+ specification_version: 4
164
+ summary: Have 3-state radiobuttons instead of a 2-state checkbox for your Boolean
165
+ columns which can store NULL
166
+ test_files: []