Project

General

Profile

Defect #35192 » 0001-Paginate-the-watchers-selection-dialog-and-load-it-u.patch

Marius BĂLTEANU, 2026-10-13 19:50

View differences:

app/assets/stylesheets/application.css
1481 1481

  
1482 1482
#users_for_watcher {block-size: 200px; overflow: auto;}
1483 1483
#users_for_watcher label {display: block;}
1484
#users_for_watcher .watcher-more-users.ajax-loading {background: url(/loading.gif) no-repeat left center; padding-inline-start: 20px;}
1484 1485

  
1485 1486
input#principal_search, input#user_search {inline-size: 90%}
1486 1487
.roles-selection label {display: inline-block; inline-size: 210px;}
app/controllers/watchers_controller.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
class WatchersController < ApplicationController
21
  WATCHER_USERS_PER_PAGE = 100
22

  
21 23
  before_action :require_login, :find_watchables, :only => [:watch, :unwatch]
22 24

  
23 25
  def watch
......
148 150
    end
149 151
  end
150 152

  
153
  # Returns a paginated array of users that can be added as watchers to the object(s).
151 154
  def users_for_new_watcher
152
    scope = nil
153
    if params[:q].blank?
154
      if @project.present?
155
        scope = @project.principals.assignable_watchers
156
      elsif @projects.present? && @projects.size > 1
157
        scope = Principal.joins(:members).where(:members => { :project_id => @projects }).assignable_watchers.distinct
155
    limit = WATCHER_USERS_PER_PAGE
156
    offset = params[:offset].to_i
157
    offset = 0 if offset < 0
158
    q = params[:q].to_s.strip
159

  
160
    valid_users, next_offset, more = scan_watcher_candidates(watcher_candidate_scope(q), offset, limit)
161

  
162
    @more_users_for_watcher = more
163
    @watcher_users_next_offset = next_offset
164
    valid_users
165
  end
166

  
167
  def watcher_candidate_scope(q)
168
    scope =
169
      if q.blank?
170
        if @project.present?
171
          @project.principals.assignable_watchers
172
        elsif @projects.present? && @projects.size > 1
173
          Principal.joins(:members).where(:members => {:project_id => @projects}).assignable_watchers.distinct
174
        else
175
          Principal.none
176
        end
177
      else
178
        Principal.assignable_watchers
158 179
      end
159
    else
160
      scope = Principal.assignable_watchers.limit(100)
161
    end
162
    users = scope.sorted.like(params[:q]).to_a
163
    if @watchables && @watchables.size == 1
164
      watchable_object = @watchables.first
165
      users -= watchable_object.visible_watcher_users
180

  
181
    if @watchables&.size == 1
182
      watcher_ids = @watchables.first.visible_watcher_users.map(&:id)
183
      scope = scope.where.not(:id => watcher_ids) if watcher_ids.present?
166 184
    end
167
    @watchables&.each do |watchable|
168
      users.reject!{|user| !watchable.valid_watcher?(user)}
185

  
186
    scope.sorted.like(q)
187
  end
188

  
189
  def scan_watcher_candidates(scope, offset, limit)
190
    valid_users = []
191
    current_offset = offset
192
    has_more = false
193

  
194
    while valid_users.size < limit
195
      batch = scope.offset(current_offset).limit(limit + 1).to_a
196
      has_more = batch.size > limit
197
      batch = batch.first(limit)
198
      break if batch.empty?
199

  
200
      batch.each_with_index do |user, idx|
201
        if @watchables.blank? || @watchables.all? {|w| w.valid_watcher?(user)}
202
          valid_users << user
203
          if valid_users.size == limit
204
            has_more = has_more || (idx + 1 < batch.size)
205
            current_offset += (idx + 1)
206
            break
207
          end
208
        end
209
      end
210

  
211
      break if valid_users.size == limit
212

  
213
      current_offset += batch.size
214
      break unless has_more
169 215
    end
170
    users
216

  
217
    [valid_users, current_offset, has_more && (valid_users.size == limit)]
171 218
  end
172 219

  
173 220
  def users_for_mention
app/javascript/controllers/searchable_selection_controller.js
1
/**
2
 * Redmine - project management software
3
 * Copyright (C) 2006-  Jean-Philippe Lang
4
 * This code is released under the GNU General Public License.
5
 */
