Jens Krämer

The Proper Way to Add a Project Settings Tab for Your Redmine Plugin

 |  redmine, ruby, rails

Redmine’s project settings consist of a series of tabs for things like general project information, project members, issue categories and so on. If your plugin has any project specific configuration of any kind, it might be a good thing to add an entry there for it, as well. But how? Let’s see how the row of tabs is rendered:

def project_settings_tabs
  tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
          {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
          # [ I cut a couple similar-looking lines...]
          {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
          ]
  tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
end

This method resides in the ProjectsHelper module and as such is automatically available in any views rendered by ProjectsController. At first sight, not very extension-friendly, especially when compared to the other menus in Redmine which make it very easy to add new entries through the MenuManager interface.

Old and busted: alias_method_chain

Many plugins extend that method using the alias_method_chain mechanism like this:

module IssueTemplates
  module ProjectsHelperPatch
    def self.included(base)
      base.class_eval do
        alias_method_chain :project_settings_tabs, :issue_templates
      end
    end

    def project_settings_tabs_with_issue_templates
      tabs = project_settings_tabs_without_issue_templates
      action = { name: 'issue_templates',
                 controller: 'issue_templates_settings',
                 action: :show,
                 partial: 'issue_templates_settings/show',
                 label: :project_module_issue_templates }
      tabs << action if User.current.allowed_to?(action, @project)
      tabs
    end
  end
end

That example stems from the issue templates plugin and was slightly shortened for clarity.

So why is this bad? alias_method_chain calls once could were all over the place in the Rails code base, but nowadays it is frowned upon. Deprecated in Rails 4, removed from Rails 5 (which Redmine 4 will be using), this is definitely not the way to go. Of course you could ship your own implementation of alias_method_chain or simply do the aliasing by hand, but luckily there’s a better way. But first, another wrong approach that looks good at first:

Maybe prepend()?

Instead of using aliasing, we can simply override the project_settings_tabs method:

module SomePlugin
  module ProjectsHelperPatch
    def self.apply
      unless ProjectsHelper < InstanceMethods
        ProjectsHelper.prepend InstanceMethods
      end 
    end

    module InstanceMethods
      def project_settings_tabs
        tabs = super
        if User.current.allowed_to?(:some_permission, @project)
          tabs.push({
            name: 'some_plugin_settings',
            partial: 'projects/settings/some_plugin',
            label: :label_some_plugin
          })
          end
        end
        tabs
      end
    end
  end
end

Looks nice and makes use of prepend which is awesome and modern, right? But in this case it’s not a good choice.

Why? Because modifying ProjectsHelper (or any other rails helper for that matter) may or may not lead to the desired result, depending on the fact what code has already been loaded and what not. If you manage to change the helper module early enough (to be precise: before the corresponding controller is loaded), your changed method will make it into the views, but if you are to late, your modification will still be successful but views will use the ‘original’ version of the method. Since your plugin will probably have to co-exist with a bunch of other plugins, and you never know what these do (i.e. it’s not so uncommon to extend ProjectsController - once another plugin does that, and it’s loaded before yours, it’s game over for your helper patch), you cannot rely on this.

The fact that prepend is ineffective when the module has been included already but method aliasing still works has to do with how Ruby internally handles these things - let’s just say prepend creates a new version of the module under the old name, while method aliasing just switches around handles to methods in the existing module.

So, to come to the point of this article, what should you do to add your plugin’s project settings tab?

Declare a new helper

It’s that simple. Just declare a new helper and tell the controller to make use of it: ~~~~ruby module SomePlugin module ProjectSettingsTabs def self.apply ProjectsController.send :helper, SomePlugin::ProjectSettingsTabs end

def project_settings_tabs
  tabs = super
  if User.current.allowed_to?(:some_permission, @project)
    tabs.push({
      name: 'some_plugin_settings',
      partial: 'projects/settings/some_plugin',
      label: :label_some_plugin
    })
    end
  end
  tabs
end

end end ~~~~

As you can see, the implementation of the new project_settings_tabs method is identical to the previous approach, including the call to super.

By explicitly referring to the ProjectsController class we make sure it, and by extension ProjectsHelper, is loaded. Since we do not have control what code other plugins load, our safest bet is to load whatever concerns us to create a known state and go on from there. Depending on code not being loaded on the other hand is never going to work reliably.

The call to super in this case works because the view context, at the time our helper is included, already holds the implementation of project_settings_tabs from Redmine’s ProjectsHelper. super simply refers to the ‘old’, already existing method which we are replacing, even if it comes from another module.

Comments

You can use Markdown here.

For the sake of spam checking any data you submit, including your IP address, will be transferred to the US based Akismet web service (akismet.com). If that's not acceptable for you, you can also reach me by other means.