Site-wide configuration with Administrate

Nick Charlton

Every so often, you’ll have certain things you’d like to configure site-wide. Not quite a feature flag as we don’t want to control a full feature, but something helpful to be able to let admins control. They might cover many aspects of the system, and you might have a few, too, so holding them together in one place is helpful.

You could use environment variables for some of these (and sometimes that might be appropriate), but that can be error-prone (what if you set the wrong type for a value?) and restrictive for who might want to change the value.

With Administrate and some model tricks, we can implement a dashboard accessible to admin users and lean on the singleton pattern to provide a nice way to access and allow us to validate configuration values.

A configuration singleton

A singleton is a class we can have only one instance of, so calling it will give us back the same object each time (creating it, if necessary, the first time). In this case, we’ll have only one record and referring to it will mean we retrieve the same record each time.

There are some tradeoffs with singletons, as their global nature can make things hard to test because they couple state in different areas of code, but for occasional use, it’s a helpful pattern.

To start with, a migration:

class CreateConfigurations < ActiveRecord::Migration[7.0]
  def change
    create_table :configurations do |t|
      t.integer :example_attribute, default: 45, null: false

      t.timestamps
    end
  end
end

We want a default value here to simplify our model later on. Ensuring it can’t be null avoids some error cases too. Then, our model:

class Configuration < ApplicationRecord
  before_create :check_for_existing
  before_destroy :check_for_existing

  def self.load
    config = Configuration.first

    if config.nil?
      config = Configuration.create
    end

    config
  end

  private

  def check_for_existing
    raise ActiveRecord::RecordInvalid if Configuration.count >= 1
  end
end

Of course, a test:

require "rails_helper"

FactoryBot.define do
  factory :configuration do
    example_attribute { 45 }
  end
end

RSpec.describe Configuration do
  it "has a valid factory" do
    expect(build(:configuration)).to be_valid
  end

  it "does not allow another record to be created" do
    create(:configuration)
    config_2 = build(:configuration)

    expect(config_2.save).to be_falsey
  end

  it "does not allow destruction of the record" do
    config = create(:configuration)

    expect do
      config.destroy
    end.to raise_error(ActiveRecord::RecordInvalid)
  end

  describe ".load" do
    context "when a configuration record exists" do
      it "returns it" do
        config = create(:configuration)

        expect(described_class.load).to eq(config)
      end
    end

    context "when a configuration doesn't yet exist" do
      it "creates and returns a new one" do
        config = described_class.load

        expect(config).to be_a(Configuration)
      end
    end
  end
end

We end up with a database table called configurations, then our matching Configuration model. We create a class method to facilitate loading, which ensures we have a record, then lean on before_create/before_destroy to ensure we don’t accidentally create another record (or destroy our current one).

Creating an Administrate dashboard

Assuming you’ve already configured administrate, we can create a new dashboard:

rails g administrate:dashboard configuration

The dashboard, once trimmed down, is very conventional. We just override the display_resource method to always show “Configuration” as a string:

require "administrate/base_dashboard"

class ConfigurationDashboard < Administrate::BaseDashboard
  ATTRIBUTE_TYPES = {
    id: Field::Number,
    example_attribute: Field::Number,
    created_at: Field::DateTime,
    updated_at: Field::DateTime
  }.freeze

  COLLECTION_ATTRIBUTES = %i[
    id
    created_at
    updated_at
  ].freeze

  SHOW_PAGE_ATTRIBUTES = %i[
    example_attribute
    updated_at
  ].freeze

  FORM_ATTRIBUTES = %i[
    example_attribute
  ].freeze

  def display_resource(configuration)
    "Configuration"
  end
end

In the controller we need to do a few more things:

module Admin
  class ConfigurationsController < Admin::ApplicationController
    def index
      redirect_to admin_configuration_path(find_resource)
    end

    def find_resource(*)
      ::Configuration.load
    end
  end
end

Administrate controllers use find_resource to fetch the relevant record (usually based on the path) but here, we’ll use our .load method. We redirect from the index to our model, mainly to make the route helpers nicer to use as it’s more straightforward to refer to.

Finally, set the routes:

namespace :admin do
  resources :configurations, only: [:index, :show, :edit, :update]
end

I implemented this over four months before writing this, and it’s worked very well. It’s helped us provide a clear place to put the few configuration options we have; for the more complex ones, we can also provide validations. Because most of Administrate is straightforward in its implementation, implementing cases like this doesn’t become complicated.