Skip to content

Commit be63c78

Browse files
gav-fyicarltongibson
authored andcommitted
Fixed #24179 -- Added filtering to selected side of vertical/horizontal filters.
1 parent fc220d2 commit be63c78

File tree

6 files changed

+188
-30
lines changed

6 files changed

+188
-30
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ answer newbie questions, and generally made Django that much better:
346346
Gary Wilson <[email protected]>
347347
Gasper Koren
348348
Gasper Zejn <[email protected]>
349+
Gav O'Connor <https://github.com/Scalamoosh>
349350
Gavin Wahl <[email protected]>
350351
Ge Hanbin <[email protected]>
351352

django/contrib/admin/static/admin/css/widgets.css

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,26 @@
2020
flex-direction: column;
2121
}
2222

23-
.selector-chosen select {
24-
border-top: none;
25-
}
26-
2723
.selector-available h2, .selector-chosen h2 {
2824
border: 1px solid var(--border-color);
2925
border-radius: 4px 4px 0 0;
3026
}
3127

28+
.selector-chosen .list-footer-display {
29+
border: 1px solid var(--border-color);
30+
border-top: none;
31+
border-radius: 0 0 4px 4px;
32+
margin: 0 0 10px;
33+
padding: 8px;
34+
text-align: center;
35+
background: var(--primary);
36+
color: var(--header-link-color);
37+
cursor: pointer;
38+
}
39+
.selector-chosen .list-footer-display__clear {
40+
color: var(--breadcrumbs-fg);
41+
}
42+
3243
.selector-chosen h2 {
3344
background: var(--primary);
3445
color: var(--header-link-color);
@@ -60,7 +71,8 @@
6071
line-height: 1;
6172
}
6273

63-
.selector .selector-available input {
74+
.selector .selector-available input,
75+
.selector .selector-chosen input {
6476
width: 320px;
6577
margin-left: 8px;
6678
}
@@ -86,6 +98,15 @@
8698
margin: 0 0 10px;
8799
border-radius: 0 0 4px 4px;
88100
}
101+
.selector .selector-chosen--with-filtered select {
102+
margin: 0;
103+
border-radius: 0;
104+
height: 14em;
105+
}
106+
107+
.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display {
108+
display: none;
109+
}
89110

90111
.selector-add, .selector-remove {
91112
width: 16px;

django/contrib/admin/static/admin/js/SelectBox.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
}
4242
SelectBox.redisplay(id);
4343
},
44+
get_hidden_node_count(id) {
45+
const cache = SelectBox.cache[id] || [];
46+
return cache.filter(node => node.displayed === 0).length;
47+
},
4448
delete_from_cache: function(id, value) {
4549
let delete_index = null;
4650
const cache = SelectBox.cache[id];

django/contrib/admin/static/admin/js/SelectFilter2.js

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Requires core.js and SelectBox.js.
7878
remove_link.className = 'selector-remove';
7979

8080
// <div class="selector-chosen">
81-
const selector_chosen = quickElement('div', selector_div);
81+
const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen');
8282
selector_chosen.className = 'selector-chosen';
8383
const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name]));
8484
quickElement(
@@ -93,9 +93,30 @@ Requires core.js and SelectBox.js.
9393
[field_name]
9494
)
9595
);
96+
97+
const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected');
98+
filter_selected_p.className = 'selector-filter';
99+
100+
const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input');
101+
102+
quickElement(
103+
'span', search_filter_selected_label, '',
104+
'class', 'help-tooltip search-label-icon',
105+
'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name])
106+
);
107+
108+
filter_selected_p.appendChild(document.createTextNode(' '));
109+
110+
const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter"));
111+
filter_selected_input.id = field_id + '_selected_input';
96112

97113
const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name);
98114
to_box.className = 'filtered';
115+
116+
const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display');
117+
quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text');
118+
quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear');
119+
99120
const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link');
100121
clear_all.className = 'selector-clearall';
101122

