An Interest In:
Web News this Week
- April 24, 2024
- April 23, 2024
- April 22, 2024
- April 21, 2024
- April 20, 2024
- April 19, 2024
- April 18, 2024
Fun with Rails Enums and PORO
I really like enums. They can be really powerful if they are used wisely. Lets see what we can do with them in a multilingual Rails app with a little help from PORO (Plain Old Ruby Object).
In this article, I will quickly explain the basics and introduce a few tasks that can occur when you are using enums in the Rails app. For each, I will try to show the usual way (which I know about) and a more fun way (at least for me) with PORO.
Basics
I believe enums dont need to be heavily introduced. Below is a short description from the documentation:
Declare an enum attribute where the values map to integers in the database, but can be queried by name.
And thats it. You can also set the default value, get scopes with optional prefix or suffix, a few nice methods and default validation. A small example that will be used in the article:
enum theme_color: { classic: 0, dark: 1, brown: 2}, _suffix: true
The
_suffix
,_prefix
and_default
will be changed in not yet released (in time of writing the article) Rails 7 to a version without the_
prefix (according to the edge docs).
You will get:
Setting.classic_theme_color # scope with the suffixSetting.theme_colors # enum hashsetting.classic_theme_color! # updatesetting.classic_theme_color? # checksetting.theme_color # string instead of the integer from the database
As was already noted, Rails will store values as integers in the database, so you can easily change keys (that are mainly used in Rails) in the enum. That can be handy if you dont exactly know the right name for them.
When should you use them?
I was tempted to write it depends, but my rule is simple: use enums, if you have an array of options for a single attribute, that is not editable by any user and the codebase depends on its content.
Especially the part "depends on its content" is my favourite. Imagine you have enum format
with available export formats (pdf
, html
and so on).
You can then easily create a class structure that is based on the values and you will know that adding a new format will require changing the enum and adding a new export class.
Example:
class Document enum format: { pdf: 0, html: 1 }, _prefix: true def export "Exports::#{format.classify}Export".constantize.new(self).export endend
Task 1: Translations
The first and the most common one: how to translate enums?
With helper
The easiest way is to use I18n
with scope
:
I18n.t(setting.theme_color, scope: "activerecord.enums.setting")
Lets make it more versatile and use a helper method.
# app/helpers/application_helper.rbmodule ApplicationHelper def enum_t(value, key:, count: 1) I18n.t(value, scope: ["activerecord.enums", key], count: count) endend
And the final usage:
<p><%= enum_t(setting.theme_color, key: "setting.theme_color") %></p>
If we would have only this task, I would stick with this solution. There would be no need to make it a little bit more complex only for translations. But I know we will have much more to do :)
With object
Lets start the fun with PORO and create our enum object.
# app/enums/setting/theme_color.rbclass Setting::ThemeColor def self.t(value, count: 1) I18n.t(value, scope: "activerecord.enums.setting.theme_color", count: count) endend
I chose a custom folder
enums
inside of theapp
folder, instead of having them inside eg.app/models
. It is much cleaner for me to have them in their own specific folder.
And the usage:
<p><%= Setting::ThemeColor.t(setting.theme_color) %></p>
It is very similar, but we are now directly using a simple class that makes it more readable. Another benefit is that we can use it everywhere and not only in views and it is also easily testable.
This example is not the same as above, as it is limited to the enum only and it is not versatile as the helper method. We will get there in the next task.
Task 2: List values in a form
The usual way
You can use this below, but it would be without translations with nicer output for users.
options_for_select(Setting.theme_colors)
We could prepare options data in each controller or add it as a method inside the model, but to make it more versatile, lets use a helper method again.
I am one of those who do not like fat controllers and models. It is also the reason, why I am using PORO for enums and not models or view helpers.
# app/helpers/application_helper.rbdef enum_options(source, tkey:) source.map do |key, _value| [enum_t(key, key: tkey), key] endend
This can be used like this:
<%= form.select :theme_color, options_for_select(enum_options(Setting.theme_colors, tkey: "setting.theme_color"), setting.theme_color), {prompt: t("shared.views.select")}, required: true %>
With object
For only one enum, we could leave it. When you will have them more (and you probably will), you can refactor it (make it more versatile) and create a base class for our enums with shared code.
# app/enums/base_enum.rbclass BaseEnum class << self def t(value, count: 1) I18n.t(value, scope: ["activerecord.enums", translation_key], count: count) end def translation_key to_s.split("::").map(&:underscore).join(".") end endend
Our enum class will be a little bit empty after it:
# app/enums/setting/theme_color.rbclass Setting::ThemeColor < BaseEnum; end
Now, we can update the base class with the code we will need to help us display our values in the form.
# app/enums/base_enum.rbclass BaseEnum attr_reader :id, :title def initialize(id:, title:) @id = id @title = title end class << self def all source.map do |key, _value| new(id: key, title: t(key)) end end def source raise NotImplementedError end # the rest endend
You can find the final version of all used classes at the end of the article.
And finally, we can add something to the Setting::ThemeColor
class:
# app/enums/setting/theme_color.rbclass Setting::ThemeColor < BaseEnum def self.source Setting.theme_colors endend
The usage:
<%= form.collection_select :theme_color, Setting::ThemeColor.all, :id, :title, {include_blank: t("shared.views.select"), allow_blank: false}, {autofocus: false, required: true} %>
It now looks like we basically only have more code for removing one thing key: "setting.theme_color"
for translations and we have a different form method. But all changes will help us with upcoming tasks.
You probably noticed that I did not use the value (integer) from our enum. It is because Rails returns the string instead of the integer in the model attribute. Thanks to that we can display the saved value in the form easily.
Task 3: Descriptions
One day, you will get a feature request: add description text to theme color options.
The usual way
The solution is easy, we can just add a new helper method that will use a different key for translations. We will add a title key to the previous translations and add a new description key to keep the structure nice.
def enum_t(value, key:, count: 1) I18n.t(value, scope: ["activerecord.enums", key, "title"], count: count)enddef enum_description_t(value, key:, count: 1) I18n.t(value, scope: ["activerecord.enums", key, "description"], count: count)end
But we have now 3 helper methods in total. Maybe it would be good to move them into a single file to have them in at least one place without any other unrelated methods.
For me, this is not a good solution (obviously) because:
- you need to know that there are these methods (not the hardest one, but it is still better not to need to know it)
- for each new attribute (like the description), you will have a new method (or you would create a general where you would state the attribute in params), in each case, you will need to look to translation file to know, what attributes are available for the enum
With object
First, we need to change the translation key in BaseEnum
:
def t(value, count: 1) I18n.t(value, scope: ["activerecord.enums", translation_key, "title"], count: count)end
We will need to add a new method that will return us the object for needed enum. We can add it as a class method on BaseEnum
:
# app/enums/base_enum.rbdef find(key) new(id: key, title: t(key))end
We could make the description attribute available for all and add it into the BaseEnum
class, but we dont need it right now
# app/enums/setting/theme_color.rbdef description I18n.t(id, scope: ["activerecord.enums", self.class.translation_key, "description"])end
And the usage:
Setting::ThemeColor.find(setting.theme_color).description
We can finally fully benefit from the PORO way and have it clean and readable.
Task 4: Custom methods
Imagine, you would like to have the color in hex (eg. dark
= #000000
). With a usual way, we would create just another helper method or add a method to the model but with enum object we can just add a new method to the class and have it all in one place.
# app/enums/setting/theme_color.rbdef hex case id when "classic" "#FF0000" when "dark" "#000000" when "brown" "#D2691E" endend
We are now able to use it everywhere, not only in views.
Setting::ThemeColor.find("dark").hex
This was just an example. But the main point is, you now have a place where you can add these kinds of methods when you will need them.
Task 5: Limit displaying values
Another interesting task: allowing to display brown
option only for admins (for some weird reason). Again, with the usual way, we would have another helper or model method.
Luckily we have our enum class, where we can change the source method to our needs.
# app/enums/setting/theme_color.rbdef self.source if Current.user.admin? Setting.theme_colors else Setting.theme_colors.except("brown") endend
In this example, I am using CurrentAttributes for storing the current user.
Summary
With enum objects, you can make your enums more fun and it opens you a lot of possibilities.
- your code will be cleaner (slimmer models or helpers)
- enums will have their own place where you can extend them to your needs
- you will be able to use them everywhere
- all added logic will be easily testable
I hope you found this article interesting. If you find any inconsistencies or you know a better way, I would be glad if you let me know on Twitter.
Final version of classes
BaseEnum
class
# app/enums/base_enum.rbclass BaseEnum attr_reader :id, :title def initialize(id:, title:) @id = id @title = title end class << self def all source.map do |key, _value| new(id: key, title: t(key)) end end def find(key) new(id: key, title: t(key)) end def source raise NotImplementedError end def t(value, count: 1) I18n.t(value, scope: ["activerecord.enums", translation_key, "title"], count: count) end def translation_key to_s.split("::").map(&:underscore).join(".") end endend
Setting::ThemeColor
class
# app/enums/setting/theme_color.rbclass Setting::ThemeColor < BaseEnum def description I18n.t(id, scope: ["activerecord.enums", self.class.translation_key, "description"]) end def hex case id when "classic" "#FF0000" when "dark" "#000000" when "brown" "#D2691E" end end def self.source if Current.user.admin? Setting.theme_colors else Setting.theme_colors.except("brown") end endend
Original Link: https://dev.to/citronak/fun-with-rails-enums-and-poro-o86
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To