diff --git a/atst/routes/users.py b/atst/routes/users.py index 5aa720d6..30564196 100644 --- a/atst/routes/users.py +++ b/atst/routes/users.py @@ -1,3 +1,4 @@ +import datetime as dt from flask import Blueprint, render_template, g, request as http_request, redirect from atst.forms.edit_user import EditUserForm from atst.domain.users import Users @@ -16,7 +17,14 @@ def user(): if next_: flash("user_must_complete_profile") - return render_template("user/edit.html", next=next_, form=form, user=user) + return render_template( + "user/edit.html", + next=next_, + form=form, + user=user, + mindate=(dt.datetime.now() - dt.timedelta(days=365)), + maxdate=dt.datetime.now(), + ) @bp.route("/user", methods=["POST"]) diff --git a/js/components/__tests__/date_selector.test.js b/js/components/__tests__/date_selector.test.js new file mode 100644 index 00000000..660292c8 --- /dev/null +++ b/js/components/__tests__/date_selector.test.js @@ -0,0 +1,172 @@ +import Vue from 'vue' + +import DateSelector from '../date_selector' + +describe('DateSelector', () => { + const component = new Vue(DateSelector).$mount() + + describe('isDateValid', () => { + it('returns true when a valid date', () => { + component.day = 4 + component.month = 8 + component.year = 1776 + + expect(component.isDateValid).toEqual(true) + }) + + it('returns false when an invalid date', () => { + component.day = 32 + component.month = 13 + component.year = 2019 + + expect(component.isDateValid).toEqual(false) + }) + + it('returns false when parts of the date are missing', () => { + component.day = 31 + component.year = 2019 + + expect(component.isDateValid).toEqual(false) + }) + }) + + describe('daysMaxCalculation', () => { + it('calculates correctly for each month', () => { + let months = { + '1': 31, + '2': 29, + '3': 31, + '4': 30, + '5': 31, + '6': 30, + '7': 31, + '8': 31, + '9': 30, + '10': 31, + '11': 30, + '12': 31, + } + + for (var month in months) { + component.month = parseInt(month) + expect(component.daysMaxCalculation).toEqual(months[month]) + } + }) + }) + + describe('isMonthValid', () => { + it('returns false when over 12', () => { + component.month = 13 + expect(component.isMonthValid).toEqual(false) + }) + + it('returns true when between 1 and 12', () => { + component.month = 3 + expect(component.isMonthValid).toEqual(true) + }) + + it('returns false when null', () => { + component.month = null + expect(component.isMonthValid).toEqual(false) + }) + }) + + describe('isDayValid', () => { + it('returns true when 31 and no month', () => { + component.day = 31 + component.month = null + expect(component.isDayValid).toEqual(true) + }) + + it('returns false when 31 and in February', () => { + component.day = 31 + component.month = 2 + expect(component.isDayValid).toEqual(false) + }) + + it('returns false when 32 and no month', () => { + component.day = 32 + component.month = null + expect(component.isDayValid).toEqual(false) + }) + + it('returns false when null', () => { + component.day = null + expect(component.isDayValid).toEqual(false) + }) + }) + + describe('isWithinDateRange', () => { + beforeEach(() => { + component.day = 24 + component.month = 1 + component.year = 2019 + }) + + it('always returns true when no mindate or maxdate', () => { + expect(component.isWithinDateRange).toEqual(true) + }) + + it('handles mindate only', () => { + component.mindate = '2019-01-25' + expect(component.isWithinDateRange).toEqual(false) + + component.mindate = '2014-01-25' + expect(component.isWithinDateRange).toEqual(true) + }) + + it('handles maxdate only', () => { + component.maxdate = '2019-01-25' + expect(component.isWithinDateRange).toEqual(true) + + component.maxdate = '2014-01-25' + expect(component.isWithinDateRange).toEqual(false) + }) + + it('handles mindate and maxdate', () => { + component.mindate = '2019-01-25' + component.maxdate = '2019-02-28' + expect(component.isWithinDateRange).toEqual(false) + + component.mindate = '2013-01-25' + component.maxdate = '2016-02-28' + expect(component.isWithinDateRange).toEqual(false) + + component.mindate = '2014-01-25' + component.maxdate = '2020-02-28' + expect(component.isWithinDateRange).toEqual(true) + }) + }) + + describe('isYearValid', () => { + it('returns false if year is null', () => { + component.year = null + expect(component.isYearValid).toEqual(false) + }) + + it('returns true if year is present', () => { + component.year = new Date().getFullYear() + expect(component.isYearValid).toEqual(true) + }) + }) + + describe('formattedDate', () => { + it('returns null if not all parts are present', () => { + component.day = null + component.month = 1 + component.year = 1988 + + expect(component.formattedDate).toBeNull() + }) + + it('joins date components into a JS date', () => { + component.mindate = null + component.maxdate = null + component.day = 22 + component.month = 1 + component.year = 1988 + + expect(component.formattedDate).toEqual('01/22/1988') + }) + }) +}) diff --git a/js/components/date_selector.js b/js/components/date_selector.js new file mode 100644 index 00000000..3093ce42 --- /dev/null +++ b/js/components/date_selector.js @@ -0,0 +1,101 @@ +import Vue from 'vue' + +var paddedNumber = function(number) { + if ((number + '').length === 1) { + return `0${number}` + } else { + return number + } +} + +export default Vue.component('date-selector', { + props: ['initialday', 'initialmonth', 'initialyear', 'mindate', 'maxdate'], + + data: function() { + return { + day: this.initialday, + month: this.initialmonth, + year: this.initialyear, + } + }, + + computed: { + formattedDate: function() { + if (!this.isDateValid) { + return null + } + + let day = paddedNumber(this.day) + let month = paddedNumber(this.month) + + return `${month}/${day}/${this.year}` + }, + + isMonthValid: function() { + var _month = parseInt(this.month) + + return _month >= 0 && _month <= 12 + }, + + isDayValid: function() { + var _day = parseInt(this.day) + + return _day >= 0 && _day <= this.daysMaxCalculation + }, + + isYearValid: function() { + return parseInt(this.year) >= 1 + }, + + isWithinDateRange: function() { + let _mindate = this.mindate ? Date.parse(this.mindate) : null + let _maxdate = this.maxdate ? Date.parse(this.maxdate) : null + let _dateTimestamp = Date.UTC(this.year, this.month - 1, this.day) + + if (_mindate !== null && _mindate >= _dateTimestamp) { + return false + } + + if (_maxdate !== null && _maxdate <= _dateTimestamp) { + return false + } + + return true + }, + + isDateValid: function() { + return ( + this.day && + this.month && + this.year && + this.isDayValid && + this.isMonthValid && + this.isYearValid && + this.isWithinDateRange + ) + }, + + daysMaxCalculation: function() { + switch (parseInt(this.month)) { + case 2: // February + return 29 + break + + case 4: // April + case 6: // June + case 9: // September + case 11: // November + return 30 + break + + default: + // All other months, or null, go with 31 + return 31 + } + }, + }, + + render: function(createElement) { + return createElement('p', 'Please implement inline-template') + }, +}) diff --git a/js/index.js b/js/index.js index f5692103..b12de788 100644 --- a/js/index.js +++ b/js/index.js @@ -30,6 +30,7 @@ import LocalDatetime from './components/local_datetime' import RequestsList from './components/requests_list' import ConfirmationPopover from './components/confirmation_popover' import { isNotInVerticalViewport } from './lib/viewport' +import DateSelector from './components/date_selector' Vue.config.productionTip = false @@ -62,6 +63,7 @@ const app = new Vue({ RequestsList, ConfirmationPopover, funding, + DateSelector, }, mounted: function() { diff --git a/styles/components/_forms.scss b/styles/components/_forms.scss index 4b4eb9cc..546cd960 100644 --- a/styles/components/_forms.scss +++ b/styles/components/_forms.scss @@ -116,3 +116,25 @@ } } +.date-picker { + display: inline-block; + margin-top: 10px; + width: 100%; + + label { + font-weight: 400; + } + + input[type="number"]::-webkit-inner-spin-button { + appearance: none; + } + + input.usa-input-error { + right: 0; + } + + .usa-form-group-date-ok { + padding-top: 30px; + } +} + diff --git a/templates/components/date_picker.html b/templates/components/date_picker.html new file mode 100644 index 00000000..5e4376a2 --- /dev/null +++ b/templates/components/date_picker.html @@ -0,0 +1,70 @@ +{% from "components/icon.html" import Icon %} + +{% macro DatePicker(field, mindate=None, maxdate=None) -%} + + + +
+

+ {% if maxdate and mindate %}Date must be between {{maxdate.strftime("%Y-%m-%d")}} and {{mindate.strftime("%Y-%m-%d")}}{% endif %} + {% if maxdate and not mindate %}Date must be before or on {{maxdate.strftime("%Y-%m-%d")}}{% endif %} + {% if mindate and not maxdate %}Date must be after or on {{mindate.strftime("%Y-%m-%d")}}{% endif %} +

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {{ Icon("ok", classes="icon--green") }} +
+ + +
+
+
+ +{%- endmacro %} diff --git a/templates/fragments/edit_user_form.html b/templates/fragments/edit_user_form.html index 706165d0..88e2ef9e 100644 --- a/templates/fragments/edit_user_form.html +++ b/templates/fragments/edit_user_form.html @@ -2,6 +2,7 @@ {% from "components/options_input.html" import OptionsInput %} {% from "components/date_input.html" import DateInput %} {% from "components/phone_input.html" import PhoneInput %} +{% from "components/date_picker.html" import DatePicker %}
{{ form.csrf_token }} @@ -23,14 +24,16 @@ {{ OptionsInput(form.service_branch) }} {{ OptionsInput(form.citizenship) }} {{ OptionsInput(form.designation) }} - {{ - DateInput( - form.date_latest_training, - tooltip=("fragments.edit_user_form.date_last_training_tooltip" | translate), - placeholder="MM / DD / YYYY", - validation="date" - ) - }} + + +
+ + + {{ "forms.date_hint" | translate }} + {{ DatePicker(form.date_latest_training, mindate=mindate, maxdate=maxdate) }} +
diff --git a/translations.yaml b/translations.yaml index 2cf3d4e7..f1203014 100644 --- a/translations.yaml +++ b/translations.yaml @@ -43,6 +43,7 @@ footer: browser_support: JEDI Cloud supported on these web browsers jedi_help_link_text: Questions? Contact your CCPO Representative forms: + date_hint: "For example: 11 28 1986" ccpo_review: comment_description: Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. This message will be shared with the person making the JEDI request.. comment_label: Instructions or comments