formtastic_tristate_radio 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []