diff --git a/atst/domain/date.py b/atst/domain/date.py new file mode 100644 index 00000000..4a131671 --- /dev/null +++ b/atst/domain/date.py @@ -0,0 +1,12 @@ +import pendulum + + +def parse_date(data): + date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] + for _format in date_formats: + try: + return pendulum.from_format(data, _format).date() + except (ValueError, pendulum.parsing.exceptions.ParserError): + pass + + raise ValueError("Unable to parse string {}".format(data)) diff --git a/atst/forms/fields.py b/atst/forms/fields.py index 00e53529..2c06154a 100644 --- a/atst/forms/fields.py +++ b/atst/forms/fields.py @@ -1,20 +1,14 @@ from wtforms.fields.html5 import DateField from wtforms.fields import Field from wtforms.widgets import TextArea -import pendulum + +from atst.domain.date import parse_date class DateField(DateField): def _value(self): if self.data: - date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] - for _format in date_formats: - try: - return pendulum.from_format(self.data, _format).date() - except (ValueError, pendulum.parsing.exceptions.ParserError): - pass - - raise ValueError("Unable to parse string {}".format(self.data)) + return parse_date(self.data) else: return None diff --git a/atst/forms/org.py b/atst/forms/org.py index 7fc21986..9ec4036c 100644 --- a/atst/forms/org.py +++ b/atst/forms/org.py @@ -12,9 +12,11 @@ class OrgForm(ValidatedForm): lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) - email_request = EmailField("Email Address", validators=[Required(), Email()]) + email_request = EmailField("E-mail Address", validators=[Required(), Email()]) - phone_number = TelField("Phone Number", validators=[Required(), PhoneNumber()]) + phone_number = TelField("Phone Number", + description='Enter a 10-digit phone number', + validators=[Required(), PhoneNumber()]) service_branch = StringField("Service Branch or Agency", validators=[Required()]) diff --git a/atst/forms/validators.py b/atst/forms/validators.py index 3937dabb..241a5401 100644 --- a/atst/forms/validators.py +++ b/atst/forms/validators.py @@ -2,18 +2,19 @@ import re from wtforms.validators import ValidationError import pendulum +from atst.domain.date import parse_date + def DateRange(lower_bound=None, upper_bound=None, message=None): def _date_range(form, field): now = pendulum.now().date() + date = parse_date(field.data) if lower_bound is not None: - date = pendulum.parse(field.data).date() if (now - lower_bound) > date: raise ValidationError(message) if upper_bound is not None: - date = pendulum.parse(field.data).date() if (now + upper_bound) < date: raise ValidationError(message) diff --git a/js/components/text_input.js b/js/components/text_input.js index e027a800..60e9021f 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -14,32 +14,54 @@ export default { type: String, default: () => 'anything' }, - value: { + initialValue: { type: String, default: () => '' - } + }, + initialErrors: Array }, data: function () { return { - showError: false, + showError: (this.initialErrors && this.initialErrors.length) || false, showValid: false, mask: inputValidations[this.validation].mask, - renderedValue: this.value + pipe: inputValidations[this.validation].pipe || undefined, + keepCharPositions: inputValidations[this.validation].keepCharPositions || false, + validationError: inputValidations[this.validation].validationError || '', + value: this.initialValue, + modified: false + } + }, + + computed:{ + rawValue: function () { + return this._rawValue(this.value) } }, mounted: function () { - const value = this.$refs.input.value - if (value) { - this._checkIfValid({ value, invalidate: true }) - this.renderedValue = conformToMask(value, this.mask).conformedValue + if (this.value) { + this._checkIfValid({ value: this.value, invalidate: true }) + + if (this.mask && this.validation !== 'email') { + const mask = typeof this.mask.mask !== 'function' + ? this.mask + : mask.mask(this.value).filter((val) => val !== '[]') + + this.value = conformToMask(this.value, mask).conformedValue + } } }, methods: { // When user types a character - onInput: function (value) { + onInput: function (e) { + // When we use the native textarea element, we receive an event object + // When we use the masked-input component, we receive the value directly + const value = typeof e === 'object' ? e.target.value : e + this.value = value + this.modified = true this._checkIfValid({ value }) }, @@ -52,7 +74,11 @@ export default { // _checkIfValid: function ({ value, invalidate = false}) { // Validate the value - const valid = this._validate(value) + let valid = this._validate(value) + + if (!this.modified && this.initialErrors && this.initialErrors.length) { + valid = false + } // Show error messages or not if (valid) { @@ -70,13 +96,14 @@ export default { }) }, - _validate: function (value) { - // Strip out all the mask characters - let rawValue = inputValidations[this.validation].unmask.reduce((currentValue, character) => { + _rawValue: function (value) { + return inputValidations[this.validation].unmask.reduce((currentValue, character) => { return currentValue.split(character).join('') }, value) + }, - return inputValidations[this.validation].match.test(rawValue) + _validate: function (value) { + return inputValidations[this.validation].match.test(this._rawValue(value)) } } } diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index 6e7a066d..db5396c4 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -1,20 +1,56 @@ import createNumberMask from 'text-mask-addons/dist/createNumberMask' import emailMask from 'text-mask-addons/dist/emailMask' +import createAutoCorrectedDatePipe from 'text-mask-addons/dist/createAutoCorrectedDatePipe' export default { anything: { mask: false, match: /^(?!\s*$).+/, unmask: [], + validationError: 'Please enter a response.' + }, + integer: { + mask: createNumberMask({ prefix: '', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [','], + validationError: 'Please enter a number.' }, dollars: { mask: createNumberMask({ prefix: '$', allowDecimal: true }), match: /^-?\d+\.?\d*$/, - unmask: ['$',','] + unmask: ['$',','], + validationError: 'Please enter a dollar amount.' + }, + gigabytes: { + mask: createNumberMask({ prefix: '', suffix:'GB', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [',','GB'], + validationError: 'Please enter an amount of data in gigabytes.' }, email: { mask: emailMask, match: /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, unmask: [], + validationError: 'Please enter a valid e-mail address.' + }, + date: { + mask: [/\d/,/\d/,'/',/\d/,/\d/,'/',/\d/,/\d/,/\d/,/\d/], + match: /(0[1-9]|1[012])[- \/.](0[1-9]|[12][0-9]|3[01])[- \/.](19|20)\d\d/, + unmask: [], + pipe: createAutoCorrectedDatePipe('mm/dd/yyyy HH:MM'), + keepCharPositions: true, + validationError: 'Please enter a valid date in the form MM/DD/YYYY.' + }, + usPhone: { + mask: ['(', /[1-9]/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/], + match: /^\d{10}$/, + unmask: ['(',')','-',' '], + validationError: 'Please enter a 10-digit phone number.' + }, + dodId: { + mask: createNumberMask({ prefix: '', allowDecimal: false, includeThousandsSeparator: false }), + match: /^\d{10}$/, + unmask: [], + validationError: 'Please enter a 10-digit D.O.D. ID number.' } } diff --git a/templates/components/text_input.html b/templates/components/text_input.html index 80c24ec2..33dfb29e 100644 --- a/templates/components/text_input.html +++ b/templates/components/text_input.html @@ -1,23 +1,64 @@ {% from "components/icon.html" import Icon %} -{% macro TextInput(field, placeholder='') -%} -
-
+ +{%- endmacro %} diff --git a/templates/requests/screen-1.html b/templates/requests/screen-1.html index 7dbfb367..5f6bbf9f 100644 --- a/templates/requests/screen-1.html +++ b/templates/requests/screen-1.html @@ -22,27 +22,27 @@

All fields are required, unless specified optional.

General

-{{ TextInput(f.dod_component) }} -{{ TextInput(f.jedi_usage,placeholder="e.g. We are migrating XYZ application to the cloud so that...") }} +{{ OptionsInput(f.dod_component) }} +{{ TextInput(f.jedi_usage, paragraph=True, placeholder="e.g. We are migrating XYZ application to the cloud so that...") }}

Cloud Readiness

-{{ TextInput(f.num_software_systems,placeholder="Number of systems") }} +{{ TextInput(f.num_software_systems, validation='integer') }} {{ OptionsInput(f.jedi_migration) }} {{ OptionsInput(f.rationalization_software_systems) }} {{ OptionsInput(f.technical_support_team) }} {{ OptionsInput(f.organization_providing_assistance) }} {{ OptionsInput(f.engineering_assessment) }} -{{ TextInput(f.data_transfers) }} -{{ TextInput(f.expected_completion_date) }} +{{ OptionsInput(f.data_transfers) }} +{{ OptionsInput(f.expected_completion_date) }} {{ OptionsInput(f.cloud_native) }}

Financial Usage

-{{ TextInput(f.estimated_monthly_spend) }} +{{ TextInput(f.estimated_monthly_spend, validation='dollars') }}

So this means you are spending approximately $X annually

-{{ TextInput(f.dollar_value) }} -{{ TextInput(f.number_user_sessions) }} -{{ TextInput(f.average_daily_traffic) }} -{{ TextInput(f.start_date) }} +{{ TextInput(f.dollar_value, validation='dollars') }} +{{ TextInput(f.number_user_sessions, validation='integer') }} +{{ TextInput(f.average_daily_traffic, placeholder='Gigabytes per day', validation='gigabytes') }} +{{ TextInput(f.start_date, validation='date', placeholder='MM / DD / YYYY') }} {% endblock %} diff --git a/templates/requests/screen-2.html b/templates/requests/screen-2.html index be32dc90..50dc9007 100644 --- a/templates/requests/screen-2.html +++ b/templates/requests/screen-2.html @@ -19,16 +19,16 @@

Please tell us more about you.

-{{ TextInput(f.fname_request,placeholder='First Name') }} -{{ TextInput(f.lname_request,placeholder='Last Name') }} -{{ TextInput(f.email_request,placeholder='jane@mail.mil') }} -{{ TextInput(f.phone_number,placeholder='(123) 456-7890') }} +{{ TextInput(f.fname_request, placeholder='First Name') }} +{{ TextInput(f.lname_request, placeholder='Last Name') }} +{{ TextInput(f.email_request, placeholder='jane@mail.mil', validation='email') }} +{{ TextInput(f.phone_number, placeholder='e.g. (123) 456-7890', validation='usPhone') }}

We want to collect the following information from you for security auditing and determining priviledged user access

{{ TextInput(f.service_branch,placeholder='e.g. US Air Force, US Army, US Navy, Marine Corps, Defense Media Agency') }} {{ OptionsInput(f.citizenship) }} {{ OptionsInput(f.designation) }} -{{ TextInput(f.date_latest_training) }} +{{ TextInput(f.date_latest_training, placeholder='MM / DD / YYYY', validation='date') }} {% endblock %} diff --git a/templates/requests/screen-3.html b/templates/requests/screen-3.html index 7de95813..8971215c 100644 --- a/templates/requests/screen-3.html +++ b/templates/requests/screen-3.html @@ -30,7 +30,7 @@ {{ TextInput(f.fname_poc,placeholder='First Name') }} {{ TextInput(f.lname_poc,placeholder='Last Name') }} -{{ TextInput(f.email_poc,placeholder='jane@mail.mil') }} -{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC') }} +{{ TextInput(f.email_poc,placeholder='jane@mail.mil', validation='email') }} +{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC', validation='dodId') }} {% endblock %} diff --git a/tests/domain/test_date.py b/tests/domain/test_date.py new file mode 100644 index 00000000..fe80530d --- /dev/null +++ b/tests/domain/test_date.py @@ -0,0 +1,21 @@ +import pytest +import pendulum + +from atst.domain.date import parse_date + + +def test_date_with_slashes(): + date_str = "1/2/2020" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_date_with_dashes(): + date_str = "2020-1-2" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_invalid_date(): + date_str = "This is not a valid data" + with pytest.raises(ValueError): + parse_date(date_str) + diff --git a/tests/routes/test_request_new.py b/tests/routes/test_request_new.py index d3120a6a..fadf0ce9 100644 --- a/tests/routes/test_request_new.py +++ b/tests/routes/test_request_new.py @@ -75,9 +75,9 @@ def test_creator_info_is_autopopulated(monkeypatch, client, user_session): response = client.get("/requests/new/2/{}".format(request.id)) body = response.data.decode() - assert 'value="{}"'.format(user.first_name) in body - assert 'value="{}"'.format(user.last_name) in body - assert 'value="{}"'.format(user.email) in body + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user_session): @@ -86,9 +86,9 @@ def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user response = client.get("/requests/new/2") body = response.data.decode() - assert 'value="{}"'.format(user.first_name) in body - assert 'value="{}"'.format(user.last_name) in body - assert 'value="{}"'.format(user.email) in body + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session):