diff --git a/README.md b/README.md index d06dafb..1fb2b43 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,21 @@ TextFilter( ) ``` +#### DateFilter + +A datepicker using Wagtail's default picker. + +```python +from admin_list_controls.filters import DateFilter + +DateFilter( + name='date', + label='Date', + format='%d/%m/%Y', + apply_to_queryset=lambda queryset, value: queryset.filter(date__gte=value), +) +``` + #### BooleanFilter A checkbox input. diff --git a/admin_list_controls/filters.py b/admin_list_controls/filters.py index 512b4f5..cc49550 100644 --- a/admin_list_controls/filters.py +++ b/admin_list_controls/filters.py @@ -1,3 +1,8 @@ +import datetime +from django.conf import settings +from django.core import validators +from django.utils import datetime_safe, formats +from wagtail.admin.datetimepicker import to_datetimepicker_format from .components import BaseComponent from .actions import RemoveValue, SubmitForm @@ -93,6 +98,54 @@ def clean(self, *args, **kwargs): return bool(value) +class DateFilter(BaseFilter): + format_key = 'DATE_INPUT_FORMATS' + filter_type = 'date' + empty_values = list(validators.EMPTY_VALUES) + + def __init__(self, format=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.format = format or getattr(settings, 'WAGTAIL_DATE_FORMAT', None) + fallback_format = formats.get_format(self.format_key)[0] + self.format = fallback_format if self.format is None else self.format + self.js_format = to_datetimepicker_format(self.format) + + def format_value(self, value): + return formats.localize_input(value, self.format) + + def to_python(self, value): + """ + Validate that the input can be converted to a date. Return a Python + datetime.date object. + """ + if value in self.empty_values: + return None + + value = value.strip() + # Try to strptime against each input format. + try: + return datetime.datetime.strptime(value, self.format).date() + except (ValueError, TypeError): + return None + + def clean(self, *args, **kwargs): + value = super().clean(*args, **kwargs) + return self.to_python(value) if value else None + + def get_summary_display_value_for_value(self, value): + """ + Returns the corresponding display value for the raw value + """ + return self.format_value(value) + + def serialize(self): + return dict(super().serialize(), **{ + 'value': self.format_value(self.cleaned_value), + 'format': self.js_format, + }) + + class BaseChoiceFilter(BaseFilter): def __init__(self, choices, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/admin_list_controls/src/admin_list_controls/components/filters/date.js b/admin_list_controls/src/admin_list_controls/components/filters/date.js new file mode 100644 index 0000000..d64810e --- /dev/null +++ b/admin_list_controls/src/admin_list_controls/components/filters/date.js @@ -0,0 +1,72 @@ +import React, {useState, useEffect} from "react"; +import {SET_VALUE} from '../../constants'; +import {store} from '../../state'; +import c from "classnames"; +import {submit_form} from "../../index"; + +export function DateFilter({control}) { + const [value, set_value] = useState(control.value); + + const input_id = `alc__filter-${control.component_id}-${control.name}`; + const inputChange = event => { + const value = event.target.value; + set_value(value); + store.dispatch({ + type: SET_VALUE, + name: control.name, + value: value, + }); + } + + useEffect(() => { + let start = null; + window.initDateChooser(input_id, { + "dayOfWeekStart": 1, "format": control.format, + onGenerate: function(current, input) { + start = start || current; + let picker = this[0]; + if (start && !dateEqual(start, current)) { + picker.querySelectorAll( + '.xdsoft_datepicker .xdsoft_current:not(.xdsoft_today)' + ).forEach( + i => i.classList.remove("xdsoft_current") + ); + } + }, + onChangeDateTime(current, $el) { + start = current; + let el = $el.get(0); + el.dispatchEvent(new Event('change')); + inputChange({'target': el}); + } + }); + }, [input_id] // avoid init on every render + ); + + return ( +
+ {/* A form element allows for keyboards to trigger submit events (enter keypress, etc) */} +
{ + event.preventDefault(); + submit_form(); + }}> + {control.label + ? + : null + } +
+ +
+
+
+ ); +} diff --git a/admin_list_controls/src/admin_list_controls/components/root.js b/admin_list_controls/src/admin_list_controls/components/root.js index 2ae4077..108390e 100644 --- a/admin_list_controls/src/admin_list_controls/components/root.js +++ b/admin_list_controls/src/admin_list_controls/components/root.js @@ -9,6 +9,7 @@ import {Divider} from "./divider"; import {Block} from "./block"; import {Selector} from "./selector"; import {TextFilter} from "./filters/text"; +import {DateFilter} from "./filters/date"; import {BooleanFilter} from "./filters/boolean"; import {RadioFilter} from "./filters/radio"; import {ChoiceFilter} from "./filters/choice"; @@ -56,6 +57,8 @@ export function render_control(control) { return ; case 'choice': return ; + case 'date': + return ; default: console.error('Unknown filter type', control.filter_type, control); throw new Error(`Unknown filter type "${control.filter_type}`); @@ -65,4 +68,4 @@ export function render_control(control) { console.error('Unknown control type', control); throw new Error(`Unknown control ${control.object_type}`); } -} \ No newline at end of file +} diff --git a/admin_list_controls/tests/test_filters.py b/admin_list_controls/tests/test_filters.py index 891fd47..b64e86f 100644 --- a/admin_list_controls/tests/test_filters.py +++ b/admin_list_controls/tests/test_filters.py @@ -1,7 +1,8 @@ +from datetime import date from django.test import RequestFactory from django_webtest import WebTest from admin_list_controls.filters import BaseFilter, TextFilter, BooleanFilter, ChoiceFilter, \ - RadioFilter + RadioFilter, DateFilter from admin_list_controls.tests.utils import BaseTestCase @@ -54,6 +55,18 @@ def test_text_filter_value(self): filter_.handle_request(self.factory.get('/?test_name=test_value')) self.assertEqual(filter_.cleaned_value, 'test_value') + def test_date_filter_value(self): + filter_ = DateFilter( + name='test_name', + label='test_label', + ) + filter_.handle_request(self.factory.get('/')) + self.assertEqual(filter_.cleaned_value, None) + filter_.handle_request(self.factory.get('/?test_name=')) + self.assertEqual(filter_.cleaned_value, None) + filter_.handle_request(self.factory.get('/?test_name=2021-01-01')) + self.assertEqual(filter_.cleaned_value, date(2021, 1, 1)) + def test_boolean_filter_value(self): filter_ = BooleanFilter( name='test_name', diff --git a/admin_list_controls/views.py b/admin_list_controls/views.py index d8eb11d..26978d4 100644 --- a/admin_list_controls/views.py +++ b/admin_list_controls/views.py @@ -1,9 +1,12 @@ -import os import json +import os from collections import Iterable +from django import forms from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.utils.safestring import mark_safe +from wagtail.admin.staticfiles import versioned_static from wagtail.contrib.modeladmin.views import IndexView from .components import ListControls from .selectors import LayoutSelector @@ -84,7 +87,7 @@ def get_context_data(self, **kwargs): # Consumed by the front-end code to build the UI 'initial_state': json.dumps({ 'admin_list_controls': self.get_list_controls().serialize(), - }), + }, cls=DjangoJSONEncoder), 'selected_layout_template': selected_layout_template, 'widget_js': self.get_list_controls_widget_js(), } @@ -158,6 +161,17 @@ def prepare_list_controls(self): if hasattr(obj, 'derive_from_components'): obj.derive_from_components(flattened_tree) + @property + def media(self): + js = self.model_admin.get_index_view_extra_js() + # @TODO only inject datechooser when needed + js += [versioned_static( + 'wagtailadmin/js/date-time-chooser.js')] + + return forms.Media( + css={'all': self.model_admin.get_index_view_extra_css()}, + js=js + ) class ListControlsIndexView(ListControlsIndexViewMixin, IndexView): pass diff --git a/test_project/shop/wagtail_hooks.py b/test_project/shop/wagtail_hooks.py index 1263b32..5f4a166 100644 --- a/test_project/shop/wagtail_hooks.py +++ b/test_project/shop/wagtail_hooks.py @@ -1,10 +1,10 @@ -from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from admin_list_controls.selectors import LayoutSelector from admin_list_controls.views import ListControlsIndexView from admin_list_controls.components import Button, Icon, Text, Panel, Divider, Block, Spacer, \ Columns, Summary from admin_list_controls.actions import TogglePanel, CollapsePanel, SubmitForm, Link -from admin_list_controls.filters import TextFilter, ChoiceFilter, RadioFilter, BooleanFilter +from admin_list_controls.filters import TextFilter, ChoiceFilter, RadioFilter, BooleanFilter, DateFilter +from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from .models import Product @@ -94,6 +94,13 @@ def build_list_controls(self): Panel(ref='panel_2', collapsed=True)( Text('medium text ', size=Text.MEDIUM), Text('large text', size=Text.LARGE), + DateFilter( + name='date_start', + label='Date', + format='%d/%m/%Y', + ), + Spacer(), + Button(action=SubmitForm())('Apply filters'), ), Summary(), ]