Merge pull request #147 from dod-ccpo/ui/input-field-frontend-validation

Ui/input field frontend validation
This commit is contained in:
andrewdds 2018-08-13 11:19:16 -04:00 committed by GitHub
commit 73205e9d90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 201 additions and 67 deletions

12
atst/domain/date.py Normal file
View File

@ -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))

View File

@ -1,20 +1,14 @@
from wtforms.fields.html5 import DateField from wtforms.fields.html5 import DateField
from wtforms.fields import Field from wtforms.fields import Field
from wtforms.widgets import TextArea from wtforms.widgets import TextArea
import pendulum
from atst.domain.date import parse_date
class DateField(DateField): class DateField(DateField):
def _value(self): def _value(self):
if self.data: if self.data:
date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] return parse_date(self.data)
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))
else: else:
return None return None

View File

@ -12,9 +12,11 @@ class OrgForm(ValidatedForm):
lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) 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()]) service_branch = StringField("Service Branch or Agency", validators=[Required()])

View File

@ -2,18 +2,19 @@ import re
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
import pendulum import pendulum
from atst.domain.date import parse_date
def DateRange(lower_bound=None, upper_bound=None, message=None): def DateRange(lower_bound=None, upper_bound=None, message=None):
def _date_range(form, field): def _date_range(form, field):
now = pendulum.now().date() now = pendulum.now().date()
date = parse_date(field.data)
if lower_bound is not None: if lower_bound is not None:
date = pendulum.parse(field.data).date()
if (now - lower_bound) > date: if (now - lower_bound) > date:
raise ValidationError(message) raise ValidationError(message)
if upper_bound is not None: if upper_bound is not None:
date = pendulum.parse(field.data).date()
if (now + upper_bound) < date: if (now + upper_bound) < date:
raise ValidationError(message) raise ValidationError(message)

View File

@ -14,32 +14,54 @@ export default {
type: String, type: String,
default: () => 'anything' default: () => 'anything'
}, },
value: { initialValue: {
type: String, type: String,
default: () => '' default: () => ''
} },
initialErrors: Array
}, },
data: function () { data: function () {
return { return {
showError: false, showError: (this.initialErrors && this.initialErrors.length) || false,
showValid: false, showValid: false,
mask: inputValidations[this.validation].mask, 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 () { mounted: function () {
const value = this.$refs.input.value if (this.value) {
if (value) { this._checkIfValid({ value: this.value, invalidate: true })
this._checkIfValid({ value, invalidate: true })
this.renderedValue = conformToMask(value, this.mask).conformedValue 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: { methods: {
// When user types a character // 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 }) this._checkIfValid({ value })
}, },
@ -52,7 +74,11 @@ export default {
// //
_checkIfValid: function ({ value, invalidate = false}) { _checkIfValid: function ({ value, invalidate = false}) {
// Validate the value // 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 // Show error messages or not
if (valid) { if (valid) {
@ -70,13 +96,14 @@ export default {
}) })
}, },
_validate: function (value) { _rawValue: function (value) {
// Strip out all the mask characters return inputValidations[this.validation].unmask.reduce((currentValue, character) => {
let rawValue = inputValidations[this.validation].unmask.reduce((currentValue, character) => {
return currentValue.split(character).join('') return currentValue.split(character).join('')
}, value) }, value)
},
return inputValidations[this.validation].match.test(rawValue) _validate: function (value) {
return inputValidations[this.validation].match.test(this._rawValue(value))
} }
} }
} }

View File

@ -1,20 +1,56 @@
import createNumberMask from 'text-mask-addons/dist/createNumberMask' import createNumberMask from 'text-mask-addons/dist/createNumberMask'
import emailMask from 'text-mask-addons/dist/emailMask' import emailMask from 'text-mask-addons/dist/emailMask'
import createAutoCorrectedDatePipe from 'text-mask-addons/dist/createAutoCorrectedDatePipe'
export default { export default {
anything: { anything: {
mask: false, mask: false,
match: /^(?!\s*$).+/, match: /^(?!\s*$).+/,
unmask: [], unmask: [],
validationError: 'Please enter a response.'
},
integer: {
mask: createNumberMask({ prefix: '', allowDecimal: false }),
match: /^[1-9]\d*$/,
unmask: [','],
validationError: 'Please enter a number.'
}, },
dollars: { dollars: {
mask: createNumberMask({ prefix: '$', allowDecimal: true }), mask: createNumberMask({ prefix: '$', allowDecimal: true }),
match: /^-?\d+\.?\d*$/, 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: { email: {
mask: emailMask, 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])+)\])/, 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: [], 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.'
} }
} }

