Repository: openwisp/openwisp-utils (language: python)

Issue:
[enhancement] Restyle filters
### Design reference 
https://miro.com/app/board/o9J_lMAQQZk=/
This is an old ref.
New one:
https://www.figma.com/file/vCdBAfFQ3HNH9KWwdO4XgC/OpenWISP?node-id=0%3A1

### Tasks

- [x] Restyle filter and find a way to show filters on top instead of showing them in the lateral sidebars.
- [x] Create an apply filter button so that all filters are applied at once.
- [x] Create dropdown in filters so they don't occupy large space.
- [x] Create a slider for the filter
- [x] Write some basic tests to test the functionality ( if possible ).


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 3cdbf9a3..2ee2c7d5 100644
--- a/README.rst
+++ b/README.rst
@@ -44,6 +44,7 @@ Current features
 
 * `Configurable admin theme <#using-the-admin_theme>`_
 * `Configurable navigation menu <#main-navigation-menu>`_
+* `Improved admin filters <#admin-filters>`_
 * `OpenAPI / Swagger documentation <#openwisp_api_docs>`_
 * `Model utilities <#model-utilities>`_
 * `Storage utilities <#storage-utilities>`_
@@ -680,6 +681,23 @@ Follow the instructions in
 `Supplying custom CSS and JS for the admin theme <#supplying-custom-css-and-js-for-the-admin-theme>`_
 to know how to configure your OpenWISP instance to load custom CSS files.
 
+Admin filters 
+-------------
+
+.. figure:: https://github.com/openwisp/openwisp-utils/blob/media/docs/filter.gif
+  :align: center
+
+The ``admin_theme`` sub app provides an improved UI for the changelist filter
+which occupies less space compared to the original implementation in django:
+filters are displayed horizontally on the top (instead of vertically on the side)
+and filter options are hidden in dropdown menus which are expanded once clicked.
+
+Multiple filters can be applied at same time with the help of "apply filter" button.
+This button is only visible when total number of filters is greater than 4.
+When filters in use are less or equal to 4 the "apply filter" button is not visible
+and filters work like in the original django implementation
+(as soon as a filter option is selected the filter is applied and the page is reloaded).
+
 Model utilities
 ---------------
 
diff --git a/docs/filter.gif b/docs/filter.gif
new file mode 100644
index 00000000..a092eaf7
Binary files /dev/null and b/docs/filter.gif differ
diff --git a/openwisp_utils/admin_theme/static/admin/css/openwisp.css b/openwisp_utils/admin_theme/static/admin/css/openwisp.css
index 964cd2c5..e486a484 100644
--- a/openwisp_utils/admin_theme/static/admin/css/openwisp.css
+++ b/openwisp_utils/admin_theme/static/admin/css/openwisp.css
@@ -11,6 +11,10 @@ body {
   z-index: 9999;
   display: none;
 }
