Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline filter forms #817

Merged
merged 21 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hawc/apps/animal/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AutocompleteModelChoiceFilter,
AutocompleteModelMultipleChoiceFilter,
BaseFilterSet,
FilterForm,
PaginationFilter,
)
from ..study.autocomplete import StudyAutocomplete
Expand Down Expand Up @@ -166,6 +167,7 @@ class EndpointFilterSet(BaseFilterSet):

class Meta:
model = models.Endpoint
form = FilterForm
fields = [
"studies",
"chemical",
Expand Down
159 changes: 58 additions & 101 deletions hawc/apps/common/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
import django_filters as df
from crispy_forms import layout as cfl
from django import forms
from django_filters.utils import get_model_field
from pydantic import BaseModel, conlist

from ..assessment.models import Assessment
from . import autocomplete
from .forms import BaseFormHelper, form_actions_apply_filters
from .forms import (
BaseFormHelper,
ExpandableFilterFormHelper,
InlineFilterFormHelper,
form_actions_apply_filters,
)


def filter_noop(queryset, name, value):
Expand Down Expand Up @@ -88,111 +92,71 @@ def apply_layout(self, helper):
row.apply_layout(helper, i)


class FilterForm(forms.Form):
def __init__(self, *args, **kwargs):
grid_layout = kwargs.pop("grid_layout", None)
self.grid_layout = GridLayout.parse_obj(grid_layout) if grid_layout is not None else None
class InlineFilterForm(forms.Form):
"""Form model used for inline filterset forms."""

helper_class = InlineFilterFormHelper

def __init__(self, *args, **kwargs):
"""Grabs grid_layout kwarg and passes it to GridLayout to apply the layout."""
layout = kwargs.pop("grid_layout", None)
self.grid_layout = GridLayout.parse_obj(layout) if layout else None
self.main_field = kwargs.pop("main_field", None)
self.appended_fields = kwargs.pop("appended_fields", [])
self.dynamic_fields = kwargs.pop("dynamic_fields", [])
super().__init__(*args, **kwargs)

@property
def helper(self):
helper = BaseFormHelper(self, form_actions=form_actions_apply_filters())
"""Helper method for setting up the form."""
helper = self.helper_class(
self,
main_field=self.main_field,
appended_fields=self.appended_fields,
)
helper.form_method = "GET"

if self.grid_layout:
self.grid_layout.apply_layout(helper)

return helper


class FilterSetOptions:
def __init__(self, options=None):
self.model = getattr(options, "model", None)
self.fields = getattr(options, "fields", None)
self.exclude = getattr(options, "exclude", None)

self.filter_overrides = getattr(options, "filter_overrides", {})
self.field_kwargs = getattr(options, "field_kwargs", {})
self.grid_layout = getattr(options, "grid_layout", None)
class ExpandableFilterForm(InlineFilterForm):
"""Form model used for expandable inline filterset forms."""

self.form = getattr(options, "form", FilterForm)
helper_class = ExpandableFilterFormHelper

is_expanded = forms.CharField(initial="false", widget=forms.HiddenInput())

class FilterSetMetaclass(df.filterset.FilterSetMetaclass):
def __new__(cls, name, bases, attrs):
attrs["declared_filters"] = cls.get_declared_filters(bases, attrs)
def clean(self):
"""Remove 'is_expanded' from form data before data is used for filtering."""
cleaned_data = super().clean()
cleaned_data.pop("is_expanded", None)

new_class = type.__new__(cls, name, bases, attrs)
new_class._meta = FilterSetOptions(getattr(new_class, "Meta", None))
new_class.base_filters = new_class.get_filters()

for filter_name, field_kwargs in new_class._meta.field_kwargs.items():
new_class.base_filters[filter_name].extra.update(field_kwargs)
class FilterForm(forms.Form):
# TODO: remove once all filtersets are converted to Inline or Expandable forms
def __init__(self, *args, **kwargs):
grid_layout = kwargs.pop("grid_layout", None)
self.grid_layout = GridLayout.parse_obj(grid_layout) if grid_layout is not None else None
self.dynamic_fields = kwargs.pop("dynamic_fields", None)
super().__init__(*args, **kwargs)

return new_class
@property
def helper(self):
helper = BaseFormHelper(self, form_actions=form_actions_apply_filters())
helper.form_method = "GET"

if self.grid_layout:
self.grid_layout.apply_layout(helper)

class FilterSet(df.filterset.BaseFilterSet, metaclass=FilterSetMetaclass):
pass
return helper


class BaseFilterSet(FilterSet):
def __init__(self, *args, assessment: Assessment | None = None, **kwargs):
class BaseFilterSet(df.FilterSet):
def __init__(self, *args, assessment: Assessment | None = None, form_kwargs=None, **kwargs):
self.assessment = assessment
super().__init__(*args, **kwargs)

@classmethod
def get_filters(cls):
"""
Get all filters for the filterset. This is the combination of declared and
generated filters.

Overridden from django-filter's BaseFilterSet; we do not include declared filters by default
near the end of the method.
"""

# No model specified - skip filter generation
if not cls._meta.model:
return cls.declared_filters.copy()

# Determine the filters that should be included on the filterset.
filters = {}
fields = cls.get_fields()
undefined = []

for field_name, lookups in fields.items():
field = get_model_field(cls._meta.model, field_name)

# warn if the field doesn't exist.
if field is None:
undefined.append(field_name)

for lookup_expr in lookups:
filter_name = cls.get_filter_name(field_name, lookup_expr)

# If the filter is explicitly declared on the class, skip generation
if filter_name in cls.declared_filters:
filters[filter_name] = cls.declared_filters[filter_name]
continue

if field is not None:
filters[filter_name] = cls.filter_for_field(field, field_name, lookup_expr)

# Allow Meta.fields to contain declared filters *only* when a list/tuple
if isinstance(cls._meta.fields, list | tuple):
undefined = [f for f in undefined if f not in cls.declared_filters]

if undefined:
raise TypeError(
"'Meta.fields' must not contain non-model field names: %s" % ", ".join(undefined)
)

# Add in declared filters. This is necessary since we don't enforce adding
# declared filters to the 'Meta.fields' option
### We actually do want to enforce Meta.fields; this allows dynamically specifying fields
# filters.update(cls.declared_filters)
return filters
self.form_kwargs = form_kwargs or {}
if "grid_layout" not in self.form_kwargs and hasattr(self.Meta, "grid_layout"):
self.form_kwargs.update(grid_layout=self.Meta.grid_layout)

@property
def perms(self):
Expand All @@ -204,21 +168,14 @@ def form(self):
self._form = self.create_form()
return self._form

def create_form(self) -> forms.Form:
"""Create the form used for the filterset.

Returns:
forms.Form: a django.Form instance
"""
Form = self.get_form_class()
def create_form(self):
form_class = self.get_form_class()
if self.is_bound:
form = Form(self.data, prefix=self.form_prefix, grid_layout=self._meta.grid_layout)
form = form_class(self.data, prefix=self.form_prefix, **self.form_kwargs)
else:
form = Form(prefix=self.form_prefix, grid_layout=self._meta.grid_layout)
form = form_class(prefix=self.form_prefix, **self.form_kwargs)
if form.dynamic_fields: # removes unwanted fields from a filterset if specified
for field in list(form.fields.keys()):
if field not in form.dynamic_fields and field != "is_expanded":
form.fields.pop(field)
return form


def dynamic_filterset(_class: type[BaseFilterSet], **meta_kwargs):
default_kwargs = _class._meta.__dict__
Meta = type("Meta", (object,), {**default_kwargs, **meta_kwargs})
return type("DynamicFilterset", (_class,), {"Meta": Meta})
157 changes: 157 additions & 0 deletions hawc/apps/common/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ def form_actions_apply_filters():
]


