diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js index d8317b3f1a..2e165486e5 100644 --- a/app/assets/javascripts/application-legacy.js +++ b/app/assets/javascripts/application-legacy.js @@ -859,6 +859,10 @@ function observeSearchfield(fieldId, targetId, url, options) { $form.on('ajax:complete', 'a[data-remote]', function() { restoreChecked(); }); + // Restore selection after "load more" appends users to the list + $form.on('redmine:watcherUsersUpdated', function() { + restoreChecked(); + }); } var check = function() { diff --git a/app/controllers/watchers_controller.rb b/app/controllers/watchers_controller.rb index 13ede5d752..17a06fda80 100644 --- a/app/controllers/watchers_controller.rb +++ b/app/controllers/watchers_controller.rb @@ -18,6 +18,9 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class WatchersController < ApplicationController + # Number of users listed per page in the "add watcher" dialog + WATCHER_USERS_PER_PAGE = 100 unless const_defined?(:WATCHER_USERS_PER_PAGE) + before_action :require_login, :find_watchables, :only => [:watch, :unwatch] def watch @@ -150,6 +153,9 @@ class WatchersController < ApplicationController def users_for_new_watcher scope = nil + limit = WATCHER_USERS_PER_PAGE + offset = params[:offset].to_i + offset = 0 if offset < 0 if params[:q].blank? if @project.present? scope = @project.principals.assignable_watchers @@ -157,9 +163,20 @@ class WatchersController < ApplicationController scope = Principal.joins(:members).where(:members => { :project_id => @projects }).assignable_watchers.distinct end else - scope = Principal.assignable_watchers.limit(100) + # A search query already caps the result, so no "load more" is needed + scope = Principal.assignable_watchers.limit(limit) + end + scope = scope.sorted.like(params[:q]) + if params[:q].blank? + # Fetch one extra record to know whether a further page exists + users = scope.offset(offset).limit(limit + 1).to_a + @more_users_for_watcher = users.size > limit + @watcher_users_next_offset = offset + limit + users = users.first(limit) + else + @more_users_for_watcher = false + users = scope.to_a end - users = scope.sorted.like(params[:q]).to_a if @watchables && @watchables.size == 1 watchable_object = @watchables.first users -= watchable_object.visible_watcher_users diff --git a/app/javascript/controllers/load_more_controller.js b/app/javascript/controllers/load_more_controller.js new file mode 100644 index 0000000000..0a12cfd505 --- /dev/null +++ b/app/javascript/controllers/load_more_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" +import { get } from "@rails/request.js" + +// Loads the next page of a list and appends it in place of the "load more" +// trigger. The server is expected to return an HTML fragment (the next page of +// items, optionally followed by a new trigger). +export default class extends Controller { + static targets = ["trigger"] + static values = { afterLoadEvent: String } + + async load(event) { + event.preventDefault() + + // Keep a direct reference to the clicked trigger: the fragment we insert may + // contain a new trigger, so resolving triggerTarget again would be ambiguous. + const trigger = this.triggerTarget + + const response = await get(event.currentTarget.href, { responseKind: "html" }) + if (!response.ok) { + return + } + + trigger.insertAdjacentHTML("beforebegin", await response.html) + trigger.remove() + this.dispatchAfterLoadEvent() + } + + dispatchAfterLoadEvent() { + if (this.hasAfterLoadEventValue) { + this.element.dispatchEvent(new CustomEvent(this.afterLoadEventValue, { bubbles: true })) + } + } +} diff --git a/app/views/watchers/_new.html.erb b/app/views/watchers/_new.html.erb index 85a1530fcf..05b860a5ea 100644 --- a/app/views/watchers/_new.html.erb +++ b/app/views/watchers/_new.html.erb @@ -38,8 +38,10 @@ title = {checkboxSelector: 'input[name=\"watcher[user_ids][]\"]'} )" ) %> -
- <%= principals_check_box_tags('watcher[user_ids][]', users) %> +
+ <%= render :partial => 'watchers/users_for_watcher', :locals => {:users => users} %>

diff --git a/app/views/watchers/_users_for_watcher.html.erb b/app/views/watchers/_users_for_watcher.html.erb new file mode 100644 index 0000000000..c38d219230 --- /dev/null +++ b/app/views/watchers/_users_for_watcher.html.erb @@ -0,0 +1,19 @@ +<%= principals_check_box_tags 'watcher[user_ids][]', users %> + +<% if @more_users_for_watcher %> +

+ <%= link_to( + l(:label_more), + url_for( + :controller => 'watchers', + :action => 'autocomplete_for_user', + :object_type => params[:object_type], + :object_id => params[:object_id], + :project_id => params[:project_id], + :q => params[:q], + :offset => @watcher_users_next_offset + ), + :data => {:action => 'load-more#load'} + ) %> +

+<% end %> diff --git a/app/views/watchers/autocomplete_for_user.html.erb b/app/views/watchers/autocomplete_for_user.html.erb index a24d28eb46..640be84f01 100644 --- a/app/views/watchers/autocomplete_for_user.html.erb +++ b/app/views/watchers/autocomplete_for_user.html.erb @@ -1 +1 @@ -<%= principals_check_box_tags 'watcher[user_ids][]', @users %> +<%= render :partial => 'watchers/users_for_watcher', :locals => {:users => @users} %> diff --git a/config/locales/en.yml b/config/locales/en.yml index d0d93746a2..0d523ec72c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -792,6 +792,7 @@ en: label_nobody: nobody label_next: Next label_previous: Previous + label_more: More label_used_by: Used by label_details: Details label_add_note: Add a note diff --git a/config/locales/ja.yml b/config/locales/ja.yml index e7f8bbe9da..8aebc3a7b9 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -602,6 +602,7 @@ ja: label_nobody: 無記名 label_next: 次 label_previous: 前 + label_more: さらに表示 label_used_by: 使用中 label_details: 詳細 label_add_note: コメントを追加 diff --git a/test/functional/watchers_controller_test.rb b/test/functional/watchers_controller_test.rb index 62a5e59558..bba6dee69b 100644 --- a/test/functional/watchers_controller_test.rb +++ b/test/functional/watchers_controller_test.rb @@ -516,6 +516,62 @@ class WatchersControllerTest < Redmine::ControllerTest assert_select 'input[name=?][value="10"]', 'watcher[user_ids][]' end + def test_autocomplete_without_query_should_not_paginate_within_a_page + @request.session[:user_id] = 2 + get :autocomplete_for_user, :params => {:project_id => 'ecookbook'}, :xhr => true + assert_response :success + assert_select 'input[name=?]', 'watcher[user_ids][]', :count => 2 + assert_select 'p.watcher-more-users', :count => 0 + end + + def test_autocomplete_without_query_should_load_more_users + project = Project.find(1) + WatchersController::WATCHER_USERS_PER_PAGE.times do + User.add_to_project(User.generate!, project) + end + + @request.session[:user_id] = 2 + get :autocomplete_for_user, :params => {:project_id => 'ecookbook'}, :xhr => true + assert_response :success + assert_select 'input[name=?]', 'watcher[user_ids][]', + :count => WatchersController::WATCHER_USERS_PER_PAGE + assert_select 'p.watcher-more-users a[data-action=?][href*=?]', + 'load-more#load', "offset=#{WatchersController::WATCHER_USERS_PER_PAGE}" + # The old submit-style onclick handler must not be used + assert_select 'a[onclick]', false + + get :autocomplete_for_user, + :params => {:project_id => 'ecookbook', + :offset => WatchersController::WATCHER_USERS_PER_PAGE}, + :xhr => true + assert_response :success + # eCookbook has 2 fixture watchers, so the second page holds the remaining 2 + assert_select 'input[name=?]', 'watcher[user_ids][]', :count => 2 + assert_select 'p.watcher-more-users', :count => 0 + end + + def test_autocomplete_with_query_should_not_paginate + (WatchersController::WATCHER_USERS_PER_PAGE + 1).times do |i| + User.generate!(:firstname => 'Loadmore', :lastname => "User#{i}") + end + + @request.session[:user_id] = 2 + get :autocomplete_for_user, + :params => {:project_id => 'ecookbook', :q => 'loadmore'}, :xhr => true + assert_response :success + # A search query is capped by limit and never shows a "load more" trigger + assert_select 'input[name=?]', 'watcher[user_ids][]', + :count => WatchersController::WATCHER_USERS_PER_PAGE + assert_select 'p.watcher-more-users', :count => 0 + end + + def test_new_should_wire_load_more_controller + @request.session[:user_id] = 2 + get :new, :params => {:object_type => 'issue', :object_id => '2'}, :xhr => true + assert_response :success + assert_match /data-controller=\\"load-more\\"/, response.body + end + def test_append @request.session[:user_id] = 2 assert_no_difference 'Watcher.count' do