+.force-inactive {
+  opacity: 0.1 !important;
+  pointer-events: none !important;
+}
 #main-content .breadcrumbs,
 .module caption,
 div[class^="app-"].module th, div[class^="app-"].module td {
@@ -495,6 +499,8 @@ input[type='button']:hover,
   cursor: pointer;
   justify-content: center;
   align-items: center;
+  background: #fff;
+  border-bottom: 1px solid #00000026;
 }
 .hamburger span {
   background: url(../../ui/openwisp/images/hamburger.svg) no-repeat center;
diff --git a/openwisp_utils/admin_theme/static/admin/css/ow-filters.css b/openwisp_utils/admin_theme/static/admin/css/ow-filters.css
new file mode 100644
index 00000000..de38b67b
--- /dev/null
+++ b/openwisp_utils/admin_theme/static/admin/css/ow-filters.css
@@ -0,0 +1,204 @@
+#changelist .results,
+#changelist .actions,
+#changelist .paginator {
+  margin-right: 0px;
+}
+#ow-changelist-filter {
+  padding: 1.25rem 2.5rem;
+  background: #fff;
+  border-bottom: 2px solid #f6f6f6;
+}
+#ow-changelist-filter h2,
+#changelist-filter-clear {
+  margin: 0px;
+}
+#changelist-filter-clear {
+  margin-right: 1.25rem;
+}
+#ow-apply-filter {
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 20px;
+  padding: 0.4rem 0.8rem;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+.filters-control {
+  display: flex;
+  align-items: center;
+}
+.filters-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 1rem;
+}
+.filters-bottom {
+  height: 3.125rem;
+  margin: auto 10px;
+  margin-top: 1.25rem;
+  position: relative;
+}
+.filter {
+  margin-right: 0.625rem;
+}
+.filter-options input {
+  display: none;
+}
+.filter-options {
+  flex-direction: column;
+  margin-top: 3px;
+  border-radius: 0.35rem;
+  width: min-content;
+  background: #ffffff;
+  max-height: 0px;
+  width: min-content;
+  min-width: 100%;
+  overflow-y: auto;
+  display: inline-flex;
+  transition: all 0.2s ease;
+  box-shadow: 0px 1px 3px #00000040;
+  pointer-events: none;
+}
+.filter-title {
+  border: 1px solid #747272;
+  border-radius: 0.2rem;
+  padding: 0.4rem 1.25rem 0.3125rem 1rem;
+  cursor: pointer;
+  height: 1.5rem;
+  pointer-events: all;
+  position: relative;
+  min-width: 11.5rem;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  line-height: 20px;
+}
+.filter-title h3 {
+  margin: 0px;
+  margin: -17px 0 1px -8px;
+  padding: 0px 5px;
+  width: fit-content;
+  width: -moz-fit-content;
+  background: #fff;
+  color: rgba(0, 0, 0, 0.65);
+  pointer-events: none;
+  font-weight: 300;
+}
+.selected-option {
+  pointer-events: none;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  width: 87%;
+  left: 12px;
+  margin: auto;
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+  box-sizing: border-box;
+  white-space: nowrap;
+}
+.ow-filter {
+  flex-shrink: 0;
+  z-index: 1;
+  margin-right: 1.25rem;
+  position: relative;
+  letter-spacing: 0.5px;
+  font-size: 14px;
+}
+.ow-filter.active .filter-title {
+  border-color: #df5d43;
+  color: #df5d43;
+}
+.filter-options label,
+.filter-options a {
+  padding: 0.5rem 0.75rem;
+  cursor: pointer;
+  border-radius: 4px;
+  margin: 0.12rem;
+}
+.ow-filter.active .filter-options {
+  pointer-events: all;
+  max-height: 18.75rem;
+}
+.filter-options .selected,.filter-options .selected:hover,
+.filter-options .selected:focus {
+  background: #df5d43;
+  color: #ffff;
+  cursor: default;
+}
+.filter-options label:hover,
+.filter-options a:hover,
+.filter-options label:focus,
+.filter-options a:focus {
+  background: #ffe5e5;
+  color: #ba2121;
+}
+.ow-filter-slider {
+  overflow-x: auto;
+  padding: 0.625rem 1rem;
+  scroll-behavior: smooth;
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+}
+.ow-filter-slider::-webkit-scrollbar {
+  display: none;
+}
+.slider {
+  display: flex;
+  position: relative;
+  z-index: 1;
+}
+.left-arrow,
+.right-arrow {
+  height: 2.5rem;
+  width: 2.5rem;
+  border-radius: 50%;
+  cursor: pointer;
+  top: 5px;
+  display: flex;
+  position: absolute;
+  align-items: center;
+  justify-content: center;
+  transition-timing-function: ease;
+  z-index: 3;
+}
+.left-arrow {
+  left: -2.125rem;
+}
+.right-arrow {
+  right: -2.125rem;
+}
+.left-arrow img,
+.right-arrow img {
+  pointer-events: none;
+  width: 1.25rem;
+  height: 1.25rem;
+}
+.left-arrow:hover,
+.right-arrow:hover {
+  background-color: #f7f7f7;
+}
+.left-arrow.down,
+.right-arrow.down {
+  background: rgb(185 185 185 / 20%) !important;
+}
+.disabled-click{
+  pointer-events: none;
+}
+
+/* RESPONSIVE */
+@media (max-width: 1024px) {
+  #ow-changelist-filter {
+    padding: 1.25rem 1.875rem;
+  }
+  .filters-bottom{
+    margin: auto 30px;
+    margin-top: 1.25rem;
+  }
+}
+@media (max-width: 768px) {
+  #ow-changelist-filter {
+    padding: 0.9375rem;
+  }
+}
diff --git a/openwisp_utils/admin_theme/static/admin/js/ow-filter.js b/openwisp_utils/admin_theme/static/admin/js/ow-filter.js
new file mode 100644
index 00000000..86e13013
--- /dev/null
+++ b/openwisp_utils/admin_theme/static/admin/js/ow-filter.js
@@ -0,0 +1,189 @@
+'use strict';
+var leftArrow, rightArrow, slider;
+const scrollDX = 200,
+  btnAnimationTime = 100; //ms
+(function () {
+  document.addEventListener(
+    'DOMContentLoaded',
+    function () {
+      leftArrow = document.querySelector('.filters-bottom .left-arrow');
+      rightArrow = document.querySelector('.filters-bottom .right-arrow');
+      slider = document.querySelector('.ow-filter-slider');
+      initFilterDropdownHandler();
+      initSliderHandlers();
+      filterHandlers();
+      if (slider) {
+        setArrowButtonVisibility();
+      }
+    },
+    false
+  );
+})();
+
+function initFilterDropdownHandler() {
+  const filters = document.querySelectorAll('.ow-filter');
+  // When filter title is clicked
+  filters.forEach(function (filter) {
+    var toggler = filter.querySelector('.filter-title');
+    toggler.addEventListener('click', function () {
+      // Close if any active filter
+      var activeFilter = document.querySelector('.ow-filter.active');
+      if (activeFilter && activeFilter !== filter) {
+        activeFilter.classList.remove('active');
+      }
+      filter.classList.toggle('active');
+    });
+  });
+  // Handle click outside of an active filter
+  document.addEventListener('click', function (e) {
+    var activeFilter = document.querySelector('.ow-filter.active');
+    if (activeFilter && !activeFilter.contains(e.target)) {
+      activeFilter.classList.remove('active');
+    }
+  });
+  // Handle change in filter option
+  const filterRadios = document.querySelectorAll('.filter-options input');
+  filterRadios.forEach(function (filterRadio) {
+    filterRadio.addEventListener('change', function () {
+      let filter = document.querySelector('.ow-filter.active');
+      let view = document.querySelector('.ow-filter.active .selected-option');
+      let selectedElement = document.querySelector(
+        '.ow-filter.active .selected'
+      );
+      selectedElement.classList.remove('selected');
+      filterRadio.previousElementSibling.classList.add('selected');
+      var text = filterRadio.previousElementSibling.innerHTML;
+      view.innerHTML = text;
+      filter.querySelector('.filter-title').setAttribute('title', text);
+      filter.classList.remove('active');
+    });
+  });
+}
+
+function buttonAnimation(button) {
+  // Animate button by adding and removing classes
+  button.classList.add('down');
+  setTimeout(function () {
+    button.classList.remove('down');
+  }, btnAnimationTime);
+}
+
+function scrollLeft() {
+  buttonAnimation(leftArrow);
+  slider.scrollLeft -= scrollDX;
+  if (slider.scrollLeft == 0) {
+    leftArrow.classList.add('force-inactive');
+  } else {
+    leftArrow.classList.remove('force-inactive');
+  }
+  rightArrow.classList.remove('force-inactive');
+}
+
+function scrollRight() {
+  buttonAnimation(rightArrow);
+  slider.scrollLeft += scrollDX;
+  if (slider.scrollLeft + slider.offsetWidth >= slider.scrollWidth) {
+    rightArrow.classList.add('force-inactive');
+  } else {
+    rightArrow.classList.remove('force-inactive');
+  }
+  leftArrow.classList.remove('force-inactive');
+}
+
+function setArrowButtonVisibility() {
+  if (slider.scrollLeft === 0) {
+    leftArrow.classList.add('force-inactive');
+  } else {
+    leftArrow.classList.remove('force-inactive');
+  }
+  if (slider.scrollLeft + slider.offsetWidth + 1 >= slider.scrollWidth) {
+    rightArrow.classList.add('force-inactive');
+  } else {
+    rightArrow.classList.remove('force-inactive');
+  }
+}
+
+function initSliderHandlers() {
+  // When left arrow is clicked
+  if (leftArrow) {
+    leftArrow.addEventListener('click', scrollLeft);
+  }
+  // When right arrow is clicked
+  if (rightArrow) {
+    rightArrow.addEventListener('click', scrollRight);
+  }
+  // When slider is scrolled
+  if (slider) {
+    slider.addEventListener('scroll', setArrowButtonVisibility);
+  }
+  window.addEventListener('resize', setArrowButtonVisibility);
+}
+
+function filterHandlers() {
+  const filterButton = document.querySelector('#ow-apply-filter');
+  if (!filterButton) {
+    return;
+  }
+  filterButton.addEventListener('click', function () {
+    const selectedInputs = document.querySelectorAll(
+      '.filter-options input:checked'
+    );
+    // Create params map which knows about the last applied filters
+    var path = window.location.href.split('?');
+    var paramsMap = {};
+    if (path.length > 1) {
+      // Only if path contains query params
+      path[1].split('&').map(function (param) {
+        const [name, val] = param.split('=');
+        paramsMap[name] = val;
+      });
+    }
+    var qs = Object.assign({}, paramsMap);
+    // qs will be modified according to the last applied filters
+    // and current filters that need to be applied
+    selectedInputs.forEach(function (selectedInput) {
+      let value = selectedInput.value;
+      // create params map for each option
+      let currParamsMap = {};
+      value
+        .substring(1)
+        .split('&')
+        .forEach(function (param) {
+          if (param != '') {
+            let [name, val] = param.split('=');
+            currParamsMap[name] = val;
+          }
+        });
+      Object.keys(paramsMap).forEach(function (key) {
+        /*
+            LOGIC: 
+              For any filter if we check the values present in the options available
+              for it, we will notice that only its options have the different pararms
+              that can change or remove from currently applied filter but all other
+              options of other filters always remain same.
+          */
+        if (key in qs) {
+          if (key in currParamsMap) {
+            if (currParamsMap[key] != paramsMap[key]) {
+              qs[key] = currParamsMap[key];
+            }
+          } else {
+            delete qs[key];
+          }
+        }
+        delete currParamsMap[key];
+      });
+      Object.keys(currParamsMap).forEach(function (key) {
+        // Add if any new filter is applied
+        qs[key] = currParamsMap[key];
+      });
+    });
+    var queryParams = '';
+    if (Object.keys(qs).length) {
+      queryParams = '?' + Object.keys(qs).map(function (q) {
+        return `${q}=${qs[q]}`;
+      }).join('&');
+    }
+    window.location.href = window.location.pathname + queryParams;
+  });
+}
diff --git a/openwisp_utils/admin_theme/static/ui/openwisp/images/right-arrow.svg b/openwisp_utils/admin_theme/static/ui/openwisp/images/right-arrow.svg
new file mode 100644
index 00000000..a4b4abcf
--- /dev/null
+++ b/openwisp_utils/admin_theme/static/ui/openwisp/images/right-arrow.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
+  <path d="M0 0h24v24H0V0z" fill="none"/>
+  <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/>
+</svg>
diff --git a/openwisp_utils/admin_theme/templates/admin/base.html b/openwisp_utils/admin_theme/templates/admin/base.html
index 67ad5b60..69b984fd 100644
--- a/openwisp_utils/admin_theme/templates/admin/base.html
+++ b/openwisp_utils/admin_theme/templates/admin/base.html
@@ -71,6 +71,7 @@
             {% endfor %}</ul>
           {% endif %}
         {% endblock messages %}
