diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f906d0d..96f495f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -893,7 +893,8 @@ module ApplicationHelper end end html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s} - content_tag('h2', strings.join(' » ').html_safe) + content_tag('h2', strings.join(' » ').html_safe) + + call_hook(:view_layouts_base_title_after, :title => args) end # Sets the html title @@ -1807,13 +1808,24 @@ module ApplicationHelper end def sidebar_content? - content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present? + content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present? || + view_layouts_base_sidebar_top_hook_response.present? end def view_layouts_base_sidebar_hook_response @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar) end + # The CSS classes that plugins add to an element through +hook+, as a plain + # (not html_safe) String so that it is escaped in the attribute + def css_classes_from_hook(hook, context={}) + String.new(call_hook(hook, context)).split.join(' ') + end + + def view_layouts_base_sidebar_top_hook_response + @view_layouts_base_sidebar_top_hook_response ||= call_hook(:view_layouts_base_sidebar_top) + end + def email_delivery_enabled? !!ActionMailer::Base.perform_deliveries end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index c5bed08..3439905 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -23,7 +23,7 @@ module GroupsHelper tabs << {:name => 'general', :partial => 'groups/general', :label => :label_general} tabs << {:name => 'users', :partial => 'groups/users', :label => :label_user_plural} if group.givable? tabs << {:name => 'memberships', :partial => 'groups/memberships', :label => :label_project_plural} - tabs + Redmine::Tabs.add_to(:group, tabs, :group => group) end def render_principals_for_new_group_users(group, limit=100) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index fbfa7fb..2269d02 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -522,6 +522,10 @@ module IssuesHelper # Returns the textual representation of a single journal detail def show_detail(detail, no_html=false, options={}) + if (renderer = Redmine::JournalDetails.renderer_for(detail)) + return renderer.call(detail, self, no_html, options) + end + multiple = false show_diff = false no_details = false @@ -758,7 +762,7 @@ module IssuesHelper "'#{issue_path(@issue, :tab => 'changesets')}')" } end - tabs + Redmine::Tabs.add_to(:issue_history, tabs, :issue => @issue) end def issue_history_default_tab diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 9ec6a76..ffd4578 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -42,6 +42,8 @@ module ProjectsHelper {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :label_time_tracking} ] + # A plugin tab without a :permission needs the one of the project tab + tabs = Redmine::Tabs.add_to(:project_settings, tabs, :project => @project).each {|tab| tab[:action] ||= :edit_project} tabs. select {|tab| User.current.allowed_to?(tab[:action], @project)}. select {|tab| tab[:module].nil? || @project.module_enabled?(tab[:module])} diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 89cbc62..6d55deb 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -39,6 +39,7 @@ module SettingsHelper {:name => 'repositories', :partial => 'settings/repositories', :label => :label_repository_plural} ] + Redmine::Tabs.add_to(:administration_settings, tabs) end def render_settings_error(errors) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 0da236e..89b2d7e 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -98,6 +98,6 @@ module UsersHelper if Group.givable.any? tabs.insert 1, {:name => 'groups', :partial => 'users/groups', :label => :label_group_plural} end - tabs + Redmine::Tabs.add_to(:user, tabs, :user => @user) end end diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 2fd6c60..caaa483 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -10,6 +10,7 @@ {:controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'}, :class => 'icon icon-settings' if User.current.allowed_to?(:manage_boards, @project) %> +<%= call_hook(:view_boards_show_contextual, {:board => @board, :project => @project}) %> diff --git a/app/views/issues/_form.html.erb b/app/views/issues/_form.html.erb index a97b854..261e1d0 100644 --- a/app/views/issues/_form.html.erb +++ b/app/views/issues/_form.html.erb @@ -28,6 +28,7 @@ <% if @issue.safe_attribute? 'subject' %>

<%= f.text_field :subject, :size => 80, :maxlength => 255, :required => true %>

<% end %> +<%= call_hook(:view_issues_form_after_subject, { :issue => @issue, :form => f }) %> <% if @issue.safe_attribute? 'description' %>

@@ -44,10 +45,12 @@

