From 080887e0bff4b9276071fd9e45b85d24973f6748 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 30 Apr 2019 18:30:50 +0800 Subject: [PATCH] adds favorites and recently used projects lists to project jump box - users may declare projects as favorites - these will always be shown on the top of the jump box - 3 most recently used projects will be rendered below (recently used favorites however will not be rendered again). The number can be configured in user preferences - last, all remaining projects that would normally be rendered are shown - favorites / recently used projects can be projects the user is not a member of but has access to - all three lists will be filtered when the user enters a search term. --- app/controllers/application_controller.rb | 8 ++ app/controllers/projects_controller.rb | 13 +++ app/helpers/application_helper.rb | 28 +++++- app/helpers/projects_helper.rb | 20 ++++ app/models/user_preference.rb | 6 +- app/views/projects/bookmark.js.erb | 2 + app/views/projects/show.html.erb | 1 + app/views/users/_preferences.html.erb | 1 + config/locales/en.yml | 6 ++ config/routes.rb | 1 + lib/redmine.rb | 2 +- lib/redmine/project_jump_box.rb | 94 ++++++++++++++++++ public/images/tag_blue_add.png | Bin 0 -> 671 bytes public/images/tag_blue_delete.png | Bin 0 -> 701 bytes public/stylesheets/application.css | 2 + test/functional/projects_controller_test.rb | 21 +++++ test/unit/lib/redmine/project_jump_box_test.rb | 126 +++++++++++++++++++++++++ 17 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 app/views/projects/bookmark.js.erb create mode 100644 lib/redmine/project_jump_box.rb create mode 100644 public/images/tag_blue_add.png create mode 100644 public/images/tag_blue_delete.png create mode 100644 test/unit/lib/redmine/project_jump_box_test.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 54f1e63d7..38b5ce641 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -54,6 +54,7 @@ class ApplicationController < ActionController::Base end before_action :session_expiration, :user_setup, :check_if_login_required, :set_localization, :check_password_change + after_action :record_project_usage rescue_from ::Unauthorized, :with => :deny_access rescue_from ::ActionView::MissingTemplate, :with => :missing_template @@ -400,6 +401,13 @@ class ApplicationController < ActionController::Base end end + def record_project_usage + if @project && @project.id && User.current.logged? && User.current.allowed_to?(:view_project, @project) + Redmine::ProjectJumpBox.new(User.current).project_used(@project) + end + true + end + def back_url url = params[:back_url] if url.nil? && referer = request.env['HTTP_REFERER'] diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1f0aba53f..85949216a 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -221,6 +221,19 @@ class ProjectsController < ApplicationController redirect_to_referer_or admin_projects_path(:status => params[:status]) end + def bookmark + jump_box = Redmine::ProjectJumpBox.new User.current + if request.delete? + jump_box.delete_project_bookmark @project + elsif request.post? + jump_box.bookmark_project @project + end + respond_to do |format| + format.js + format.html { redirect_to project_path(@project) } + end + end + def close @project.close redirect_to project_path(@project) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 09df2656b..7b1808bde 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -426,12 +426,38 @@ module ApplicationHelper end def render_projects_for_jump_box(projects, selected=nil) + jump_box = Redmine::ProjectJumpBox.new User.current + bookmarked = jump_box.bookmarked_projects(params[:q]) + recents = jump_box.recently_used_projects(params[:q]) + projects = projects - (recents + bookmarked) + + projects_label = (bookmarked.any? || recents.any?) ? :label_optgroup_others : :label_project_plural + jump = params[:jump].presence || current_menu_item s = (+'').html_safe - project_tree(projects) do |project, level| + + build_project_link = ->(project, level = 0){ padding = level * 16 text = content_tag('span', project.name, :style => "padding-left:#{padding}px;") s << link_to(text, project_path(project, :jump => jump), :title => project.name, :class => (project == selected ? 'selected' : nil)) + } + + [ + [bookmarked, :label_optgroup_bookmarks, true], + [recents, :label_optgroup_recents, false], + [projects, projects_label, true] + ].each do |projects, label, is_tree| + + next if projects.blank? + + s << content_tag(:strong, l(label)) + if is_tree + project_tree(projects, &build_project_link) + else + # we do not want to render recently used projects as a tree, but in the + # order they were used (most recent first) + projects.each(&build_project_link) + end end s end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 6a34a79e7..3cbdd4b02 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -138,4 +138,24 @@ module ProjectsHelper end end if include_in_api_response?('enabled_modules') end + + def bookmark_link(project, user = User.current) + return '' unless user && user.logged? + @jump_box ||= Redmine::ProjectJumpBox.new user + bookmarked = @jump_box.bookmark?(project) + css = +"icon bookmark " + + if bookmarked + css << "icon-bookmark" + method = "delete" + text = l(:button_project_bookmark_delete) + else + css << "icon-bookmark-off" + method = "post" + text = l(:button_project_bookmark) + end + + url = bookmark_project_url(project) + link_to text, url, remote: true, method: method, class: css + end end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index d55b8ac58..7cb9b0ca6 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -32,7 +32,8 @@ class UserPreference < ActiveRecord::Base 'comments_sorting', 'warn_on_leaving_unsaved', 'no_self_notified', - 'textarea_font' + 'textarea_font', + 'recently_used_projects' TEXTAREA_FONT_OPTIONS = ['monospace', 'proportional'] @@ -90,6 +91,9 @@ class UserPreference < ActiveRecord::Base def textarea_font; self[:textarea_font] end def textarea_font=(value); self[:textarea_font]=value; end + def recently_used_projects; (self[:recently_used_projects] || 3).to_i; end + def recently_used_projects=(value); self[:recently_used_projects] = value.to_i; end + # Returns the names of groups that are displayed on user's page # Example: # preferences.my_page_groups diff --git a/app/views/projects/bookmark.js.erb b/app/views/projects/bookmark.js.erb new file mode 100644 index 000000000..559585c16 --- /dev/null +++ b/app/views/projects/bookmark.js.erb @@ -0,0 +1,2 @@ +$('#project-jump div.drdn-items.projects').html('<%= j render_projects_for_jump_box(projects_for_jump_box(User.current), @project) %>'); +$('.contextual a.icon.bookmark').replaceWith('<%= j bookmark_link @project %>'); diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index 16645b759..06eca9970 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -2,6 +2,7 @@ <% if User.current.allowed_to?(:add_subprojects, @project) %> <%= link_to l(:label_subproject_new), new_project_path(:parent_id => @project), :class => 'icon icon-add' %> <% end %> + <%= bookmark_link @project %> <% if User.current.allowed_to?(:close_project, @project) %> <% if @project.active? %> <%= link_to l(:button_close), close_project_path(@project), :data => {:confirm => l(:text_are_you_sure)}, :method => :post, :class => 'icon icon-lock' %> diff --git a/app/views/users/_preferences.html.erb b/app/views/users/_preferences.html.erb index f8769125e..6c4e5337a 100644 --- a/app/views/users/_preferences.html.erb +++ b/app/views/users/_preferences.html.erb @@ -4,4 +4,5 @@

