Convention-Based Inline Field Configuration

This is part 3 of the Controller Refactoring Epic.

Inline edit UIs need field metadata: labels, input types, select options. Controllers shouldn't own this configuration — it's model-level knowledge.

The Problem

class Admin::JobListingsController < ApplicationController
  def inline_edit_locals(field_name, error: nil)
    base = { field_name:, record: job_listing, error: }

    case field_name
    when "status"
      base.merge(label: "Status", input_type: :select,
                 collection: JobListing.statuses.keys.map { |s| [s.humanize, s] })
    when "department_id"
      base.merge(label: "Department", input_type: :select,
                 collection: Department.order(:name).pluck(:name, :id))
    when "title"
      base.merge(label: "Title", input_type: :text)
    when "description"
      base.merge(label: "Description", input_type: :textarea)
    when "salary_min", "salary_max"
      base.merge(label: field_name.humanize, input_type: :number)
    when "skill_ids"
      base.merge(label: "Skills", input_type: :multi_select,
                 collection: Skill.order(:name).pluck(:name, :id))
    # ... 30 more fields
    end
  end
end

Every new field means editing this case statement. The controller is 50+ lines of configuration that belongs with the model.

The Solution: Three Layers

1. Model Concern for Declaration

# app/models/concerns/inline_editable.rb
module InlineEditable
  extend ActiveSupport::Concern

  class_methods do
    def inline_field(name, **options)
      inline_field_configs[name.to_s] = options
    end

    def inline_field_configs
      @inline_field_configs ||= {}
    end
  end

  def inline_field_config(field)
    self.class.inline_field_configs[field.to_s] || {}
  end
end

2. Model Declares Only Exceptions

# app/models/job_listing.rb
class JobListing < ApplicationRecord
  include InlineEditable

  # Only declare what can't be inferred
  inline_field :status, type: :select,
               collection: -> { statuses.keys.map { |s| [s.humanize, s] } }

  inline_field :department_id, type: :select,
               collection: -> { Department.order(:name).pluck(:name, :id) }

  inline_field :skill_ids, type: :multi_select,
               collection: -> { Skill.order(:name).pluck(:name, :id) }

  # These fields use convention-based inference — no declaration needed:
  # - title (string column → text input)
  # - description (text column → textarea)
  # - salary_min, salary_max (integer columns → number input)
end

3. Resolver Infers the Rest

# app/services/inline_field_resolver.rb
class InlineFieldResolver
  def initialize(record, field)
    @record = record
    @field = field.to_s
    @config = record.inline_field_config(@field)
  end

  def to_locals(error: nil)
    {
      field_name: @field,
      record: @record,
      label: label,
      input_type: input_type,
      collection: collection,
      value: current_value,
      error: error
    }
  end

  def label
    @config[:label] || @field.humanize.titleize
  end

  def input_type
    @config[:type] || infer_type
  end

  def collection
    coll = @config[:collection]
    coll.respond_to?(:call) ? coll.call : coll
  end

  def current_value
    @record.public_send(@field)
  end

  private

  def infer_type
    return :multi_select if @field.end_with?("_ids")

    case column_type
    when :integer, :decimal, :float then :number
    when :boolean then :checkbox
    when :date then :date
    when :datetime then :datetime
    when :text then :textarea
    else :text
    end
  end

  def column_type
    @record.class.columns_hash[@field]&.type
  end
end

4. Controller Becomes One Line

class Admin::JobListingsController < ApplicationController
  def inline_edit_locals(field, error: nil)
    InlineFieldResolver.new(job_listing, field).to_locals(error:)
  end
end

Why This Works

Convention over configuration. Most fields don't need explicit setup. The resolver examines the column type and derives sensible defaults.

Exceptions are explicit. When a field needs a custom collection or non-standard input type, the model declares it. You can see all inline-editable fields by looking at the model.

Lambdas keep collections fresh. Using -> { ... } for collections means they're evaluated at render time, not class load time. Add a new department? It appears immediately.

Testing

RSpec.describe InlineFieldResolver do
  let(:job_listing) { build(:job_listing) }

  describe "#input_type" do
    it "infers :textarea for text columns" do
      resolver = described_class.new(job_listing, :description)
      expect(resolver.input_type).to eq :textarea
    end

    it "uses explicit config over inference" do
      resolver = described_class.new(job_listing, :status)
      expect(resolver.input_type).to eq :select
    end

    it "infers :multi_select for _ids fields" do
      resolver = described_class.new(job_listing, :skill_ids)
      expect(resolver.input_type).to eq :multi_select
    end
  end
end

The Pattern

Put configuration where it belongs: with the model. Let convention handle the common cases. Reserve explicit configuration for exceptions.

← Back to all articles