Project

General

Profile

Feature #43825 » preserve_checkbox_state_v2.patch

[Agileware]Kota Uchino, 2026-02-16 06:19

View differences:

app/assets/javascripts/application-legacy.js
796 796
  });
797 797
}
798
function observeSearchfield(fieldId, targetId, url) {
798
function observeSearchfield(fieldId, targetId, url, options) {
799 799
  $('#'+fieldId).each(function() {
800 800
    var $this = $(this);
801 801
    $this.addClass('autocomplete');
802 802
    $this.attr('data-value-was', $this.val());
803
    var checkedValues = {};
804
    var cbSelector = options && options.checkboxSelector;
805
    var $form = cbSelector ? $this.closest('form') : null;
806
    var cbName = cbSelector ? $form.find(cbSelector).first().attr('name') : null;
807

  
808
    function saveChecked() {
809
      if (!cbSelector) return;
810
      $form.find(cbSelector).not('.hidden-checked-value').each(function() {
811
        if ($(this).prop('checked')) {
812
          checkedValues[$(this).val()] = true;
813
        } else {
814
          delete checkedValues[$(this).val()];
815
        }
816
      });
817
    }
818

  
819
    function restoreChecked() {
820
      if (!cbSelector) return;
821
      // Restore checkboxes that are visible in the current page
822
      $form.find(cbSelector).not('.hidden-checked-value').each(function() {
823
        if (checkedValues[$(this).val()]) {
824
          $(this).prop('checked', true);
825
        }
826
      });
827
      // Sync hidden inputs for checked values not visible as checkboxes
828
      $form.find('input.hidden-checked-value').remove();
829
      if (!cbName) return;
830
      $.each(checkedValues, function(val) {
831
        if ($form.find(cbSelector + '[value="' + val + '"]').length === 0) {
832
          $form.append(
833
            $('<input type="hidden" class="hidden-checked-value">').attr('name', cbName).val(val)
834
          );
835
        }
836
      });
837
    }
838

  
839
    if (cbSelector) {
840
      // Track checkbox changes via delegation
841
      $form.on('change', cbSelector, function() {
842
        if ($(this).prop('checked')) {
843
          checkedValues[$(this).val()] = true;
844
        } else {
845
          delete checkedValues[$(this).val()];
846
        }
847
        restoreChecked();
848
      });
849
      // Handle pagination (remote links replacing content)
850
      $form.on('ajax:before', 'a[data-remote]', function() {
851
        saveChecked();
852
      });
853
      $form.on('ajax:complete', 'a[data-remote]', function() {
854
        restoreChecked();
855
      });
856
    }
857

  
803 858
    var check = function() {
804 859
      var val = $this.val();
805 860
      if ($this.attr('data-value-was') != val){
806 861
        $this.attr('data-value-was', val);
862
        saveChecked();
807 863
        $.ajax({
808 864
          url: url,
809 865
          type: 'get',
810 866
          data: {q: $this.val()},
811 867
          success: function(data){ if(targetId) $('#'+targetId).html(data); },
812 868
          beforeSend: function(){ $this.addClass('ajax-loading'); },
813
          complete: function(){ $this.removeClass('ajax-loading'); }
869
          complete: function(){
870
            $this.removeClass('ajax-loading');
871
            restoreChecked();
872
          }
814 873
        });
815 874
      }
816 875
    };
app/views/groups/_new_users_form.html.erb
1 1
<fieldset class="box">
2 2
  <legend><%= label_tag "user_search", l(:label_user_search) %></legend>
3 3
  <p><%= text_field_tag 'user_search', nil %></p>
4
  <%= javascript_tag "observeSearchfield('user_search', null, '#{ escape_javascript autocomplete_for_user_group_path(@group) }')" %>
4
  <%= javascript_tag "observeSearchfield('user_search', null, '#{ escape_javascript autocomplete_for_user_group_path(@group) }', {checkboxSelector: 'input[name=\"user_ids[]\"]'})" %>
5 5
  <div id="users">
6 6
    <%= render_principals_for_new_group_users(@group) %>
app/views/members/_new_form.html.erb
1 1
<fieldset class="box">
2 2
  <legend><%= label_tag("principal_search", l(:label_principal_search)) %></legend>
3 3
  <p><%= text_field_tag('principal_search', nil) %></p>
4
  <%= javascript_tag "observeSearchfield('principal_search', null, '#{ escape_javascript autocomplete_project_memberships_path(@project, :format => 'js') }')" %>
4
  <%= javascript_tag "observeSearchfield('principal_search', null, '#{ escape_javascript autocomplete_project_memberships_path(@project, :format => 'js') }', {checkboxSelector: 'input[name=\"membership[user_ids][]\"]'})" %>
5 5
  <div id="principals_for_new_member">
6 6
    <%= render_principals_for_new_members(@project) %>
7 7
  </div>
app/views/watchers/_new.html.erb
34 34
                 :object_id => (watchables.present? ? watchables.map(&:id) : nil),
35 35
                 :project_id => @project
36 36
               )
37
             )}'
37
             )}',
38
          {checkboxSelector: 'input[name=\"watcher[user_ids][]\"]'}