6

  
7
import { Controller } from "@hotwired/stimulus"
8
import { get } from "@rails/request.js"
9

  
10
export default class extends Controller {
11
  static targets = ["input", "list", "hiddenContainer"]
12
  static values = {
13
    url: String,
14
    checkboxSelector: String,
15
    checkboxName: String,
16
    debounceDelay: { type: Number, default: 300 }
17
  }
18

  
19
  connect() {
20
    this.selectedValues = new Set()
21
    this.isLoadingMore = false
22
    this.debounceTimer = null
23
    this.searchAbortController = null
24

  
25
    // Track any checkboxes that are checked initially or stored in hidden inputs
26
    if (this.hasHiddenContainerTarget) {
27
      this.hiddenContainerTarget.querySelectorAll('input[type="hidden"]').forEach(input => {
28
        this.selectedValues.add(input.value)
29
      })
30
    }
31
    if (this.hasListTarget) {
32
      this.listTarget.querySelectorAll(`${this.checkboxSelector}:checked`).forEach(cb => {
33
        this.selectedValues.add(cb.value)
34
      })
35
    }
36
  }
37

  
38
  disconnect() {
39
    clearTimeout(this.debounceTimer)
40
    if (this.searchAbortController) {
41
      this.searchAbortController.abort()
42
    }
43
  }
44

  
45
  get checkboxName() {
46
    if (this.hasCheckboxNameValue && this.checkboxNameValue) {
47
      return this.checkboxNameValue
48
    }
49
    const firstCheckbox = this.hasListTarget ? this.listTarget.querySelector('input[type="checkbox"]') : null
50
    return firstCheckbox ? firstCheckbox.name : ""
51
  }
52

  
53
  get checkboxSelector() {
54
    return this.hasCheckboxSelectorValue && this.checkboxSelectorValue
55
      ? this.checkboxSelectorValue
56
      : (this.checkboxName ? `input[name="${this.checkboxName}"]` : 'input[type="checkbox"]')
57
  }
58

  
59
  search() {
60
    clearTimeout(this.debounceTimer)
61
    this.debounceTimer = setTimeout(() => {
62
      this.performSearch()
63
    }, this.debounceDelayValue)
64
  }
65

  
66
  keydown(event) {
67
    if (event.key === "Enter") {
68
      event.preventDefault()
69
      clearTimeout(this.debounceTimer)
70
      this.performSearch()
71
    }
72
  }
73

  
74
  performSearch() {
75
    const q = this.hasInputTarget ? this.inputTarget.value.trim() : ""
76
    this.fetchResults({ q }, { append: false })
77
  }
78

  
79
  async loadMore(event) {
80
    event.preventDefault()
81
    if (this.isLoadingMore) return
82

  
83
    const trigger = event.currentTarget.closest('[data-role="load-more"]')
84
    const url = new URL(event.currentTarget.href, window.location.origin)
85
    const offset = url.searchParams.get("offset")
86
    const q = this.hasInputTarget ? this.inputTarget.value.trim() : ""
87

  
88
    await this.fetchResults({ q, offset }, { append: true, trigger })
89
  }
90

  
91
  toggle(event) {
92
    const checkbox = event.target
93
    if (!checkbox.matches(this.checkboxSelector)) return
94

  
95
    if (checkbox.checked) {
96
      this.selectedValues.add(checkbox.value)
97
    } else {
98
      this.selectedValues.delete(checkbox.value)
99
    }
100
    this.syncHiddenInputs()
101
  }
102

  
103
  async fetchResults(params, { append = false, trigger = null } = {}) {
104
    if (append) {
105
      if (this.isLoadingMore) return
106
      this.isLoadingMore = true
107
    } else {
108
      if (this.searchAbortController) {
109
        this.searchAbortController.abort()
110
      }
111
      this.searchAbortController = new AbortController()
112
    }
113

  
114
    const targetElement = trigger || (this.hasInputTarget ? this.inputTarget : this.element)
115
    targetElement.classList.add("ajax-loading")
116

  
117
    try {
118
      const url = new URL(this.urlValue, window.location.origin)
119
      Object.entries(params).forEach(([key, value]) => {
120
        if (value !== undefined && value !== null && value !== "") {
121
          url.searchParams.set(key, value)
122
        }
123
      })
124

  
125
      const signal = append ? null : this.searchAbortController.signal
126
      const response = await get(url.toString(), { responseKind: "html", signal })
127
      if (!response.ok) return
128

  
129
      const html = await response.html
130

  
131
      if (append && trigger) {
132
        trigger.insertAdjacentHTML("beforebegin", html)
133
        const nextTrigger = this.hasListTarget ? this.listTarget.querySelector('[data-role="load-more"] a') : null
134
        trigger.remove()
135
        if (nextTrigger) nextTrigger.focus()
136
      } else if (this.hasListTarget) {
137
        this.listTarget.innerHTML = html
138
      }
139

  
140
      this.syncCheckboxes()
141
      this.syncHiddenInputs()
142
    } catch (error) {
143
      if (error.name === "AbortError") return
144
      throw error
145
    } finally {
146
      if (append) {
147
        this.isLoadingMore = false
148
      }
149
      if (targetElement.isConnected) {
150
        targetElement.classList.remove("ajax-loading")
151
      }
152
    }
153
  }
154

  
155
  syncCheckboxes() {
156
    if (!this.hasListTarget) return
157
    this.listTarget.querySelectorAll(this.checkboxSelector).forEach(cb => {
158
      cb.checked = this.selectedValues.has(cb.value)
159
    })
160
  }
161

  
162
  syncHiddenInputs() {
163
    if (!this.hasHiddenContainerTarget) return
164

  
165
    const name = this.checkboxName
166
    if (!name) return
167

  
168
    this.hiddenContainerTarget.innerHTML = ""
169
    this.selectedValues.forEach(val => {
170
      const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(val) : val
171
      if (!this.hasListTarget || !this.listTarget.querySelector(`${this.checkboxSelector}[value="${escaped}"]`)) {
172
        const input = document.createElement("input")
173
        input.type = "hidden"
174
        input.name = name
175
        input.value = val
176
        this.hiddenContainerTarget.appendChild(input)
177
      }
178
    })
179
  }
180
}
app/views/watchers/_new.html.erb
11 11
<%= form_tag(watchables.present? ? watchers_path : watchers_append_path,
12 12
             :remote => true,
13 13
             :method => :post,
14
             :id => 'new-watcher-form') do %>
