diff --git a/app/views/wiki/show.html.erb b/app/views/wiki/show.html.erb
index 4b222ef..5ed4e6b 100644
--- a/app/views/wiki/show.html.erb
+++ b/app/views/wiki/show.html.erb
@@ -7,6 +7,7 @@
<% end %>
<% end %>
+ <%= call_hook(:view_wiki_show_contextual, {:page => @page, :project => @project}) %>
<%= actions_dropdown do %>
<%= link_to_if_authorized(sprite_icon('history', l(:label_history)), { :action => 'history', :id => @page.title}, :class => 'icon icon-history') %>
@@ -24,6 +25,7 @@
<% if User.current.allowed_to?(:edit_wiki_pages, @project) %>
<%= link_to sprite_icon('add', l(:label_wiki_page_new)), new_project_wiki_page_path(@project, :parent => @page.title), :remote => true, :class => 'icon icon-add' %>
<% end %>
+ <%= call_hook(:view_wiki_show_actions_menu, {:page => @page, :project => @project}) %>
<% end %>
diff --git a/doc/HOOKS b/doc/HOOKS
new file mode 100644
index 0000000..127d908
--- /dev/null
+++ b/doc/HOOKS
@@ -0,0 +1,741 @@
+= Redmine hooks
+
+Every hook that Redmine calls, with the file it is called in and the keys of its
+context (besides :project, :controller, :request and :hook_caller, which view hooks
+always get). A plugin implements a hook as a method of a Redmine::Hook::Listener
+(controller and model hooks) or Redmine::Hook::ViewListener (view hooks), or with
+render_on. A hook listed here is kept; it is removed only after a deprecation notice
+in this file and in the changelog. test/unit/lib/redmine/hook_list_test.rb checks
+that every hook called in app/ and lib/ is listed here, and the other way round.
+
+Since: the version the hook appeared in; - for hooks older than this list.
+Context: the keys given on the line of the call (see the file for the rest).
+
+after_plugins_loaded
+ Since: -
+ Context: -
+ Called in: lib/redmine/plugin_loader.rb
+
+another_hook
+ Since: -
+ Context: :foo
+ Called in: lib/redmine/hook.rb
+
+controller_account_success_authentication_after
+ Since: -
+ Context: :user
+ Called in: app/controllers/account_controller.rb
+
+controller_custom_fields_edit_after_save
+ Since: -
+ Context: :params, :custom_field
+ Called in: app/controllers/custom_fields_controller.rb
+
+controller_custom_fields_new_after_save
+ Since: -
+ Context: :params, :custom_field
+ Called in: app/controllers/custom_fields_controller.rb
+
+controller_issues_bulk_edit_after_save
+ Since: -
+ Context: :params, :issue, :journal
+ Called in: app/controllers/issues_controller.rb
+
+controller_issues_bulk_edit_before_save
+ Since: -
+ Context: :params, :issue
+ Called in: app/controllers/issues_controller.rb
+
+controller_issues_edit_after_save
+ Since: 7.1.0
+ Context: -
+ Called in: app/controllers/issues_controller.rb
+
+controller_issues_edit_before_save
+ Since: 7.1.0
+ Context: -
+ Called in: app/controllers/issues_controller.rb
+
+controller_issues_new_after_save
+ Since: -
+ Context: :params, :issue
+ Called in: app/controllers/issues_controller.rb
+
+controller_issues_new_before_save
+ Since: -
+ Context: :params, :issue
+ Called in: app/controllers/issues_controller.rb
+
+controller_journals_edit_post
+ Since: -
+ Context: :journal, :params
+ Called in: app/controllers/journals_controller.rb
+
+controller_messages_new_after_save
+ Since: -
+ Context: :params, :message
+ Called in: app/controllers/messages_controller.rb
+
+controller_messages_reply_after_save
+ Since: -
+ Context: :params, :message
+ Called in: app/controllers/messages_controller.rb
+
+controller_time_entries_bulk_edit_before_save
+ Since: 7.1.0
+ Context: -
+ Called in: app/controllers/timelog_controller.rb
+
+controller_timelog_edit_before_save
+ Since: -
+ Context: -
+ Called in: app/controllers/timelog_controller.rb
+
+controller_wiki_edit_after_save
+ Since: -
+ Context: :params, :page
+ Called in: app/controllers/wiki_controller.rb
+
+controller_wiki_show_before_render
+ Since: -
+ Context: :content, :format
+ Called in: app/controllers/wiki_controller.rb
+
+helper_issues_show_detail_after_setting
+ Since: -
+ Context: -
+ Called in: app/helpers/issues_helper.rb
+
+helper_queries_column_content
+ Since: -
+ Context: -
+ Called in: app/helpers/queries_helper.rb
+
+helper_queries_column_value
+ Since: -
+ Context: -
+ Called in: app/helpers/queries_helper.rb
+
+model_changeset_scan_commit_for_issue_ids_pre_issue_update
+ Since: -
+ Context: -
+ Called in: app/models/changeset.rb
+
+model_project_copy_before_save
+ Since: -
+ Context: -
+ Called in: app/models/project.rb
+
+some_hook
+ Since: -
+ Context: -
+ Called in: lib/redmine/hook.rb
+
+view_account_left_bottom
+ Since: -
+ Context: :user
+ Called in: app/views/users/show.html.erb
+
+view_account_login_bottom
+ Since: -
+ Context: -
+ Called in: app/views/account/login.html.erb
+
+view_account_login_top
+ Since: -
+ Context: -
+ Called in: app/views/account/login.html.erb
+
+view_account_right_bottom
+ Since: -
+ Context: :user
+ Called in: app/views/users/show.html.erb
+
+view_admin_projects_sidebar_queries_bottom
+ Since: -
+ Context: -
+ Called in: app/views/admin/projects.html.erb
+
+view_boards_show_contextual
+ Since: 7.1.0
+ Context: :board, :project
+ Called in: app/views/boards/show.html.erb
+
+view_calendars_show_bottom
+ Since: -
+ Context: :year, :month, :project, :query
+ Called in: app/views/calendars/show.html.erb
+
+view_custom_fields_form_upper_box
+ Since: -
+ Context: :custom_field, :form
+ Called in: app/views/custom_fields/_form.html.erb
+
+view_documents_index_contextual
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/documents/index.html.erb
+
+view_documents_show_contextual
+ Since: 7.1.0
+ Context: :document, :project
+ Called in: app/views/documents/show.html.erb
+
+view_groups_form_bottom
+ Since: 7.1.0
+ Context: :group, :form
+ Called in: app/views/groups/_form.html.erb
+
+view_groups_form_top
+ Since: 7.1.0
+ Context: :group, :form
+ Called in: app/views/groups/_form.html.erb
+
+view_groups_memberships_table_header
+ Since: -
+ Context: :group
+ Called in: app/helpers/principal_memberships_helper.rb
+
+view_groups_memberships_table_row
+ Since: -
+ Context: :group, :membership
+ Called in: app/helpers/principal_memberships_helper.rb
+
+view_groups_show_contextual
+ Since: 7.1.0
+ Context: :group
+ Called in: app/views/groups/show.html.erb
+
+view_issue_statuses_form
+ Since: -
+ Context: :issue_status
+ Called in: app/views/issue_statuses/_form.html.erb
+
+view_issues_bulk_edit_details_bottom
+ Since: -
+ Context: :issues
+ Called in: app/views/issues/bulk_edit.html.erb
+
+view_issues_context_menu_end
+ Since: -
+ Context: :issues, :can, :back
+ Called in: app/views/context_menus/issues.html.erb
+
+view_issues_context_menu_start
+ Since: -
+ Context: :issues, :can, :back
+ Called in: app/views/context_menus/issues.html.erb
+
+view_issues_edit_notes_bottom
+ Since: -
+ Context: :issue, :notes, :form
+ Called in: app/views/issues/_edit.html.erb
+
+view_issues_edit_top
+ Since: -
+ Context: :issue, :form
+ Called in: app/views/issues/_edit.html.erb
+
+view_issues_form_after_attributes
+ Since: 7.1.0
+ Context: :issue, :form
+ Called in: app/views/issues/_form.html.erb
+
+view_issues_form_after_description
+ Since: 7.1.0
+ Context: :issue, :form
+ Called in: app/views/issues/_form.html.erb
+
+view_issues_form_after_subject
+ Since: 7.1.0
+ Context: :issue, :form
+ Called in: app/views/issues/_form.html.erb
+
+view_issues_form_custom_fields_bottom
+ Since: 7.1.0
+ Context: :issue, :custom_field_values
+ Called in: app/views/issues/_form_custom_fields.html.erb
+
+view_issues_form_custom_fields_top
+ Since: 7.1.0
+ Context: :issue, :custom_field_values
+ Called in: app/views/issues/_form_custom_fields.html.erb
+
+view_issues_form_details_bottom
+ Since: -
+ Context: :issue, :form
+ Called in: app/views/issues/_form.html.erb
+
+view_issues_form_details_top
+ Since: -
+ Context: :issue, :form
+ Called in: app/views/issues/_form.html.erb
+
+view_issues_history_changeset_bottom
+ Since: -
+ Context: :changeset
+ Called in: app/views/issues/tabs/_changesets.html.erb
+
+view_issues_history_journal_bottom
+ Since: -
+ Context: :journal
+ Called in: app/views/issues/tabs/_history.html.erb
+
+view_issues_history_time_entry_bottom
+ Since: -
+ Context: :time_entry
+ Called in: app/views/issues/tabs/_time_entries.html.erb
+
+view_issues_index_actions_menu
+ Since: 7.1.0
+ Context: :project, :query
+ Called in: app/views/issues/index.html.erb
+
+view_issues_index_bottom
+ Since: -
+ Context: :issues, :project, :query
+ Called in: app/views/issues/index.html.erb
+
+view_issues_index_contextual
+ Since: 7.1.0
+ Context: :project, :query
+ Called in: app/views/issues/index.html.erb
+
+view_issues_list_row_after
+ Since: 7.1.0
+ Context: :issue, :query, :colspan
+ Called in: app/views/issues/_list.html.erb
+
+view_issues_new_top
+ Since: -
+ Context: :issue
+ Called in: app/views/issues/new.html.erb
+
+view_issues_show_actions_menu
+ Since: 7.1.0
+ Context: :issue, :project
+ Called in: app/views/issues/_action_menu.html.erb
+
+view_issues_show_bottom
+ Since: 7.1.0
+ Context: :issue
+ Called in: app/views/issues/show.html.erb
+
+view_issues_show_contextual
+ Since: 7.1.0
+ Context: :issue, :project
+ Called in: app/views/issues/_action_menu.html.erb
+
+view_issues_show_description_bottom
+ Since: -
+ Context: :issue
+ Called in: app/views/issues/show.html.erb
+
+view_issues_show_details_bottom
+ Since: -
+ Context: :issue
+ Called in: app/views/issues/show.html.erb
+
+view_issues_show_details_end
+ Since: 7.1.0
+ Context: :issue
+ Called in: app/views/issues/show.html.erb
+
+view_issues_show_top
+ Since: 7.1.0
+ Context: :issue
+ Called in: app/views/issues/show.html.erb
+
+view_issues_sidebar_issues_bottom
+ Since: -
+ Context: -
+ Called in: app/views/issues/_sidebar.html.erb
+
+view_issues_sidebar_planning_bottom
+ Since: -
+ Context: -
+ Called in: app/views/issues/_sidebar.html.erb
+
+view_issues_sidebar_queries_bottom
+ Since: -
+ Context: -
+ Called in: app/views/issues/_sidebar.html.erb
+
+view_journals_notes_form_after_notes
+ Since: -
+ Context: :journal
+ Called in: app/views/journals/_notes_form.html.erb
+
+view_journals_update_js_bottom
+ Since: -
+ Context: :journal
+ Called in: app/views/journals/update.js.erb
+
+view_layouts_base_body_bottom
+ Since: -
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_body_top
+ Since: -
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_content
+ Since: -
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_content_top
+ Since: 7.1.0
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_footer_bottom
+ Since: 7.1.0
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_footer_top
+ Since: 7.1.0
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_header_bottom
+ Since: 7.1.0
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_header_top
+ Since: 7.1.0
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_html_head
+ Since: -
+ Context: -
+ Called in: app/views/layouts/base.html.erb
+
+view_layouts_base_sidebar
+ Since: -
+ Context: -
+ Called in: app/helpers/application_helper.rb
+
+view_layouts_base_sidebar_top
+ Since: 7.1.0
+ Context: -
+ Called in: app/helpers/application_helper.rb
+
+view_layouts_base_title_after
+ Since: 7.1.0
+ Context: :title
+ Called in: app/helpers/application_helper.rb
+
+view_messages_show_contextual
+ Since: 7.1.0
+ Context: :topic, :message, :project
+ Called in: app/views/messages/show.html.erb
+
+view_my_account
+ Since: -
+ Context: :user, :form
+ Called in: app/views/my/account.html.erb
+
+view_my_account_contextual
+ Since: -
+ Context: :user
+ Called in: app/views/my/account.html.erb
+
+view_my_account_preferences
+ Since: -
+ Context: :user, :form
+ Called in: app/views/my/account.html.erb
+
+view_my_page_contextual
+ Since: -
+ Context: :user
+ Called in: app/views/my/page.html.erb
+
+view_my_page_splitcontent
+ Since: -
+ Context: :user
+ Called in: app/views/my/page.html.erb
+
+view_news_index_contextual
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/news/index.html.erb
+
+view_news_show_contextual
+ Since: 7.1.0
+ Context: :news, :project
+ Called in: app/views/news/show.html.erb
+
+view_projects_copy_only_items
+ Since: -
+ Context: :project, :f
+ Called in: app/views/projects/copy.html.erb
+
+view_projects_form
+ Since: -
+ Context: :project, :form
+ Called in: app/views/projects/_form.html.erb
+
+view_projects_form_bottom
+ Since: 7.1.0
+ Context: :project, :form
+ Called in: app/views/projects/_form.html.erb
+
+view_projects_form_top
+ Since: 7.1.0
+ Context: :project, :form
+ Called in: app/views/projects/_form.html.erb
+
+view_projects_index_contextual
+ Since: 7.1.0
+ Context: :query
+ Called in: app/views/projects/index.html.erb
+
+view_projects_list_row_after
+ Since: 7.1.0
+ Context: :project, :query, :colspan
+ Called in: app/views/projects/_list.html.erb
+
+view_projects_roadmap_version_bottom
+ Since: -
+ Context: :version
+ Called in: app/views/versions/index.html.erb
+
+view_projects_settings_members_table_header
+ Since: -
+ Context: :project
+ Called in: app/views/members/_members.html.erb
+
+view_projects_settings_members_table_row
+ Since: -
+ Context: :project, :member
+ Called in: app/views/members/_members.html.erb
+
+view_projects_show_actions_menu
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/projects/show.html.erb
+
+view_projects_show_contextual
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/projects/show.html.erb
+
+view_projects_show_left
+ Since: -
+ Context: :project
+ Called in: app/views/projects/show.html.erb
+
+view_projects_show_right
+ Since: -
+ Context: :project
+ Called in: app/views/projects/show.html.erb
+
+view_projects_show_sidebar_bottom
+ Since: -
+ Context: :project
+ Called in: app/views/projects/show.html.erb
+
+view_projects_sidebar_queries_bottom
+ Since: -
+ Context: -
+ Called in: app/views/projects/_sidebar.html.erb
+
+view_reports_issue_report_split_content_left
+ Since: -
+ Context: :project
+ Called in: app/views/reports/issue_report.html.erb
+
+view_reports_issue_report_split_content_right
+ Since: -
+ Context: :project
+ Called in: app/views/reports/issue_report.html.erb
+
+view_repositories_navigation_actions_menu
+ Since: 7.1.0
+ Context: :repository, :project
+ Called in: app/views/repositories/_navigation.html.erb
+
+view_repositories_show_contextual
+ Since: -
+ Context: :repository, :project
+ Called in: app/views/repositories/annotate.html.erb
+ Called in: app/views/repositories/changes.html.erb
+ Called in: app/views/repositories/entry.html.erb
+ Called in: app/views/repositories/show.html.erb
+
+view_search_index_options_content_bottom
+ Since: -
+ Context: -
+ Called in: app/views/search/index.html.erb
+
+view_settings_general_form
+ Since: -
+ Context: -
+ Called in: app/views/settings/_general.html.erb
+
+view_time_entries_bulk_edit_details_bottom
+ Since: -
+ Context: :time_entries
+ Called in: app/views/timelog/bulk_edit.html.erb
+
+view_time_entries_context_menu_end
+ Since: -
+ Context: :time_entries, :can, :back
+ Called in: app/views/context_menus/time_entries.html.erb
+
+view_time_entries_context_menu_start
+ Since: -
+ Context: :time_entries, :can, :back
+ Called in: app/views/context_menus/time_entries.html.erb
+
+view_timelog_edit_form_bottom
+ Since: -
+ Context: :time_entry, :form
+ Called in: app/views/timelog/_form.html.erb
+
+view_timelog_index_actions_menu
+ Since: 7.1.0
+ Context: :project, :query
+ Called in: app/views/timelog/index.html.erb
+
+view_timelog_index_contextual
+ Since: 7.1.0
+ Context: :project, :query
+ Called in: app/views/timelog/index.html.erb
+
+view_trackers_form_bottom
+ Since: 7.1.0
+ Context: :tracker, :form
+ Called in: app/views/trackers/_form.html.erb
+
+view_trackers_form_top
+ Since: 7.1.0
+ Context: :tracker, :form
+ Called in: app/views/trackers/_form.html.erb
+
+view_users_form
+ Since: -
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_form_after_authentication
+ Since: 7.1.0
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_form_after_mail_notifications
+ Since: 7.1.0
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_form_bottom
+ Since: 7.1.0
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_form_preferences
+ Since: -
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_form_top
+ Since: 7.1.0
+ Context: :user, :form
+ Called in: app/views/users/_form.html.erb
+
+view_users_index_actions_menu
+ Since: 7.1.0
+ Context: :query
+ Called in: app/views/users/index.html.erb
+
+view_users_index_contextual
+ Since: 7.1.0
+ Context: :query
+ Called in: app/views/users/index.html.erb
+
+view_users_list_row_after
+ Since: 7.1.0
+ Context: :user, :query, :colspan
+ Called in: app/views/users/_list.html.erb
+
+view_users_memberships_table_header
+ Since: -
+ Context: :user
+ Called in: app/helpers/principal_memberships_helper.rb
+
+view_users_memberships_table_row
+ Since: -
+ Context: :user, :membership
+ Called in: app/helpers/principal_memberships_helper.rb
+
+view_users_show_contextual
+ Since: 7.1.0
+ Context: :user
+ Called in: app/views/users/show.html.erb
+
+view_users_sidebar_queries_bottom
+ Since: -
+ Context: -
+ Called in: app/views/users/index.html.erb
+
+view_versions_form_bottom
+ Since: 7.1.0
+ Context: :version, :form
+ Called in: app/views/versions/_form.html.erb
+
+view_versions_form_top
+ Since: 7.1.0
+ Context: :version, :form
+ Called in: app/views/versions/_form.html.erb
+
+view_versions_index_actions_menu
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/versions/index.html.erb
+
+view_versions_index_contextual
+ Since: 7.1.0
+ Context: :project
+ Called in: app/views/versions/index.html.erb
+
+view_versions_show_bottom
+ Since: -
+ Context: :version
+ Called in: app/views/versions/show.html.erb
+
+view_versions_show_contextual
+ Since: -
+ Context: :version, :project
+ Called in: app/views/versions/show.html.erb
+
+view_welcome_index_left
+ Since: -
+ Context: -
+ Called in: app/views/welcome/index.html.erb
+
+view_welcome_index_right
+ Since: -
+ Context: -
+ Called in: app/views/welcome/index.html.erb
+
+view_wiki_show_actions_menu
+ Since: 7.1.0
+ Context: :page, :project
+ Called in: app/views/wiki/show.html.erb
+
+view_wiki_show_contextual
+ Since: 7.1.0
+ Context: :page, :project
+ Called in: app/views/wiki/show.html.erb
+
+view_wiki_show_sidebar_bottom
+ Since: -
+ Context: :wiki, :page
+ Called in: app/views/wiki/_sidebar.html.erb
+
diff --git a/lib/redmine/journal_details.rb b/lib/redmine/journal_details.rb
new file mode 100644
index 0000000..590990f
--- /dev/null
+++ b/lib/redmine/journal_details.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module Redmine
+ # Registry of the renderers for journal details that plugins write with a
+ # property of their own (journal_details.property), so that a plugin does not
+ # have to override IssuesHelper#show_detail.
+ #
+ # A renderer responds to +call+ with the detail, the view and the options of
+ # show_detail, and returns the line as HTML or, when +no_html+ is true, as text
+ # (the text is also used in emails):
+ #
+ # class APlugin::JournalDetail
+ # def self.call(detail, view, no_html, options)
+ # label = no_html ? 'Sample' : view.content_tag('strong', 'Sample')
+ # view.l(:text_journal_set_to, :label => label, :value => detail.value)
+ # end
+ # end
+ #
+ # Redmine::Plugin.register :a_plugin do
+ # journal_detail :property => 'a_plugin', :renderer => APlugin::JournalDetail
+ # end
+ #
+ # A renderer registered with a :prop_key is used for that key only and comes
+ # before one registered for the whole property.
+ module JournalDetails
+ @renderers = {}
+
+ class << self
+ def register(property, renderer, prop_key: nil)
+ unless renderer.respond_to?(:call)
+ raise ArgumentError, "Invalid renderer for journal detail property '#{property}'"
+ end
+
+ @renderers[[property.to_s, prop_key&.to_s]] = renderer
+ end
+
+ # Returns the renderer for +detail+, or nil when none is registered
+ def renderer_for(detail)
+ property = detail.property.to_s
+ @renderers[[property, detail.prop_key.to_s]] || @renderers[[property, nil]]
+ end
+
+ def clear
+ @renderers.clear
+ end
+ end
+ end
+end
diff --git a/lib/redmine/menu_manager.rb b/lib/redmine/menu_manager.rb
index 5d14fc1..a9d36fa 100644
--- a/lib/redmine/menu_manager.rb
+++ b/lib/redmine/menu_manager.rb
@@ -118,6 +118,52 @@ module Redmine
links.empty? ? nil : content_tag('ul', links.join.html_safe)
end
+ # Renders the items that plugins added to the menu of an object:
+ # * :issue_actions - the actions menu of an issue (object: the issue)
+ # * :issue_context_menu - the context menu of the issue list (object: the selected issues)
+ # * :project_actions - the actions menu of a project overview (object: the project)
+ # * :user_actions - the actions of a user page (object: the user)
+ # * :version_actions - the actions of a version page (object: the version)
+ #
+ # For these menus, the :if and :caption Procs and a Proc url receive the object; a
+ # Hash url gets the object as its :param (or :ids for a list of objects), and the
+ # :permission is checked in +project+.
+ # menu :issue_actions, :a_action, {:controller => 'a_plugin', :action => 'do'},
+ # :caption => :label_a_action, :icon => 'add', :if => Proc.new {|issue| issue.closed?}
+ #
+ # +list+ renders each link in a +li+ (context menu).
+ def render_object_menu_items(menu, object, project=nil, list: false)
+ links = []
+ Redmine::MenuManager.items(menu).root.children.each do |item|
+ next unless object_menu_item_allowed?(item, object, project)
+
+ url =
+ case item.url
+ when Proc then item.url.call(object)
+ when Hash
+ # a list of objects is an Array or a relation (the selected issues)
+ many = object.respond_to?(:to_ary)
+ key = many ? :ids : item.param
+ value = many ? object.to_ary.map(&:id).sort : object
+ {key => value}.merge(item.url)
+ when Symbol then send(item.url, object)
+ else item.url
+ end
+ caption = item.caption(object)
+ label = item.icon.present? ? sprite_icon(item.icon, h(caption), plugin: item.plugin) : h(caption)
+ link = link_to(label, use_absolute_controller(url), item.html_options)
+ links << (list ? content_tag('li', link) : link)
+ end
+ safe_join(links)
+ end
+
+ def object_menu_item_allowed?(item, object, project)
+ return false if project && item.permission && !User.current.allowed_to?(item.permission, project)
+ return false if item.condition && !item.condition.call(object)
+
+ true
+ end
+
def render_menu_node(node, project=nil)
if node.children.present? || !node.child_menus.nil?
return render_menu_node_with_children(node, project)
diff --git a/lib/redmine/plugin.rb b/lib/redmine/plugin.rb
index 847517b..83e3cb9 100644
--- a/lib/redmine/plugin.rb
+++ b/lib/redmine/plugin.rb
@@ -335,6 +335,48 @@ module Redmine
Redmine::MenuManager.map(menu).delete(item)
end
+ # Adds a tab to a tabbed settings page (see Redmine::Tabs for the pages and options).
+ # settings_tab :user, :a_tab, :partial => 'a_plugin/user_tab', :after => :general
+ def settings_tab(page, name, options={})
+ Redmine::Tabs.register(page, name, options)
+ end
+
+ # Adds a tab to the project settings. Without a :permission the tab needs
+ # :edit_project, as the project tab does.
+ # project_settings_tab :a_tab, :label => :label_a_tab, :partial => 'a_plugin/settings',
+ # :permission => :manage_a_plugin, :after => :members
+ def project_settings_tab(name, options={})
+ settings_tab(:project_settings, name, options)
+ end
+
+ # Adds a tab to the administration settings.
+ def administration_settings_tab(name, options={})
+ settings_tab(:administration_settings, name, options)
+ end
+
+ # Adds a tab to the user edit page.
+ def user_settings_tab(name, options={})
+ settings_tab(:user, name, options)
+ end
+
+ # Adds a tab to the group edit page.
+ def group_settings_tab(name, options={})
+ settings_tab(:group, name, options)
+ end
+
+ # Adds a tab to the history of an issue.
+ def issue_history_tab(name, options={})
+ settings_tab(:issue_history, name, options)
+ end
+
+ # Registers how the journal details of a +property+ of the plugin (optionally only
+ # those with a given +prop_key+) are rendered in the issue history, as text and in
+ # emails (see Redmine::JournalDetails).
+ # journal_detail :property => 'a_plugin', :renderer => APlugin::JournalDetail
+ def journal_detail(property:, renderer:, prop_key: nil)
+ Redmine::JournalDetails.register(property, renderer, prop_key: prop_key)
+ end
+
# Defines a permission called +name+ for the given +actions+.
#
# The +actions+ argument is a hash with controllers as keys and actions as values (a single value or an array):
diff --git a/lib/redmine/tabs.rb b/lib/redmine/tabs.rb
new file mode 100644
index 0000000..7010888
--- /dev/null
+++ b/lib/redmine/tabs.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module Redmine
+ # Registry of the tabs that plugins add to the tabbed settings pages, so that a
+ # plugin does not have to override the helper that builds the list of tabs.
+ #
+ # Pages:
+ # * :project_settings - project settings (ProjectsHelper#project_settings_tabs)
+ # * :administration_settings - administration settings (SettingsHelper#administration_settings_tabs)
+ # * :user - user edit page (UsersHelper#user_settings_tabs)
+ # * :group - group edit page (GroupsHelper#group_settings_tabs)
+ # * :issue_history - the tabs of an issue's history (IssuesHelper#issue_history_tabs);
+ # a tab given :remote => true and an :onclick loads its content as core's tabs do
+ #
+ # Options:
+ # * :partial - the partial that renders the tab (required, except for a :remote tab)
+ # * :label - the label of the tab, a symbol for l() or a string (default: :label_)
+ # * :permission - project settings only: the permission the user needs in the project
+ # * :module - project settings only: the project module that must be enabled
+ # * :if - a Proc that receives the context of the page (a Hash with :project, :user or
+ # :group) and returns false to hide the tab
+ # * :url - extra url params for the tab
+ # * :remote, :onclick - issue history only: a tab whose content is loaded on click
+ # * :after, :before - the name of the tab to place the tab after or before (appended
+ # to the list by default)
+ #
+ # Example, in a plugin's init.rb:
+ # Redmine::Plugin.register :a_plugin do
+ # project_settings_tab :a_tab, :label => :label_a_tab,
+ # :partial => 'a_plugin/settings', :permission => :manage_a_plugin, :after => :members
+ # end
+ module Tabs
+ PAGES = [:project_settings, :administration_settings, :user, :group, :issue_history].freeze
+
+ @registered = Hash.new {|h, k| h[k] = []}
+
+ class << self
+ def register(page, name, options={})
+ page = page.to_sym
+ raise ArgumentError, "Unknown tabs page '#{page}'" unless PAGES.include?(page)
+ if options[:partial].blank? && !options[:remote]
+ raise ArgumentError, "Missing :partial for tab '#{name}'"
+ end
+ if options[:if] && !options[:if].respond_to?(:call)
+ raise ArgumentError, "Invalid option :if for tab '#{name}'"
+ end
+
+ @registered[page].reject! {|tab| tab[:name] == name.to_s}
+ @registered[page] << options.merge(:name => name.to_s)
+ end
+
+ # Returns the tabs registered for +page+
+ def registered(page)
+ @registered[page.to_sym].dup
+ end
+
+ # Removes the registered tabs (all of them or those of +page+)
+ def clear(page=nil)
+ page ? @registered.delete(page.to_sym) : @registered.clear
+ end
+
+ # Returns +tabs+ (core's tabs, an Array of Hash) with the tabs registered for
+ # +page+ added at their place. +context+ is given to the :if option.
+ def add_to(page, tabs, context={})
+ tabs = tabs.dup
+ registered(page).each do |options|
+ next if options[:if] && !options[:if].call(context)
+
+ tab = {:name => options[:name], :label => options[:label] || :"label_#{options[:name]}"}
+ tab[:partial] = options[:partial] if options[:partial]
+ tab[:action] = options[:permission] if options[:permission]
+ tab[:module] = options[:module] if options[:module]
+ tab[:url] = options[:url] if options[:url]
+ tab[:remote] = options[:remote] if options.key?(:remote)
+ tab[:onclick] = options[:onclick] if options[:onclick]
+ insert(tabs, tab, options)
+ end
+ tabs
+ end
+
+ private
+
+ def insert(tabs, tab, options)
+ anchor = options[:after] || options[:before]
+ index = anchor && tabs.index {|t| t[:name].to_s == anchor.to_s}
+ if index.nil?
+ tabs << tab
+ elsif options[:after]
+ tabs.insert(index + 1, tab)
+ else
+ tabs.insert(index, tab)
+ end
+ end
+ end
+ end
+end
diff --git a/test/integration/lib/redmine/view_hooks_test.rb b/test/integration/lib/redmine/view_hooks_test.rb
new file mode 100644
index 0000000..14cb25e
--- /dev/null
+++ b/test/integration/lib/redmine/view_hooks_test.rb
@@ -0,0 +1,207 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+# The hooks, registries and menus that let plugins add to pages without
+# overriding core views or helpers: each one is called on its page, and what a
+# plugin adds through them leaves the page's HTML as valid as it was.
+class ViewHooksTest < Redmine::IntegrationTest
+ # The pages and the hooks each one must call
+ PAGES = {
+ '/projects/ecookbook' => %w(
+ view_layouts_base_header_top view_layouts_base_header_bottom view_layouts_base_sidebar_top
+ view_layouts_base_content_top view_layouts_base_footer_top view_layouts_base_footer_bottom
+ view_projects_show_contextual view_projects_show_actions_menu),
+ '/projects/ecookbook/issues' => %w(view_issues_index_contextual view_issues_index_actions_menu view_issues_list_row_after),
+ '/issues/1' => %w(
+ view_issues_show_contextual view_issues_show_actions_menu view_issues_show_top view_issues_show_details_end
+ view_issues_show_bottom),
+ '/projects/ecookbook/issues/new' => %w(
+ view_issues_form_after_subject view_issues_form_after_description view_issues_form_after_attributes
+ view_issues_form_custom_fields_top view_issues_form_custom_fields_bottom),
+ '/projects/ecookbook/settings' => %w(view_projects_form_top view_projects_form_bottom),
+ '/projects?display_type=list' => %w(view_projects_index_contextual view_projects_list_row_after),
+ '/users' => %w(view_users_index_contextual view_users_index_actions_menu view_users_list_row_after),
+ '/users/2' => %w(view_users_show_contextual),
+ '/users/2/edit' => %w(
+ view_users_form_top view_users_form_after_authentication view_users_form_after_mail_notifications
+ view_users_form_bottom),
+ '/groups/10' => %w(view_groups_show_contextual),
+ '/groups/10/edit' => %w(view_groups_form_top view_groups_form_bottom),
+ '/versions/2/edit' => %w(view_versions_form_top view_versions_form_bottom),
+ '/projects/ecookbook/roadmap' => %w(view_versions_index_contextual view_versions_index_actions_menu),
+ '/projects/ecookbook/wiki' => %w(view_wiki_show_contextual view_wiki_show_actions_menu),
+ '/projects/ecookbook/news' => %w(view_news_index_contextual),
+ '/news/1' => %w(view_news_show_contextual),
+ '/projects/ecookbook/documents' => %w(view_documents_index_contextual),
+ '/documents/1' => %w(view_documents_show_contextual),
+ '/projects/ecookbook/boards/1' => %w(view_boards_show_contextual),
+ '/boards/1/topics/1' => %w(view_messages_show_contextual),
+ '/projects/ecookbook/time_entries' => %w(view_timelog_index_contextual view_timelog_index_actions_menu),
+ '/trackers/1/edit' => %w(view_trackers_form_top view_trackers_form_bottom),
+ '/admin/plugins' => %w(view_layouts_base_title_after)
+ }
+ ROW_CLASSES = {
+ '/projects/ecookbook/issues' => 'view_issues_list_row_css_classes',
+ '/projects?display_type=list' => 'view_projects_list_row_css_classes',
+ '/users' => 'view_users_list_row_css_classes'
+ }
+
+ # Answers every hook with a marker that is valid where the hook is called
+ class MarkerListener < Redmine::Hook::ViewListener
+ (PAGES.values.flatten - ROW_CLASSES.values).uniq.each do |hook|
+ define_method(hook) do |context|
+ if hook.end_with?('_row_after')
+ content_tag('tr', content_tag('td', 'marker', :colspan => context[:colspan]), :class => "hook-#{hook}")
+ else
+ content_tag('span', 'marker', :class => "hook-#{hook}")
+ end
+ end
+ end
+ ROW_CLASSES.each_value do |hook|
+ define_method(hook) {|context| "hook-#{hook}"}
+ end
+ end
+
+ class DetailRenderer
+ def self.call(detail, view, no_html, options)
+ text = "Sample changed to #{detail.value}"
+ no_html ? text : view.content_tag('span', text, :class => 'sample-detail')
+ end
+ end
+
+ def setup
+ Redmine::Hook.clear_listeners
+ log_user('admin', 'admin')
+ end
+
+ def teardown
+ Redmine::Hook.clear_listeners
+ Redmine::Tabs.clear
+ Redmine::JournalDetails.clear
+ %w(issue_actions issue_context_menu project_actions user_actions version_actions).each do |menu|
+ Redmine::MenuManager.map(menu).delete(:sample)
+ end
+ end
+
+ def html_errors
+ Nokogiri::HTML5(response.body, :max_errors => 100).errors.map(&:to_s)
+ end
+
+ def test_hooks_should_be_called_on_their_pages
+ Redmine::Hook.add_listener(MarkerListener)
+ PAGES.each do |path, hooks|
+ get path
+ assert_response :success, path
+ hooks.each do |hook|
+ assert_select ".hook-#{hook}", {:minimum => 1}, "#{hook} not called on #{path}"
+ end
+ end
+ ROW_CLASSES.each do |path, hook|
+ get path
+ assert_select "tr.hook-#{hook}", {:minimum => 1}, "#{hook} not called on #{path}"
+ end
+ end
+
+ def test_contextual_hook_should_be_before_the_actions_menu_and_the_menu_hook_inside_it
+ Redmine::Hook.add_listener(MarkerListener)
+ PAGES.each do |path, hooks|
+ menu = hooks.detect {|hook| hook.end_with?('_actions_menu')}
+ next unless menu
+
+ contextual = hooks.detect {|hook| hook.end_with?('_contextual')}
+ get path
+ assert_select "div.contextual .hook-#{contextual} ~ span.dropdown", {:minimum => 1}, "#{contextual} not before the menu on #{path}"
+ assert_select "span.dropdown .dropdown-items .hook-#{menu}", {:minimum => 1}, "#{menu} not in the menu on #{path}"
+ end
+ end
+
+ def test_hook_output_should_not_make_the_html_invalid
+ PAGES.each_key do |path|
+ Redmine::Hook.clear_listeners
+ get path
+ without = html_errors
+ Redmine::Hook.add_listener(MarkerListener)
+ get path
+ assert_equal without, html_errors, "The hooks made the HTML of #{path} invalid"
+ end
+ end
+
+ def test_row_css_classes_should_be_escaped
+ listener = Class.new(Redmine::Hook::ViewListener) do
+ define_method(:view_issues_list_row_css_classes) {|context| '">'}
+ end
+ Redmine::Hook.add_listener(listener)
+ get '/projects/ecookbook/issues'
+ assert_response :success
+ assert_not_includes response.body, ''
+ end
+
+ def test_registered_tabs_should_be_shown
+ # The label is a locale key, as for core's tabs
+ Redmine::Tabs.register :project_settings, :sample, :partial => 'common/preview', :label => :label_preview, :after => :members
+ Redmine::Tabs.register :user, :sample, :partial => 'common/preview', :label => :label_preview
+ Redmine::Tabs.register :group, :sample, :partial => 'common/preview', :label => :label_preview
+ Redmine::Tabs.register :administration_settings, :sample, :partial => 'common/preview', :label => :label_preview
+ Redmine::Tabs.register :issue_history, :sample, :remote => true, :label => :label_preview, :onclick => 'return false;'
+ ['/projects/ecookbook/settings', '/users/2/edit', '/groups/10/edit', '/settings', '/issues/1'].each do |path|
+ get path
+ assert_response :success, path
+ assert_select 'div.tabs a#tab-sample', {:text => I18n.t(:label_preview)}, "The tab is not on #{path}"
+ end
+ end
+
+ def test_project_settings_tab_should_require_its_permission
+ Redmine::Tabs.register :project_settings, :sample, :partial => 'common/preview', :permission => :manage_sample
+ get '/projects/ecookbook/settings'
+ assert_select 'a#tab-sample', 0
+ end
+
+ def test_registered_journal_detail_should_be_rendered_by_its_renderer
+ Redmine::JournalDetails.register 'sample_plugin', DetailRenderer
+ JournalDetail.create!(:journal => Journal.find(1), :property => 'sample_plugin', :prop_key => 'level', :value => 'high')
+ get '/issues/1'
+ assert_select 'span.sample-detail', :text => 'Sample changed to high'
+ end
+
+ def test_object_menus_should_show_the_items_of_plugins
+ options = {:caption => 'Sample action', :html => {:class => 'icon sample-action'}}
+ Redmine::MenuManager.map(:issue_actions).push :sample, {:controller => 'issues', :action => 'show'}, options
+ Redmine::MenuManager.map(:project_actions).push :sample, Proc.new {|project| "/projects/#{project.identifier}"}, options
+ Redmine::MenuManager.map(:user_actions).push :sample, Proc.new {|user| "/users/#{user.id}"}, options
+ Redmine::MenuManager.map(:version_actions).push :sample, '/versions', options.merge(:if => Proc.new {|version| version.id == 2})
+ Redmine::MenuManager.map(:issue_context_menu).push :sample, {:controller => 'issues', :action => 'index'}, options
+
+ get '/issues/1'
+ assert_select 'a.sample-action[href="/issues/1"]', :text => 'Sample action'
+ get '/projects/ecookbook'
+ assert_select 'a.sample-action[href="/projects/ecookbook"]'
+ get '/users/2'
+ assert_select 'a.sample-action[href="/users/2"]'
+ get '/versions/2'
+ assert_select 'a.sample-action'
+ get '/versions/3'
+ assert_select 'a.sample-action', 0
+ get '/issues/context_menu', :params => {:ids => [1, 2]}, :xhr => true
+ # the context menu links are full URLs
+ assert_select 'li a.sample-action[href$=?]', '/issues?ids%5B%5D=1&ids%5B%5D=2', {},
+ "Links found: #{css_select('a.sample-action').pluck('href').inspect}"
+ end
+end
diff --git a/test/unit/lib/redmine/hook_list_test.rb b/test/unit/lib/redmine/hook_list_test.rb
new file mode 100644
index 0000000..87c5c96
--- /dev/null
+++ b/test/unit/lib/redmine/hook_list_test.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+# doc/HOOKS lists the hooks that Redmine calls; plugins rely on that list
+class Redmine::HookListTest < ActiveSupport::TestCase
+ def documented_hooks
+ Rails.root.join('doc/HOOKS').readlines.filter_map {|line| line[/\A([a-z0-9_]+)\s*\z/, 1]}
+ end
+
+ def called_hooks
+ Dir.glob(Rails.root.join('{app,lib}/**/*.{erb,rb}')).flat_map do |path|
+ File.read(path).scan(/call_hook\(?\s*:([a-z0-9_]+)/).flatten
+ end.uniq
+ end
+
+ def test_every_hook_called_should_be_documented
+ missing = called_hooks - documented_hooks
+ assert missing.empty?, "Hooks called but not listed in doc/HOOKS: #{missing.sort.join(', ')}"
+ end
+
+ def test_every_documented_hook_should_be_called
+ gone = documented_hooks - called_hooks
+ assert gone.empty?, "Hooks listed in doc/HOOKS but no longer called: #{gone.sort.join(', ')}"
+ end
+end
diff --git a/test/unit/lib/redmine/journal_details_test.rb b/test/unit/lib/redmine/journal_details_test.rb
new file mode 100644
index 0000000..ce5c2e9
--- /dev/null
+++ b/test/unit/lib/redmine/journal_details_test.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::JournalDetailsTest < ActiveSupport::TestCase
+ def setup
+ Redmine::JournalDetails.clear
+ end
+
+ def teardown
+ Redmine::JournalDetails.clear
+ end
+
+ def test_renderer_for_should_return_the_renderer_of_the_property
+ renderer = Proc.new {|detail, view, no_html, options| 'foo'}
+ Redmine::JournalDetails.register 'foo_plugin', renderer
+ assert_equal renderer, Redmine::JournalDetails.renderer_for(JournalDetail.new(:property => 'foo_plugin', :prop_key => 'any'))
+ assert_nil Redmine::JournalDetails.renderer_for(JournalDetail.new(:property => 'attr', :prop_key => 'subject'))
+ end
+
+ def test_renderer_for_should_prefer_the_renderer_of_the_prop_key
+ whole = Proc.new {'whole'}
+ one = Proc.new {'one'}
+ Redmine::JournalDetails.register 'foo_plugin', whole
+ Redmine::JournalDetails.register 'foo_plugin', one, prop_key: 'bar'
+ assert_equal one, Redmine::JournalDetails.renderer_for(JournalDetail.new(:property => 'foo_plugin', :prop_key => 'bar'))
+ assert_equal whole, Redmine::JournalDetails.renderer_for(JournalDetail.new(:property => 'foo_plugin', :prop_key => 'baz'))
+ end
+
+ def test_register_should_refuse_a_renderer_that_does_not_respond_to_call
+ assert_raise(ArgumentError) {Redmine::JournalDetails.register 'foo_plugin', 'not a renderer'}
+ end
+
+ def test_plugin_should_register_a_journal_detail_renderer
+ renderer = Proc.new {'foo'}
+ Redmine::Plugin.register :journal_details_test_plugin do
+ directory Rails.root.join('test/fixtures/plugins/foo_plugin').to_s
+ journal_detail :property => 'foo_plugin', :renderer => renderer
+ end
+ assert_equal renderer, Redmine::JournalDetails.renderer_for(JournalDetail.new(:property => 'foo_plugin'))
+ ensure
+ Redmine::Plugin.unregister(:journal_details_test_plugin)
+ end
+end
diff --git a/test/unit/lib/redmine/tabs_test.rb b/test/unit/lib/redmine/tabs_test.rb
new file mode 100644
index 0000000..04398ca
--- /dev/null
+++ b/test/unit/lib/redmine/tabs_test.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::TabsTest < ActiveSupport::TestCase
+ def setup
+ Redmine::Tabs.clear
+ end
+
+ def teardown
+ Redmine::Tabs.clear
+ end
+
+ def core_tabs
+ [{:name => 'general', :partial => 'x/general'}, {:name => 'members', :partial => 'x/members'}]
+ end
+
+ def test_add_to_should_append_a_registered_tab
+ Redmine::Tabs.register :project_settings, :foo, :partial => 'foo/settings'
+ tabs = Redmine::Tabs.add_to(:project_settings, core_tabs)
+ assert_equal %w(general members foo), tabs.pluck(:name)
+ assert_equal({:name => 'foo', :partial => 'foo/settings', :label => :label_foo}, tabs.last)
+ end
+
+ def test_add_to_should_not_change_the_given_tabs
+ Redmine::Tabs.register :project_settings, :foo, :partial => 'foo/settings'
+ tabs = core_tabs
+ Redmine::Tabs.add_to(:project_settings, tabs)
+ assert_equal 2, tabs.size
+ end
+
+ def test_add_to_should_place_a_tab_after_or_before_another
+ Redmine::Tabs.register :user, :after_general, :partial => 'a', :after => :general
+ Redmine::Tabs.register :user, :before_general, :partial => 'b', :before => 'general'
+ tabs = Redmine::Tabs.add_to(:user, core_tabs)
+ assert_equal %w(before_general general after_general members), tabs.pluck(:name)
+ end
+
+ def test_add_to_should_append_a_tab_placed_after_an_unknown_tab
+ Redmine::Tabs.register :group, :foo, :partial => 'foo', :after => :unknown
+ assert_equal %w(general members foo), Redmine::Tabs.add_to(:group, core_tabs).pluck(:name)
+ end
+
+ def test_add_to_should_map_permission_and_module_to_the_keys_core_filters_on
+ Redmine::Tabs.register :project_settings, :foo, :partial => 'foo',
+ :permission => :manage_foo, :module => :foo_module, :label => 'Foo'
+ tab = Redmine::Tabs.add_to(:project_settings, []).first
+ assert_equal :manage_foo, tab[:action]
+ assert_equal :foo_module, tab[:module]
+ assert_equal 'Foo', tab[:label]
+ end
+
+ def test_add_to_should_skip_a_tab_whose_condition_is_false
+ Redmine::Tabs.register :group, :foo, :partial => 'foo', :if => Proc.new {|context| context[:group].nil?}
+ assert_equal 1, Redmine::Tabs.add_to(:group, []).size
+ assert_equal 0, Redmine::Tabs.add_to(:group, [], :group => Group.new).size
+ end
+
+ def test_register_should_replace_a_tab_with_the_same_name
+ Redmine::Tabs.register :user, :foo, :partial => 'one'
+ Redmine::Tabs.register :user, :foo, :partial => 'two'
+ assert_equal ['two'], Redmine::Tabs.registered(:user).pluck(:partial)
+ end
+
+ def test_register_should_refuse_an_unknown_page_or_a_missing_partial
+ assert_raise(ArgumentError) {Redmine::Tabs.register :unknown_page, :foo, :partial => 'foo'}
+ assert_raise(ArgumentError) {Redmine::Tabs.register :user, :foo}
+ assert_raise(ArgumentError) {Redmine::Tabs.register :user, :foo, :partial => 'foo', :if => true}
+ end
+
+ def test_plugin_should_register_tabs
+ Redmine::Plugin.register :tabs_test_plugin do
+ directory Rails.root.join('test/fixtures/plugins/foo_plugin').to_s
+ project_settings_tab :foo, :partial => 'foo/project'
+ administration_settings_tab :foo, :partial => 'foo/admin'
+ user_settings_tab :foo, :partial => 'foo/user'
+ group_settings_tab :foo, :partial => 'foo/group'
+ end
+ assert_equal ['foo/project'], Redmine::Tabs.registered(:project_settings).pluck(:partial)
+ assert_equal ['foo/admin'], Redmine::Tabs.registered(:administration_settings).pluck(:partial)
+ assert_equal ['foo/user'], Redmine::Tabs.registered(:user).pluck(:partial)
+ assert_equal ['foo/group'], Redmine::Tabs.registered(:group).pluck(:partial)
+ ensure
+ Redmine::Plugin.unregister(:tabs_test_plugin)
+ end
+end