+        {% block filters %}{% endblock  %}
         <!-- Content -->
         <div id="content" class="{% block coltype %}colM{% endblock %}">
           <div class="title-wrapper">
diff --git a/openwisp_utils/admin_theme/templates/admin/change_list.html b/openwisp_utils/admin_theme/templates/admin/change_list.html
new file mode 100644
index 00000000..6c1bc4f5
--- /dev/null
+++ b/openwisp_utils/admin_theme/templates/admin/change_list.html
@@ -0,0 +1,111 @@
+{% extends "admin/base_site.html" %}
+{% load i18n admin_urls static admin_list ow_create_filter %}
+
+{% block extrastyle %}
+  {{ block.super }}
+  <link rel="stylesheet" type="text/css" href="{% static "admin/css/changelists.css" %}">
+  {% if cl.formset %}
+    <link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">
+  {% endif %}
+  <link rel="stylesheet" type="text/css" href="{% static "admin/css/ow-filters.css" %}">
+  {% if cl.formset or action_form %}
+    <script src="{% url 'admin:jsi18n' %}"></script>
+    <script src="{% static "admin/js/ow-filter.js" %}"></script>
+  {% endif %}
+  {{ media.css }}
+  {% if not actions_on_top and not actions_on_bottom %}
+    <style>
+      #changelist table thead th:first-child {width: inherit}
+    </style>
+  {% endif %}
+{% endblock %}
+
+{% block extrahead %}
+{{ block.super }}
+{{ media.js }}
+{% endblock %}
+
+{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
+
+{% if not is_popup %}
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
+&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
+&rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}
+</div>
+{% endblock %}
+{% endif %}
+
+{% block coltype %}{% endblock %}
+
+{% block filters %}
+  {% if cl.has_filters %}
+    <div id="ow-changelist-filter">
+      <div class="filters-top">
+        <h2>{% trans 'Filter' %}</h2>
+        <div class="filters-control">
+          {% if cl.has_active_filters %}
+            <h3 id="changelist-filter-clear">
+              <a href="{{ cl.clear_all_filters_qs }}">&#10006; {% trans "Clear all filters" %}</a>
+            </h3>
+          {% endif %}
+          {% if cl.filter_specs|length > 4 %}
+            <button id="ow-apply-filter" class="danger-btn">Apply Filters</button>
+          {% endif %}
+        </div>
+      </div>
+      <div class="filters-bottom">
+        <span class="left-arrow">
+          <img src="{% static "ui/openwisp/images/left-arrow.svg" %}" alt="left">
+        </span>
+        <span class="right-arrow">
+          <img src="{% static "ui/openwisp/images/right-arrow.svg" %}" alt="right">
+        </span>
+        <div class="ow-filter-slider">
+          <div class="slider">
+            {% for spec in cl.filter_specs %}{% ow_create_filter cl spec cl.filter_specs|length %}{% endfor %}
+          </div>
+        </div>
+      </div>
+    </div>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  <div id="content-main">
+    {% block object-tools %}
+        <ul class="object-tools">
+          {% block object-tools-items %}
+            {% change_list_object_tools %}
+          {% endblock %}
+        </ul>
+    {% endblock %}
+    {% if cl.formset and cl.formset.errors %}
+        <p class="errornote">
+        {% if cl.formset.total_error_count == 1 %}{% trans "Please correct the error below." %}
+        {% else %}{% trans "Please correct the errors below." %}{% endif %}
+        </p>
+        {{ cl.formset.non_form_errors }}
+    {% endif %}
+    <div class="module{% if cl.has_filters %} filtered{% endif %}" id="changelist">
+      <div class="changelist-form-container">
+        {% block search %}{% search_form cl %}{% endblock %}
+          {% block date_hierarchy %}{% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}{% endblock %}
+        <form id="changelist-form" method="post"{% if cl.formset and cl.formset.is_multipart %}
+          enctype="multipart/form-data"{% endif %} novalidate>{% csrf_token %}
+        {% if cl.formset %}
+          <div>{{ cl.formset.management_form }}</div>
+        {% endif %}
+        {% block result_list %}
+          {% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %}
+          {% result_list cl %}
+          {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %}
+        {% endblock %}
+        {% block pagination %}{% pagination cl %}{% endblock %}
+        </form>
+      </div>
+    </div>
+  </div>
+  
+{% endblock %}
diff --git a/openwisp_utils/admin_theme/templates/admin/filter.html b/openwisp_utils/admin_theme/templates/admin/filter.html
new file mode 100644
index 00000000..037bca4a
--- /dev/null
+++ b/openwisp_utils/admin_theme/templates/admin/filter.html
@@ -0,0 +1,28 @@
+{% load i18n %}
+
+<div class="ow-filter">
+  {% for choice in choices %}
+    {% if choice.selected %}
+      <div class="filter-title" title="{{ choice.display }}">
+      <h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
+        <div class="selected-option">
+          {{ choice.display }}
+        </div>
+      </div>
+    {% endif %}
+  {% endfor %}
+  <div class="filter-options">
+    {% for choice in choices %}
+      {% if show_button %}
+        <label for="{{ title }}{{choice.display}}" {% if choice.selected %} class="selected"{% endif %}>
+          {{ choice.display }}</label>
+        <input type="radio" name="{{ title }}" id="{{ title }}{{choice.display}}"
+          {% if choice.selected %} checked {% endif %} value="{{ choice.query_string|iriencode }}">
+      {% else %}
+        <a href="{{ choice.query_string|iriencode }}" {% if choice.selected %} class="selected"{% endif %}
+          title="{{ choice.display }}">
+        {{ choice.display }}</a>
+      {% endif %}
+    {% endfor %}
+  </div>
+</div>
diff --git a/openwisp_utils/admin_theme/templatetags/ow_create_filter.py b/openwisp_utils/admin_theme/templatetags/ow_create_filter.py
new file mode 100644
index 00000000..b904c3de
--- /dev/null
+++ b/openwisp_utils/admin_theme/templatetags/ow_create_filter.py
@@ -0,0 +1,17 @@
+from django import template
+from django.template.loader import get_template
+
+register = template.Library()
+
+
+@register.simple_tag
+def ow_create_filter(cl, spec, total_filters):
+    tpl = get_template(spec.template)
+    return tpl.render(
+        {
+            'title': spec.title,
+            'choices': list(spec.choices(cl)),
+            'spec': spec,
+            'show_button': total_filters > 4,
+        }
+    )