38 39
         )"
39 40
       ) %>
40 41
  <div id="users_for_watcher">
test/system/groups_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../application_system_test_case'
21

  
22
class GroupsSystemTest < ApplicationSystemTestCase
23
  fixtures :users, :email_addresses, :groups_users
24

  
25
  def test_add_user_to_group_after_search_preserves_selection
26
    group = Group.find(10)  # A Team
27
    rhill = User.find_by_login('rhill')
28
    someone = User.find_by_login('someone')
29

  
30
    assert_not group.users.include?(rhill)
31
    assert_not group.users.include?(someone)
32

  
33
    log_user('admin', 'admin')
34
    visit "/groups/#{group.id}/edit?tab=users"
35

  
36
    click_on 'New user'
37

  
38
    within '#ajax-modal' do
39
      # Check Robert Hill
40
      find('label', text: 'Robert Hill').click
41

  
42
      # Search for 'Some One' - only Some One should remain
43
      fill_in 'user_search', with: 'Some One'
44
      assert page.has_no_css?('label', text: 'Robert Hill')
45

  
46
      # Check Some One while Robert Hill is hidden
47
      find('label', text: 'Some One').click
48

  
49
      # Clear search - both should be visible and Robert Hill should still be checked
50
      fill_in 'user_search', with: ''
51
      assert page.has_css?('label', text: 'Robert Hill')
52
      assert page.has_css?('label', text: 'Some One')
53

  
54
      # Submit
55
      click_on 'Add'
56
    end
57

  
58
    # Wait for modal to close
59
    assert page.has_no_css?('#ajax-modal')
60

  
61
    # Verify both users were added to the group
62
    group.reload
63
    assert group.users.include?(rhill),
64
      "Expected Robert Hill to be added to group"
65
    assert group.users.include?(someone),
66
      "Expected Some One to be added to group"
67
  end
68

  
69
  def test_add_user_to_group_submitting_while_filtered
70
    group = Group.find(10)  # A Team
71
    rhill = User.find_by_login('rhill')
72
    someone = User.find_by_login('someone')
73

  
74
    assert_not group.users.include?(rhill)
75
    assert_not group.users.include?(someone)
76

  
77
    log_user('admin', 'admin')
78
    visit "/groups/#{group.id}/edit?tab=users"
79

  
80
    click_on 'New user'
81

  
82
    within '#ajax-modal' do
83
      # Check Robert Hill
84
      find('label', text: 'Robert Hill').click
85

  
86
      # Search for 'Some One' - Robert Hill disappears
87
      fill_in 'user_search', with: 'Some One'
88
      assert page.has_no_css?('label', text: 'Robert Hill')
89

  
90
      # Check Some One
91
      find('label', text: 'Some One').click
92

  
93
      # Verify hidden input exists for Robert Hill before submitting
94
      rhill_id = rhill.id.to_s
95
      hidden_values = page.evaluate_script(
96
        "$('form input.hidden-checked-value').map(function(){return $(this).val()}).get()"
97
      )
98
      assert_includes hidden_values, rhill_id,
99
        "Expected hidden input for Robert Hill (id=#{rhill_id}) but got: #{hidden_values.inspect}"
100

  
101
      # Submit without clearing search
102
      click_on 'Add'
103
    end
104

  
105
    assert page.has_no_css?('#ajax-modal')
106

  
107
    # Both users should be added even though Robert Hill was not visible
108
    group.reload
109
    assert group.users.include?(rhill),
110
      "Expected Robert Hill to be added to group"
111
    assert group.users.include?(someone),
112
      "Expected Some One to be added to group"
113
  end
114

  
115
  def test_unchecked_user_should_not_be_rechecked_after_search
116
    group = Group.find(10)  # A Team
117
    rhill = User.find_by_login('rhill')
118

  
119
    assert_not group.users.include?(rhill)
120

  
121
    log_user('admin', 'admin')
122
    visit "/groups/#{group.id}/edit?tab=users"
123

  
124
    click_on 'New user'
125

  
126
    within '#ajax-modal' do
127
      # Step 2: Check Robert Hill
128
      find('label', text: 'Robert Hill').click
129

  
130
      # Step 3: Search that hides Robert Hill
131
      fill_in 'user_search', with: 'Some One'
132
      assert page.has_no_css?('label', text: 'Robert Hill')
133

  
134
      # Step 4: Clear search - Robert Hill reappears and should be checked
135
      fill_in 'user_search', with: ''
136
      assert page.has_css?('label', text: 'Robert Hill')
137
      rhill_cb = find('label', text: 'Robert Hill').find('input[type=checkbox]', visible: :all)
138
      assert rhill_cb.checked?, "Robert Hill should be checked after clearing search"
139

  
140
      # Step 5: Uncheck Robert Hill
141
      find('label', text: 'Robert Hill').click
142
      rhill_cb = find('label', text: 'Robert Hill').find('input[type=checkbox]', visible: :all)
143
      assert_not rhill_cb.checked?, "Robert Hill should be unchecked after clicking again"
144

  
145
      # Step 6: Search that matches Robert Hill
