Simple Preferences to Any Resource for Rails

Minimalistic, abstract 3D-like image of a dashboard in duotone sky blue and cyan, featuring stylized buttons, knobs, and sliders.

Having an option for your users to set their preferences is something that is likely required rather sooner than later. These might range from aesthetic preferences to usability settings.

Some examples for users: keyboard_shortcuts_enabled, language_preference, timezone, text_editor_theme, notification_sound_enabled.

Or for other domain models: contact_sort_order, default_campaign_filters, task_view_default, favorite_projects.

If you’ve ever built, or are part of a team that builds, SaaS products, you know these feature requests are not all known beforehand. Feature requests come in, requirements change and so on.

There are a few gems out there that provide a solution to this, but to me this is the kind of functionality I want to control and and not rely on a third-party (YMMV).

This article outlines a super simple, but extendable way to add one or many preferences to any domain model. There is some syntactic sugar added to make them quack more like Rails does too. 🦆

I want to preface that I use these preferences only for UI purposes. They are never needed to be queried/searched-for in some way.

Let’s dig right in. What is needed:

Just want to use it in your app right now? Run this template: rails app:template LOCATION="https://railsdesigner.com/simple-preferences/template/" in your Rails app’s root folder.

Let’s create the data model that holds it all:

rails g model Vault resource:belongs_to{polymorphic} scope:string payload:jsonb

I’ve chosen the general name “Vault” here as it fits quite well for the various purposes, but you can of course name it however you want.

Now let’s update the created migration file to add some sane validations, default values and some indexes.

class CreateVaults < ActiveRecord::Migration[7.1]
  def change
    create_table :vaults do |t|
      t.belongs_to :resource, polymorphic: true, null: false
      t.string :scope, null: false
      t.jsonb :payload, null: false, default: {}

      t.timestamps
    end

    add_index :vaults, :scope
    add_index :vaults, :payload, using: :gin
  end
end

Next add the store_attribute gem: bundle add store_attribute.

Now open the created Vault model and add these two class methods:

# app/models/vault.rb
class Vault < ApplicationRecord
  # …
  def self.vault_scope(scope_name)
    default_scope { where(scope: scope_name) }
  end

  def self.vault_attribute(key, *attributes)
    options = attributes.extract_options!

    store_attribute :payload, key, *attributes, **options
  end
  # …
end

Up next: a Vaults concern.

# app/models/concern/vaults.rb
module Vaults
  extend ActiveSupport::Concern

  class_methods do
    def vault(association_name, class_name: nil)
      has_one association_name, as: :resource, class_name: class_names.presence || "#{self}::#{association_name.to_s.camelize}", dependent: :destroy
    end
end

Now the basics are done already! Let’s create the first Vault to store some User preferences. Create the following file:

# app/models/user/preferences.rb
class User::Preferences < Vault
  vault_scope :user_preferences

  vault_attribute :time_zone, :string, default: "UTC"
  vault_attribute :datetime_format, :string, default: "dd-mm-yyyy"
  vault_attribute :notification_sound_enabled, :boolean, default: true
end

Note: this folder structure is, by convention, through the set up of the class_name in the Vaults concern: "#{self}::#{association_name.to_s.camelize}". Set a custom one by setting the class_name below.

Then in your User model:

# app/models/user.rb
class User < ApplicationRecord
# …
  include Vaults

  vault :preferences
  # or when using a different folder structure:
  # `vault :preferences, class_name: "CustomPreferences"`
# …
end

Get User’s preferences like this:

user = User.find(1)
=> #<User:0x00000007146fb100>
user.preferences.time_zone
=> "UTC"
user.preferences.notification_sound_enabled
=> true

Need to update a preference?

user.preferences.update notification_sound_enabled: false

And that’s all! 😎

Want to add this to your app without doing any manual work? 🧠 Run this command in your Rails app’s root folder: rails app:template LOCATION="https://railsdesigner.com/simple-preferences/template/".

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Last updated at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built using ViewComponent

  • Designed with Tailwind CSS

  • Enhanced with Hotwire