Skip to content

Commit

Permalink
Merge pull request #2348 from nautobot/timizuo_filterform_revamp
Browse files Browse the repository at this point in the history
FilterForm UI Revamp
  • Loading branch information
timizuoebideri1 committed Nov 3, 2022
2 parents 7ccdfd9 + 9ccdde6 commit 9f98b9a
Show file tree
Hide file tree
Showing 42 changed files with 2,080 additions and 361 deletions.
1 change: 1 addition & 0 deletions changes/1998.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added DynamicFilterForm to list views.
1 change: 1 addition & 0 deletions changes/2460.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added search box filter form to generic list views.
1 change: 1 addition & 0 deletions changes/2617.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added dynamic filter form support to specialized list views.
20 changes: 19 additions & 1 deletion nautobot/core/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,30 @@

from nautobot.core.api.views import (
APIRootView,
StatusView,
GetFilterSetFieldDOMElementAPIView,
GetFilterSetFieldLookupExpressionChoicesAPIView,
GraphQLDRFAPIView,
StatusView,
NautobotSpectacularSwaggerView,
NautobotSpectacularRedocView,
)
from nautobot.extras.plugins.urls import plugin_api_patterns


core_api_patterns = [
# Lookup Expr
path(
"filterset-fields/lookup-choices/",
GetFilterSetFieldLookupExpressionChoicesAPIView.as_view(),
name="filtersetfield-list-lookupchoices",
),
path(
"filterset-fields/lookup-value-dom-element/",
GetFilterSetFieldDOMElementAPIView.as_view(),
name="filtersetfield-retrieve-lookupvaluedomelement",
),
]

urlpatterns = [
# Base views
path("", APIRootView.as_view(), name="api-root"),
Expand All @@ -36,4 +52,6 @@
path("graphql/", GraphQLDRFAPIView.as_view(), name="graphql-api"),
# Plugins
path("plugins/", include((plugin_api_patterns, "plugins-api"))),
# Core
path("core/", include((core_api_patterns, "core-api"))),
]
72 changes: 71 additions & 1 deletion nautobot/core/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.http.response import HttpResponseBadRequest
from django.db import transaction
from django.db.models import ProtectedError
Expand Down Expand Up @@ -36,6 +36,13 @@
from nautobot.core.api import BulkOperationSerializer
from nautobot.core.api.exceptions import SerializerNotFound
from nautobot.utilities.api import get_serializer_for_model
from nautobot.utilities.utils import (
get_all_lookup_expr_for_field,
get_filterset_parameter_form_field,
get_form_for_model,
FilterSetFieldNotFound,
ensure_content_type_and_field_name_inquery_params,
)
from . import serializers

