Skip to content

Commit

Permalink
Merge pull request #110 from basxsoftwareassociation/feature/defaults…
Browse files Browse the repository at this point in the history
…earchforbrowseviews

Added default generic search functionality to BrowseView
  • Loading branch information
saemideluxe authored Jan 13, 2022
2 parents 2c8d7af + af7a59c commit 21bddc0
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 44 deletions.
76 changes: 51 additions & 25 deletions bread/layout/components/datatable.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import html
from typing import Any, Iterable, List, NamedTuple, Optional, Union

import htmlgenerator as hg
Expand All @@ -13,7 +14,7 @@
from .icon import Icon
from .overflow_menu import OverflowMenu
from .pagination import Pagination, PaginationConfig
from .search import Search, SearchBackendConfig
from .search import Search


class DataTable(hg.TABLE):
Expand Down Expand Up @@ -139,18 +140,17 @@ def with_toolbar(
title: Any,
helper_text: Any = None,
primary_button: Optional[Button] = None,
search_backend: Optional[SearchBackendConfig] = None,
bulkactions: Iterable[Link] = (),
pagination_config: Optional[PaginationConfig] = None,
checkbox_for_bulkaction_name="_selected",
checkbox_for_bulkaction_name: str = "_selected",
search_urlparameter: Optional[str] = None,
settingspanel: Any = None,
):
"""
wrap this datatable with title and toolbar
title: table title
helper_text: sub title
primary_button: bread.layout.button.Button instance
search_backend: search endpoint
bulkactions: List of bread.utils.links.Link instances. Will send a post or a get (depending on its "method" attribute) to the target url the sent data will be a form with the selected checkboxes as fields if the head-checkbox has been selected only that field will be selected
"""
checkboxallid = f"datatable-check-{hg.html_id(self)}"
Expand All @@ -161,7 +161,6 @@ def with_toolbar(
header.append(
hg.P(helper_text, _class="bx--data-table-header__description")
)
resultcontainerid = f"datatable-search-{hg.html_id(self)}"
bulkactionlist = []
for link in bulkactions:
bulkactionlist.append(
Expand Down Expand Up @@ -258,17 +257,7 @@ def with_toolbar(
aria_label=_("Table Action Bar"),
),
hg.DIV(
hg.DIV(
Search(
widgetattributes={"autofocus": True},
backend=search_backend,
resultcontainerid=resultcontainerid,
show_result_container=False,
),
_class="bx--toolbar-search-container-persistent",
)
if search_backend
else None,
searchbar(search_urlparameter) if search_urlparameter else None,
Button(
icon="settings--adjust",
buttontype="ghost",
Expand All @@ -281,13 +270,6 @@ def with_toolbar(
),
_class="bx--table-toolbar",
),
hg.DIV(
hg.DIV(
id=resultcontainerid,
style="width: 100%; position: absolute; z-index: 999",
),
style="width: 100%; position: relative",
),
hg.DIV(
hg.DIV(
hg.DIV(
Expand Down Expand Up @@ -327,8 +309,8 @@ def from_model(
title=None,
primary_button: Optional[Button] = None,
settingspanel: Any = None,
search_backend: Optional[SearchBackendConfig] = None,
pagination_config: Optional[PaginationConfig] = None,
search_urlparameter: Optional[str] = None,
**kwargs,
):
"""TODO: Write Docs!!!!
Expand Down Expand Up @@ -451,10 +433,10 @@ def from_model(
model._meta.verbose_name_plural,
),
primary_button=primary_button,
search_backend=search_backend,
bulkactions=bulkactions,
pagination_config=pagination_config,
checkbox_for_bulkaction_name=checkbox_for_bulkaction_name,
search_urlparameter=search_urlparameter,
settingspanel=settingspanel,
)

Expand Down Expand Up @@ -546,6 +528,50 @@ def as_header_cell(self, orderingurlparameter="ordering"):
return hg.TH(headcontent, lazy_attributes=self.th_attributes)


def searchbar(search_urlparameter: str):
"""
Creates a searchbar element for datatables to submit an entered search
term via a GET url parameter
"""
searchinput = Search(
widgetattributes={
"autofocus": True,
"name": search_urlparameter,
"value": hg.F(
lambda c: html.escape(c["request"].GET.get(search_urlparameter, ""))
),
"onfocus": "this.setSelectionRange(this.value.length, this.value.length);",
}
)
searchinput.close_button.attributes[
"onclick"
] = "this.closest('form').querySelector('input').value = ''; this.closest('form').submit()"

return hg.DIV(
hg.FORM(
searchinput,
hg.Iterator(
hg.C("request").GET.lists(),
"urlparameter",
hg.If(
hg.F(lambda c: c["urlparameter"][0] != search_urlparameter),
hg.Iterator(
hg.C("urlparameter")[1],
"urlvalue",
hg.INPUT(
type="hidden",
name=hg.C("urlparameter")[0],
value=hg.C("urlvalue"),
),
),
),
),
method="GET",
),
_class="bx--toolbar-search-container-persistent",
)


def sortingclass_for_column(orderingurlparameter, columnname):
def extracturlparameter(context):
value = context["request"].GET.get(orderingurlparameter, "")
Expand Down
11 changes: 8 additions & 3 deletions bread/layout/components/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ def __init__(
)
widgetattributes["name"] = backend.query_parameter

self.close_button = _close_button(resultcontainerid, widgetattributes)

super().__init__(
hg.DIV(
hg.LABEL(_("Search"), _class="bx--label", _for=widgetattributes["id"]),
hg.INPUT(**widgetattributes),
_search_icon(),
_close_button(resultcontainerid),
self.close_button,
hg.If(backend is not None, _loading_indicator(resultcontainerid)),
**kwargs,
),
Expand Down Expand Up @@ -105,9 +107,12 @@ def _loading_indicator(resultcontainerid):
)


def _close_button(resultcontainerid):
def _close_button(resultcontainerid, widgetattributes):
kwargs = {
"_class": "bx--search-close bx--search-close--hidden",
"_class": hg.BaseElement(
"bx--search-close",
hg.If(widgetattributes.get("value"), None, " bx--search-close--hidden"),
),
"title": _("Clear search input"),
"aria_label": _("Clear search input"),
"type": "button",
Expand Down
71 changes: 71 additions & 0 deletions bread/utils/queryset_from_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import django_countries
from django.db import models
from django.db.models import Q
from django_countries.fields import CountryField


def get_char_text_qset(fields, searchquery, prefix):
char_text_fields = {
f
for f in fields
if isinstance(f, models.fields.CharField)
or isinstance(f, models.fields.TextField)
}

return {
Q(**{prefix + "_".join((f.name, "_contains")): searchquery})
for f in char_text_fields
}


def get_country_qset(fields, searchquery, prefix):
countries = {name.lower(): code for code, name in django_countries.countries}
country_fields = {f for f in fields if isinstance(f, CountryField)}

if not country_fields:
return set()

match_countries = {
country_name for country_name in countries if searchquery in country_name
}

return {
Q(**{prefix + f.name: countries[key]})
for f in country_fields
for key in match_countries
}


def get_field_queryset(fields, searchquery, prefix="", follow_relationships=1):
queryset = {
*get_char_text_qset(fields, searchquery, prefix),
*get_country_qset(fields, searchquery, prefix),
}

qs = Q()
for query in queryset:
qs |= query

if follow_relationships > 0:
foreignkey_fields = {
f
for f in fields
if isinstance(f, models.fields.related.ForeignKey)
or isinstance(f, models.fields.related.ManyToManyField)
}
for foreignkey_field in foreignkey_fields:
# skip fields with a name beginning with '_'
if foreignkey_field.name[0] == "_":
continue

if foreignkey_field.related_model:
foreign_fields = foreignkey_field.related_model._meta.fields
else:
foreign_fields = foreignkey_field._meta.fields

new_prefix = prefix + "__".join([foreignkey_field.name, ""])
qs |= get_field_queryset(
foreign_fields, searchquery, new_prefix, follow_relationships - 1
)

return qs
40 changes: 24 additions & 16 deletions bread/views/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView
from djangoql.exceptions import DjangoQLError
from djangoql.queryset import apply_search
from guardian.mixins import PermissionListMixin

from bread.utils import expand_ALL_constant, filter_fieldlist
from bread.utils import expand_ALL_constant, filter_fieldlist, queryset_from_fields

from .. import layout
from ..utils import (
Expand Down Expand Up @@ -69,10 +70,10 @@ class BrowseView(BreadView, LoginRequiredMixin, PermissionListMixin, ListView):
bulkaction_urlparameter: str = "_bulkaction"
items_per_page_options: Optional[Iterable[int]] = None
itemsperpage_urlparameter: str = "itemsperpage"
search_urlparameter: str = "q"

title: Optional[hg.BaseElement] = None
columns: Iterable[Union[str, layout.datatable.DataTableColumn]] = ("__all__",)
search_backend = None
rowclickaction: Optional[Link] = None
# bulkactions: List[(Link, function(request, queryset))]
# - link.js should be a slug and not a URL
Expand Down Expand Up @@ -105,12 +106,14 @@ def __init__(self, *args, **kwargs):
or self.items_per_page_options
or getattr(settings, "DEFAULT_PAGINATION_CHOICES", [25, 100, 500])
)
self.search_urlparameter = (
kwargs.get("search_urlparameter") or self.search_urlparameter
)
self.title = kwargs.get("title") or self.title
self.rowactions = kwargs.get("rowactions") or self.rowactions
self.columns = expand_ALL_constant(
kwargs["model"], kwargs.get("columns") or self.columns
)
self.search_backend = kwargs.get("search_backend") or self.search_backend
self.rowclickaction = kwargs.get("rowclickaction") or self.rowclickaction
self.backurl = kwargs.get("backurl") or self.backurl
self.primary_button = kwargs.get("primary_button") or self.primary_button
Expand Down Expand Up @@ -148,7 +151,6 @@ def get_layout(self):
rowactions=self.rowactions,
rowactions_dropdown=len(self.rowactions)
> 2, # recommendation from carbon design
search_backend=self.search_backend,
rowclickaction=self.rowclickaction,
pagination_config=layout.pagination.PaginationConfig(
items_per_page_options=self.items_per_page_options,
Expand All @@ -161,6 +163,7 @@ def get_layout(self):
settingspanel=self.get_settingspanel(),
backurl=self.backurl,
primary_button=self.primary_button,
search_urlparameter=self.search_urlparameter,
)

def get_context_data(self, *args, **kwargs):
Expand All @@ -178,7 +181,6 @@ def get_required_permissions(self, request):

def get(self, *args, **kwargs):
if "reset" in self.request.GET:
# reset clear state and reset filters and sorting
if (
self.viewstate_sessionkey
and self.viewstate_sessionkey in self.request.session
Expand Down Expand Up @@ -231,18 +233,24 @@ def get_paginate_by(self, queryset):
def get_queryset(self):
"""Prefetch related tables to speed up queries. Also order result by get-parameters."""
qs = super().get_queryset()
if (
self.search_backend
and self.search_backend.query_parameter in self.request.GET
):
qs = apply_search(
qs,
"("
+ ") and (".join(
self.request.GET.getlist(self.search_backend.query_parameter)
if self.search_urlparameter and self.search_urlparameter in self.request.GET:
searchquery = self.request.GET[self.search_urlparameter].strip()
if searchquery.startswith("="):
try:
qs = apply_search(qs, searchquery[1:])
except DjangoQLError as e:
messages.error(
self.request,
_("Bad filter string '%s': '%s'") % (searchquery, e),
)

else:
qs = self.model.objects.filter(
queryset_from_fields.get_field_queryset(
[*self.model._meta.fields, *self.model._meta.many_to_many],
searchquery,
)
)
+ ")",
)

selectedobjects = self.request.GET.getlist(self.objectids_urlparameter)
if selectedobjects and "all" not in selectedobjects:
Expand Down

0 comments on commit 21bddc0

Please sign in to comment.