From be63c78760924e1335603c36babd0ad6cfaea3c4 Mon Sep 17 00:00:00 2001 From: Gav O'Connor Date: Thu, 25 Aug 2022 20:50:31 +0100 Subject: [PATCH] Fixed #24179 -- Added filtering to selected side of vertical/horizontal filters. --- AUTHORS | 1 + .../admin/static/admin/css/widgets.css | 31 +++++- .../admin/static/admin/js/SelectBox.js | 4 + .../admin/static/admin/js/SelectFilter2.js | 105 ++++++++++++++---- docs/releases/4.2.txt | 5 + js_tests/admin/SelectFilter2.test.js | 72 +++++++++++- 6 files changed, 188 insertions(+), 30 deletions(-) diff --git a/AUTHORS b/AUTHORS index e3c54397ff..dd45f1bd8c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -346,6 +346,7 @@ answer newbie questions, and generally made Django that much better: Gary Wilson Gasper Koren Gasper Zejn + Gav O'Connor Gavin Wahl Ge Hanbin geber@datacollect.com diff --git a/django/contrib/admin/static/admin/css/widgets.css b/django/contrib/admin/static/admin/css/widgets.css index 0e14efe50d..ad5d9090d6 100644 --- a/django/contrib/admin/static/admin/css/widgets.css +++ b/django/contrib/admin/static/admin/css/widgets.css @@ -20,15 +20,26 @@ flex-direction: column; } -.selector-chosen select { - border-top: none; -} - .selector-available h2, .selector-chosen h2 { border: 1px solid var(--border-color); border-radius: 4px 4px 0 0; } +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + .selector-chosen h2 { background: var(--primary); color: var(--header-link-color); @@ -60,7 +71,8 @@ line-height: 1; } -.selector .selector-available input { +.selector .selector-available input, +.selector .selector-chosen input { width: 320px; margin-left: 8px; } @@ -86,6 +98,15 @@ margin: 0 0 10px; border-radius: 0 0 4px 4px; } +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} .selector-add, .selector-remove { width: 16px; diff --git a/django/contrib/admin/static/admin/js/SelectBox.js b/django/contrib/admin/static/admin/js/SelectBox.js index ace6d9dfb8..3db4ec7fa6 100644 --- a/django/contrib/admin/static/admin/js/SelectBox.js +++ b/django/contrib/admin/static/admin/js/SelectBox.js @@ -41,6 +41,10 @@ } SelectBox.redisplay(id); }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, delete_from_cache: function(id, value) { let delete_index = null; const cache = SelectBox.cache[id]; diff --git a/django/contrib/admin/static/admin/js/SelectFilter2.js b/django/contrib/admin/static/admin/js/SelectFilter2.js index 194c2db2fe..5189107aeb 100644 --- a/django/contrib/admin/static/admin/js/SelectFilter2.js +++ b/django/contrib/admin/static/admin/js/SelectFilter2.js @@ -78,7 +78,7 @@ Requires core.js and SelectBox.js. remove_link.className = 'selector-remove'; //
- const selector_chosen = quickElement('div', selector_div); + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); selector_chosen.className = 'selector-chosen'; const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); quickElement( @@ -93,9 +93,30 @@ Requires core.js and SelectBox.js. [field_name] ) ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + 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'); clear_all.className = 'selector-clearall'; @@ -106,6 +127,8 @@ Requires core.js and SelectBox.js. if (elem.classList.contains('active')) { move_func(from, to); SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); } e.preventDefault(); }; @@ -121,14 +144,29 @@ Requires core.js and SelectBox.js. clear_all.addEventListener('click', function(e) { move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); filter_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id); + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); }); filter_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id); + SelectFilter.filter_key_up(e, field_id, '_from'); }); filter_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id); + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); }); selector_div.addEventListener('change', function(e) { if (e.target.tagName === 'SELECT') { @@ -146,6 +184,7 @@ Requires core.js and SelectBox.js. } }); from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); SelectBox.select_all(field_id + '_to'); }); SelectBox.init(field_id + '_from'); @@ -163,6 +202,20 @@ Requires core.js and SelectBox.js. field.required = false; return any_selected; }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(gettext('%s selected options not visible'), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, refresh_icons: function(field_id) { const from = document.getElementById(field_id + '_from'); const to = document.getElementById(field_id + '_to'); @@ -172,39 +225,47 @@ Requires core.js and SelectBox.js. // Active if the corresponding box isn't empty document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); }, - filter_key_press: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); // don't submit form if user pressed Enter if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { - from.selectedIndex = 0; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = 0; + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; event.preventDefault(); } }, - filter_key_up: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); - const temp = from.selectedIndex; - SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value); - from.selectedIndex = temp; + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); }, - filter_key_down: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; // right arrow -- move across - if ((event.which && event.which === 39) || (event.keyCode && event.keyCode === 39)) { - const old_index = from.selectedIndex; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = (old_index === from.length) ? from.length - 1 : old_index; + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; return; } // down arrow -- wrap around if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { - from.selectedIndex = (from.length === from.selectedIndex + 1) ? 0 : from.selectedIndex + 1; + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; } // up arrow -- wrap around if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { - from.selectedIndex = (from.selectedIndex === 0) ? from.length - 1 : from.selectedIndex - 1; + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; } } }; diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index 199abf4e19..71ba5c0dc6 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -43,6 +43,11 @@ Minor features ` template now has some additional blocks and scripting hooks to ease customization. +* The chosen options of + :attr:`~django.contrib.admin.ModelAdmin.filter_horizontal` and + :attr:`~django.contrib.admin.ModelAdmin.filter_vertical` widgets are now + filterable. + :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/js_tests/admin/SelectFilter2.test.js b/js_tests/admin/SelectFilter2.test.js index 32a30c5889..387bfd669c 100644 --- a/js_tests/admin/SelectFilter2.test.js +++ b/js_tests/admin/SelectFilter2.test.js @@ -30,7 +30,7 @@ QUnit.test('filtering available options', function(assert) { const search_term = 'r'; const event = new KeyboardEvent('keyup', {'key': search_term}); $('#select_input').val(search_term); - SelectFilter.filter_key_up(event, 'select'); + SelectFilter.filter_key_up(event, 'select', '_from'); setTimeout(() => { assert.equal($('#select_from option').length, 2); assert.equal($('#select_to option').length, 0); @@ -40,6 +40,29 @@ QUnit.test('filtering available options', function(assert) { }); }); +QUnit.test('filtering selected options', function(assert) { + const $ = django.jQuery; + $('
').appendTo('#qunit-fixture'); + $('').appendTo('#select'); + $('').appendTo('#select'); + $('').appendTo('#select'); + SelectFilter.init('select', 'items', 0); + assert.equal($('#select_from option').length, 0); + assert.equal($('#select_to option').length, 3); + const done = assert.async(); + const search_term = 'r'; + const event = new KeyboardEvent('keyup', {'key': search_term}); + $('#select_selected_input').val(search_term); + SelectFilter.filter_key_up(event, 'select', '_to', '_selected_input'); + setTimeout(() => { + assert.equal($('#select_from option').length, 0); + assert.equal($('#select_to option').length, 2); + assert.equal($('#select_to option')[0].value, '1'); + assert.equal($('#select_to option')[1].value, '3'); + done(); + }); +}); + QUnit.test('filtering available options to nothing', function(assert) { const $ = django.jQuery; $('
').appendTo('#qunit-fixture'); @@ -53,7 +76,28 @@ QUnit.test('filtering available options to nothing', function(assert) { const search_term = 'x'; const event = new KeyboardEvent('keyup', {'key': search_term}); $('#select_input').val(search_term); - SelectFilter.filter_key_up(event, 'select'); + SelectFilter.filter_key_up(event, 'select', '_from'); + setTimeout(() => { + assert.equal($('#select_from option').length, 0); + assert.equal($('#select_to option').length, 0); + done(); + }); +}); + +QUnit.test('filtering selected options to nothing', function(assert) { + const $ = django.jQuery; + $('
').appendTo('#qunit-fixture'); + $('').appendTo('#select'); + $('').appendTo('#select'); + $('').appendTo('#select'); + SelectFilter.init('select', 'items', 0); + assert.equal($('#select_from option').length, 0); + assert.equal($('#select_to option').length, 3); + const done = assert.async(); + const search_term = 'x'; + const event = new KeyboardEvent('keyup', {'key': search_term}); + $('#select_selected_input').val(search_term); + SelectFilter.filter_key_up(event, 'select', '_to', '_selected_input'); setTimeout(() => { assert.equal($('#select_from option').length, 0); assert.equal($('#select_to option').length, 0); @@ -74,7 +118,7 @@ QUnit.test('selecting option', function(assert) { const done = assert.async(); $('#select_from')[0].selectedIndex = 0; const event = new KeyboardEvent('keydown', {'keyCode': 39, 'charCode': 39}); - SelectFilter.filter_key_down(event, 'select'); + SelectFilter.filter_key_down(event, 'select', '_from', '_to'); setTimeout(() => { assert.equal($('#select_from option').length, 2); assert.equal($('#select_to option').length, 1); @@ -82,3 +126,25 @@ QUnit.test('selecting option', function(assert) { done(); }); }); + +QUnit.test('deselecting option', function(assert) { + const $ = django.jQuery; + $('
').appendTo('#qunit-fixture'); + $('').appendTo('#select'); + $('').appendTo('#select'); + $('').appendTo('#select'); + SelectFilter.init('select', 'items', 0); + assert.equal($('#select_from option').length, 2); + assert.equal($('#select_to option').length, 1); + assert.equal($('#select_to option')[0].value, '1'); + // move back to the left + const done_left = assert.async(); + $('#select_to')[0].selectedIndex = 0; + const event_left = new KeyboardEvent('keydown', {'keyCode': 37, 'charCode': 37}); + SelectFilter.filter_key_down(event_left, 'select', '_to', '_from'); + setTimeout(() => { + assert.equal($('#select_from option').length, 3); + assert.equal($('#select_to option').length, 0); + done_left(); + }); +});