HTTP_ACTIONS = {
Expand Down Expand Up @@ -702,3 +709,66 @@ def execute_graphql_request(self, request, data, query, variables, operation_nam
return document.execute(**options)
except Exception as e:
return ExecutionResult(errors=[e], invalid=True)


#
# Lookup Expr
#


class GetFilterSetFieldLookupExpressionChoicesAPIView(NautobotAPIVersionMixin, APIView):
"""API View that gets all lookup expression choices for a FilterSet field."""

permission_classes = [IsAuthenticated]

@extend_schema(exclude=True)
def get(self, request):
try:
field_name, model = ensure_content_type_and_field_name_inquery_params(request.GET)
data = get_all_lookup_expr_for_field(model, field_name)
except FilterSetFieldNotFound:
return Response("field_name not found", status=404)
except ValidationError as err:
return Response(err.args[0], status=err.code)

# Needs to be returned in this format because this endpoint is used by
# DynamicModelChoiceField which requires the response of an api in this exact format
return Response(
{
"count": len(data),
"next": None,
"previous": None,
"results": data,
}
)


class GetFilterSetFieldDOMElementAPIView(NautobotAPIVersionMixin, APIView):
"""API View that gets the DOM element representation of a FilterSet field."""

permission_classes = [IsAuthenticated]

@extend_schema(exclude=True)
def get(self, request):
try:
field_name, model = ensure_content_type_and_field_name_inquery_params(request.GET)
except ValidationError as err:
return Response(err.args[0], status=err.code)
model_form = get_form_for_model(model)
if model_form is None:
logger = logging.getLogger(__name__)

logger.warning(f"Form for {model} model not found")
# Because the DOM Representation cannot be derived from a CharField without a Form, the DOM Representation must be hardcoded.
return Response(
{
"dom_element": f"<input type='text' name='{field_name}' class='form-control lookup_value-input' id='id_{field_name}'>"
}
)
try:
form_field = get_filterset_parameter_form_field(model, field_name)
except FilterSetFieldNotFound:
return Response("field_name not found", 404)

field_dom_representation = form_field.get_bound_field(model_form(), field_name).as_widget()
return Response({"dom_element": field_dom_representation})
6 changes: 6 additions & 0 deletions nautobot/core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@
class SearchForm(BootstrapMixin, forms.Form):
q = forms.CharField(label="Search")
obj_type = forms.ChoiceField(choices=OBJ_TYPE_CHOICES, required=False, label="Type")

def __init__(self, *args, q_placeholder=None, **kwargs):
super().__init__(*args, **kwargs)

if q_placeholder:
self.fields["q"].widget.attrs["placeholder"] = q_placeholder
2 changes: 1 addition & 1 deletion nautobot/core/management/commands/generate_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def handle(self, *args, **options):
StatusFactory.create_batch(10)
self.stdout.write("Creating Tags...")
# Ensure that we have some tags that are applicable to all relevant content-types
TagFactory.create_batch(5, content_types=TaggableClassesQuery().as_queryset)
TagFactory.create_batch(5, content_types=TaggableClassesQuery().as_queryset())
# ...and some tags that apply to a random subset of content-types
TagFactory.create_batch(15)
self.stdout.write("Creating TenantGroups...")
Expand Down
152 changes: 143 additions & 9 deletions nautobot/core/templates/generic/object_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,45 @@
{% load helpers %}
{% load static %}

{% block header %}
<div class="row noprint">
<div class="{% if search_form %}col-sm-8 col-md-9 {% else %} col-md-12 {% endif %}">
<ol class="breadcrumb">
{% block breadcrumbs %}
{% if list_url %}
<li><a href="{% url list_url %}">{{ title }}</a></li>
{% endif %}
{% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %}
{% endblock breadcrumbs %}
</ol>
</div>
{% if search_form %}
<div class="col-sm-4 col-md-3">
<form action="#" method="get" id="search-form">
<div class="input-group">
{{ search_form.q }}
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-magnify"></i>
</button>
</span>
</div>
</form>
</div>
{% endif %}
</div>
{% endblock header %}


{% block content %}
<div class="pull-right noprint">
{% block buttons %}{% endblock %}
{% if request.user.is_authenticated and table_config_form %}
{% if table and request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#ObjectTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
{% endif %}
{% if filter_form or dynamic_filter_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#FilterForm_modal" title="Add filters"><i class="mdi mdi-filter"></i> Filter</button>
{% endif %}
{% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|validated_viewname:"add" %}
{% endif %}
Expand All @@ -19,15 +52,46 @@
{% export_button content_type %}
{% endif %}
</div>
<h1>{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}</h1>
<h1>{% block title %}{{ title }}{% endblock %}</h1>
{% if filter_params %}
<div class="filters-applied">
<b>Filters:</b>
{% for field, values in filter_params %}
<span class="filter-container" dir="ltr">
<span
class="filter-selection">
<b>{{ field }}:</b>
<span
class="remove-filter-param"
title="Remove all items"
data-field-type="parent"
data-field-value={{ field }}
>×</span>
<ul class="filter-selection-rendered">
{% for value in values %}
<li
class="filter-selection-choice"
title="{{ value }}"
>
<span
class="filter-selection-choice-remove remove-filter-param"
data-field-type="child"
data-field-parent={{ field }}
data-field-value={{ value }}
>×</span>{{ value }}
</li>
{% endfor %}
</ul>
</span>
</span>
{% endfor %}
</div>
<hr>
{% endif %}

<div class="row">
{% block table %}
<div class="col-md-12">
{% if filter_form %}
<div class="col-md-3 pull-right right-side-panel noprint">
{% include 'inc/search_panel.html' %}
{% block sidebar %}{% endblock %}
</div>
{% endif %}
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
Expand Down Expand Up @@ -81,10 +145,80 @@ <h1>{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bett
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>
</div>
{% endblock %}
</div>
{% table_config_form table table_name="ObjectTable" %}
{% if table %}{% table_config_form table table_name="ObjectTable" %}{% endif %}
{% filter_form_modal filter_form dynamic_filter_form model_plural_name=title %}
{% endblock %}

{% block javascript %}
<script src="{% static 'js/tableconfig.js' %}"></script>
<script src="{% static 'jquery/jquery.formset.js' %}"></script>
<script>
$('.formset_row-dynamic-filterform').formset({
addText: '<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add another Filter',
addCssClass: 'btn btn-primary add-row',
deleteText: '<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>',
deleteCssClass: 'btn btn-danger delete-row',
prefix: 'form',
formCssClass: 'dynamic-filterform',
added: jsify_form,
removed: (row) => { row.remove(); }
});

// By default on lookup_value field names are form-\d-lookup_value, thats why
// on page load we change all `lookup_value` name to its relevant `lookup_type` value
$(".dynamic-filterform").each(function(){
lookup_type_value = $(this).find(".lookup_type-select").val();
lookup_value = $(this).find(".lookup_value-input");
lookup_value.attr("name", lookup_type_value);
})

// Remove applied filters
$(".remove-filter-param").on("click", function(){
let query_params = location.search;
let type = $(this).attr("data-field-type");
let field_value = $(this).attr("data-field-value");
let query_string = location.search.substr(1).split("&");

if (type === "parent") {
query_string = query_string.filter(item => item.search(field_value) < 0);
} else {
let parent = $(this).attr("data-field-parent");
query_string = query_string.filter(item => item.search(parent + "=" + field_value) < 0)
}
location.replace("?" + query_string.join("&"))
})

// On submit of filter form
$("#dynamic-filter-form").on("submit", function(e){
let this_form = $(this);
this_form.find(`input[name*="form-"], select[name*="form-"]`).removeAttr("name")
// Append q form field to to dynamic filter form via hidden input
let q_field = $('#id_q')
let q_field_phantom = $('<input type="hidden" name="q" />')
q_field_phantom.val(q_field.val())
this_form.append(q_field_phantom);
})

// On submit of filter search form
$("#search-form").on("submit", function(e){
// Since the Dynamic Filter Form will already grab my q field, just have it do a majority of the work.
e.preventDefault()
$("#dynamic-filter-form").submit()
})

// Clear new row values upon creation
$(".dynamic-filterform-add .add-row").click(function(){
let new_fields_parent_element = $(".dynamic-filterform").last()
let lookup_field_classes = [".lookup_field-select", ".lookup_type-select", ".lookup_value-input"];
lookup_field_classes.forEach(field_class => {
let element = new_fields_parent_element.find(field_class);
element.val(null).trigger('change')
})
// reinitialize jsify_form
jsify_form($(document))
})

</script>
{% endblock %}
2 changes: 1 addition & 1 deletion nautobot/core/templates/inc/nav_menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
{% if request.user.is_authenticated or not "HIDE_RESTRICTED_UI"|settings_or_config %}
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<input type="text" name="q" class="form-control" placeholder="Search {{ settings.BRANDING_TITLE }}">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-magnify"></i>
Expand Down
Loading

0 comments on commit 9f98b9a

Please sign in to comment.