View File

@ -1,23 +1,64 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% macro TextInput(field, placeholder='') -%} {% macro TextInput(field, placeholder='', validation='anything', paragraph=False) -%}
<div class='usa-input {% if field.errors %}usa-input--error{% endif %}'> <textinput
name='{{ field.name }}'
validation='{{ validation }}'
{% if field.data %}initial-value='{{ field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %}
inline-template>
<div
v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid }]">
<label for={{field.name}}> <label for={{field.name}}>
{{ field.label }} {{ field.label | striptags }}
{% if field.description %} {% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span> <span class='usa-input__help'>{{ field.description | safe }}</span>
{% endif %} {% endif %}
{% if field.errors %}{{ Icon('alert') }}{% endif %} <span v-show='showError'>{{ Icon('alert') }}</span>
<span v-show='showValid'>{{ Icon('ok') }}</span>
</label> </label>
{{ field(placeholder=placeholder) | safe }} {% if paragraph %}
<textarea
v-on:input='onInput'
v-on:change='onChange'
v-bind:value='value'
id='{{ field.name }}'
ref='input'
placeholder='{{ placeholder }}'>
</textarea>
{% else %}
<masked-input
v-on:input='onInput'
v-on:change='onChange'
v-bind:value='value'
v-bind:mask='mask'
v-bind:pipe='pipe'
v-bind:keep-char-positions='keepCharPositions'
v-bind:aria-invalid='showError'
id='{{ field.name }}'
type='text'
ref='input'
placeholder='{{ placeholder }}'>
</masked-input>
{% if field.errors %}
{% for error in field.errors %}
<span class='usa-input__message'>{{ error }}</span>
{% endfor %}
{% endif %} {% endif %}
<input type='hidden' v-bind:value='rawValue' name='{{ field.name }}' />
<template v-if='showError'>
<span v-if='initialErrors' v-for='error in initialErrors' class='usa-input__message' v-html='error'></span>
<span v-if='!initialErrors' class='usa-input__message' v-html='validationError'></span>
</template>
</div> </div>
</textinput>
{%- endmacro %} {%- endmacro %}

View File

@ -22,27 +22,27 @@
<p><em>All fields are required, unless specified optional.</em></p> <p><em>All fields are required, unless specified optional.</em></p>
<h2>General</h2> <h2>General</h2>
{{ TextInput(f.dod_component) }} {{ OptionsInput(f.dod_component) }}
{{ TextInput(f.jedi_usage,placeholder="e.g. We are migrating XYZ application to the cloud so that...") }} {{ TextInput(f.jedi_usage, paragraph=True, placeholder="e.g. We are migrating XYZ application to the cloud so that...") }}
<h2>Cloud Readiness</h2> <h2>Cloud Readiness</h2>
{{ TextInput(f.num_software_systems,placeholder="Number of systems") }} {{ TextInput(f.num_software_systems, validation='integer') }}
{{ OptionsInput(f.jedi_migration) }} {{ OptionsInput(f.jedi_migration) }}
{{ OptionsInput(f.rationalization_software_systems) }} {{ OptionsInput(f.rationalization_software_systems) }}
{{ OptionsInput(f.technical_support_team) }} {{ OptionsInput(f.technical_support_team) }}
{{ OptionsInput(f.organization_providing_assistance) }} {{ OptionsInput(f.organization_providing_assistance) }}
{{ OptionsInput(f.engineering_assessment) }} {{ OptionsInput(f.engineering_assessment) }}
{{ TextInput(f.data_transfers) }} {{ OptionsInput(f.data_transfers) }}
{{ TextInput(f.expected_completion_date) }} {{ OptionsInput(f.expected_completion_date) }}
{{ OptionsInput(f.cloud_native) }} {{ OptionsInput(f.cloud_native) }}
<h2>Financial Usage</h2> <h2>Financial Usage</h2>
{{ TextInput(f.estimated_monthly_spend) }} {{ TextInput(f.estimated_monthly_spend, validation='dollars') }}
<p>So this means you are spending approximately <b>$X</b> annually</p> <p>So this means you are spending approximately <b>$X</b> annually</p>
{{ TextInput(f.dollar_value) }} {{ TextInput(f.dollar_value, validation='dollars') }}
{{ TextInput(f.number_user_sessions) }} {{ TextInput(f.number_user_sessions, validation='integer') }}
{{ TextInput(f.average_daily_traffic) }} {{ TextInput(f.average_daily_traffic, placeholder='Gigabytes per day', validation='gigabytes') }}
{{ TextInput(f.start_date) }} {{ TextInput(f.start_date, validation='date', placeholder='MM / DD / YYYY') }}
{% endblock %} {% endblock %}

View File

@ -21,14 +21,14 @@
{{ TextInput(f.fname_request, placeholder='First Name') }} {{ TextInput(f.fname_request, placeholder='First Name') }}
{{ TextInput(f.lname_request, placeholder='Last Name') }} {{ TextInput(f.lname_request, placeholder='Last Name') }}
{{ TextInput(f.email_request,placeholder='jane@mail.mil') }} {{ TextInput(f.email_request, placeholder='jane@mail.mil', validation='email') }}
{{ TextInput(f.phone_number,placeholder='(123) 456-7890') }} {{ TextInput(f.phone_number, placeholder='e.g. (123) 456-7890', validation='usPhone') }}
<p>We want to collect the following information from you for security auditing and determining priviledged user access</p> <p>We want to collect the following information from you for security auditing and determining priviledged user access</p>
{{ TextInput(f.service_branch,placeholder='e.g. US Air Force, US Army, US Navy, Marine Corps, Defense Media Agency') }} {{ TextInput(f.service_branch,placeholder='e.g. US Air Force, US Army, US Navy, Marine Corps, Defense Media Agency') }}
{{ OptionsInput(f.citizenship) }} {{ OptionsInput(f.citizenship) }}
{{ OptionsInput(f.designation) }} {{ OptionsInput(f.designation) }}
{{ TextInput(f.date_latest_training) }} {{ TextInput(f.date_latest_training, placeholder='MM / DD / YYYY', validation='date') }}
{% endblock %} {% endblock %}

View File

@ -30,7 +30,7 @@
{{ TextInput(f.fname_poc,placeholder='First Name') }} {{ TextInput(f.fname_poc,placeholder='First Name') }}
{{ TextInput(f.lname_poc,placeholder='Last Name') }} {{ TextInput(f.lname_poc,placeholder='Last Name') }}
{{ TextInput(f.email_poc,placeholder='jane@mail.mil') }} {{ TextInput(f.email_poc,placeholder='jane@mail.mil', validation='email') }}
{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC') }} {{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC', validation='dodId') }}
{% endblock %} {% endblock %}

21
tests/domain/test_date.py Normal file
View File

@ -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)

View File

@ -75,9 +75,9 @@ def test_creator_info_is_autopopulated(monkeypatch, client, user_session):
response = client.get("/requests/new/2/{}".format(request.id)) response = client.get("/requests/new/2/{}".format(request.id))
body = response.data.decode() body = response.data.decode()
assert 'value="{}"'.format(user.first_name) in body assert "initial-value='{}'".format(user.first_name) in body
assert 'value="{}"'.format(user.last_name) in body assert "initial-value='{}'".format(user.last_name) in body
assert 'value="{}"'.format(user.email) in body assert "initial-value='{}'".format(user.email) in body
def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user_session): 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") response = client.get("/requests/new/2")
body = response.data.decode() body = response.data.decode()
assert 'value="{}"'.format(user.first_name) in body assert "initial-value='{}'".format(user.first_name) in body
assert 'value="{}"'.format(user.last_name) in body assert "initial-value='{}'".format(user.last_name) in body
assert 'value="{}"'.format(user.email) in body assert "initial-value='{}'".format(user.email) in body
def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session): def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session):