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.