Test patch:
diff --git a/tests/test_project/admin.py b/tests/test_project/admin.py
index 6e3bef89..83e4c197 100644
--- a/tests/test_project/admin.py
+++ b/tests/test_project/admin.py
@@ -1,4 +1,5 @@
 from django.contrib import admin
+from django.contrib.auth.models import User
 from django.forms import ModelForm
 from openwisp_utils.admin import (
     AlwaysHasChangedMixin,
@@ -10,11 +11,19 @@
 
 from .models import Operator, Project, RadiusAccounting, Shelf
 
+admin.site.unregister(User)
+
+
+@admin.register(User)
+class UserAdmin(admin.ModelAdmin):
+    list_display = ['username', 'is_staff', 'is_superuser', 'is_active']
+    list_filter = ['username', 'is_staff', 'is_superuser', 'is_active']
+
 
 @admin.register(Operator)
 class OperatorAdmin(admin.ModelAdmin):
     list_display = ['first_name', 'last_name']
-    list_filter = ['project__name']
+    list_filter = ['project__name']  # DO NOT CHANGE: used for testing filters
 
 
 @admin.register(RadiusAccounting)
@@ -44,4 +53,11 @@ class ProjectAdmin(UUIDAdmin, ReceiveUrlAdmin):
 
 @admin.register(Shelf)
 class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
-    pass
+    # DO NOT CHANGE: used for testing filters
+    list_filter = [
+        'books_type',
+        'name',
+        'owner__username',
+        'owner__is_staff',
+        'owner__is_active',
+    ]
diff --git a/tests/test_project/migrations/0004_shelf_data.py b/tests/test_project/migrations/0004_shelf_data.py
new file mode 100644
index 00000000..aca097ec
--- /dev/null
+++ b/tests/test_project/migrations/0004_shelf_data.py
@@ -0,0 +1,69 @@
+# Generated by Django 3.1.13 on 2021-07-12 06:51
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('test_project', '0003_project_operator_data'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='shelf',
+            name='books_count',
+            field=models.PositiveIntegerField(
+                default=0, verbose_name='Number of books'
+            ),
+        ),
+        migrations.AddField(
+            model_name='shelf',
+            name='books_type',
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ('HORROR', 'HORROR'),
+                    ('FANTASY', 'FANTASY'),
+                    ('FACTUAL', 'FACTUAL'),
+                    ('Mystery', 'Mystery'),
+                    ('Historical Fiction', 'Historical Fiction'),
+                    ('Literary Fiction', 'Literary Fiction'),
+                    ('Romance', 'Romance'),
+                    ('Science Fiction', 'Science Fiction'),
+                    ('Short Stories', 'Short Stories'),
+                    ('Thrillers', 'Thrillers'),
+                    ('Biographies', 'Biographies'),
+                ],
+                max_length=50,
+                null=True,
+                verbose_name='Type of Books',
+            ),
+        ),
+        migrations.AddField(
+            model_name='shelf',
+            name='created_at',
+            field=models.DateTimeField(
+                auto_now_add=True, null=True, verbose_name='Create at'
+            ),
+        ),
+        migrations.AddField(
+            model_name='shelf',
+            name='locked',
+            field=models.BooleanField(default=True, verbose_name='Is locked'),
+        ),
+        migrations.AddField(
+            model_name='shelf',
+            name='owner',
+            field=models.OneToOneField(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.AUTH_USER_MODEL,
+                verbose_name='Owner of shelf',
+            ),
+        ),
+    ]
diff --git a/tests/test_project/models.py b/tests/test_project/models.py
index ce5b683d..9e260591 100644
--- a/tests/test_project/models.py
+++ b/tests/test_project/models.py
@@ -5,7 +5,35 @@
 
 
 class Shelf(TimeStampedEditableModel):
