This is part 4 of the Controller Refactoring Epic.
When multiple resources need inline editing, don't copy the action. Extract it to a concern that controllers can include with minimal configuration.
The Problem
class Admin::JobListingsController < ApplicationController
def update_field
field_name = validated_field_name(params[:field_name])
old_value = edit_service.read_field(field_name)
if job_listing.update(job_listing_params)
edit_service.record_edit!(field_name, old_value, edit_service.read_field(field_name))
render turbo_stream: turbo_stream.replace(
inline_edit_element_id(field_name),
partial: "admin/job_listings/inline_edit_field",
locals: inline_edit_locals(field_name)
)
else
render turbo_stream: turbo_stream.replace(
inline_edit_element_id(field_name),
partial: "admin/job_listings/inline_edit_field",
locals: inline_edit_locals(field_name, error: job_listing.errors[field_name].first)
)
end
end
private
def inline_edit_element_id(field_name)
"inline_edit_#{job_listing.id}_#{field_name}"
end
def inline_edit_locals(field_name, error: nil)
InlineFieldResolver.new(job_listing, field_name).to_locals(error:)
end
end
Now imagine copying this to Admin::CandidatesController, Admin::CompaniesController, and Admin::UsersController. Four copies of the same action logic.
The Solution
# app/controllers/concerns/inline_editable_controller.rb
module InlineEditableController
extend ActiveSupport::Concern
def update_field
field_name = validated_field_name(params[:field_name])
if inline_editable_record.update(inline_editable_params)
record_inline_edit(field_name) if respond_to?(:record_inline_edit, true)
render_field_success(field_name)
else
render_field_error(field_name)
end
end
private
# === Interface methods — override in controller ===
def inline_editable_record
raise NotImplementedError, "Define inline_editable_record to return the record being edited"
end
def inline_editable_partial
raise NotImplementedError, "Define inline_editable_partial to return the partial path"
end
def inline_editable_param_key
raise NotImplementedError, "Define inline_editable_param_key to return the params key"
end
def inline_editable_service
nil # Optional: override to provide edit tracking service
end
# === Shared implementation ===
def validated_field_name(field)
field.to_s.tap do |f|
service = inline_editable_service
if service && !service.valid_field?(f)
raise ActionController::BadRequest, "Invalid field: #{f}"
end
end
end
def inline_editable_params
params.require(inline_editable_param_key).permit(params[:field_name])
end
def inline_edit_element_id(field_name)
"inline_edit_#{inline_editable_record.id}_#{field_name}"
end
def inline_edit_locals(field_name, error: nil)
InlineFieldResolver.new(inline_editable_record, field_name).to_locals(error:)
end
def render_field_success(field_name)
render turbo_stream: turbo_stream.replace(
inline_edit_element_id(field_name),
partial: inline_editable_partial,
locals: inline_edit_locals(field_name)
)
end
def render_field_error(field_name)
error = inline_editable_record.errors[field_name].first
render turbo_stream: turbo_stream.replace(
inline_edit_element_id(field_name),
partial: inline_editable_partial,
locals: inline_edit_locals(field_name, error:)
), status: :unprocessable_entity
end
end
Using the Concern
class Admin::JobListingsController < ApplicationController
include InlineEditableController
private
def inline_editable_record = job_listing
def inline_editable_partial = "admin/job_listings/inline_edit_field"
def inline_editable_param_key = :job_listing
def inline_editable_service = JobListingEditService.new(record: job_listing, user: current_user)
def record_inline_edit(field_name)
old_value = inline_editable_service.read_field(field_name)
inline_editable_service.record_edit!(field_name, old_value, inline_editable_service.read_field(field_name))
end
end
Adding inline editing to another controller:
class Admin::CandidatesController < ApplicationController
include InlineEditableController
private
def inline_editable_record = candidate
def inline_editable_partial = "admin/candidates/inline_edit_field"
def inline_editable_param_key = :candidate
# No edit tracking service — skips audit records
end
Three lines define the contract. The concern handles everything else.
The Route
# config/routes.rb
namespace :admin do
resources :job_listings do
member do
patch :update_field
end
end
end
Testing the Concern
RSpec.describe Admin::JobListingsController, type: :controller do
describe "PATCH #update_field" do
let(:job_listing) { create(:job_listing, title: "Old Title") }
let(:admin) { create(:user, :admin) }
before { sign_in admin }
it "updates the field" do
patch :update_field, params: {
id: job_listing.id,
field_name: "title",
job_listing: { title: "New Title" }
}
expect(job_listing.reload.title).to eq "New Title"
end
it "returns turbo stream response" do
patch :update_field, params: {
id: job_listing.id,
field_name: "title",
job_listing: { title: "New Title" }
}
expect(response.media_type).to eq Mime[:turbo_stream]
end
end
end
The Complete Picture
After this four-part refactoring:
| Component | Lines | Responsibility |
|---|---|---|
JobListingsFilterPresenter |
~25 | Filter option queries |
JobListingEditService |
~35 | Edit tracking & audit |
InlineEditable concern |
~15 | Model field declarations |
InlineFieldResolver |
~45 | Field metadata resolution |
InlineEditableController |
~50 | Shared action logic |
| Controller | ~30 | Interface methods only |
The controller went from 317 lines to ~30. The extracted code is reusable across any model that needs inline editing.