<%= wikitoolbar_for 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) %> <% end %> +<%= call_hook(:view_issues_form_after_description, { :issue => @issue, :form => f }) %>
<%= render :partial => 'issues/attributes' %>
+<%= call_hook(:view_issues_form_after_attributes, { :issue => @issue, :form => f }) %> <%= call_hook(:view_issues_form_details_bottom, { :issue => @issue, :form => f }) %> <% end %> diff --git a/app/views/issues/_form_custom_fields.html.erb b/app/views/issues/_form_custom_fields.html.erb index fddcd74..2da460f 100644 --- a/app/views/issues/_form_custom_fields.html.erb +++ b/app/views/issues/_form_custom_fields.html.erb @@ -1,6 +1,7 @@ <% custom_field_values = @issue.editable_custom_field_values %> <% custom_field_values_full_width = custom_field_values.select { |value| value.custom_field.full_width_layout? } %> <% custom_field_values -= custom_field_values_full_width %> +<%= call_hook(:view_issues_form_custom_fields_top, { :issue => @issue, :custom_field_values => custom_field_values }) %> <% if custom_field_values.present? %>
@@ -22,3 +23,4 @@

<%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %>

<%= wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %> <% end %> +<%= call_hook(:view_issues_form_custom_fields_bottom, { :issue => @issue, :custom_field_values => custom_field_values }) %> diff --git a/app/views/issues/_list.html.erb b/app/views/issues/_list.html.erb index b181195..7608762 100644 --- a/app/views/issues/_list.html.erb +++ b/app/views/issues/_list.html.erb @@ -35,7 +35,7 @@ <% end %> - "> + <%= css_classes_from_hook(:view_issues_list_row_css_classes, {:issue => issue, :query => query}) %>"> <%= check_box_tag("ids[]", issue.id, false, :id => nil) %> <% query.inline_columns.each do |column| %> <%= content_tag('td', column_content(column, issue), :class => column.css_classes) %> @@ -54,6 +54,7 @@ <% end -%> <% end -%> + <%= call_hook(:view_issues_list_row_after, {:issue => issue, :query => query, :colspan => query.inline_columns.size + 2}) %> <% end -%> diff --git a/app/views/issues/index.html.erb b/app/views/issues/index.html.erb index aa88291..2e93b4a 100644 --- a/app/views/issues/index.html.erb +++ b/app/views/issues/index.html.erb @@ -2,6 +2,7 @@ <% if User.current.allowed_to?(:add_issues, @project, :global => true) && (@project.nil? || Issue.allowed_target_trackers(@project).any?) %> <%= link_to sprite_icon('add', l(:label_issue_new)), _new_project_issue_path(@project), :class => 'icon icon-add new-issue' %> <% end %> + <%= call_hook(:view_issues_index_contextual, {:project => @project, :query => @query}) %> <%= actions_dropdown do %> <% if @project %> <%= link_to sprite_icon('summary', l(:field_summary)), project_issues_report_path(@project), :class => 'icon icon-stats' %> @@ -14,6 +15,7 @@ <%= link_to_if_authorized sprite_icon('settings', l(:label_settings)), {:controller => 'projects', :action => 'settings', :id => @project, :tab => 'issues'}, :class => 'icon icon-settings' if User.current.allowed_to?(:edit_project, @project) %> + <%= call_hook(:view_issues_index_actions_menu, {:project => @project, :query => @query}) %> <% end %>
diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 3b5a19d..c240fbe 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -4,6 +4,7 @@ <% if @issue.is_private? %> <%= l(:field_is_private) %> <% end %> +<%= call_hook(:view_issues_show_top, :issue => @issue) %>
<% if @prev_issue_id || @next_issue_id %> @@ -143,8 +144,9 @@ end %> <%= render :partial => 'relations' %> <% end %> <% end %> - +<%= call_hook(:view_issues_show_details_end, :issue => @issue) %>
+<%= call_hook(:view_issues_show_bottom, :issue => @issue) %> <%= render partial: 'action_menu_edit' if User.current.wants_comments_in_reverse_order? %> diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index e4ffb42..a1302a7 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -88,6 +88,7 @@ <% end %> + <%= call_hook :view_layouts_base_header_bottom %>

