瀏覽代碼

Fixed #24179 -- Added filtering to selected side of vertical/horizontal filters.

Gav O'Connor 2 年之前
父節點
當前提交
be63c78760

+ 1 - 0
AUTHORS

@@ -346,6 +346,7 @@ answer newbie questions, and generally made Django that much better:
     Gary Wilson <gary.wilson@gmail.com>
     Gasper Koren
     Gasper Zejn <zejn@kiberpipa.org>
+    Gav O'Connor <https://github.com/Scalamoosh>
     Gavin Wahl <gavinwahl@gmail.com>
     Ge Hanbin <xiaomiba0904@gmail.com>
     geber@datacollect.com

+ 26 - 5
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;

+ 4 - 0
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];

+ 83 - 22
django/contrib/admin/static/admin/js/SelectFilter2.js

@@ -78,7 +78,7 @@ Requires core.js and SelectBox.js.
             remove_link.className = 'selector-remove';
 
             // <div class="selector-chosen">
-            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;
             }
         }
     };

+ 5 - 0
docs/releases/4.2.txt

@@ -43,6 +43,11 @@ Minor features
   <django/contrib/admin/templates/admin/delete_confirmation.html>` 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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 69 - 3
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;
+    $('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
+    $('<option selected value="1" title="Red">Red</option>').appendTo('#select');
+    $('<option selected value="2" title="Blue">Blue</option>').appendTo('#select');
+    $('<option selected value="3" title="Green">Green</option>').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;
     $('<form><select multiple id="select"></select></form>').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;
+    $('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
+    $('<option selected value="1" title="Red">Red</option>').appendTo('#select');
+    $('<option selected value="2" title="Blue">Blue</option>').appendTo('#select');
+    $('<option selected value="3" title="Green">Green</option>').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;
+    $('<form><select multiple id="select"></select></form>').appendTo('#qunit-fixture');
+    $('<option selected value="1" title="Red">Red</option>').appendTo('#select');
+    $('<option value="2" title="Blue">Blue</option>').appendTo('#select');
+    $('<option value="3" title="Green">Green</option>').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();
+    });
+});