@@ -106,6 +127,8 @@ Requires core.js and SelectBox.js.
106127
if (elem.classList.contains('active')) {
107128
move_func(from, to);
108129
SelectFilter.refresh_icons(field_id);
130+
SelectFilter.refresh_filtered_selects(field_id);
131+
SelectFilter.refresh_filtered_warning(field_id);
109132
}
110133
e.preventDefault();
111134
};
@@ -121,14 +144,29 @@ Requires core.js and SelectBox.js.
121144
clear_all.addEventListener('click', function(e) {
122145
move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from');
123146
});
147+
warning_footer.addEventListener('click', function(e) {
148+
filter_selected_input.value = '';
149+
SelectBox.filter(field_id + '_to', '');
150+
SelectFilter.refresh_filtered_warning(field_id);
151+
SelectFilter.refresh_icons(field_id);
152+
});
124153
filter_input.addEventListener('keypress', function(e) {
125-
SelectFilter.filter_key_press(e, field_id);
154+
SelectFilter.filter_key_press(e, field_id, '_from', '_to');
126155
});
127156
filter_input.addEventListener('keyup', function(e) {
128-
SelectFilter.filter_key_up(e, field_id);
157+
SelectFilter.filter_key_up(e, field_id, '_from');
129158
});
130159
filter_input.addEventListener('keydown', function(e) {
131-
SelectFilter.filter_key_down(e, field_id);
160+
SelectFilter.filter_key_down(e, field_id, '_from', '_to');
161+
});
162+
filter_selected_input.addEventListener('keypress', function(e) {
163+
SelectFilter.filter_key_press(e, field_id, '_to', '_from');
164+
});
165+
filter_selected_input.addEventListener('keyup', function(e) {
166+
SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input');
167+
});
168+
filter_selected_input.addEventListener('keydown', function(e) {
169+
SelectFilter.filter_key_down(e, field_id, '_to', '_from');
132170
});
133171
selector_div.addEventListener('change', function(e) {
134172
if (e.target.tagName === 'SELECT') {
@@ -146,6 +184,7 @@ Requires core.js and SelectBox.js.
146184
}
147185
});
148186
from_box.closest('form').addEventListener('submit', function() {
187+
SelectBox.filter(field_id + '_to', '');
149188
SelectBox.select_all(field_id + '_to');
150189
});
151190
SelectBox.init(field_id + '_from');
@@ -163,6 +202,20 @@ Requires core.js and SelectBox.js.
163202
field.required = false;
164203
return any_selected;
165204
},
205+
refresh_filtered_warning: function(field_id) {
206+
const count = SelectBox.get_hidden_node_count(field_id + '_to');
207+
const selector = document.getElementById(field_id + '_selector_chosen');
208+
const warning = document.getElementById(field_id + '_list-footer-display-text');
209+
selector.className = selector.className.replace('selector-chosen--with-filtered', '');
210+
warning.textContent = interpolate(gettext('%s selected options not visible'), [count]);
211+
if(count > 0) {
212+
selector.className += ' selector-chosen--with-filtered';
213+
}
214+
},
215+
refresh_filtered_selects: function(field_id) {
216+
SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value);
217+
SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value);
218+
},
166219
refresh_icons: function(field_id) {
167220
const from = document.getElementById(field_id + '_from');
168221
const to = document.getElementById(field_id + '_to');
@@ -172,39 +225,47 @@ Requires core.js and SelectBox.js.
172225
// Active if the corresponding box isn't empty
173226
document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option'));
174227
document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option'));
228+
SelectFilter.refresh_filtered_warning(field_id);
175229
},
176-
filter_key_press: function(event, field_id) {
177-
const from = document.getElementById(field_id + '_from');
230+
filter_key_press: function(event, field_id, source, target) {
231+
const source_box = document.getElementById(field_id + source);
178232
// don't submit form if user pressed Enter
179233
if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) {
180-
from.selectedIndex = 0;
181-
SelectBox.move(field_id + '_from', field_id + '_to');
182-
from.selectedIndex = 0;
234+
source_box.selectedIndex = 0;
235+
SelectBox.move(field_id + source, field_id + target);
236+
source_box.selectedIndex = 0;
183237
event.preventDefault();
184238
}
185239
},
186-
filter_key_up: function(event, field_id) {
187-
const from = document.getElementById(field_id + '_from');
188-
const temp = from.selectedIndex;
189-
SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value);
190-
from.selectedIndex = temp;
240+
filter_key_up: function(event, field_id, source, filter_input) {
241+
const input = filter_input || '_input';
242+
const source_box = document.getElementById(field_id + source);
243+
const temp = source_box.selectedIndex;
244+
SelectBox.filter(field_id + source, document.getElementById(field_id + input).value);
245+
source_box.selectedIndex = temp;
246+
SelectFilter.refresh_filtered_warning(field_id);
247+
SelectFilter.refresh_icons(field_id);
191248
},
192-
filter_key_down: function(event, field_id) {
193-
const from = document.getElementById(field_id + '_from');
249+
filter_key_down: function(event, field_id, source, target) {
250+
const source_box = document.getElementById(field_id + source);
251+
// right key (39) or left key (37)
252+
const direction = source === '_from' ? 39 : 37;
194253
// right arrow -- move across
195-
if ((event.which && event.which === 39) || (event.keyCode && event.keyCode === 39)) {
196-
const old_index = from.selectedIndex;
197-
SelectBox.move(field_id + '_from', field_id + '_to');
198-
from.selectedIndex = (old_index === from.length) ? from.length - 1 : old_index;
254+
if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) {
255+
const old_index = source_box.selectedIndex;
256+
SelectBox.move(field_id + source, field_id + target);
257+
SelectFilter.refresh_filtered_selects(field_id);
258+
SelectFilter.refresh_filtered_warning(field_id);
259+
source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index;
199260
return;
200261
}
201262
// down arrow -- wrap around
202263
if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) {
203-
from.selectedIndex = (from.length === from.selectedIndex + 1) ? 0 : from.selectedIndex + 1;
264+
source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1;
204265
}
205266
// up arrow -- wrap around
206267
if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) {
207-
from.selectedIndex = (from.selectedIndex === 0) ? from.length - 1 : from.selectedIndex - 1;
268+
source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1;
208269
}
209270
}
210271
};