14
             :id => 'new-watcher-form',
15
             :data => {
16
               :controller => 'searchable-selection',
17
               :searchable_selection_url_value => url_for(
18
                 :controller => 'watchers',
19
                 :action => 'autocomplete_for_user',
20
                 :object_type => (watchables.present? ? watchables.first.class.name.underscore : nil),
21
                 :object_id => (watchables.present? ? watchables.map(&:id) : nil),
22
                 :project_id => @project
23
               )
24
             }) do %>
15 25

  
16 26
  <% if watchables.present? %>
17 27
    <%= hidden_field_tag 'object_type', watchables.first.class.name.underscore %>
......
21 31
  <% end %>
22 32
  <%= hidden_field_tag 'project_id', @project.id if @project %>
23 33

  
24
  <p><%= label_tag 'user_search', l(:label_user_search) %><%= text_field_tag 'user_search', nil %></p>
25
  <%= javascript_tag(
26
        "observeSearchfield(
27
          'user_search',
28
          'users_for_watcher',
29
          '#{escape_javascript(
30
               url_for(
31
                 :controller => 'watchers',
32
                 :action => 'autocomplete_for_user',
33
                 :object_type => (watchables.present? ? watchables.first.class.name.underscore : nil),
34
                 :object_id => (watchables.present? ? watchables.map(&:id) : nil),
35
                 :project_id => @project
36
               )
37
             )}',
38
          {checkboxSelector: 'input[name=\"watcher[user_ids][]\"]'}
39
         )"
40
       ) %>
41
  <div id="users_for_watcher">
42
    <%= principals_check_box_tags('watcher[user_ids][]', users) %>
34
  <p>
35
    <%= label_tag 'user_search', l(:label_user_search) %>
36
    <%= text_field_tag 'user_search', nil,
37
                       :class => 'autocomplete',
38
                       :autocomplete => 'off',
39
                       :data => {
40
                         :searchable_selection_target => 'input',
41
                         :action => 'input->searchable-selection#search keydown->searchable-selection#keydown'
42
                       } %>
43
  </p>
44

  
45
  <div id="users_for_watcher"
46
       data-searchable-selection-target="list"
47
       data-action="change->searchable-selection#toggle">
48
    <%= render :partial => 'watchers/users_for_watcher', :locals => {:users => users} %>
43 49
  </div>
44 50

  
51
  <div data-searchable-selection-target="hiddenContainer"></div>
52

  
45 53
  <p class="buttons">
46 54
    <%= submit_tag l(:button_add), :name => nil, :onclick => "hideModal(this);" %>
47 55
    <%= link_to_function l(:button_cancel), "hideModal(this);" %>
app/views/watchers/_users_for_watcher.html.erb
1
<%= principals_check_box_tags 'watcher[user_ids][]', users %>
2

  
3
<% if @more_users_for_watcher %>
4
  <p class="watcher-more-users" data-role="load-more">