def form_actions_big_apply_filters():
"""Create big, centered Submit and Cancel buttons for filter forms."""
return cfl.HTML(
"""
<div class="d-flex justify-content-center">
<input type="submit" name="save" value="Apply Filters" class="btn btn-primary mx-2 py-2" id="submit-id-save" style="width: 15%;">
<a role="button" class="btn btn-light mx-2 py-2" href="." style="width: 10%;">Cancel</a>
</div>
"""
)


class BaseFormHelper(cf.FormHelper):
error_text_inline = False
use_custom_control = True
Expand Down Expand Up @@ -146,6 +158,151 @@ def add_refresh_page_note(self):
self.layout.insert(len(self.layout) - 1, note)


class InlineFilterFormHelper(BaseFormHelper):
"""Helper class for creating an inline filtering form with a primary field."""

def __init__(
self,
form,
main_field: str,
appended_fields: list[str],
legend_text: str | None = None,
help_text: str | None = None,
**kwargs,
):
"""Inline form field helper, with primary search and appended fields.

This form will have a single search bar with a primary search, inline
appended fields, and inline cancel/search buttons.

Args:
form: form
main_field: A text input field
appended_fields: 1 or more checkbox or select fields (to right of main)
legend_text: Legend text to show on the form
help_text: help text to show on the form
**kwargs: Extra arguments
"""
self.attrs = {}
self.kwargs = kwargs
self.inputs = []
self.form = form
self.main_field = main_field
self.appended_fields = appended_fields
self.legend_text = legend_text
self.help_text = help_text
self.build_inline_layout()

