From 210ac2b397b7b6a83e5818a938448f0ebed133bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Avil=C3=A9s?= Date: Wed, 15 Jul 2020 16:37:47 +0200 Subject: [PATCH] Improve IndicoDateField facilities Added: - DateRange validator - LinkedDate validator Improved: - IndicoDateField now renders with the React date picker --- CHANGES.rst | 2 + .../js/jquery/widgets/jinja/date_widget.js | 52 +++++++ .../client/js/jquery/widgets/jinja/index.js | 1 + .../js/react/components/WTFDateField.jsx | 135 ++++++++++++++++++ .../web/client/js/react/components/index.js | 1 + indico/web/forms/fields/datetime.py | 29 +++- indico/web/forms/validators.py | 88 +++++++++++- indico/web/templates/forms/date_widget.html | 26 +++- 8 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 indico/web/client/js/jquery/widgets/jinja/date_widget.js create mode 100644 indico/web/client/js/react/components/WTFDateField.jsx diff --git a/CHANGES.rst b/CHANGES.rst index d010d7564a3..326432a5a21 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -127,6 +127,8 @@ Internal Changes :user:`omegak`) - Add ``before-registration-actions`` template hook (:issue:`4524`, thanks :user:`omegak`) +- Improve ``IndicoDateField`` rendering and validators (:issue:`4535`, thanks + :user:`omegak`) ---- diff --git a/indico/web/client/js/jquery/widgets/jinja/date_widget.js b/indico/web/client/js/jquery/widgets/jinja/date_widget.js new file mode 100644 index 00000000000..230ba36917b --- /dev/null +++ b/indico/web/client/js/jquery/widgets/jinja/date_widget.js @@ -0,0 +1,52 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2020 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +import React from 'react'; +import ReactDOM from 'react-dom'; +import {WTFDateField} from 'indico/react/components'; + +(function(global) { + 'use strict'; + + global.setupDateWidget = function setupDateWidget(options) { + options = $.extend( + true, + { + fieldId: null, + required: false, + disabled: false, + allowClear: false, + earliest: null, + latest: null, + linkedField: { + id: null, + notBefore: false, + notAfter: false, + }, + }, + options + ); + + // Make sure the results dropdown are displayed above the dialog. + const field = $(`#${options.fieldId}`); + field.closest('.ui-dialog-content').css('overflow', 'inherit'); + field.closest('.exclusivePopup').css('overflow', 'inherit'); + + ReactDOM.render( + , + document.getElementById(options.fieldId) + ); + }; +})(window); diff --git a/indico/web/client/js/jquery/widgets/jinja/index.js b/indico/web/client/js/jquery/widgets/jinja/index.js index b6fa5dda7de..ba58822036d 100644 --- a/indico/web/client/js/jquery/widgets/jinja/index.js +++ b/indico/web/client/js/jquery/widgets/jinja/index.js @@ -8,6 +8,7 @@ import './category_picker_widget'; import './ckeditor_widget'; import './color_picker_widget'; +import './date_widget'; import './datetime_widget'; import './linking_widget'; import './location_widget'; diff --git a/indico/web/client/js/react/components/WTFDateField.jsx b/indico/web/client/js/react/components/WTFDateField.jsx new file mode 100644 index 00000000000..9cd27e7db5b --- /dev/null +++ b/indico/web/client/js/react/components/WTFDateField.jsx @@ -0,0 +1,135 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2020 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +import 'react-dates/initialize'; +import PropTypes from 'prop-types'; +import React, {useEffect, useMemo, useRef, useState, useCallback} from 'react'; +import moment from 'moment'; +import {SingleDatePicker} from 'indico/react/components'; +import {Translate} from 'indico/react/i18n'; +import {toMoment} from 'indico/utils/date'; + +function triggerChange(id) { + document.getElementById(id).dispatchEvent(new Event('change', {bubbles: true})); +} + +export default function WTFDateField({ + dateId, + required, + disabled, + allowClear, + earliest, + latest, + linkedField, +}) { + const dateField = useMemo(() => document.getElementById(dateId), [dateId]); + const linkedFieldDateElem = useMemo( + () => linkedField && document.getElementById(`${linkedField.id}-datestorage`), + [linkedField] + ); + const [date, setDate] = useState(toMoment(dateField.value, 'DD/MM/YYYY', true)); + const earliestMoment = earliest ? moment(earliest) : null; + const latestMoment = latest ? moment(latest) : null; + const clearRef = useRef(null); + + const updateDate = useCallback( + value => { + dateField.value = value ? value.format('DD/MM/YYYY') : ''; + setDate(value); + triggerChange(dateId); + }, + [dateField, dateId] + ); + + useEffect(() => { + if (!linkedField) { + return; + } + function handleDateChange() { + const linkedDate = moment(linkedFieldDateElem.value, 'DD/MM/YYYY'); + if ( + (linkedField.notBefore && linkedDate.isAfter(date, 'day')) || + (linkedField.notAfter && linkedDate.isBefore(date, 'day')) + ) { + updateDate(linkedDate); + } + } + linkedFieldDateElem.addEventListener('change', handleDateChange); + + return () => { + linkedFieldDateElem.removeEventListener('change', handleDateChange); + }; + }, [date, linkedFieldDateElem, linkedField, updateDate]); + + const clearFields = () => { + updateDate(null); + clearRef.current.dispatchEvent(new Event('indico:closeAutoTooltip')); + }; + + const isOutsideRange = useCallback( + value => { + if (linkedField) { + const linkedDate = moment(linkedFieldDateElem.value, 'DD/MM/YYYY'); + if ( + (linkedField.notBefore && value.isBefore(linkedDate, 'day')) || + (linkedField.notAfter && value.isAfter(linkedDate, 'day')) + ) { + return true; + } + } + return ( + (earliestMoment && value.isBefore(earliestMoment, 'day')) || + (latestMoment && value.isAfter(latestMoment, 'day')) + ); + }, + [earliestMoment, latestMoment, linkedField, linkedFieldDateElem] + ); + + return ( + <> + + {date && allowClear && ( + + )} + + ); +} + +WTFDateField.propTypes = { + dateId: PropTypes.string.isRequired, + required: PropTypes.bool, + disabled: PropTypes.bool, + allowClear: PropTypes.bool, + earliest: PropTypes.string, + latest: PropTypes.string, + linkedField: PropTypes.object, +}; + +WTFDateField.defaultProps = { + required: false, + disabled: false, + allowClear: false, + earliest: null, + latest: null, + linkedField: null, +}; diff --git a/indico/web/client/js/react/components/index.js b/indico/web/client/js/react/components/index.js index 67ba659af08..3d88b7bfa06 100644 --- a/indico/web/client/js/react/components/index.js +++ b/indico/web/client/js/react/components/index.js @@ -37,5 +37,6 @@ export {default as ReviewRating} from './ReviewRating'; export {default as ManagementPageBackButton} from './ManagementPageBackButton'; export {default as ManagementPageSubTitle} from './ManagementPageSubTitle'; export {default as ManagementPageTitle} from './ManagementPageTitle'; +export {default as WTFDateField} from './WTFDateField'; export {default as WTFDateTimeField} from './WTFDateTimeField'; export {default as WTFOccurrencesField} from './WTFOccurrencesField'; diff --git a/indico/web/forms/fields/datetime.py b/indico/web/forms/fields/datetime.py index 2f9a4eb7198..0cf9fb5f354 100644 --- a/indico/web/forms/fields/datetime.py +++ b/indico/web/forms/fields/datetime.py @@ -23,7 +23,7 @@ from indico.util.date_time import localize_as_utc, relativedelta from indico.util.i18n import _, get_current_locale from indico.web.forms.fields import JSONField -from indico.web.forms.validators import DateTimeRange, LinkedDateTime +from indico.web.forms.validators import DateRange, DateTimeRange, LinkedDate, LinkedDateTime from indico.web.forms.widgets import JinjaWidget @@ -179,9 +179,36 @@ class IndicoDateField(DateField): widget = JinjaWidget('forms/date_widget.html', single_line=True, single_kwargs=True) def __init__(self, *args, **kwargs): + self.allow_clear = kwargs.pop('allow_clear', True) super(IndicoDateField, self).__init__(*args, parse_kwargs={'dayfirst': True}, display_format='%d/%m/%Y', **kwargs) + @property + def earliest_date(self): + if self.flags.date_range: + for validator in self.validators: + if isinstance(validator, DateRange): + return validator.get_earliest(self.get_form(), self) + + @property + def latest_date(self): + if self.flags.date_range: + for validator in self.validators: + if isinstance(validator, DateRange): + return validator.get_latest(self.get_form(), self) + + @property + def linked_field(self): + validator = self.linked_date_validator + return validator.linked_field if validator else None + + @property + def linked_date_validator(self): + if self.flags.linked_date: + for validator in self.validators: + if isinstance(validator, LinkedDate): + return validator + class IndicoDateTimeField(DateTimeField): """Friendly datetime field that handles timezones and validations. diff --git a/indico/web/forms/validators.py b/indico/web/forms/validators.py index 02a4063e77b..72401422580 100644 --- a/indico/web/forms/validators.py +++ b/indico/web/forms/validators.py @@ -8,12 +8,12 @@ from __future__ import unicode_literals import re -from datetime import timedelta +from datetime import date, timedelta from types import NoneType from wtforms.validators import EqualTo, Length, Regexp, StopValidation, ValidationError -from indico.util.date_time import as_utc, format_datetime, format_human_timedelta, format_time, now_utc +from indico.util.date_time import as_utc, format_date, format_datetime, format_human_timedelta, format_time, now_utc from indico.util.i18n import _, ngettext from indico.util.string import is_valid_mail @@ -109,8 +109,55 @@ def __call__(self, form, field): raise ValidationError(msg) +class DateRange(object): + """Validates that a date is within the specified boundaries""" + + field_flags = ('date_range',) + + def __init__(self, earliest='today', latest=None): + self.earliest = earliest + self.latest = latest + # set to true in get_earliest/get_latest if applicable + self.earliest_today = False + self.latest_today = False + + def __call__(self, form, field): + if field.data is None: + return + field_date = field.data + earliest_date = self.get_earliest(form, field) + latest_date = self.get_latest(form, field) + if field_date != field.object_data: + if earliest_date and field_date < earliest_date: + if self.earliest_today: + msg = _("'{}' can't be in the past").format(field.label) + else: + msg = _("'{}' can't be before {}").format(field.label, format_date(earliest_date)) + raise ValidationError(msg) + if latest_date and field_date > latest_date: + if self.latest_today: + msg = _("'{}' can't be in the future").format(field.label) + else: + msg = _("'{}' can't be after {}").format(field.label, format_date(latest_date)) + raise ValidationError(msg) + + def get_earliest(self, form, field): + earliest = self.earliest(form, field) if callable(self.earliest) else self.earliest + if earliest == 'today': + self.earliest_today = True + return date.today() + return earliest + + def get_latest(self, form, field): + latest = self.latest(form, field) if callable(self.latest) else self.latest + if latest == 'today': + self.latest_today = True + return date.today() + return latest + + class DateTimeRange(object): - """Validates a datetime is within the specified boundaries""" + """Validates that a datetime is within the specified boundaries""" field_flags = ('datetime_range',) @@ -158,8 +205,41 @@ def get_latest(self, form, field): return as_utc(latest) if latest else latest +class LinkedDate(object): + """Validates that a date field happens before or/and after another. + + If both ``not_before`` and ``not_after`` are set to ``True``, both fields have to + be equal. + """ + + field_flags = ('linked_date',) + + def __init__(self, field, not_before=True, not_after=False, not_equal=False): + if not not_before and not not_after: + raise ValueError("Invalid validation") + self.not_before = not_before + self.not_after = not_after + self.not_equal = not_equal + self.linked_field = field + + def __call__(self, form, field): + if field.data is None: + return + linked_field = form[self.linked_field] + if linked_field.data is None: + return + linked_field_date = linked_field.data + field_date = field.data + if self.not_before and field_date < linked_field_date: + raise ValidationError(_("{} can't be before {}").format(field.label, linked_field.label)) + if self.not_after and field_date > linked_field_date: + raise ValidationError(_("{} can't be after {}").format(field.label, linked_field.label)) + if self.not_equal and field_date == linked_field_date: + raise ValidationError(_("{} can't be equal to {}").format(field.label, linked_field.label)) + + class LinkedDateTime(object): - """Validates a datetime field happens before or/and after another. + """Validates that a datetime field happens before or/and after another. If both ``not_before`` and ``not_after`` are set to ``True``, both fields have to be equal. diff --git a/indico/web/templates/forms/date_widget.html b/indico/web/templates/forms/date_widget.html index 03231b45be1..1107fcb8b7e 100644 --- a/indico/web/templates/forms/date_widget.html +++ b/indico/web/templates/forms/date_widget.html @@ -1,12 +1,32 @@ {% extends 'forms/base_widget.html' %} {% block html %} - +
+ {# the hidden field has autofocus on purpose to prevent our JS from focusing the date picker #} + + +
{% endblock %} {% block javascript %} {% endblock %}