5
    <%= link_to(
6
          l(:label_show_more),
7
          url_for(
8
            :controller => 'watchers',
9
            :action => 'autocomplete_for_user',
10
            :object_type => (params[:object_type].presence || (@watchables.present? ? @watchables.first.class.name.underscore : nil)),
11
            :object_id => (params[:object_id].presence || (@watchables.present? ? @watchables.map(&:id) : nil)),
12
            :project_id => (params[:project_id].presence || (@project ? @project.to_param : nil)),
13
            :q => params[:q].presence,
14
            :offset => @watcher_users_next_offset
15
          ),
16
          :data => {:action => 'searchable-selection#loadMore'}
17
        ) %>
18
  </p>
19
<% end %>
app/views/watchers/autocomplete_for_user.html.erb
1
<%= principals_check_box_tags 'watcher[user_ids][]', @users %>
1
<%= render :partial => 'watchers/users_for_watcher', :locals => {:users => @users} %>
config/locales/en.yml
796 796
  label_nobody: nobody
797 797
  label_next: Next
798 798
  label_previous: Previous
799
  label_show_more: Show more
799 800
  label_used_by: Used by
800 801
  label_details: Details
801 802
  label_add_note: Add a note
config/locales/ja.yml
602 602
  label_nobody: 無記名
603 603
  label_next: 次
604 604
  label_previous: 前
605
  label_show_more: さらに表示
605 606
  label_used_by: 使用中
606 607
  label_details: 詳細
607 608
  label_add_note: コメントを追加
test/functional/watchers_controller_test.rb
668 668
      assert_response :not_found
669 669
    end
670 670
  end
671

  
672
  def test_new_renders_searchable_selection_controller
673
    @request.session[:user_id] = 2
674
    get :new, :params => {:object_type => 'issue', :object_id => '1'}, :xhr => true
675
    assert_response :success
676
    assert_match 'data-controller=\"searchable-selection\"', response.body
677
    assert_match 'data-searchable-selection-target=\"input\"', response.body
678
    assert_match 'data-searchable-selection-target=\"list\"', response.body
679
    assert_match 'data-searchable-selection-target=\"hiddenContainer\"', response.body
680
  end
681

  
682
  def test_autocomplete_for_user_without_q_should_not_show_load_more_when_count_is_within_limit
683
    @request.session[:user_id] = 2
684
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook'}, :xhr => true
685
    assert_response :success
686
    assert_select 'p.watcher-more-users', :count => 0
687
  end
688

  
689
  def test_autocomplete_for_user_without_q_should_show_load_more_when_count_exceeds_limit
690
    @request.session[:user_id] = 2
691
    role = Role.find(1)
692
    project = Project.find('ecookbook')
693
    105.times do
694
      u = User.generate!
695
      Member.create!(:project => project, :principal => u, :roles => [role])
696
    end
697

  
698
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook'}, :xhr => true
699
    assert_response :success
700
    assert_select 'p.watcher-more-users', :count => 1
701
    assert_select 'p.watcher-more-users a[href*="offset=100"]'
702
    assert_select 'p.watcher-more-users a[data-action="searchable-selection#loadMore"]'
703
    assert_select 'input[name=?]', 'watcher[user_ids][]', :count => 100
704
  end
705

  
706
  def test_autocomplete_for_user_with_offset_should_load_next_page
707
    @request.session[:user_id] = 2
708
    role = Role.find(1)
709
    project = Project.find('ecookbook')
710
    105.times do
711
      u = User.generate!
712
      Member.create!(:project => project, :principal => u, :roles => [role])
713
    end
714

  
715
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook', :offset => 100}, :xhr => true
716
    assert_response :success
717
    assert_select 'p.watcher-more-users', :count => 0
718
    assert_select 'input[name=?]', 'watcher[user_ids][]', :count => (project.principals.assignable_watchers.count - 100)
719
  end
720

  
721
  def test_autocomplete_for_user_with_negative_offset_should_default_to_zero
722
    @request.session[:user_id] = 2
723
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook', :offset => -10}, :xhr => true
724
    assert_response :success
725
  end
726

  
727
  def test_autocomplete_for_user_excludes_existing_watchers_in_scope
728
    @request.session[:user_id] = 2
729
    issue = Issue.find(1)
730
    project = issue.project
731
    role = Role.find(1)
732

  
733
    20.times do
734
      u = User.generate!
735
      Member.create!(:project => project, :principal => u, :roles => [role])
736
      Watcher.create!(:watchable => issue, :user => u)
737
    end
738

  
739
    105.times do
740
      u = User.generate!
741
      Member.create!(:project => project, :principal => u, :roles => [role])
742
    end
743

  
744
    get :autocomplete_for_user, :params => {:object_type => 'issue', :object_id => issue.id}, :xhr => true