def build_inline_layout(self):
"""Build the custom inline layout, including the grid layout."""
if self.main_field:
self.layout = cfl.Layout(*list(self.form.fields.keys()))
self.add_filter_field(self.main_field, self.appended_fields)
if self.form.grid_layout:
self.form.grid_layout.apply_layout(self)
else:
self.build_default_layout(self.form)
return self.layout

def add_filter_field(
self,
main_field: str,
appended_fields: list[str],
expandable: bool = False,
):
"""Add the primary filter field (noncollapsed field(s)) to start of layout."""
layout, index = self.get_layout_item(main_field)
field = layout.pop(index)
for app_field in appended_fields:
layout, index = self.get_layout_item(app_field)
layout.pop(index)
layout.insert(
0,
FilterFormField(
fields=field,
appended_fields=appended_fields,
expandable=expandable,
),
)


class ExpandableFilterFormHelper(InlineFilterFormHelper):
"""Helper class for an inline filtering form with collapsible advanced fields."""

collapse_field_name: str = "is_expanded"

def __init__(self, *args, **kwargs):
"""Collapsable form field helper, primary search and advanced.

This form will have a single search bar with a primary search, and the
ability to expand the bar for additional "advanced" search fields.

Args:
args: Arguments passed to InlineFilterFormHelper
kwargs: Keyword arguments passed to InlineFilterFormHelper
"""
super().__init__(*args, **kwargs)
self.build_collapsed_layout()

def build_collapsed_layout(self):
"""Build the custom collapsed layout including the grid layout."""
if self.collapse_field_name not in self.form.fields:
raise ValueError(f"Field `{self.collapse_field_name}` is required for this form")
self.layout = cfl.Layout(*list(self.form.fields.keys()))
layout, collapsed_idx = self.get_layout_item(self.collapse_field_name)
collapsed_field = layout.pop(collapsed_idx)
self.add_filter_field(self.main_field, self.appended_fields, expandable=True)
self.layout.append(form_actions_big_apply_filters())
form_index = 1
if self.legend_text:
self.layout.insert(0, cfl.HTML(f"<legend>{self.legend_text}</legend>"))
form_index += 1
if self.help_text:
self.layout.insert(
1,
cfl.HTML(f'<p class="form-text text-muted">{self.help_text}</p>'),
)
form_index += 1
if self.form.grid_layout:
self.form.grid_layout.apply_layout(self)
self[form_index:].wrap_together(cfl.Div, css_class="p-4")
is_expanded = self.form.data.get(self.collapse_field_name, "false") == "true"
self[form_index:].wrap_together(
cfl.Div,
id="ff-expand-form",
css_class="collapse show" if is_expanded else "collapse",
)
self.layout.append(collapsed_field)
return self.layout


class FilterFormField(cfl.Field):
"""Custom crispy form field that includes appended_fields in the context."""

template = "common/crispy_layout_filter_field.html"

def __init__(
self,
fields,
appended_fields: list[str],
expandable: bool = False,
**kwargs,
):
"""Set the given field values on the field model."""
self.fields = fields
self.appended_fields = appended_fields
self.expandable = expandable
super().__init__(fields, **kwargs)

def render(self, form, form_style, context, template_pack, extra_context=None, **kwargs):
"""Render the main_field and appended_fields in the template and return it."""
if extra_context is None:
extra_context = {}
extra_context["appended_fields"] = [form[field] for field in self.appended_fields]
extra_context["expandable"] = self.expandable
return super().render(form, form_style, context, template_pack, extra_context, **kwargs)


class CopyAsNewSelectorForm(forms.Form):
label = None
parent_field = None
Expand Down
Loading