<%= avatar(@topic.author) %><%= @topic.subject %>

diff --git a/app/views/news/index.html.erb b/app/views/news/index.html.erb index addb5d8..04fe812 100644 --- a/app/views/news/index.html.erb +++ b/app/views/news/index.html.erb @@ -4,6 +4,7 @@ :class => 'icon icon-add add-news-link', :onclick => 'showAndScrollTo("add-news", "news_title"); return false;') if User.current.allowed_to?(:manage_news, @project, global: true) %> <%= watcher_link(@project.enabled_module('news'), User.current) if @project && User.current.logged? %> +<%= call_hook(:view_news_index_contextual, {:project => @project}) %>

<%= avatar(@news.author) %> <%=h @news.title %>

diff --git a/app/views/projects/_form.html.erb b/app/views/projects/_form.html.erb index 33c8010..2e865c3 100644 --- a/app/views/projects/_form.html.erb +++ b/app/views/projects/_form.html.erb @@ -2,6 +2,7 @@
+<%= call_hook(:view_projects_form_top, :project => @project, :form => f) %>

<%= f.text_field :name, :required => true, :size => 60 %>

<%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %>

@@ -42,6 +43,7 @@ <%= hidden_field_tag 'project[enabled_module_names][]', '' %> <% end %> +<%= call_hook(:view_projects_form_bottom, :project => @project, :form => f) %> <% unless @project.identifier_frozen? %> diff --git a/app/views/projects/_list.html.erb b/app/views/projects/_list.html.erb index 2f5c83f..c749d47 100644 --- a/app/views/projects/_list.html.erb +++ b/app/views/projects/_list.html.erb @@ -40,7 +40,7 @@ <% end %> <% end %> - "> + <%= css_classes_from_hook(:view_projects_list_row_css_classes, {:project => entry, :query => @query}) %>"> <% if @admin_list %> <% if !entry.scheduled_for_deletion? %> <%= check_box_tag("ids[]", entry.id, false, :id => nil) %> @@ -59,6 +59,7 @@ <% end %> <% end %> + <%= call_hook(:view_projects_list_row_after, {:project => entry, :query => @query, :colspan => @query.inline_columns.size + (@admin_list ? 2 : 0)}) %> <% end -%> diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb index 875e736..7a80596 100644 --- a/app/views/projects/index.html.erb +++ b/app/views/projects/index.html.erb @@ -2,6 +2,7 @@ <%= form_tag({}, :method => :get) do %> <% end %> <%= render_project_action_links %> + <%= call_hook(:view_projects_index_contextual, {:query => @query}) %>

<%= @query.new_record? ? l(:label_project_plural) : @query.name %>

diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index ffbfcce..d6a530d 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -1,6 +1,7 @@
<%= bookmark_link @project %> + <%= call_hook(:view_projects_show_contextual, {:project => @project}) %> <%= actions_dropdown do %> <% if User.current.allowed_to?(:add_subprojects, @project) %> <%= link_to sprite_icon('add', l(:label_subproject_new)), new_project_path(:parent_id => @project), :class => 'icon icon-add' %> @@ -18,6 +19,8 @@ <%= link_to_if_authorized sprite_icon('settings', l(:label_settings)), {:controller => 'projects', :action => 'settings', :id => @project}, :class => 'icon icon-settings' if User.current.allowed_to?(:edit_project, @project) %> + <%= render_object_menu_items(:project_actions, @project, @project) %> + <%= call_hook(:view_projects_show_actions_menu, {:project => @project}) %> <% end %>
diff --git a/app/views/repositories/_navigation.html.erb b/app/views/repositories/_navigation.html.erb index dfdac39..1214112 100644 --- a/app/views/repositories/_navigation.html.erb +++ b/app/views/repositories/_navigation.html.erb @@ -22,6 +22,7 @@ <%= link_to sprite_icon('reload', l(:button_fetch_changesets)), {:action => :fetch_changesets, :id => @project, :repository_id => @repository.identifier_param}, :class => 'icon icon-reload', :method => :post if User.current.allowed_to?(:manage_repository, @project) && !Setting.autofetch_changesets? %> + <%= call_hook(:view_repositories_navigation_actions_menu, {:repository => @repository, :project => @project}) %> <% end %> <%= form_tag({:action => controller.action_name, diff --git a/app/views/timelog/index.html.erb b/app/views/timelog/index.html.erb index 5e61dd2..033b077 100644 --- a/app/views/timelog/index.html.erb +++ b/app/views/timelog/index.html.erb @@ -2,6 +2,7 @@ <%= link_to sprite_icon('time-add', l(:button_log_time)), _new_time_entry_path(@project, @query.filtered_issue_id), :class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %> +<%= call_hook(:view_timelog_index_contextual, {:project => @project, :query => @query}) %> <%= actions_dropdown do %> <% if User.current.allowed_to?(:import_time_entries, @project, :global => true) && User.current.allowed_to?(:log_time, @project, :global => true) %> <%= link_to sprite_icon('import', l(:button_import)), new_time_entries_import_path(:project_id => @project), :class => 'icon icon-import' %> @@ -10,6 +11,7 @@ <%= link_to_if_authorized sprite_icon('settings', l(:label_settings)), {:controller => 'projects', :action => 'settings', :id => @project, :tab => 'activities'}, :class => 'icon icon-settings' if User.current.allowed_to?(:manage_project_activities, @project) %> + <%= call_hook(:view_timelog_index_actions_menu, {:project => @project, :query => @query}) %> <% end %> diff --git a/app/views/trackers/_form.html.erb b/app/views/trackers/_form.html.erb index c355d89..9a9dc4b 100644 --- a/app/views/trackers/_form.html.erb +++ b/app/views/trackers/_form.html.erb @@ -4,6 +4,7 @@
+<%= call_hook(:view_trackers_form_top, :tracker => @tracker, :form => f) %>

<%= f.text_field :name, :required => true %>

<%= f.select :default_status_id, IssueStatus.sorted.map {|s| [s.name, s.id]}, @@ -42,6 +43,7 @@

<%= select_tag(:copy_workflow_from, content_tag("option") + options_from_collection_for_select(@trackers, :id, :name, params[:copy_workflow_from] || @copy_from.try(:id))) %>

<% end %> +<%= call_hook(:view_trackers_form_bottom, :tracker => @tracker, :form => f) %>
<%= submit_tag l(@tracker.new_record? ? :button_create : :button_save), class: "mobile-hide" %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 07ecbf2..275ee06 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -2,6 +2,7 @@
+<%= call_hook(:view_users_form_top, :user => @user, :form => f) %>
@@ -60,6 +61,7 @@

<% end -%>
+<%= call_hook(:view_users_form_after_authentication, :user => @user, :form => f) %>

<% if @user.new_record? %> <%= submit_tag l(:button_create) %> @@ -75,6 +77,7 @@ <%=l(:field_mail_notification)%> <%= render :partial => 'users/mail_notifications' %> +<%= call_hook(:view_users_form_after_mail_notifications, :user => @user, :form => f) %>

<%=l(:label_auto_watch_on)%> @@ -86,6 +89,7 @@ <%= render :partial => 'users/preferences' %> <%= call_hook(:view_users_form_preferences, :user => @user, :form => f) %>
+<%= call_hook(:view_users_form_bottom, :user => @user, :form => f) %>
diff --git a/app/views/users/_list.html.erb b/app/views/users/_list.html.erb index b5d85c6..64c4b58 100644 --- a/app/views/users/_list.html.erb +++ b/app/views/users/_list.html.erb @@ -31,7 +31,7 @@ <% end %> - <%= user.css_classes %>"> + <%= user.css_classes %> <%= css_classes_from_hook(:view_users_list_row_css_classes, {:user => user, :query => @query}) %>"> <%= check_box_tag("ids[]", user.id, false, id: nil) %> <% @query.inline_columns.each do |column| %> <% if column.name == :login %> @@ -56,6 +56,7 @@ <% end -%> <% end -%> + <%= call_hook(:view_users_list_row_after, {:user => user, :query => @query, :colspan => @query.inline_columns.size + 2}) %> <% end -%> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 9790700..5be352b 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -8,7 +8,7 @@ <%= page_title = title [l(:label_user_plural), users_path], @user.login - page_title.insert(page_title.rindex(' ') + 1, avatar(@user).to_s) + page_title.insert(page_title.rindex(' ', page_title.index('')) + 1, avatar(@user).to_s) %> <%= render_tabs user_settings_tabs %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index eb975a5..bd4e28f 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,9 +1,11 @@
<%= link_to sprite_icon('add', l(:label_user_new)), new_user_path, :class => 'icon icon-add' %> + <%= call_hook(:view_users_index_contextual, {:query => @query}) %> <%= actions_dropdown do %> <% if User.current.allowed_to?(:import_users, nil, :global => true) %> <%= link_to sprite_icon('import', l(:button_import)), new_users_import_path, :class => 'icon icon-import' %> <% end %> + <%= call_hook(:view_users_index_actions_menu, {:query => @query}) %> <% end %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index a221927..9229732 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,5 +1,7 @@
<%= link_to(sprite_icon('edit', l(:button_edit)), edit_user_path(@user), :class => 'icon icon-edit') if User.current.admin? && @user.logged? %> +<%= render_object_menu_items(:user_actions, @user) %> +<%= call_hook(:view_users_show_contextual, {:user => @user}) %>

<%= avatar_edit_link(@user, :size => "50") %> <%= @user.name %>

diff --git a/app/views/versions/_form.html.erb b/app/views/versions/_form.html.erb index 6211976..6aaed70 100644 --- a/app/views/versions/_form.html.erb +++ b/app/views/versions/_form.html.erb @@ -2,6 +2,7 @@ <%= error_messages_for 'version' %>
+<%= call_hook(:view_versions_form_top, :version => @version, :form => f) %>

<%= f.text_field :name, :maxlength => 60, :size => 60, :required => true %>

<%= f.text_field :description, :size => 60 %>

<% unless @version.new_record? %> @@ -17,5 +18,5 @@ <% @version.visible_custom_field_values.each do |value| %>

<%= custom_field_tag_with_label :version, value %>

<% end %> - +<%= call_hook(:view_versions_form_bottom, :version => @version, :form => f) %>
diff --git a/app/views/versions/index.html.erb b/app/views/versions/index.html.erb index 79339e7..c5ee0df 100644 --- a/app/views/versions/index.html.erb +++ b/app/views/versions/index.html.erb @@ -1,10 +1,12 @@
<%= link_to(sprite_icon('add', l(:label_version_new)), new_project_version_path(@project), :class => 'icon icon-add') if User.current.allowed_to?(:manage_versions, @project) %> + <%= call_hook(:view_versions_index_contextual, {:project => @project}) %> <%= actions_dropdown do %> <%= link_to_if_authorized sprite_icon('settings', l(:label_settings)), {:controller => 'projects', :action => 'settings', :id => @project, :tab => 'versions'}, :class => 'icon icon-settings' if User.current.allowed_to?(:manage_versions, @project) %> + <%= call_hook(:view_versions_index_actions_menu, {:project => @project}) %> <% end %>
diff --git a/app/views/versions/show.html.erb b/app/views/versions/show.html.erb index cdd2b30..22430cf 100644 --- a/app/views/versions/show.html.erb +++ b/app/views/versions/show.html.erb @@ -3,6 +3,7 @@ <%= link_to_if_authorized(sprite_icon('edit', l(:button_edit_associated_wikipage, :page_title => @version.wiki_page_title)), { :controller => 'wiki', :action => 'edit', :project_id => @version.project, :id => Wiki.titleize(@version.wiki_page_title)}, :class => 'icon icon-edit') unless @version.wiki_page_title.blank? || @version.project.wiki.nil? %> <%= delete_link version_path(@version, :back_url => url_for(:controller => 'versions', :action => 'index', :project_id => @version.project)) if User.current.allowed_to?(:manage_versions, @version.project) %> <%= link_to_new_issue(@version, @project) %> +<%= render_object_menu_items(:version_actions, @version, @version.project) %> <%= call_hook(:view_versions_show_contextual, { :version => @version, :project => @project }) %>
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