The Proper Way to Add a Project Settings Tab for Your Redmine Plugin
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:
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.
Note: In order for the above patch to be effective, you still have to call the apply
method in a to_prepare
block in init.rb
:
Rails.configuration.to_prepare do
SomePlugin::ProjectSettingsTabs.apply
end