Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 5, 2021 07:00 pm GMT

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 the app 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

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To