<%= pref_fields.select :comments_sorting, [[l(:label_chronological_order), 'asc'], [l(:label_reverse_chronological_order), 'desc']] %>

<%= pref_fields.check_box :warn_on_leaving_unsaved %>

<%= pref_fields.select :textarea_font, textarea_font_options %>

+

<%= pref_fields.text_field :recently_used_projects, :size => 2 %>

<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7b25de4a2..77467e13f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -382,6 +382,7 @@ en: field_full_width_layout: Full width layout field_digest: Checksum field_default_assigned_to: Default assignee + field_recently_used_projects: Number of recently used projects in jump box setting_app_title: Application title setting_welcome_text: Welcome text @@ -1044,6 +1045,9 @@ en: label_font_default: Default font label_font_monospace: Monospaced font label_font_proportional: Proportional font + label_optgroup_bookmarks: Bookmarks + label_optgroup_others: Other projects + label_optgroup_recents: Recently used label_last_notes: Last notes label_nothing_to_preview: Nothing to preview label_inherited_from_parent_project: "Inherited from parent project" @@ -1106,6 +1110,8 @@ en: button_close: Close button_reopen: Reopen button_import: Import + button_project_bookmark: Add bookmark + button_project_bookmark_delete: Remove bookmark button_filter: Filter button_actions: Actions diff --git a/config/routes.rb b/config/routes.rb index 3c1fd0256..74257d3ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,6 +113,7 @@ Rails.application.routes.draw do post 'close' post 'reopen' match 'copy', :via => [:get, :post] + match 'bookmark', :via => [:delete, :post] end shallow do diff --git a/lib/redmine.rb b/lib/redmine.rb index 2de351c07..805418d3d 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -76,7 +76,7 @@ Redmine::Scm::Base.add "Filesystem" # Permissions Redmine::AccessControl.map do |map| - map.permission :view_project, {:projects => [:show], :activities => [:index]}, :public => true, :read => true + map.permission :view_project, {:projects => [:show, :bookmark], :activities => [:index]}, :public => true, :read => true map.permission :search_project, {:search => :index}, :public => true, :read => true map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member diff --git a/lib/redmine/project_jump_box.rb b/lib/redmine/project_jump_box.rb new file mode 100644 index 000000000..5978dc19a --- /dev/null +++ b/lib/redmine/project_jump_box.rb @@ -0,0 +1,94 @@ +module Redmine + class ProjectJumpBox + def initialize(user) + @user = user + @pref_project_ids = {} + end + + def recent_projects_count + @user.pref.recently_used_projects + end + + def recently_used_projects(query = nil) + project_ids = recently_used_project_ids + projects = Project.where(id: project_ids) + if query + projects = projects.like(query) + end + projects. + index_by(&:id). + values_at(*project_ids). # sort according to stored order + compact + end + + def bookmarked_projects(query = nil) + projects = Project.where(id: bookmarked_project_ids).visible + if query + projects = projects.like(query) + end + projects.to_a + end + + def project_used(project) + return if project.blank? || project.id.blank? + + id_array = recently_used_project_ids + id_array.reject!{ |i| i == project.id } + # we dont want bookmarks in the recently used list: + id_array.unshift(project.id) unless bookmark?(project) + self.recently_used_project_ids = id_array[0, recent_projects_count] + end + + def bookmark_project(project) + self.recently_used_project_ids = recently_used_project_ids.reject{|id| id == project.id} + self.bookmarked_project_ids = (bookmarked_project_ids << project.id) + end + + def delete_project_bookmark(project) + self.bookmarked_project_ids = bookmarked_project_ids.reject do |id| + id == project.id + end + end + + def bookmark?(project) + project && project.id && bookmarked_project_ids.include?(project.id) + end + + private + + def bookmarked_project_ids + pref_project_ids :bookmarked_project_ids + end + + def bookmarked_project_ids=(new_ids) + set_pref_project_ids :bookmarked_project_ids, new_ids + end + + def recently_used_project_ids + pref_project_ids(:recently_used_project_ids)[0,recent_projects_count] + end + + def recently_used_project_ids=(new_ids) + set_pref_project_ids :recently_used_project_ids, new_ids + end + + def pref_project_ids(key) + return [] unless @user.logged? + + @pref_project_ids[key] ||= (@user.pref[key] || '').split(',').map(&:to_i) + end + + def set_pref_project_ids(key, new_values) + return nil unless @user.logged? + + old_value = @user.pref[key] + new_value = new_values.uniq.join(',') + if old_value != new_value + @user.pref[key] = new_value + @user.pref.save + end + @pref_project_ids.delete key + nil + end + end +end diff --git a/public/images/tag_blue_add.png b/public/images/tag_blue_add.png new file mode 100644 index 0000000000000000000000000000000000000000..f135248f82e3d0b87d29d1628057f62dc51ec6c6 GIT binary patch literal 671 zcmV;Q0$}}#P)0~-SI9%?%=X}23bKl`4NrEVf$mjFO<#Nbov&dvJ zNT<_CrBZ7ExK+Uw3I&YEL1 zq(%qHWHR0F_n|0?dQQMtEQVk(xD>*X>NJrR|6!IJgTVluzJkEFiIZBbhHyBHKp?OH z;YfLEc{Cb2#_Pl@g6c zwX8?E)9I*5#h(iF=`qaGA;gdY_^%U4sZ=5tu-olxvtB}>ke2l*x7+On$PNQM`eeub zw>sQOtcA_q_1U#pC~GN zxfc`_zq*cJS5eFyM^neBh0V3}>eD$_#z~y93x^BudEejjyyv`!5k(Q1Oa`e`3dv*= zi9`bNcpQQtAQp?w0$`>sCY?^B-|tVXYPG6B!r|~#2t$==L8Verl*{Gv2ts4T1p-Cg41%^T)c)i|3Aq-XBPb9^Eh~-MB(?O%%1HUtLQYw|; z_xr)~`~-xd%KMgOSq3fM-RomxYk()80j@IKD;A5?1w0eX zO8_7BFTq~F0B&Ih{IyeXKfVEz$#iz?L`i>Ij^m_Stu}(_2;{@7O(gElqvL#zLDr4E zPj?Wzbx~#))VC+@m1SA2+wGRu@+EI*AlQZ4U$s{{4PFIsxvodT`{7+wFD@IcbRw7dsPL` zqN+OerCF_3joECLc-<-@E9d2JYjfCrFoQ2E$G|VCW%r`$A@jfIYBU-(27}>DVEOcY jqWG2CCjT8;O!xl+&v_qytO7mD00000NkvXXu0mjfWRW`K literal 0 HcmV?d00001 diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index af34a8c88..bf7f312e9 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -1434,6 +1434,8 @@ div.wiki img {vertical-align:middle; max-width:100%;} .icon-add-bullet { background-image: url(../images/bullet_add.png); } .icon-shared { background-image: url(../images/link.png); } .icon-actions { background-image: url(../images/3_bullets.png); } +.icon-bookmark { background-image: url(../images/tag_blue_delete.png); } +.icon-bookmark-off { background-image: url(../images/tag_blue_add.png); } .icon-file { background-image: url(../images/files/default.png); } .icon-file.text-plain { background-image: url(../images/files/text.png); } diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb index e1a17059b..508efa202 100644 --- a/test/functional/projects_controller_test.rb +++ b/test/functional/projects_controller_test.rb @@ -1000,6 +1000,27 @@ class ProjectsControllerTest < Redmine::ControllerTest assert_select_error /Identifier cannot be blank/ end + def test_bookmark_should_create_bookmark + @request.session[:user_id] = 3 + post :bookmark, params: { id: 'ecookbook' } + assert_redirected_to controller: 'projects', action: 'show', id: 'ecookbook' + jb = Redmine::ProjectJumpBox.new(User.find(3)) + assert jb.bookmark?(Project.find('ecookbook')) + refute jb.bookmark?(Project.find('onlinestore')) + end + + def test_bookmark_should_delete_bookmark + @request.session[:user_id] = 3 + jb = Redmine::ProjectJumpBox.new(User.find(3)) + project = Project.find('ecookbook') + jb.bookmark_project project + delete :bookmark, params: { id: 'ecookbook' } + assert_redirected_to controller: 'projects', action: 'show', id: 'ecookbook' + + jb = Redmine::ProjectJumpBox.new(User.find(3)) + refute jb.bookmark?(Project.find('ecookbook')) + end + def test_jump_without_project_id_should_redirect_to_active_tab get :index, :params => { :jump => 'issues' diff --git a/test/unit/lib/redmine/project_jump_box_test.rb b/test/unit/lib/redmine/project_jump_box_test.rb new file mode 100644 index 000000000..375375556 --- /dev/null +++ b/test/unit/lib/redmine/project_jump_box_test.rb @@ -0,0 +1,126 @@ +require File.expand_path('../../../../test_helper', __FILE__) + +class Redmine::ProjectJumpBoxTest < ActiveSupport::TestCase + fixtures :users, :projects, :user_preferences + + def setup + @user = User.find_by_login 'dlopper' + @ecookbook = Project.find 'ecookbook' + @onlinestore = Project.find 'onlinestore' + end + + def test_should_filter_bookmarked_projects + pjb = Redmine::ProjectJumpBox.new @user + pjb.bookmark_project @ecookbook + + assert_equal 1, pjb.bookmarked_projects.size + assert_equal 0, pjb.bookmarked_projects('online').size + assert_equal 1, pjb.bookmarked_projects('ecook').size + end + + def test_should_not_include_bookmark_in_recently_used_list + pjb = Redmine::ProjectJumpBox.new @user + pjb.project_used @ecookbook + + assert_equal 1, pjb.recently_used_projects.size + + pjb.bookmark_project @ecookbook + assert_equal 0, pjb.recently_used_projects.size + end + + def test_should_filter_recently_used_projects + pjb = Redmine::ProjectJumpBox.new @user + pjb.project_used @ecookbook + + assert_equal 1, pjb.recently_used_projects.size + assert_equal 0, pjb.recently_used_projects('online').size + assert_equal 1, pjb.recently_used_projects('ecook').size + end + + def test_should_limit_recently_used_projects + pjb = Redmine::ProjectJumpBox.new @user + pjb.project_used @ecookbook + pjb.project_used Project.find 'onlinestore' + + @user.pref.recently_used_projects = 1 + + assert_equal 1, pjb.recently_used_projects.size + assert_equal 1, pjb.recently_used_projects('online').size + assert_equal 0, pjb.recently_used_projects('ecook').size + end + + def test_should_record_recently_used_projects_order + pjb = Redmine::ProjectJumpBox.new @user + other = Project.find 'onlinestore' + pjb.project_used @ecookbook + pjb.project_used other + + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 2, pjb.recently_used_projects.size + assert_equal [other, @ecookbook], pjb.recently_used_projects + + pjb.project_used other + + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 2, pjb.recently_used_projects.size + assert_equal [other, @ecookbook], pjb.recently_used_projects + + pjb.project_used @ecookbook + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 2, pjb.recently_used_projects.size + assert_equal [@ecookbook, other], pjb.recently_used_projects + end + + def test_should_unbookmark_project + pjb = Redmine::ProjectJumpBox.new @user + assert pjb.bookmarked_projects.blank? + + # same instance should reflect new data + pjb.bookmark_project @ecookbook + assert pjb.bookmark?(@ecookbook) + refute pjb.bookmark?(@onlinestore) + assert_equal 1, pjb.bookmarked_projects.size + assert_equal @ecookbook, pjb.bookmarked_projects.first + + # new instance should reflect new data as well + pjb = Redmine::ProjectJumpBox.new @user + assert pjb.bookmark?(@ecookbook) + refute pjb.bookmark?(@onlinestore) + assert_equal 1, pjb.bookmarked_projects.size + assert_equal @ecookbook, pjb.bookmarked_projects.first + + pjb.bookmark_project @ecookbook + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 1, pjb.bookmarked_projects.size + assert_equal @ecookbook, pjb.bookmarked_projects.first + + pjb.delete_project_bookmark @onlinestore + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 1, pjb.bookmarked_projects.size + assert_equal @ecookbook, pjb.bookmarked_projects.first + + pjb.delete_project_bookmark @ecookbook + pjb = Redmine::ProjectJumpBox.new @user + assert pjb.bookmarked_projects.blank? + end + + def test_should_update_recents_list + pjb = Redmine::ProjectJumpBox.new @user + assert pjb.recently_used_projects.blank? + + pjb.project_used @ecookbook + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 1, pjb.recently_used_projects.size + assert_equal @ecookbook, pjb.recently_used_projects.first + + pjb.project_used @ecookbook + pjb = Redmine::ProjectJumpBox.new @user + assert_equal 1, pjb.recently_used_projects.size + assert_equal @ecookbook, pjb.recently_used_projects.first + + pjb.project_used @onlinestore + assert_equal 2, pjb.recently_used_projects.size + assert_equal @onlinestore, pjb.recently_used_projects.first + assert_equal @ecookbook, pjb.recently_used_projects.last + end +end -- 2.11.0