Defect #35192 » 0001-Paginate-the-watchers-selection-dialog-and-load-it-u.patch
| 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 |