Skip to content

Create a controller

Paths on this page are relative to your plugin directory, for example plugins/easyproject/easy_plugins/modification_acme/.

Controller

Controller files live in app/controllers. A controller inherits from ApplicationController. Its name is the plural of the model name plus Controller: MyModelsController for MyModel. A controller that works with something other than a model is named after that object, for example a dashboard (Easy pages).

Namespaced controllers do not work well with Redmine routes. For a model in a namespace, prefix the controller name instead: MyNamespaceMyModelsController for MyNamespace::MyModel.

Base controller definition
class MyModelsController < ApplicationController
  before_action :authorize_global
  before_action :custom_before_action, only: %i[index show]

  helper :attachments
  include AttachmentsHelper
  include_query_helpers

  def index
    index_for_easy_query MyModelQuery
  end

  def show
    my_model
    respond_to do |format|
      format.html
    end
  end

  def new
    @my_model = MyModel.new
    @my_model.safe_attributes = params[:my_model]
    respond_to do |format|
      format.html
    end
  end

  def create
    @my_model = MyModel.new
    @my_model.safe_attributes = params[:my_model]
    respond_to do |format|
      format.html do
        if @my_model.save
          flash[:notice] = l(:notice_successful_create)
          redirect_back_or_default my_model_path(@my_model)
        else
          render action: :new
        end
      end
    end
  end

  def edit
    # implementation
  end

  def update
    my_model.safe_attributes = params[:my_model]
    # implementation
  end

  def destroy
    # implementation
  end

  # custom actions

  private

  def my_model
    @my_model ||= MyModel.visible.find(params[:id])
  end

  def custom_before_action
    # implementation
  end
end

Hotwire responses

For dynamic server-rendered screens that do not need Vue, use Hotwire/Turbo instead of legacy AJAX endpoints. See Hotwire in Easy8.

Controller structure

Start with before_action declarations. Common ones are authorize_global and a custom guard. Limit them to actions with only or except.

before_action :authorize_global
before_action :custom_before_action, only: %i[index show]

Entity retrieval

ApplicationController rescues ActiveRecord::RecordNotFound and renders a 404 page, so you do not need to rescue around .find. Prefer a memoised getter over a find_* before-action.

Instead of a find_* before-action with a local rescue:

# Avoid this pattern
before_action :find_my_model

def find_my_model
  @my_model = MyModel.visible.find(params[:id])
rescue ActiveRecord::RecordNotFound
  render_404
end

Use a private memoised getter and call it from the actions:

def show
  my_model # raises RecordNotFound, rendered as 404
  respond_to { |f| f.html }
end

private

# @return [MyModel]
# @raise [ActiveRecord::RecordNotFound] rendered as 404
def my_model
  @my_model ||= MyModel.visible.find(params[:id])
end

If the record is optional (not finding it is not an error), use .find_by and handle nil explicitly:

def my_optional_model
  @my_optional_model ||= MyModel.visible.find_by(id: params[:id])
end

If the record is derived from an association rather than loaded by find, raise ActiveRecord::RecordNotFound yourself to get the same 404:

# @raise [ActiveRecord::RecordNotFound] when no active quote exists
def active_quote
  @active_quote ||= my_model.active_quote || raise(ActiveRecord::RecordNotFound)
end

Next, include helpers. include AttachmentsHelper makes the methods available in the controller; helper :attachments makes them available in views. A controller that uses queries also calls include_query_helpers.

helper :attachments
include AttachmentsHelper
include_query_helpers

Then define the actions as public methods. End each action with a respond_to block that lists the response formats.

def my_action
  # request process implementation
  respond_to do |format|
    format.html do
      flash[:notice] = l(:notice)
      redirect_to path
    end
    format.js { render_api_ok }
  end
end

Private methods come last: before-action guards and request-handling logic.

Views

Action views live in app/views/my_models and are named after the action. A GET action renders its view by default; call render or redirect_to to do otherwise.

Routes

Routes are defined in your plugin's config/routes.rb. resources defines the default routes; restrict them with only or except. Add custom routes inside resources with member or collection blocks, each with an HTTP method and a name. For an action that accepts several HTTP methods, use match with via: %i[get put].

You can also define a route outside those blocks as method + path + options, where the method is an HTTP method or match. Options:

  • to - controller#action
  • as - route name
  • via - additional REST methods
  • controller - controller name
  • action - action name
routes.rb
resources :my_models do
  member do
    match :my_model_action, via: %i[get put]
  end
  collection do
    get :my_models_action
  end
end

match 'my_models/:id/my_model_action', to: 'my_models#my_model_action', via: %i[get put]
get 'my_models/my_models_action', controller: 'my_models', action: 'my_models_action', via: %i[get put]

Tests

Test controllers with request specs in test/spec/requests. Cover every endpoint of the controller, including rendering.

request spec example
require "easy_extensions/spec_helper"

RSpec.describe "MyModels", type: :request, logged: true do
  subject { response }

  describe "GET index" do
    let!(:request) { get my_models_path }

    it { is_expected.to have_http_status(:success) }
  end

  describe "GET show" do
    let(:my_model) { FactoryBot.create(:my_model) }
    let!(:request) { get my_model_path(my_model) }

    it { is_expected.to have_http_status(:success) }
  end

  describe "GET new" do
    # implementation
  end

  describe "POST create" do
    let(:my_model_params) { { name: "new model name" } }
    let!(:request) { post my_models_path(my_model: my_model_params) }

    it { is_expected.to redirect_to(/my_models\/\d+/) }

    context "with invalid request" do
      let(:my_model_params) { { name: "" } }

      it "Handle validation error" do
        expect(response).to have_http_status(:success)
        expect(response).to render_template(:new)
      end
    end

  end

  describe "GET edit" do
    # implementation
  end

  describe "PUT update" do
    let(:my_model) { FactoryBot.create(:my_model, name: "Super name") }
    let(:my_model_params) { { name: "changed model name" } }
    let!(:request) { patch my_model_path(my_model, my_model: my_model_params) }

    it { is_expected.to redirect_to(my_model_path(my_model)) }

    it "name was changed" do
      expect { my_model.reload }.to change { my_model.name }.from("Super name").to("changed model name")
    end

    context "with invalid name" do
      let(:my_model_params) { { name: "" } }

      it "Handle validation error" do
        expect(response).to render_template(:edit)
      end
    end
  end

  describe "DELETE destroy" do
    let(:my_model) { FactoryBot.create(:my_model) }
    let!(:request) { delete my_model_path(my_model) }

    it { is_expected.to redirect_to(my_models_path) }

    it "record was really deleted" do
      expect { my_model.reload }.to raise_error(ActiveRecord::RecordNotFound)
    end
  end
end