Skip to content

Commit

Permalink
Improve IndicoDateField facilities
Browse files Browse the repository at this point in the history
Added:
- DateRange validator
- LinkedDate validator

Improved:
- IndicoDateField now renders with the React date picker
  • Loading branch information
OmeGak committed Jul 15, 2020
1 parent 3268cc2 commit 210ac2b
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 8 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Expand Up @@ -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`)


----
Expand Down
52 changes: 52 additions & 0 deletions 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(
<WTFDateField
dateId={options.fieldId + '-datestorage'}
required={options.required}
disabled={options.disabled}
allowClear={options.allowClear}
earliest={options.earliest}
latest={options.latest}
linkedField={options.linkedField}
/>,
document.getElementById(options.fieldId)
);
};
})(window);
1 change: 1 addition & 0 deletions indico/web/client/js/jquery/widgets/jinja/index.js
Expand Up @@ -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';
Expand Down
135 changes: 135 additions & 0 deletions 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 (
<>
<SingleDatePicker
id=""
date={date}
onDateChange={updateDate}
placeholder="DD/MM/YYYY"
isOutsideRange={isOutsideRange}
required={required}
verticalSpacing={10}
showDefaultInputIcon={false}
disabled={disabled}
noBorder
/>
{date && allowClear && (
<span
onClick={clearFields}
className="clear-pickers"
title={Translate.string('Clear date')}
ref={clearRef}
/>
)}
</>
);
}

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,
};
1 change: 1 addition & 0 deletions indico/web/client/js/react/components/index.js
Expand Up @@ -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';
29 changes: 28 additions & 1 deletion indico/web/forms/fields/datetime.py
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down
88 changes: 84 additions & 4 deletions indico/web/forms/validators.py
Expand Up @@ -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

Expand Down Expand Up @@ -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',)

Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 210ac2b

Please sign in to comment.