Skip to content

Commit

Permalink
feat: filters in table searializer and naive filters/search for model…
Browse files Browse the repository at this point in the history
… data
  • Loading branch information
smotornyuk committed Jan 24, 2024
1 parent 747579b commit 8175728
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% extends "collection/serialize/table/filter.html" %}
Original file line number Diff line number Diff line change
@@ -1,43 +1,30 @@
{% set form_id = collection.serializer.form_id %}
{% set base_class = collection.serializer.base_class %}
{% extends "collection/serialize/table/form.html" %}
{% set render_url = collection.serializer.render_url %}

{% block form %}
<form
class="{{ base_class }}--form" id="{{ form_id }}"
hx-on:htmx:config-request="
{% if collection.serializer.debug %}console.debug(event.detail.parameters);{% endif %}
{% if collection.serializer.push_url %}
const url = new URL(window.location)
url.searchParams.delete('{{ collection.name }}:page')
Object.entries(event.detail.parameters).forEach(([k, v]) => url.searchParams.set(k, v));
window.history.pushState({}, null, url);
{% endif %}
"
{# catch plain submission/changes as well as changes from elements that
live outside the form and select2-dropdowns that emit special change
event and require a small delay for clearing them all at once via
ClearFilters #}
hx-trigger='
change,
submit,
change from:[form="{{ form_id }}"],
collection-trigger from:[data-collection="{{ collection.name }}"],
change.htmx-select2 from:[form="{{ form_id }}"] delay:20'
hx-target="closest .{{ base_class }}"
hx-indicator="closest .{{ base_class }}"
hx-get="{{ render_url }}"
hx-swap="outerHTML"
>

{% block counter %}
{% snippet collection.serializer.counter_template, collection=collection %}
{% endblock counter %}
<form{% block form_attrs %}
{{ super() }}
hx-on:htmx:config-request="
{% if collection.serializer.debug %}console.debug(event.detail.parameters);{% endif %}
{% if collection.serializer.push_url %}
const url = new URL(window.location)
url.searchParams.delete('{{ collection.name }}:page')
Object.entries(event.detail.parameters).forEach(([k, v]) => url.searchParams.set(k, v));
window.history.pushState({}, null, url);
{% endif %}
"
{# catch plain submission/changes as well as changes from elements that
live outside the form and select2-dropdowns that emit special change event
and require a small delay for clearing them all at once via ClearFilters #}
hx-trigger='
change,
submit,
change from:[form="{{ form_id }}"],
collection-trigger from:[data-collection="{{ collection.name }}"],
change.htmx-select2 from:[form="{{ form_id }}"] delay:20'
hx-target="closest .{{ base_class }}"
hx-indicator="closest .{{ base_class }}"
hx-get="{{ render_url }}"
hx-swap="outerHTML"

{# this input catches search field submissions. Without it, submission
will include one of page navigation links, which are also implemented
as a submit #}
<input type="submit" hidden>

</form>
{% endblock form%}
{% endblock form_attrs %}>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% import 'macros/form.html' as form %}

{#% set form_id = collection.serializer.form_id %#}
{% set base_class = collection.serializer.base_class %}

{% for filter in collection.filters.filters %}
{% set name = collection.name ~ ":" ~ filter.name %}

<div class="{{ base_class }}--filter">
{% if filter.type == "input" %}
{{ form.input(name,
label=_(filter.options.label or filter.name),
value=request.args[name],
placeholder=_(filter.options.placeholder) if filter.options.placeholder else null,
type=filter.options.type or 'text') }}

{% elif filter.type == "select" %}
{{ form.select(name,
label=_(filter.options.label or filter.name),
options=filter.options.options or [],
selected=request.args[name]) }}

{% elif filter.type == "link" %}
{% if filter.options.href %}
{% set url = filter.options.href %}

{% elif filter.options.endpoint %}
{% set url = h.url_for(filter.options.endpoint, **(filter.options.kwargs or {})) %}

{% else %}
{% set url = "" %}

{% endif %}

<a href="{{ url }}" class="btn btn-primary">{{ _(filter.options.label or filter.name) }}</a>

{% elif filter.type == "button" %}
<button type="{{ filter.options.type or 'submit' }}" class="btn btn-primary">
{{ _(filter.options.label or filter.name) }}
</button>

{% endif %}
</div>
{% endfor %}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@

{% block form %}
<form
class="{{ base_class }}--form" id="{{ form_id }}"
{% block form_attrs %}
class="{{ base_class }}--form" id="{{ form_id }}"
{% endblock form_attrs %}
>

{% block filters %}
{% snippet collection.serializer.filter_template, collection=collection %}
{% endblock filters %}

{% block counter %}
{% snippet collection.serializer.counter_template, collection=collection %}
{% endblock counter %}
Expand Down
55 changes: 52 additions & 3 deletions ckanext/collection/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import abc
from collections.abc import Sized
from typing import Any, Callable, Generic, Iterable, Sequence
from typing import Any, Callable, Generic, Iterable, Literal, Sequence, Union

from typing_extensions import TypeAlias, TypedDict, TypeVar
from typing_extensions import NotRequired, TypeAlias, TypedDict, TypeVar

CollectionFactory: TypeAlias = "Callable[[str, dict[str, Any]], BaseCollection[Any]]"
TDataCollection = TypeVar("TDataCollection", bound="BaseCollection[Any]")
Expand All @@ -28,6 +28,7 @@ class BaseColumns(abc.ABC, Service):
visible: set[str]
sortable: set[str]
filterable: set[str]
searchable: set[str]
labels: dict[str, str]
serializers: dict[str, list[tuple[str, dict[str, Any]]]]

Expand Down Expand Up @@ -148,10 +149,58 @@ class Filter(TypedDict, Generic[TFilterOptions]):
"""Filter details."""

name: str
type: str
type: Any
options: TFilterOptions


class _SelectOptions(TypedDict):
text: str
value: str


class SelectFilterOptions(TypedDict):
label: str
options: Sequence[_SelectOptions]


class InputFilterOptions(TypedDict):
label: str
placeholder: NotRequired[str]
type: NotRequired[str]


class ButtonFilterOptions(TypedDict):
label: str
type: NotRequired[str]


class StaticLinkFilterOptions(TypedDict):
label: str
href: str


class DynamicLinkFilterOptions(TypedDict):
label: str
endpoint: str
kwargs: dict[str, Any]


class SelectFilter(Filter[SelectFilterOptions]):
type: Literal["select"]


class InputFilter(Filter[InputFilterOptions]):
type: Literal["input"]


class ButtonFilter(Filter[ButtonFilterOptions]):
type: Literal["button"]


class LinkFilter(Filter[Union[StaticLinkFilterOptions, DynamicLinkFilterOptions]]):
type: Literal["link"]


ValueSerializer: TypeAlias = Callable[
[Any, "dict[str, Any]", str, Any, BaseSerializer],
Any,
Expand Down
4 changes: 4 additions & 0 deletions ckanext/collection/utils/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ class Columns(
visible: set[str] = shared.configurable_attribute(SENTINEL)
sortable: set[str] = shared.configurable_attribute(SENTINEL)
filterable: set[str] = shared.configurable_attribute(SENTINEL)
searchable: set[str] = shared.configurable_attribute(
default_factory=lambda self: set(),
)
labels: dict[str, str] = shared.configurable_attribute(SENTINEL)

serializers: dict[
str,
list[tuple[str, dict[str, Any]]],
Expand Down
34 changes: 33 additions & 1 deletion ckanext/collection/utils/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class BaseModelData(
"""Data source for custom SQL statement."""

_data: cached_property[TStatement]
use_naive_filters: bool = shared.configurable_attribute(False)
use_naive_search: bool = shared.configurable_attribute(False)

def compute_data(self):
stmt = self.get_base_statement()
Expand Down Expand Up @@ -136,8 +138,38 @@ def count_statement(self, stmt: TStatement) -> int:
).scalar(),
)

def statement_with_filters(self, stmt: TStatement):
def statement_with_filters(self, stmt: TStatement) -> TStatement:
"""Add normal filter to statement."""
if not isinstance(stmt, Select):
return stmt

params = self.attached.params
if self.use_naive_filters:
stmt = stmt.where(
sa.and_(
sa.true(),
*[
stmt.selected_columns[name] == params[name]
for name in self.attached.columns.filterable
if name in params
and params[name] != ""
and name in stmt.selected_columns
],
),
)

if self.use_naive_search and (q := params.get("q")):
stmt = stmt.where(
sa.or_(
sa.false(),
*[
stmt.selected_columns[name].ilike(f"%{q}%")
for name in self.attached.columns.searchable
if name in stmt.selected_columns
],
),
)

return stmt

def statement_with_sorting(self, stmt: TStatement):
Expand Down
6 changes: 6 additions & 0 deletions ckanext/collection/utils/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ class TableSerializer(HtmlSerializer[types.TDataCollection]):
form_template: str = shared.configurable_attribute(
"collection/serialize/table/form.html",
)
filter_template: str = shared.configurable_attribute(
"collection/serialize/table/filter.html",
)

prefix: str = shared.configurable_attribute("collection-table")
base_class: str = shared.configurable_attribute("collection")
Expand Down Expand Up @@ -276,6 +279,9 @@ class HtmxTableSerializer(TableSerializer[types.TDataCollection]):
form_template: str = shared.configurable_attribute(
"collection/serialize/htmx_table/form.html",
)
filter_template: str = shared.configurable_attribute(
"collection/serialize/htmx_table/filter.html",
)

debug: str = shared.configurable_attribute(False)
push_url: str = shared.configurable_attribute(False)
Expand Down

0 comments on commit 8175728

Please sign in to comment.