diff --git a/app/__init__.py b/app/__init__.py index 0fd40481c..88e2e8d25 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -117,6 +117,7 @@ def create_app(): application.add_template_filter(format_date) application.add_template_filter(format_date_normal) application.add_template_filter(format_date_short) + application.add_template_filter(format_datetime_relative) application.add_template_filter(format_delta) application.add_template_filter(format_notification_status) application.add_template_filter(format_notification_status_as_time) @@ -232,6 +233,23 @@ def format_datetime_short(date): ) +def format_datetime_relative(date): + return '{} at {}'.format( + get_human_day(date), + format_time(date) + ) + + +def get_human_day(time): + # Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’ + time = (gmt_timezones(time) - timedelta(hours=1)).strftime('%A') + if time == datetime.utcnow().strftime('%A'): + return 'today' + if time == (datetime.utcnow() + timedelta(days=1)).strftime('%A'): + return 'tomorrow' + return time + + def format_time(date): return { '12:00AM': 'Midnight', diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js index 8923376ef..8d359cc7d 100644 --- a/app/assets/javascripts/radioSelect.js +++ b/app/assets/javascripts/radioSelect.js @@ -2,88 +2,144 @@ "use strict"; - var render = ($options, $button) => ( - filterOptionVisibility($options) && setButtonState($options, $button) - ); + let states = { + 'initial': Hogan.compile(` +
+ +
+
+ {{#categories}} + + {{/categories}} +
+ `), + 'choose': Hogan.compile(` +
+ +
+
+ {{#choices}} + + {{/choices}} +
+ `), + 'chosen': Hogan.compile(` +
+ +
+
+ {{#choices}} + + {{/choices}} +
+
+ +
+ `) + }; - var filterOptionVisibility = $options => $options - .removeClass('js-visible') - .filter( - (index, element) => (index === 0 || $(element).has(':checked').length) - ) - .addClass('js-visible'); - - var setButtonState = ($options, $button) => $button - .addClass('js-visible') - .prop( - 'value', - $options.has(':checked').find('input').attr('id') === $options.eq(0).find('input').attr('id') ? - 'Later' : 'Choose a different time' + let focusSelected = function() { + setTimeout( + () => $('[type=radio]:checked').parent('label').blur().trigger('focus').addClass('selected'), + 10 ); - - // Workaround because GOV.UK SelectionButtons doesn’t deselect in this case - var deselectUnchecked = $options => $options - .filter( - (index, element) => $(element).not(':has(:checked)') - ).removeClass('selected'); - - var refocus = $element => setTimeout( - () => $element.blur().trigger('focus'), - 10 - ); - - var renderIfComponentLosesFocus = ($options, $button, $focused) => () => - ($focused.attr('type') !== 'radio') && - render($options, $button) && - refocus($focused); // Make sure that window scrolls to focused element + }; Modules.RadioSelect = function() { this.start = function(component) { let $component = $(component); - let $options = $('label', $component); + let render = (state, data) => $component.html(states[state].render(data)); + let choices = $('label', $component).toArray().map(function(element) { + let $element = $(element); + return { + 'id': $element.attr('for'), + 'label': $.trim($element.text()), + 'value': $element.find('input').attr('value') + }; + }); + let categories = $component.data('categories').split(','); + let name = $component.find('input').eq(0).attr('name'); - $component.append( - $button = $('') - ); + $component + .on('click', '.js-category-button', function(event) { - $button.on('click', () => - $options.addClass('js-visible').has(':checked').focus() && - $button.removeClass('js-visible') - ); + event.preventDefault(); + let wordsInDay = $(this).attr('value').split(' '); + let day = wordsInDay[wordsInDay.length - 1].toLowerCase(); + render('choose', { + 'choices': choices.filter( + element => element.label.toLowerCase().indexOf(day) > -1 + ), + 'name': name + }); + $('.js-option').eq(0).parent('label').trigger('focus'); - $component.on('keydown', 'input[type=radio]', function() { + }) + .on('click', '.js-option', function(event) { - // intercept keypresses which aren’t enter or space - if (event.which !== 13 && event.which !== 32) { - setTimeout( - renderIfComponentLosesFocus($options, $button, $(document.activeElement)), - 200 - ); - return true; - } + // stop click being triggered by keyboard events + if (!event.pageX) return true; - event.preventDefault(); + event.preventDefault(); + let value = $(this).attr('value'); + render('chosen', { + 'choices': choices.filter( + element => element.value == value + ), + 'name': name + }); + focusSelected(); - render($options, $button); - refocus($(this)); + }) + .on('keydown', 'input[type=radio]', function(event) { + // intercept keypresses which aren’t enter or space + if (event.which !== 13 && event.which !== 32) { + return true; + } + + event.preventDefault(); + let value = $(this).attr('value'); + render('chosen', { + 'choices': choices.filter( + element => element.value == value + ), + 'name': name + }); + focusSelected(); + + }) + .on('click', '.js-reset-button', function(event) { + + event.preventDefault(); + render('initial', { + 'categories': categories, + 'name': name + }); + focusSelected(); + + }); + + render('initial', { + 'categories': categories, + 'name': name }); - $component.on('click', 'input[type=radio]', function(event) { - - deselectUnchecked($options); - - // stop click being triggered by keyboard events - if (!event.pageX) return true; - - render($options, $button); - refocus($(this)); - - }); - - render($options, $button); + $component.css({'height': 'auto'}); }; diff --git a/app/assets/stylesheets/components/radio-select.scss b/app/assets/stylesheets/components/radio-select.scss index 7462eed1e..1440d0ad6 100644 --- a/app/assets/stylesheets/components/radio-select.scss +++ b/app/assets/stylesheets/components/radio-select.scss @@ -6,7 +6,9 @@ vertical-align: top; .block-label { - margin-right: 10px; + margin-right: 5px; + padding-right: $gutter - 10px; + padding-left: 54px - 10px; } } @@ -15,31 +17,21 @@ display: inline-block; vertical-align: top; width: auto; - padding: 20px 30px 15px 30px; + padding: 20px $gutter-half 15px $gutter-half; + margin-right: 5px; } .js-enabled & { + height: 60px; + overflow: visible; .block-label { - &:last-child { - margin-bottom: 10px; - } - } - - .block-label, - .tertiary-button { display: none; } - .js-visible { - - display: block; - - &.tertiary-button { - display: inline-block; - } - + .js-block-label { + display: inline-block; } } diff --git a/app/main/forms.py b/app/main/forms.py index 650f72891..1695ac682 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -27,27 +27,58 @@ from app.main.validators import (Blacklist, CsvFileValidator, ValidGovEmail, NoC def get_time_value_and_label(future_time): return ( future_time.replace(tzinfo=None).isoformat(), - get_human_time(future_time.astimezone(pytz.timezone('Europe/London'))) + '{} at {}'.format( + get_human_day(future_time.astimezone(pytz.timezone('Europe/London'))), + get_human_time(future_time.astimezone(pytz.timezone('Europe/London'))) + ) ) def get_human_time(time): return { - '0': 'Midnight', - '12': 'Midday' + '0': 'midnight', + '12': 'midday' }.get( time.strftime('%-H'), time.strftime('%-I%p').lower() ) -def get_next_hours_from(now, hours=23): +def get_human_day(time, prefix_today_with='T'): + # Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’ + time = (time - timedelta(hours=1)).strftime('%A') + if time == datetime.utcnow().strftime('%A'): + return '{}oday'.format(prefix_today_with) + if time == (datetime.utcnow() + timedelta(days=1)).strftime('%A'): + return 'Tomorrow' + return time + + +def get_furthest_possible_scheduled_time(): + return (datetime.utcnow() + timedelta(days=4)).replace(hour=0) + + +def get_next_hours_until(until): + now = datetime.utcnow() + hours = int((until - now).total_seconds() / (60 * 60)) return [ (now + timedelta(hours=i)).replace(minute=0, second=0).replace(tzinfo=pytz.utc) for i in range(1, hours + 1) ] +def get_next_days_until(until): + now = datetime.utcnow() + days = int((until - now).total_seconds() / (60 * 60 * 24)) + return [ + get_human_day( + (now + timedelta(days=i)).replace(tzinfo=pytz.utc), + prefix_today_with='Later t' + ) + for i in range(0, days + 1) + ] + + def email_address(label='Email address', gov_user=True): validators = [ Length(min=5, max=255), @@ -310,8 +341,11 @@ class ChooseTimeForm(Form): def __init__(self, *args, **kwargs): super(ChooseTimeForm, self).__init__(*args, **kwargs) self.scheduled_for.choices = [('', 'Now')] + [ - get_time_value_and_label(hour) for hour in get_next_hours_from(datetime.utcnow()) + get_time_value_and_label(hour) for hour in get_next_hours_until( + get_furthest_possible_scheduled_time() + ) ] + self.scheduled_for.categories = get_next_days_until(get_furthest_possible_scheduled_time()) scheduled_for = RadioField( 'When should Notify send these messages?', diff --git a/app/main/views/send.py b/app/main/views/send.py index 9ed33784d..90f423d13 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -21,7 +21,7 @@ from notifications_utils.template import Template from notifications_utils.recipients import RecipientCSV, first_column_heading, validate_and_format_phone_number from app.main import main -from app.main.forms import CsvUploadForm, ChooseTimeForm +from app.main.forms import CsvUploadForm, ChooseTimeForm, get_next_days_until, get_furthest_possible_scheduled_time from app.main.uploader import ( s3upload, s3download diff --git a/app/templates/components/radios.html b/app/templates/components/radios.html index 3a0f19a7c..c361c443e 100644 --- a/app/templates/components/radios.html +++ b/app/templates/components/radios.html @@ -38,7 +38,7 @@ {% endif %} -
+
{% for option in field %}