From 2fc55a9afc23ef75c859e0b7f6d734206bf7e7f6 Mon Sep 17 00:00:00 2001 From: brooketopcoder Date: Fri, 25 Mar 2022 12:27:12 -0700 Subject: [PATCH 1/2] PROD-501 #comment url validation #time 2h --- src/lib/form/Form.tsx | 37 +++++++++++++++---- src/lib/form/form-functions/form.functions.ts | 14 +++---- src/lib/form/form-input.model.ts | 7 +++- .../form-field-wrapper/FormFieldWrapper.tsx | 4 +- .../form/form-input/input-text/InputText.tsx | 13 +++++-- .../input-textarea/InputTextarea.tsx | 10 ++++- src/lib/form/validator-functions/index.ts | 4 ++ .../validator.functions.ts | 14 +++++++ .../work-intake/work-intake-form.config.ts | 9 +++-- .../password-reset-form.config.ts | 6 +-- .../profile-update-form.config.ts | 7 ++-- 11 files changed, 92 insertions(+), 33 deletions(-) diff --git a/src/lib/form/Form.tsx b/src/lib/form/Form.tsx index 6e8efce20..e956f16ca 100644 --- a/src/lib/form/Form.tsx +++ b/src/lib/form/Form.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FormEvent, SetStateAction, useState } from 'react' +import { Dispatch, FocusEvent, FormEvent, SetStateAction, useState } from 'react' import { Button } from '../button' import '../styles/index.scss' @@ -36,12 +36,29 @@ const Form: (props: FormProps>] = useState(Date.now()) + async function onBlur(event: FocusEvent): Promise { + const inputDef: FormInputModel = formGetInputModel(props.formDef.inputs, event.target.name) + inputDef.validateOnBlur + ?.forEach(async validator => { + if (!inputDef.error) { + inputDef.error = await validator(event.target.value, event.target.form?.elements, inputDef.dependentField) + setFormDef({ ...formDef }) + } + }) + } + function onChange(event: FormEvent): void { const isValid: boolean = formValidateAndUpdate(event, formDef.inputs) setFormDef({ ...formDef }) setDisableSave(!isValid) } + function onFocus(event: FocusEvent): void { + const inputDef: FormInputModel = formGetInputModel(props.formDef.inputs, event.target.name) + inputDef.dirtyOrTouched = true + setFormDef({ ...formDef }) + } + function onReset(): void { setFormDef({ ...formDef }) formReset(props.formDef.inputs, props.formValues) @@ -73,17 +90,23 @@ const Form: (props: FormProps { switch (inputModel.type) { case 'textarea': - return + return ( + + ) default: return ( , fieldName: export function initializeValues(inputs: ReadonlyArray, formValues?: T): void { inputs - .filter(input => !input.dirty) + .filter(input => !input.dirtyOrTouched) .forEach(input => { input.value = !!(formValues as any)?.hasOwnProperty(input.name) ? (formValues as any)[input.name] @@ -37,7 +37,7 @@ export function initializeValues(inputs: ReadonlyArray, formV export function reset(inputs: ReadonlyArray, formValue?: any): void { inputs .forEach(inputDef => { - inputDef.dirty = false + inputDef.dirtyOrTouched = false inputDef.error = undefined inputDef.value = formValue?.[inputDef.name] }) @@ -56,7 +56,7 @@ export async function submitAsync( setDisableButton(true) // if there are no dirty fields, display a message and stop submitting - const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirty) + const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirtyOrTouched) if (!dirty) { toast.info('No changes detected.') return @@ -90,7 +90,7 @@ export function validateAndUpdate(event: FormEvent, inputs: Rea const input: HTMLInputElement = (event.target as HTMLInputElement) // set the input def info const inputDef: FormInputModel = getInputModel(inputs, input.name) - inputDef.dirty = true + inputDef.dirtyOrTouched = true inputDef.value = input.value // validate the form @@ -104,9 +104,9 @@ function validate(inputs: ReadonlyArray, formElements: HTMLFormC const errors: ReadonlyArray = inputs .filter(formInputDef => { formInputDef.error = undefined - formInputDef.dirty = formInputDef.dirty || !!formDirty - formInputDef.validators - .forEach(validator => { + formInputDef.dirtyOrTouched = formInputDef.dirtyOrTouched || !!formDirty + formInputDef.validateOnChange + ?.forEach(validator => { if (!formInputDef.error) { formInputDef.error = validator(formInputDef.value, formElements, formInputDef.dependentField) } diff --git a/src/lib/form/form-input.model.ts b/src/lib/form/form-input.model.ts index 1ae55e6cf..60eac6354 100644 --- a/src/lib/form/form-input.model.ts +++ b/src/lib/form/form-input.model.ts @@ -1,6 +1,8 @@ +import { ValidatorFn } from './validator-functions' + export interface FormInputModel { readonly dependentField?: string - dirty?: boolean + dirtyOrTouched?: boolean disabled?: boolean error?: string readonly hint?: string @@ -10,6 +12,7 @@ export interface FormInputModel { readonly placeholder?: string readonly preventAutocomplete?: boolean readonly type: 'password' | 'text' | 'textarea' - readonly validators: Array<(value: string | undefined, formValues?: HTMLFormControlsCollection, otherField?: string) => string | undefined> + readonly validateOnBlur?: ValidatorFn + readonly validateOnChange?: Array<(value: string | undefined, formValues?: HTMLFormControlsCollection, otherField?: string) => string | undefined> value?: string } diff --git a/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx b/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx index d654dd1a0..f5f163c01 100644 --- a/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx +++ b/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx @@ -9,7 +9,7 @@ export const optionalHint: string = '(optional)' interface FormFieldWrapperProps { readonly children: ReactNode - readonly dirty?: boolean + readonly dirtyOrTouched?: boolean readonly disabled: boolean readonly error?: string readonly hint?: string @@ -21,7 +21,7 @@ const FormFieldWrapper: FC = (props: FormFieldWrapperProp const [focusStyle, setFocusStyle]: [string | undefined, Dispatch>] = useState() - const showError: boolean = !!props.error && !!props.dirty + const showError: boolean = !!props.error && !!props.dirtyOrTouched const formFieldClasses: string = classNames( styles['form-field'], props.disabled ? styles.disabled : undefined, diff --git a/src/lib/form/form-input/input-text/InputText.tsx b/src/lib/form/form-input/input-text/InputText.tsx index 48fdc7a96..3cad3ccc3 100644 --- a/src/lib/form/form-input/input-text/InputText.tsx +++ b/src/lib/form/form-input/input-text/InputText.tsx @@ -1,5 +1,6 @@ -import { FC } from 'react' +import { FC, FocusEvent } from 'react' +import { ValidatorFn } from '../../validator-functions' import { FormFieldWrapper } from '../form-field-wrapper' import styles from './InputText.module.scss' @@ -7,23 +8,27 @@ import styles from './InputText.module.scss' export const optionalHint: string = '(optional)' interface InputTextProps { - readonly dirty?: boolean + readonly dirtyOrTouched?: boolean readonly disabled?: boolean readonly error?: string readonly hint?: string readonly label?: string readonly name: string + readonly onBlur: (event: FocusEvent) => void + readonly onFocus: (event: FocusEvent) => void readonly placeholder?: string readonly preventAutocomplete?: boolean readonly tabIndex: number readonly type: 'password' | 'text' + readonly validateOnBlur?: ValidatorFn readonly value?: string | number } const InputText: FC = (props: InputTextProps) => { + return ( = (props: InputTextProps) => { className={styles['form-input-text']} defaultValue={props.value} disabled={!!props.disabled} + onBlur={props.onBlur} + onFocus={props.onFocus} name={props.name} placeholder={props.placeholder} tabIndex={props.tabIndex} diff --git a/src/lib/form/form-input/input-textarea/InputTextarea.tsx b/src/lib/form/form-input/input-textarea/InputTextarea.tsx index 78d2202f9..525ee2f4a 100644 --- a/src/lib/form/form-input/input-textarea/InputTextarea.tsx +++ b/src/lib/form/form-input/input-textarea/InputTextarea.tsx @@ -1,5 +1,6 @@ -import { FC } from 'react' +import { FC, FocusEvent } from 'react' +import { ValidatorFn } from '../../validator-functions' import { FormFieldWrapper } from '../form-field-wrapper' import styles from './InputTextarea.module.scss' @@ -11,16 +12,19 @@ interface InputTextareaProps { readonly hint?: string readonly label?: string readonly name: string + readonly onBlur: (event: FocusEvent) => void + readonly onFocus: (event: FocusEvent) => void readonly placeholder?: string readonly preventAutocomplete?: boolean readonly tabIndex: number + readonly validateOnBlur?: ValidatorFn readonly value?: string | number } const InputTextarea: FC = (props: InputTextareaProps) => { return ( = (props: InputTextareaProps) => { defaultValue={props.value} disabled={!!props.disabled} name={props.name} + onBlur={props.onBlur} + onFocus={props.onFocus} placeholder={props.placeholder} tabIndex={props.tabIndex} /> diff --git a/src/lib/form/validator-functions/index.ts b/src/lib/form/validator-functions/index.ts index 2fa88aaa4..144ff4caf 100644 --- a/src/lib/form/validator-functions/index.ts +++ b/src/lib/form/validator-functions/index.ts @@ -5,4 +5,8 @@ export { password as validatorPassword, required as validatorRequired, requiredIfOther as validatorRequiredIfOther, + url as validatorUrl, } from './validator.functions' +export + // tslint:disable-next-line: no-unused-expression +type { ValidatorFn } from './validator.functions' diff --git a/src/lib/form/validator-functions/validator.functions.ts b/src/lib/form/validator-functions/validator.functions.ts index b0809664e..690473a6a 100644 --- a/src/lib/form/validator-functions/validator.functions.ts +++ b/src/lib/form/validator-functions/validator.functions.ts @@ -90,6 +90,20 @@ export function requiredIfOther(value: string | undefined, formElements?: HTMLFo return `required when ${getOtherFieldLabel(otherField, otherFieldName)} is not blank` } +export function url(value: string | undefined): string | undefined { + + // if there's no value, there's nothing to check + if (!value) { + return undefined + } + + const urlRegex: RegExp = /\b(https?|ftp|file):\/\/[\-A-Za-z0-9+&@#\/%?=~_|!:,.;]*[\-A-Za-z0-9+&@#\/%=~_|]/ + return !urlRegex.test(value) ? 'invalid url' : undefined +} + +export type ValidatorFn = Array<(value: string | undefined, formValues?: HTMLFormControlsCollection, otherField?: string) + => string | undefined | Promise> + function getOtherField(formElements?: HTMLFormControlsCollection, otherFieldName?: string): HTMLInputElement { // if there are no form values or an other field name, we have a problem diff --git a/src/tools/work-intake/work-intake-form.config.ts b/src/tools/work-intake/work-intake-form.config.ts index 06fff8017..4367ae2ec 100644 --- a/src/tools/work-intake/work-intake-form.config.ts +++ b/src/tools/work-intake/work-intake-form.config.ts @@ -2,6 +2,7 @@ import { FormDefinition, inputOptionalHint, validatorRequired, + validatorUrl, } from '../../lib' export const workIntakeTitle: string = 'Define your work' @@ -21,7 +22,7 @@ export const workIntakeDef: FormDefinition = { label: 'Project Title', name: 'title', type: 'text', - validators: [ + validateOnChange: [ validatorRequired, ], }, @@ -31,13 +32,15 @@ export const workIntakeDef: FormDefinition = { name: 'data', placeholder: 'Paste a link', type: 'text', - validators: [], + validateOnChange: [ + validatorUrl, + ], }, { label: 'What would you like to learn?', name: 'description', type: 'textarea', - validators: [ + validateOnChange: [ validatorRequired, ], }, diff --git a/src/utils/settings/password-reset/password-reset-form.config.ts b/src/utils/settings/password-reset/password-reset-form.config.ts index 137107a6d..bc5511f68 100644 --- a/src/utils/settings/password-reset/password-reset-form.config.ts +++ b/src/utils/settings/password-reset/password-reset-form.config.ts @@ -37,7 +37,7 @@ export const passwordFormDef: FormDefinition = { name: PasswordFieldName.currentPassword, placeholder: 'type your current password', type: 'password', - validators: [ + validateOnChange: [ validatorRequired, ], }, @@ -47,7 +47,7 @@ export const passwordFormDef: FormDefinition = { name: PasswordFieldName.newPassword, placeholder: 'type your new password', type: 'password', - validators: [ + validateOnChange: [ validatorRequired, validatorDoesNotMatchOther, validatorPassword, @@ -59,7 +59,7 @@ export const passwordFormDef: FormDefinition = { name: PasswordFieldName.confirmPassword, placeholder: 're-type your new password', type: 'password', - validators: [ + validateOnChange: [ validatorRequired, validatorMatchOther, ], diff --git a/src/utils/settings/profile-update/profile-update-form.config.ts b/src/utils/settings/profile-update/profile-update-form.config.ts index 998ecf69e..483a14e04 100644 --- a/src/utils/settings/profile-update/profile-update-form.config.ts +++ b/src/utils/settings/profile-update/profile-update-form.config.ts @@ -24,7 +24,7 @@ export const profileFormDef: FormDefinition = { label: 'First Name', name: ProfileFieldName.firstName, type: 'text', - validators: [ + validateOnChange: [ validatorRequired, ], }, @@ -32,7 +32,7 @@ export const profileFormDef: FormDefinition = { label: 'Last Name', name: ProfileFieldName.lastName, type: 'text', - validators: [ + validateOnChange: [ validatorRequired, ], }, @@ -40,7 +40,7 @@ export const profileFormDef: FormDefinition = { label: 'Email', name: ProfileFieldName.email, type: 'text', - validators: [ + validateOnChange: [ validatorRequired, validatorEmail, ], @@ -51,7 +51,6 @@ export const profileFormDef: FormDefinition = { name: ProfileFieldName.handle, notTabbable: true, type: 'text', - validators: [], }, ], title: profileFormTitle, From ff79a9120a4cd6d7cbad222e57e3a52479f1ff2f Mon Sep 17 00:00:00 2001 From: brooketopcoder Date: Fri, 25 Mar 2022 12:33:57 -0700 Subject: [PATCH 2/2] PROD-501 #comment clean-up async #time 15m --- src/lib/form/Form.tsx | 6 +++--- src/lib/form/form-functions/form.functions.ts | 12 ++++++------ src/lib/form/form-functions/index.ts | 2 +- src/lib/form/form-input.model.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/form/Form.tsx b/src/lib/form/Form.tsx index e956f16ca..0c56e3b94 100644 --- a/src/lib/form/Form.tsx +++ b/src/lib/form/Form.tsx @@ -10,7 +10,7 @@ import { formInitializeValues, formReset, formSubmitAsync, - formValidateAndUpdate, + formValidateAndUpdateAsync, } from './form-functions' import { InputText, InputTextarea } from './form-input' import { FormInputModel } from './form-input.model' @@ -47,8 +47,8 @@ const Form: (props: FormProps): void { - const isValid: boolean = formValidateAndUpdate(event, formDef.inputs) + async function onChange(event: FormEvent): Promise { + const isValid: boolean = await formValidateAndUpdateAsync(event, formDef.inputs) setFormDef({ ...formDef }) setDisableSave(!isValid) } diff --git a/src/lib/form/form-functions/form.functions.ts b/src/lib/form/form-functions/form.functions.ts index ff50e4dfe..3f701c30e 100644 --- a/src/lib/form/form-functions/form.functions.ts +++ b/src/lib/form/form-functions/form.functions.ts @@ -66,7 +66,7 @@ export async function submitAsync( const formValues: HTMLFormControlsCollection = (event.target as HTMLFormElement).elements // if there are any validation errors, display a message and stop submitting - const isValid: boolean = validate(inputs, formValues, true) + const isValid: boolean = await validateAsync(inputs, formValues, true) if (!isValid) { toast.error('Changes could not be saved. Please resolve errors.') return Promise.reject(ErrorMessage.submit) @@ -85,7 +85,7 @@ export async function submitAsync( }) } -export function validateAndUpdate(event: FormEvent, inputs: ReadonlyArray): boolean { +export async function validateAndUpdateAsync(event: FormEvent, inputs: ReadonlyArray): Promise { const input: HTMLInputElement = (event.target as HTMLInputElement) // set the input def info @@ -95,20 +95,20 @@ export function validateAndUpdate(event: FormEvent, inputs: Rea // validate the form const formElements: HTMLFormControlsCollection = (input.form as HTMLFormElement).elements - const isValid: boolean = validate(inputs, formElements) + const isValid: boolean = await validateAsync(inputs, formElements) return isValid } -function validate(inputs: ReadonlyArray, formElements: HTMLFormControlsCollection, formDirty?: boolean): boolean { +async function validateAsync(inputs: ReadonlyArray, formElements: HTMLFormControlsCollection, formDirty?: boolean): Promise { const errors: ReadonlyArray = inputs .filter(formInputDef => { formInputDef.error = undefined formInputDef.dirtyOrTouched = formInputDef.dirtyOrTouched || !!formDirty formInputDef.validateOnChange - ?.forEach(validator => { + ?.forEach(async validator => { if (!formInputDef.error) { - formInputDef.error = validator(formInputDef.value, formElements, formInputDef.dependentField) + formInputDef.error = await validator(formInputDef.value, formElements, formInputDef.dependentField) } }) return !!formInputDef.error diff --git a/src/lib/form/form-functions/index.ts b/src/lib/form/form-functions/index.ts index 68aeef0e5..fbce4c278 100644 --- a/src/lib/form/form-functions/index.ts +++ b/src/lib/form/form-functions/index.ts @@ -5,5 +5,5 @@ export { initializeValues as formInitializeValues, reset as formReset, submitAsync as formSubmitAsync, - validateAndUpdate as formValidateAndUpdate, + validateAndUpdateAsync as formValidateAndUpdateAsync, } from './form.functions' diff --git a/src/lib/form/form-input.model.ts b/src/lib/form/form-input.model.ts index 60eac6354..2d4c091c5 100644 --- a/src/lib/form/form-input.model.ts +++ b/src/lib/form/form-input.model.ts @@ -13,6 +13,6 @@ export interface FormInputModel { readonly preventAutocomplete?: boolean readonly type: 'password' | 'text' | 'textarea' readonly validateOnBlur?: ValidatorFn - readonly validateOnChange?: Array<(value: string | undefined, formValues?: HTMLFormControlsCollection, otherField?: string) => string | undefined> + readonly validateOnChange?: ValidatorFn value?: string }