Extract Inline Edit Action to a Controller Concern

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.

← Back to all articles