docs/releases/4.2.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ Minor features
4343
<django/contrib/admin/templates/admin/delete_confirmation.html>` template now
4444
has some additional blocks and scripting hooks to ease customization.
4545

46+
* The chosen options of
47+
:attr:`~django.contrib.admin.ModelAdmin.filter_horizontal` and
48+
:attr:`~django.contrib.admin.ModelAdmin.filter_vertical` widgets are now
49+
filterable.
50+
4651
:mod:`django.contrib.admindocs`
4752
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4853

js_tests/admin/SelectFilter2.test.js

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ QUnit.test('filtering available options', function(assert) {
3030
const search_term = 'r';
3131
const event = new KeyboardEvent('keyup', {'key': search_term});
3232
$('#select_input').val(search_term);
33-
SelectFilter.filter_key_up(event, 'select');
33+
SelectFilter.filter_key_up(event, 'select', '_from');
3434
setTimeout(() => {
3535
assert.equal($('#select_from option').length, 2);
3636
assert.equal($('#select_to option').length, 0);
@@ -40,6 +40,29 @@ QUnit.test('filtering available options', function(assert) {
4040
});
4141
});
4242

43+
QUnit.test('filtering selected options', function(assert) {
44+
const $ = django.jQuery;
45+
$('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
46+
$('<option selected value="1" title="Red">Red</option>').appendTo('#select');
47+
$('<option selected value="2" title="Blue">Blue</option>').appendTo('#select');
48+
$('<option selected value="3" title="Green">Green</option>').appendTo('#select');
49+
SelectFilter.init('select', 'items', 0);
50+
assert.equal($('#select_from option').length, 0);
51+
assert.equal($('#select_to option').length, 3);
52+
const done = assert.async();
53+
const search_term = 'r';
54+
const event = new KeyboardEvent('keyup', {'key': search_term});
55+
$('#select_selected_input').val(search_term);
56+
SelectFilter.filter_key_up(event, 'select', '_to', '_selected_input');
57+
setTimeout(() => {
58+
assert.equal($('#select_from option').length, 0);
59+
assert.equal($('#select_to option').length, 2);
60+
assert.equal($('#select_to option')[0].value, '1');
61+
assert.equal($('#select_to option')[1].value, '3');
62+
done();
63+
});
64+
});
65+
4366
QUnit.test('filtering available options to nothing', function(assert) {
4467
const $ = django.jQuery;
4568
$('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
@@ -53,7 +76,28 @@ QUnit.test('filtering available options to nothing', function(assert) {
5376
const search_term = 'x';
5477
const event = new KeyboardEvent('keyup', {'key': search_term});
5578
$('#select_input').val(search_term);
56-
SelectFilter.filter_key_up(event, 'select');
79+
SelectFilter.filter_key_up(event, 'select', '_from');
80+
setTimeout(() => {
81+
assert.equal($('#select_from option').length, 0);
82+
assert.equal($('#select_to option').length, 0);
83+
done();
84+
});
85+
});
86+
87+
QUnit.test('filtering selected options to nothing', function(assert) {
88+
const $ = django.jQuery;
89+
$('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
90+
$('<option selected value="1" title="Red">Red</option>').appendTo('#select');
91+
$('<option selected value="2" title="Blue">Blue</option>').appendTo('#select');
92+
$('<option selected value="3" title="Green">Green</option>').appendTo('#select');
93+
SelectFilter.init('select', 'items', 0);
94+
assert.equal($('#select_from option').length, 0);
95+
assert.equal($('#select_to option').length, 3);
96+
const done = assert.async();
97+
const search_term = 'x';
98+
const event = new KeyboardEvent('keyup', {'key': search_term});
99+
$('#select_selected_input').val(search_term);
100+
SelectFilter.filter_key_up(event, 'select', '_to', '_selected_input');
57101
setTimeout(() => {
58102
assert.equal($('#select_from option').length, 0);
59103
assert.equal($('#select_to option').length, 0);
@@ -74,11 +118,33 @@ QUnit.test('selecting option', function(assert) {
74118
const done = assert.async();
75119
$('#select_from')[0].selectedIndex = 0;
76120
const event = new KeyboardEvent('keydown', {'keyCode': 39, 'charCode': 39});
77-
SelectFilter.filter_key_down(event, 'select');
121+
SelectFilter.filter_key_down(event, 'select', '_from', '_to');
78122
setTimeout(() => {
79123
assert.equal($('#select_from option').length, 2);
80124
assert.equal($('#select_to option').length, 1);
81125
assert.equal($('#select_to option')[0].value, '1');
82126
done();
83127
});
84128
});
129+
130+
QUnit.test('deselecting option', function(assert) {
131+
const $ = django.jQuery;
132+
$('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
133+
$('<option selected value="1" title="Red">Red</option>').appendTo('#select');
134+
$('<option value="2" title="Blue">Blue</option>').appendTo('#select');
135+
$('<option value="3" title="Green">Green</option>').appendTo('#select');
136+
SelectFilter.init('select', 'items', 0);
137+
assert.equal($('#select_from option').length, 2);
138+
assert.equal($('#select_to option').length, 1);
139+
assert.equal($('#select_to option')[0].value, '1');
140+
// move back to the left
141+
const done_left = assert.async();
142+
$('#select_to')[0].selectedIndex = 0;
143+
const event_left = new KeyboardEvent('keydown', {'keyCode': 37, 'charCode': 37});
144+
SelectFilter.filter_key_down(event_left, 'select', '_to', '_from');
145+
setTimeout(() => {
146+
assert.equal($('#select_from option').length, 3);
147+
assert.equal($('#select_to option').length, 0);
148+
done_left();
149+
});
150+
});

0 commit comments

Comments
 (0)