diff --git a/src/components/Fieldsets/NameFieldset/NameFieldset.jsx b/src/components/Fieldsets/NameFieldset/NameFieldset.jsx new file mode 100644 index 000000000..99eeae771 --- /dev/null +++ b/src/components/Fieldsets/NameFieldset/NameFieldset.jsx @@ -0,0 +1,201 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import i18n from 'util/i18n' + +import { + ConnectedTextFormField, ConnectedSelectFormField, ConnectedCheckboxInput, +} from 'components/Inputs/connectedInputs' + +import nameModel from 'models/shared/name' +import { getEffectiveModel, getValidationPropsFromModel } from 'helpers/validation' + +const NameFieldset = (props) => { + const { + value, title, className, disabled, required, prefix, onUpdate, + hideMiddleName, errors, + } = props + + const classes = classnames('name', className, { disabled }) + + const suffixOptions = [ + { label: '', value: '' }, + { label: i18n.t(`${prefix}.label.jr`), value: 'Jr' }, + { label: i18n.t(`${prefix}.label.sr`), value: 'Sr' }, + { label: i18n.t(`${prefix}.label.i`), value: 'I' }, + { label: i18n.t(`${prefix}.label.ii`), value: 'II' }, + { label: i18n.t(`${prefix}.label.iii`), value: 'III' }, + { label: i18n.t(`${prefix}.label.iv`), value: 'IV' }, + { label: i18n.t(`${prefix}.label.v`), value: 'V' }, + { label: i18n.t(`${prefix}.label.vi`), value: 'VI' }, + { label: i18n.t(`${prefix}.label.vii`), value: 'VII' }, + { label: i18n.t(`${prefix}.label.viii`), value: 'VIII' }, + { label: i18n.t(`${prefix}.label.ix`), value: 'IX' }, + { label: i18n.t(`${prefix}.label.x`), value: 'X' }, + { label: i18n.t(`${prefix}.label.other`), value: 'Other' }, + ] + + const onChange = (name, newValue) => { + onUpdate({ + ...value, + [`${name}`]: newValue, + }) + } + + const { + first, middle, last, firstInitialOnly, middleInitialOnly, noMiddleName, + suffix, suffixOther, + } = value + + const inputProps = { + disabled, + required, + onChange, + } + + const errorPrefix = 'Name.model' + const effectiveNameModel = getEffectiveModel(nameModel, value) + + const validationProps = {} + + Object.keys(effectiveNameModel).forEach((field) => { + const fieldModel = getEffectiveModel(effectiveNameModel[field], value) + const fieldErrors = errors.filter(e => e.indexOf(`${errorPrefix}.${field}`) === 0) + + validationProps[field] = { + ...getValidationPropsFromModel(fieldModel, required), + errors: fieldErrors, + } + }) + + const firstInitialOnlyCheckbox = ( +
+ +
+ ) + + const middleInitialOnlyCheckbox = ( +
+ +
+ ) + + const noMiddleNameCheckbox = ( +
+ +
+ ) + + return ( +
+ {title &&

{title}

} + + + + {!hideMiddleName && ( + + {noMiddleNameCheckbox} + {middleInitialOnlyCheckbox} + + )} + /> + )} + + + + + + {suffix === 'Other' && ( + + )} + +
+ ) +} + +NameFieldset.propTypes = { + value: PropTypes.object, + title: PropTypes.node, + className: PropTypes.string, + disabled: PropTypes.bool, + required: PropTypes.bool, + prefix: PropTypes.string, + onUpdate: PropTypes.func, + errors: PropTypes.array, + hideMiddleName: PropTypes.bool, +} + +NameFieldset.defaultProps = { + value: {}, + title: null, + className: null, + disabled: false, + required: false, + prefix: 'name', + onUpdate: () => {}, + errors: [], + hideMiddleName: false, +} + +export default NameFieldset diff --git a/src/components/Fieldsets/NameFieldset/NameFieldset.stories.jsx b/src/components/Fieldsets/NameFieldset/NameFieldset.stories.jsx new file mode 100644 index 000000000..745720dda --- /dev/null +++ b/src/components/Fieldsets/NameFieldset/NameFieldset.stories.jsx @@ -0,0 +1,34 @@ +/* eslint import/no-extraneous-dependencies: 0 */ + +import React from 'react' +import { storiesOf } from '@storybook/react' + +import NameFieldset from './NameFieldset' + +storiesOf('NameFieldset', module) + .add('default', () => ( + + )) + .add('with a value', () => ( + + )) + .add('with a title', () => ( + + )) diff --git a/src/components/Inputs/CheckboxInput/CheckboxInput.jsx b/src/components/Inputs/CheckboxInput/CheckboxInput.jsx new file mode 100644 index 000000000..25ffae7c5 --- /dev/null +++ b/src/components/Inputs/CheckboxInput/CheckboxInput.jsx @@ -0,0 +1,101 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const CheckboxInput = (props) => { + const { + label, uid, name, ariaLabel, disabled, error, readonly, value, className, + valid, onChange, onFocus, onBlur, children, + } = props + + // eslint-disable-next-line eqeqeq + const checked = value == true + + const showError = !disabled && error + + const divClass = classnames( + className, + 'block', + { + disabled, + 'usa-input-error': showError, + } + ) + + const labelClass = classnames( + 'checkbox', + { + disabled, + 'usa-input-error-label': showError, + checked, + } + ) + + const inputClass = classnames({ + 'usa-input-success': valid, + }) + + const handleChange = (e) => { + onChange(e.target.checked) + } + + return ( +
+ + +
+ ) +} + +CheckboxInput.propTypes = { + name: PropTypes.string.isRequired, + uid: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + ariaLabel: PropTypes.string, + children: PropTypes.node, + value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + error: PropTypes.bool, + valid: PropTypes.bool, + disabled: PropTypes.bool, + className: PropTypes.string, + readonly: PropTypes.bool, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +} + +CheckboxInput.defaultProps = { + ariaLabel: null, + children: null, + value: '', + error: false, + valid: false, + disabled: false, + className: '', + readonly: false, + onChange: () => {}, + onFocus: () => {}, + onBlur: () => {}, +} + +export default CheckboxInput diff --git a/src/components/Inputs/FormField/FormField.jsx b/src/components/Inputs/FormField/FormField.jsx new file mode 100644 index 000000000..463640e76 --- /dev/null +++ b/src/components/Inputs/FormField/FormField.jsx @@ -0,0 +1,101 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import FormFieldHelp from './FormFieldHelp' + +const FormField = ({ + title, required, inputId, children, errors, help, toggleHelp, showHelp, + className, +}) => { + const classes = classnames( + 'field', + 'usa-form-control', + { required }, + className, + ) + + const iconClasses = classnames( + 'toggle', + { active: showHelp }, + ) + + return ( +
+ {title && ( +

+ {title} + {!required && (Optional)} +

+ )} + + {help && toggleHelp && ( + + )} + + {help && showHelp && ( +
+ +
+ )} + + {errors && errors.length > 0 && ( +
+ {errors.map((e, i) => ( +
+
{e}
+
+ ))} +
+ )} + +
+ {children} +
+
+ ) +} + +FormField.propTypes = { + title: PropTypes.node, + required: PropTypes.bool, + inputId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + errors: PropTypes.array, + help: PropTypes.string, + toggleHelp: PropTypes.func, + showHelp: PropTypes.bool, + className: PropTypes.string, +} + +FormField.defaultProps = { + title: null, + required: false, + errors: [], + help: null, + toggleHelp: null, + showHelp: false, + className: null, +} + +export default FormField diff --git a/src/components/Inputs/FormField/FormField.module.scss b/src/components/Inputs/FormField/FormField.module.scss new file mode 100644 index 000000000..6b531dc9c --- /dev/null +++ b/src/components/Inputs/FormField/FormField.module.scss @@ -0,0 +1,5 @@ +.modifiers { + display: flex; + justify-content: flex-end; + align-items: center; +} \ No newline at end of file diff --git a/src/components/Inputs/FormField/FormField.stories.jsx b/src/components/Inputs/FormField/FormField.stories.jsx new file mode 100644 index 000000000..a3800f844 --- /dev/null +++ b/src/components/Inputs/FormField/FormField.stories.jsx @@ -0,0 +1,21 @@ +/* eslint import/no-extraneous-dependencies: 0 */ + +import React from 'react' +import { storiesOf } from '@storybook/react' + +import FormField from './FormField' + +storiesOf('FormField', module) + .add('default', () => ( + + )) + .add('with a value', () => ( + + )) diff --git a/src/components/Inputs/FormField/FormFieldHelp.jsx b/src/components/Inputs/FormField/FormFieldHelp.jsx new file mode 100644 index 000000000..91ce512c5 --- /dev/null +++ b/src/components/Inputs/FormField/FormFieldHelp.jsx @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import i18n from 'util/i18n' +import FormFieldMessage from './FormFieldMessage' + +const FormFieldHelp = ({ + id, message, title, toggle, +}) => { + const helpMessage = message || i18n.m(`${id}.message`) + const helpTitle = title || i18n.t(`${id}.title`) + const helpNote = i18n.m(`${id}.note`) + + return ( +
+
+ + + {toggle && ( + + )} +
+
+ ) +} + +FormFieldHelp.propTypes = { + id: PropTypes.string.isRequired, + message: PropTypes.node, + title: PropTypes.node, + toggle: PropTypes.func, +} + +FormFieldHelp.defaultProps = { + message: null, + title: null, + toggle: null, +} + +export default FormFieldHelp diff --git a/src/components/Inputs/FormField/FormFieldMessage.jsx b/src/components/Inputs/FormField/FormFieldMessage.jsx new file mode 100644 index 000000000..0eb655f3e --- /dev/null +++ b/src/components/Inputs/FormField/FormFieldMessage.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import PropTypes from 'prop-types' + +// Used by help text & errors to render a group of text +const FormFieldMessage = ({ + id, title, message, note, +}) => ( +
+ {title && (
{title}
)} + {message && {message}} + {note && {note}} +
+) + +FormFieldMessage.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.node, + message: PropTypes.node, + note: PropTypes.node, +} + +FormFieldMessage.defaultProps = { + title: null, + message: null, + note: null, +} + +export default FormFieldMessage diff --git a/src/components/Inputs/InputConnector.jsx b/src/components/Inputs/InputConnector.jsx new file mode 100644 index 000000000..d646e1a28 --- /dev/null +++ b/src/components/Inputs/InputConnector.jsx @@ -0,0 +1,128 @@ +/** + * HOC that connects input event handlers and optionally renders inside of a + * FormField component + */ +import React from 'react' +import PropTypes from 'prop-types' + +import { newGuid } from 'components/Form/ValidationElement/helpers' +import { REQUIRED } from 'constants/errors' + +import FormField from './FormField/FormField' +import styles from './FormField/FormField.module.scss' + +const connectInput = (Component, renderFormField = true) => { + class ConnectedInput extends React.Component { + constructor(props) { + super(props) + + // TODO - newGuid should be replaced with a library for generating uuids + this.uid = newGuid() + + this.state = { + focus: false, + showHelp: false, + } + } + + toggleHelp = () => { + const { showHelp } = this.state + this.setState({ showHelp: !showHelp }) + } + + handleChange = (value) => { + const { name, onChange } = this.props + onChange(name, value) + } + + handleFocus = () => { + this.setState({ focus: true }) + } + + handleBlur = () => { + this.setState({ focus: false }) + } + + showErrors = () => { + // Only show required error if required prop is true + // Otherwise only show other errors if there is no required error (there's an invalid value) + const { errors, required } = this.props + if (required) return true + return errors.filter(e => e.indexOf(`presence.${REQUIRED}`) > -1).length < 1 + } + + filterErrors = () => { + // If there's a required error, only only that + // Otherwise show the rest of the errors + const { errors } = this.props + const requiredError = errors.filter(e => e.indexOf(`presence.${REQUIRED}`) > -1) + if (requiredError.length) return requiredError + return errors + } + + render() { + const { errors } = this.props + + const showErrors = this.showErrors() + + const errorProps = { + errors: showErrors && this.filterErrors(), + valid: !errors || errors.length < 1, + error: showErrors && errors.length > 0, + } + + const inputComponent = ( + + ) + + if (renderFormField) { + const { modifiers } = this.props + const { showHelp } = this.state + return ( + + {inputComponent} + {modifiers && ( +
{modifiers}
+ )} +
+ ) + } + + return inputComponent + } + } + + ConnectedInput.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func, + modifiers: PropTypes.array, + errors: PropTypes.array, + required: PropTypes.bool, + } + + ConnectedInput.defaultProps = { + modifiers: null, + onChange: () => {}, + errors: [], + required: false, + } + + return ConnectedInput +} + +export default connectInput diff --git a/src/components/Inputs/SelectInput/SelectInput.jsx b/src/components/Inputs/SelectInput/SelectInput.jsx new file mode 100644 index 000000000..6b9a08500 --- /dev/null +++ b/src/components/Inputs/SelectInput/SelectInput.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const SelectInput = (props) => { + const { + uid, name, value, error, valid, disabled, className, + label, options, optional, onChange, + } = props + + const wrapperClasses = classnames( + className, + { 'usa-input-error': error } + ) + + const selectClasses = classnames( + { 'usa-input-success': valid } + ) + + const handleChange = (e) => { + onChange(e.target.value) + } + + return ( +
+ + + +
+ ) +} + +SelectInput.propTypes = { + name: PropTypes.string.isRequired, + uid: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + options: PropTypes.array.isRequired, + value: PropTypes.string, + error: PropTypes.bool, + valid: PropTypes.bool, + disabled: PropTypes.bool, + className: PropTypes.string, + optional: PropTypes.bool, + onChange: PropTypes.func, +} + +SelectInput.defaultProps = { + value: '', + error: false, + valid: false, + disabled: false, + className: '', + optional: false, + onChange: () => {}, +} + +export default SelectInput diff --git a/src/components/Inputs/TextInput/TextInput.jsx b/src/components/Inputs/TextInput/TextInput.jsx new file mode 100644 index 000000000..d5bed61f6 --- /dev/null +++ b/src/components/Inputs/TextInput/TextInput.jsx @@ -0,0 +1,136 @@ +/** + * Input requirements + * Generic text input (text, email, number, password) +*/ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import { autotab } from 'components/Form/Generic' + +const TextInput = (props) => { + const { + label, uid, name, type, placeholder, ariaLabel, disabled, error, maxlength, + pattern, readonly, autocapitalize, autocorrect, autocomplete, spellcheck, + value, clipboard, className, focus, valid, onChange, onFocus, onBlur, + tabBack, tabNext, + } = props + + const divClass = classnames( + 'hide-for-print', + className, + { 'usa-input-error': !disabled && error } + ) + + const labelClass = classnames({ + disabled, + 'usa-input-error-label': !disabled && error, + }) + + const inputClass = classnames({ + 'usa-input-focus': !disabled && focus, + 'usa-input-success': !disabled && valid, + }, className) + + const handleKeyDown = (e) => { + autotab(e, maxlength, tabBack, tabNext) + } + + const allowClipboard = (e) => { + if (!clipboard) e.preventDefault() + } + + const handleChange = (e) => { + onChange(e.target.value) + } + + return ( +
+ {label && ( + + )} + + {/* for print CSS */} +
{value}
+
+ ) +} + +TextInput.propTypes = { + name: PropTypes.string.isRequired, + uid: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + ariaLabel: PropTypes.string, + type: PropTypes.oneOf(['text', 'email', 'number', 'password']), + value: PropTypes.string, + placeholder: PropTypes.string, + error: PropTypes.bool, + valid: PropTypes.bool, + focus: PropTypes.bool, + disabled: PropTypes.bool, + className: PropTypes.string, + clipboard: PropTypes.bool, + spellcheck: PropTypes.bool, + autocapitalize: PropTypes.bool, + autocorrect: PropTypes.bool, + autocomplete: PropTypes.bool, + readonly: PropTypes.bool, + maxlength: PropTypes.number, + pattern: PropTypes.string, + tabBack: PropTypes.func, + tabNext: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +} + +TextInput.defaultProps = { + ariaLabel: null, + type: 'text', + value: '', + placeholder: '', + error: false, + valid: false, + focus: false, + disabled: false, + className: '', + clipboard: true, + spellcheck: true, + autocapitalize: true, + autocorrect: true, + autocomplete: true, + readonly: false, + maxlength: 255, + pattern: '.*', + tabBack: () => {}, + tabNext: () => {}, + onChange: () => {}, + onFocus: () => {}, + onBlur: () => {}, +} + +export default TextInput diff --git a/src/components/Inputs/TextInput/TextInput.stories.jsx b/src/components/Inputs/TextInput/TextInput.stories.jsx new file mode 100644 index 000000000..c3293b5d8 --- /dev/null +++ b/src/components/Inputs/TextInput/TextInput.stories.jsx @@ -0,0 +1,55 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import TextInput from './TextInput' +import { ConnectedTextInput } from '../connectedInputs' + +storiesOf('Input', module) + .add('default', () => ( + + )) + .add('with a value', () => ( + + )) + .add('with FormField', () => ( + + )) + .add('with FormField and errors', () => ( + + )) + .add('with FormField and help text', () => ( + + )) + .add('with FormField and errors and help text', () => ( + + )) diff --git a/src/components/Inputs/connectedInputs.js b/src/components/Inputs/connectedInputs.js new file mode 100644 index 000000000..5bf0ae6b2 --- /dev/null +++ b/src/components/Inputs/connectedInputs.js @@ -0,0 +1,22 @@ +import connectInput from 'components/Inputs/InputConnector' + +import TextInput from 'components/Inputs/TextInput/TextInput' +import CheckboxInput from 'components/Inputs/CheckboxInput/CheckboxInput' +import SelectInput from 'components/Inputs/SelectInput/SelectInput' + +const ConnectedTextInput = connectInput(TextInput, false) +const ConnectedCheckboxInput = connectInput(CheckboxInput, false) +const ConnectedSelectInput = connectInput(SelectInput, false) + +const ConnectedTextFormField = connectInput(TextInput) +const ConnectedCheckboxFormField = connectInput(CheckboxInput) +const ConnectedSelectFormField = connectInput(SelectInput) + +export { + ConnectedTextInput, + ConnectedCheckboxInput, + ConnectedSelectInput, + ConnectedTextFormField, + ConnectedCheckboxFormField, + ConnectedSelectFormField, +} diff --git a/src/components/Section/Identification/ApplicantName/ApplicantName.jsx b/src/components/Section/Identification/ApplicantName/ApplicantName.jsx index 238167104..621fb841c 100644 --- a/src/components/Section/Identification/ApplicantName/ApplicantName.jsx +++ b/src/components/Section/Identification/ApplicantName/ApplicantName.jsx @@ -2,6 +2,7 @@ import React from 'react' import { i18n } from 'config' import { Name, Field } from 'components/Form' +import NameFieldset from 'components/Fieldsets/NameFieldset/NameFieldset' import { IDENTIFICATION, @@ -50,6 +51,8 @@ export class ApplicantName extends Subsection { } render() { + const { errors } = this.props + const klass = `section-content applicant-name ${this.props.className || ''}`.trim() @@ -60,6 +63,15 @@ export class ApplicantName extends Subsection { data-subsection={IDENTIFICATION_NAME.key} >

{i18n.t('identification.destination.name')}

+ + + ( return s.isValid === false }) ) + +/** Helper for using model object in UI components */ +// Gets model as a static object with given value and options +export const getEffectiveModel = (model, attributes, options = {}) => { + const effectiveModel = {} + + const constraints = Object.keys(model) + for (let i = 0; i < constraints.length; i += 1) { + const constraint = constraints[i] // attributeName + const constraintValue = model[constraint] // validation function + if (validate.isFunction(constraintValue)) { + const value = attributes[constraint] // attribute value + effectiveModel[constraint] = constraintValue(value, attributes, constraint, options) + break + } + + effectiveModel[constraint] = constraintValue + } + + return effectiveModel +} + +// Input is a validate.js constraints object +// Return object representing input attributes that handle native HTML5 validation +export const getValidationPropsFromModel = (model = {}, required = true) => { + const props = {} + + const constraints = Object.keys(model) + for (let i = 0; i < constraints.length; i += 1) { + const constraint = constraints[i] + switch (constraint) { + case 'presence': + if (required) props.required = model[constraint] + break + + case 'format': + props.pattern = model[constraint] + break + + case 'length': { + const length = model[constraint] + if (length.is) { + props.maxLength = length.is + props.minLength = length.is + } + + if (length.maximum) props.maxLength = length.maximum + if (length.minimum) props.minLength = length.minimum + break + } + + default: + // no op + } + } + + return props +}