+    TYPES = (
+        ('HORROR', 'HORROR'),
+        ('FANTASY', 'FANTASY'),
+        ('FACTUAL', 'FACTUAL'),
+        ('Mystery', 'Mystery'),
+        ('Historical Fiction', 'Historical Fiction'),
+        ('Literary Fiction', 'Literary Fiction'),
+        ('Romance', 'Romance'),
+        ('Science Fiction', 'Science Fiction'),
+        ('Short Stories', 'Short Stories'),
+        ('Thrillers', 'Thrillers'),
+        ('Biographies', 'Biographies'),
+    )
     name = models.CharField(_('name'), max_length=64)
+    books_type = models.CharField(
+        _("Type of Books"), choices=TYPES, null=True, blank=True, max_length=50
+    )
+    books_count = models.PositiveIntegerField(_("Number of books"), default=0)
+    locked = models.BooleanField(_("Is locked"), default=True)
+    owner = models.OneToOneField(
+        "auth.User",
+        blank=True,
+        null=True,
+        verbose_name=_("Owner of shelf"),
+        on_delete=models.CASCADE,
+    )
+    created_at = models.DateTimeField(
+        _("Create at"), null=True, blank=True, auto_now_add=True
+    )
 
     def __str__(self):
         return self.name
diff --git a/tests/test_project/tests/test_admin.py b/tests/test_project/tests/test_admin.py
index 6cbefbe0..fed94556 100644
--- a/tests/test_project/tests/test_admin.py
+++ b/tests/test_project/tests/test_admin.py
@@ -267,3 +267,30 @@ def test_login(self):
             response = self.client.get(url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, 'login')
+
+    def test_filters(self):
+        with self.subTest('Test when number of filters is less than equal to 4'):
+            url = reverse('admin:test_project_operator_changelist')
+            response = self.client.get(url)
+            real_count = self._assert_contains(
+                response,
+                'class="ow-filter"',
+                status_code=response.status_code,
+                msg_prefix='',
+                html=False,
+            )[1]
+            self.assertLessEqual(real_count, 4)
+            self.assertNotContains(response, 'id="ow-apply-filter"')
+
+        with self.subTest('Test when number of filters is greater than 4'):
+            url = reverse('admin:test_project_shelf_changelist')
+            response = self.client.get(url)
+            real_count = self._assert_contains(
+                response,
+                'class="ow-filter"',
+                status_code=response.status_code,
+                msg_prefix='',
+                html=False,
+            )[1]
+            self.assertGreater(real_count, 4)
+            self.assertContains(response, 'id="ow-apply-filter"')