Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions src/lib/form/Form.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -36,12 +36,29 @@ const Form: <ValueType extends any, RequestType extends any>(props: FormProps<Va
const [formKey, setFormKey]: [number, Dispatch<SetStateAction<number>>]
= useState<number>(Date.now())

function onChange(event: FormEvent<HTMLFormElement>): void {
const isValid: boolean = formValidateAndUpdate(event, formDef.inputs)
async function onBlur(event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>): Promise<void> {
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 })
}
})
}

async function onChange(event: FormEvent<HTMLFormElement>): Promise<void> {
const isValid: boolean = await formValidateAndUpdateAsync(event, formDef.inputs)
setFormDef({ ...formDef })
setDisableSave(!isValid)
}

function onFocus(event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>): 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)
Expand Down Expand Up @@ -73,17 +90,23 @@ const Form: <ValueType extends any, RequestType extends any>(props: FormProps<Va
.map((inputModel, index) => {
switch (inputModel.type) {
case 'textarea':
return <InputTextarea
{...inputModel}
key={inputModel.name}
tabIndex={inputModel.notTabbable ? -1 : index + 1}
value={inputModel.value}
/>
return (
<InputTextarea
{...inputModel}
key={inputModel.name}
onBlur={onBlur}
onFocus={onFocus}
tabIndex={inputModel.notTabbable ? -1 : index + 1}
value={inputModel.value}
/>
)
default:
return (
<InputText
{...inputModel}
key={inputModel.name}
onBlur={onBlur}
onFocus={onFocus}
tabIndex={inputModel.notTabbable ? -1 : index + 1}
type={inputModel.type || 'text'}
value={inputModel.value}
Expand Down
24 changes: 12 additions & 12 deletions src/lib/form/form-functions/form.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function getInputModel(inputs: ReadonlyArray<FormInputModel>, fieldName:

export function initializeValues<T>(inputs: ReadonlyArray<FormInputModel>, 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]
Expand All @@ -37,7 +37,7 @@ export function initializeValues<T>(inputs: ReadonlyArray<FormInputModel>, formV
export function reset(inputs: ReadonlyArray<FormInputModel>, formValue?: any): void {
inputs
.forEach(inputDef => {
inputDef.dirty = false
inputDef.dirtyOrTouched = false
inputDef.error = undefined
inputDef.value = formValue?.[inputDef.name]
})
Expand All @@ -56,7 +56,7 @@ export async function submitAsync<T, R>(
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
Expand All @@ -66,7 +66,7 @@ export async function submitAsync<T, R>(
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)
Expand All @@ -85,30 +85,30 @@ export async function submitAsync<T, R>(
})
}

export function validateAndUpdate(event: FormEvent<HTMLFormElement>, inputs: ReadonlyArray<FormInputModel>): boolean {
export async function validateAndUpdateAsync(event: FormEvent<HTMLFormElement>, inputs: ReadonlyArray<FormInputModel>): Promise<boolean> {

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
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<FormInputModel>, formElements: HTMLFormControlsCollection, formDirty?: boolean): boolean {
async function validateAsync(inputs: ReadonlyArray<FormInputModel>, formElements: HTMLFormControlsCollection, formDirty?: boolean): Promise<boolean> {
const errors: ReadonlyArray<FormInputModel> = inputs
.filter(formInputDef => {
formInputDef.error = undefined
formInputDef.dirty = formInputDef.dirty || !!formDirty
formInputDef.validators
.forEach(validator => {
formInputDef.dirtyOrTouched = formInputDef.dirtyOrTouched || !!formDirty
formInputDef.validateOnChange
?.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
Expand Down
2 changes: 1 addition & 1 deletion src/lib/form/form-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export {
initializeValues as formInitializeValues,
reset as formReset,
submitAsync as formSubmitAsync,
validateAndUpdate as formValidateAndUpdate,
validateAndUpdateAsync as formValidateAndUpdateAsync,
} from './form.functions'
7 changes: 5 additions & 2 deletions src/lib/form/form-input.model.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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?: ValidatorFn
value?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,7 +21,7 @@ const FormFieldWrapper: FC<FormFieldWrapperProps> = (props: FormFieldWrapperProp

const [focusStyle, setFocusStyle]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<string | undefined>()

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,
Expand Down
13 changes: 10 additions & 3 deletions src/lib/form/form-input/input-text/InputText.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
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'

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<HTMLInputElement>) => void
readonly onFocus: (event: FocusEvent<HTMLInputElement>) => void
readonly placeholder?: string
readonly preventAutocomplete?: boolean
readonly tabIndex: number
readonly type: 'password' | 'text'
readonly validateOnBlur?: ValidatorFn
readonly value?: string | number
}

const InputText: FC<InputTextProps> = (props: InputTextProps) => {

return (
<FormFieldWrapper
dirty={!!props.dirty}
dirtyOrTouched={!!props.dirtyOrTouched}
disabled={!!props.disabled}
error={props.error}
hint={props.hint}
Expand All @@ -35,6 +40,8 @@ const InputText: FC<InputTextProps> = (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}
Expand Down
10 changes: 8 additions & 2 deletions src/lib/form/form-input/input-textarea/InputTextarea.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,16 +12,19 @@ interface InputTextareaProps {
readonly hint?: string
readonly label?: string
readonly name: string
readonly onBlur: (event: FocusEvent<HTMLTextAreaElement>) => void
readonly onFocus: (event: FocusEvent<HTMLTextAreaElement>) => void
readonly placeholder?: string
readonly preventAutocomplete?: boolean
readonly tabIndex: number
readonly validateOnBlur?: ValidatorFn
readonly value?: string | number
}

const InputTextarea: FC<InputTextareaProps> = (props: InputTextareaProps) => {
return (
<FormFieldWrapper
dirty={!!props.dirty}
dirtyOrTouched={!!props.dirty}
disabled={!!props.disabled}
error={props.error}
hint={props.hint}
Expand All @@ -33,6 +37,8 @@ const InputTextarea: FC<InputTextareaProps> = (props: InputTextareaProps) => {
defaultValue={props.value}
disabled={!!props.disabled}
name={props.name}
onBlur={props.onBlur}
onFocus={props.onFocus}
placeholder={props.placeholder}
tabIndex={props.tabIndex}
/>
Expand Down
4 changes: 4 additions & 0 deletions src/lib/form/validator-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
14 changes: 14 additions & 0 deletions src/lib/form/validator-functions/validator.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>>

function getOtherField(formElements?: HTMLFormControlsCollection, otherFieldName?: string): HTMLInputElement {

// if there are no form values or an other field name, we have a problem
Expand Down
9 changes: 6 additions & 3 deletions src/tools/work-intake/work-intake-form.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
FormDefinition,
inputOptionalHint,
validatorRequired,
validatorUrl,
} from '../../lib'

export const workIntakeTitle: string = 'Define your work'
Expand All @@ -21,7 +22,7 @@ export const workIntakeDef: FormDefinition = {
label: 'Project Title',
name: 'title',
type: 'text',
validators: [
validateOnChange: [
validatorRequired,
],
},
Expand All @@ -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,
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const passwordFormDef: FormDefinition = {
name: PasswordFieldName.currentPassword,
placeholder: 'type your current password',
type: 'password',
validators: [
validateOnChange: [
validatorRequired,
],
},
Expand All @@ -47,7 +47,7 @@ export const passwordFormDef: FormDefinition = {
name: PasswordFieldName.newPassword,
placeholder: 'type your new password',
type: 'password',
validators: [
validateOnChange: [
validatorRequired,
validatorDoesNotMatchOther,
validatorPassword,
Expand All @@ -59,7 +59,7 @@ export const passwordFormDef: FormDefinition = {
name: PasswordFieldName.confirmPassword,
placeholder: 're-type your new password',
type: 'password',
validators: [
validateOnChange: [
validatorRequired,
validatorMatchOther,
],
Expand Down
Loading