745
    assert_response :success
746
    assert_select 'input[name=?]', 'watcher[user_ids][]', :count => 100
747
    assert_select 'p.watcher-more-users', :count => 1
748
  end
749

  
750
  def test_autocomplete_for_user_with_q_should_not_show_load_more_when_count_is_within_limit
751
    @request.session[:user_id] = 2
752
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook', :q => 'jsmith'}, :xhr => true
753
    assert_response :success
754
    assert_select 'p.watcher-more-users', :count => 0
755
  end
756

  
757
  def test_autocomplete_for_user_with_q_should_show_load_more_when_count_exceeds_limit
758
    @request.session[:user_id] = 2
759
    105.times do |i|
760
      User.generate!(:firstname => sprintf("SearchTarget%03d", i), :lastname => "User")
761
    end
762

  
763
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook', :q => 'SearchTarget'}, :xhr => true
764
    assert_response :success
765
    assert_select 'p.watcher-more-users', :count => 1
766
    assert_select 'p.watcher-more-users a[href*="offset=100"]'
767
    assert_select 'p.watcher-more-users a[href*="q=SearchTarget"]'
768
    assert_select 'input[name=?]', 'watcher[user_ids][]', :count => 100
769
  end
770

  
771
  def test_autocomplete_for_user_with_blank_whitespace_q_defaults_to_project_members
772
    @request.session[:user_id] = 2
773
    # Non-member user
774
    User.generate!(:firstname => 'WhitespaceNonMember')
775

  
776
    get :autocomplete_for_user, :params => {:project_id => 'ecookbook', :q => '   '}, :xhr => true
777
    assert_response :success
778
    assert_not_include 'WhitespaceNonMember', response.body
779
  end
780

  
781
  def test_autocomplete_for_user_with_q_should_skip_users_without_object_visibility_across_batches
782
    @request.session[:user_id] = 1
783
    issue = Issue.find(14)
784
    assert issue.is_private?
785

  
786
    # Generate 105 users that cannot view the private issue (not members of the project)
787
    105.times do |i|
788
      User.generate!(:firstname => sprintf("SearchSkip%03d", i), :lastname => "User")
789
    end
790

  
791
    # Generate 1 admin user matching the search term who can view the private issue
792
    valid_admin = User.generate!(:firstname => "SearchSkipAdmin", :lastname => "User", :admin => true)
793

  
794
    get :autocomplete_for_user, :params => {
795
      :object_type => 'issue',
796
      :object_id => issue.id,
797
      :project_id => issue.project.identifier,
798
      :q => 'SearchSkip'
799
    }, :xhr => true
800

  
801
    assert_response :success
802
    # The valid admin should be returned on the first page even though 105 invalid users matched first
803
    assert_include valid_admin.name, response.body
804
    # Since only 1 valid watcher was found, there are no more pages
805
    assert_select 'p.watcher-more-users', :count => 0
806
  end
671 807
end
808

  
test/system/watchers_test.rb
73 73
    assert issue.watched_by?(dlopper),
74 74
      "Expected Dave Lopper to be added as watcher"
75 75
  end
76

  
77
  def test_add_watcher_with_show_more
78
    issue = Issue.find(1)
79
    project = issue.project
80
    role = Role.find(1)
81
    issue.watcher_users = []
82

  
83
    users = []
84
    105.times do |i|
85
      u = User.generate!(:firstname => sprintf("Watcher%03d", i), :lastname => "User")
86
      Member.create!(:project => project, :principal => u, :roles => [role])
87
      users << u
88
    end
89

  
90
    log_user('admin', 'admin')
91
    visit "/issues/#{issue.id}"
92

  
93
    within '#sidebar' do
94
      click_on 'Add'
95
    end
96

  
97
    within '#ajax-modal' do
98
      assert page.has_css?('p.watcher-more-users a', text: 'Show more')
99

  
100
      user1 = users.first
101
      find('label', text: "#{user1.firstname} #{user1.lastname}").click
102

  
103
      click_on 'Show more'
104

  
105
      user2 = users.last
106
      assert page.has_css?('label', text: "#{user2.firstname} #{user2.lastname}")
107
      find('label', text: "#{user2.firstname} #{user2.lastname}").click
108

  
109
      click_on 'Add'
110
    end
111

  
112
    assert page.has_css?('#sidebar', text: "#{user1.firstname} #{user1.lastname}", wait: 5)
113
    issue.reload
114
    assert issue.watched_by?(user1)
115
    assert issue.watched_by?(user2)
116
  end
76 117
end
(6-6/7)