146
      fill_in 'user_search', with: 'Robert Hill'
147
      assert page.has_css?('label', text: 'Robert Hill')
148

  
149
      # Step 7: Robert Hill should still be unchecked
150
      rhill_cb = find('label', text: 'Robert Hill').find('input[type=checkbox]', visible: :all)
151
      assert_not rhill_cb.checked?,
152
        "Robert Hill should NOT be re-checked after search refresh"
153

  
154
      # Also verify no hidden input exists for Robert Hill
155
      rhill_id = rhill.id.to_s
156
      hidden_values = page.evaluate_script(
157
        "$('form input.hidden-checked-value').map(function(){return $(this).val()}).get()"
158
      )
159
      assert_not_includes hidden_values, rhill_id,
160
        "No hidden input should exist for unchecked Robert Hill"
161
    end
162
  end
163
end
test/system/members_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../application_system_test_case'
21

  
22
class MembersSystemTest < ApplicationSystemTestCase
23
  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
24
           :trackers, :projects_trackers, :enabled_modules
25

  
26
  def test_add_member_after_search_preserves_selection
27
    project = Project.find('ecookbook')
28
    rhill = User.find_by_login('rhill')
29
    admin_user = User.find_by_login('admin')
30

  
31
    assert_not project.members.map(&:user).include?(rhill)
32
    assert_not project.members.map(&:user).include?(admin_user)
33

  
34
    log_user('admin', 'admin')
35
    visit '/projects/ecookbook/settings/members'
36

  
37
    click_on 'New member'
38

  
39
    within '#ajax-modal' do
40
      # Check Robert Hill
41
      find('label', text: 'Robert Hill').click
42

  
43
      # Search for 'Redmine Admin' - only Redmine Admin should remain
44
      fill_in 'principal_search', with: 'Redmine Admin'
45
      assert page.has_no_css?('label', text: 'Robert Hill')
46

  
47
      # Check Redmine Admin while Robert Hill is hidden
48
      find('label', text: 'Redmine Admin').click
49

  
50
      # Clear search - both should be visible and Robert Hill should still be checked
51
      fill_in 'principal_search', with: ''
52
      assert page.has_css?('label', text: 'Robert Hill')
53
      assert page.has_css?('label', text: 'Redmine Admin')
54

  
55
      # Select a role
56
      check 'Manager'
57

  
58
      # Submit
59
      click_on 'Add'
60
    end
61

  
62
    # Wait for modal to close and page to update
63
    assert page.has_no_css?('#ajax-modal')
64

  
65
    # Verify both users were added as members
66
    project.reload
67
    assert project.members.map(&:user).include?(rhill),
68
      "Expected Robert Hill to be added as member"
69
    assert project.members.map(&:user).include?(admin_user),
70
      "Expected Redmine Admin to be added as member"
71

  
72
    # Verify on the page
73
    assert page.has_css?('#tab-content-members', text: 'Robert Hill')
74
    assert page.has_css?('#tab-content-members', text: 'Redmine Admin')
75
  end
76
end
test/system/watchers_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../application_system_test_case'
21

  
22
class WatchersSystemTest < ApplicationSystemTestCase
23
  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
24
           :trackers, :projects_trackers, :enabled_modules, :issues, :issue_statuses,
25
           :watchers
26

  
27
  def test_add_watcher_after_search_preserves_selection
28
    issue = Issue.find(1)
29
    jsmith = User.find_by_login('jsmith')
30
    dlopper = User.find_by_login('dlopper')
31

  
32
    # Clear existing watchers
33
    issue.watcher_users = []
34

  
35
    assert_not issue.watched_by?(jsmith)
36
    assert_not issue.watched_by?(dlopper)
37

  
38
    log_user('admin', 'admin')
39
    visit "/issues/#{issue.id}"
40

  
41
    # Open watcher modal
42
    within '#sidebar' do
43
      click_on 'Add'
44
    end
45

  
46
    within '#ajax-modal' do
47
      # Check John Smith
48
      find('label', text: 'John Smith').click
49

  
50
      # Search for 'Dave Lopper' - only Dave Lopper should remain
51
      fill_in 'user_search', with: 'Dave Lopper'
52
      assert page.has_no_css?('label', text: 'John Smith')
53

  
54
      # Check Dave Lopper while John Smith is hidden
55
      find('label', text: 'Dave Lopper').click
56

  
57
      # Clear search - both should be visible and John Smith should still be checked
58
      fill_in 'user_search', with: ''
59
      assert page.has_css?('label', text: 'John Smith')
60
      assert page.has_css?('label', text: 'Dave Lopper')
61

  
62
      # Submit
63
      click_on 'Add'
64
    end
65

  
66
    # Wait for AJAX to complete and sidebar to update
67
    assert page.has_css?('#sidebar', text: 'John Smith', wait: 5)
68

  
69
    # Verify both users were added as watchers
70
    issue.reload
71
    assert issue.watched_by?(jsmith),
72
      "Expected John Smith to be added as watcher"
73
    assert issue.watched_by?(dlopper),
74
      "Expected Dave Lopper to be added as watcher"
75
  end
76
end
(2-2/2)