From 4949f9354ecfa9da20c7b09fcb75756398028d74 Mon Sep 17 00:00:00 2001 From: Andreas M Date: Fri, 7 May 2021 15:30:50 +0200 Subject: [PATCH 1/4] added DateFilter --- admin_list_controls/filters.py | 53 +++++++++++++++++ .../components/filters/date.js | 59 +++++++++++++++++++ .../admin_list_controls/components/root.js | 5 +- admin_list_controls/tests/test_filters.py | 15 ++++- admin_list_controls/views.py | 5 +- test_project/shop/wagtail_hooks.py | 15 ++++- 6 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 admin_list_controls/src/admin_list_controls/components/filters/date.js 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..147b9c1 --- /dev/null +++ b/admin_list_controls/src/admin_list_controls/components/filters/date.js @@ -0,0 +1,59 @@ +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(() => { + window.initDateChooser(input_id, { + "dayOfWeekStart": 1, "format": control.format, + onChangeDateTime(_, $el) { + 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..c3112b6 100644 --- a/admin_list_controls/views.py +++ b/admin_list_controls/views.py @@ -1,8 +1,9 @@ -import os import json +import os from collections import Iterable from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.utils.safestring import mark_safe from wagtail.contrib.modeladmin.views import IndexView from .components import ListControls @@ -84,7 +85,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(), } diff --git a/test_project/shop/wagtail_hooks.py b/test_project/shop/wagtail_hooks.py index 1263b32..b6bf034 100644 --- a/test_project/shop/wagtail_hooks.py +++ b/test_project/shop/wagtail_hooks.py @@ -1,10 +1,11 @@ -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.admin.staticfiles import versioned_static +from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from .models import Product @@ -94,6 +95,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(), ] @@ -104,3 +112,6 @@ class ProductAdmin(ModelAdmin): model = Product index_view_class = IndexView search_fields = ('name',) + + index_view_extra_js = [versioned_static( + 'wagtailadmin/js/date-time-chooser.js')] From 571f2c053a6706ce4131128b4040deda235198dc Mon Sep 17 00:00:00 2001 From: Andreas M Date: Fri, 7 May 2021 16:59:13 +0200 Subject: [PATCH 2/4] fixed an issue with onGenerate event in wagtail --- .../components/filters/date.js | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) 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 index 147b9c1..d64810e 100644 --- a/admin_list_controls/src/admin_list_controls/components/filters/date.js +++ b/admin_list_controls/src/admin_list_controls/components/filters/date.js @@ -19,15 +19,28 @@ export function DateFilter({control}) { } useEffect(() => { - window.initDateChooser(input_id, { - "dayOfWeekStart": 1, "format": control.format, - onChangeDateTime(_, $el) { - let el = $el.get(0); - el.dispatchEvent(new Event('change')); - inputChange({'target': el}); + 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") + ); } - }); - }, [input_id] // avoid init on every render + }, + 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 ( From 84464b2fc8903679c79a04d31731571b99507b23 Mon Sep 17 00:00:00 2001 From: Andreas M Date: Fri, 7 May 2021 17:01:00 +0200 Subject: [PATCH 3/4] updated readme --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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. From a8db11e4d84ce9a09ebb3d6b4c77f9bc4cddde46 Mon Sep 17 00:00:00 2001 From: Andreas M Date: Fri, 7 May 2021 17:19:06 +0200 Subject: [PATCH 4/4] auto inject datepicker js --- admin_list_controls/views.py | 13 +++++++++++++ test_project/shop/wagtail_hooks.py | 4 ---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/admin_list_controls/views.py b/admin_list_controls/views.py index c3112b6..26978d4 100644 --- a/admin_list_controls/views.py +++ b/admin_list_controls/views.py @@ -2,9 +2,11 @@ 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 @@ -159,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 b6bf034..5f4a166 100644 --- a/test_project/shop/wagtail_hooks.py +++ b/test_project/shop/wagtail_hooks.py @@ -4,7 +4,6 @@ Columns, Summary from admin_list_controls.actions import TogglePanel, CollapsePanel, SubmitForm, Link from admin_list_controls.filters import TextFilter, ChoiceFilter, RadioFilter, BooleanFilter, DateFilter -from wagtail.admin.staticfiles import versioned_static from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from .models import Product @@ -112,6 +111,3 @@ class ProductAdmin(ModelAdmin): model = Product index_view_class = IndexView search_fields = ('name',) - - index_view_extra_js = [versioned_static( - 'wagtailadmin/js/date-time-chooser.js')]