diff --git a/package.json b/package.json index 736cbad1d..d39f28664 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-gtm-module": "^2.0.11", + "react-responsive-modal": "^6.2.0", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", "react-toastify": "^8.2.0", diff --git a/src/header/Header.module.scss b/src/header/Header.module.scss index 1be321e95..54a530f1a 100644 --- a/src/header/Header.module.scss +++ b/src/header/Header.module.scss @@ -28,4 +28,8 @@ @include xl { grid-template-columns: $left-col-width-xl 1fr auto; } + + @include xxl { + grid-template-columns: $left-col-width-xl 1fr auto; + } } diff --git a/src/lib/avatar/Avatar.module.scss b/src/lib/avatar/Avatar.module.scss index d6552d4fe..c6868f8b4 100644 --- a/src/lib/avatar/Avatar.module.scss +++ b/src/lib/avatar/Avatar.module.scss @@ -1,7 +1,9 @@ @import '../../lib/styles'; $avatar-size-sm: 32px; +$border-size-sm: $border; $avatar-size-xl: 120px; +$border-size-xl: $pad-xs; .avatar-container { overflow: hidden; @@ -9,26 +11,28 @@ $avatar-size-xl: 120px; border-radius: 50%; &.sm { - height: calc($avatar-size-sm + 2 * $border); - width: calc($avatar-size-sm + 2 * $border); + height: calc($avatar-size-sm + 2 * $border-size-sm); + width: calc($avatar-size-sm + 2 * $border-size-sm); } &.xl { - height: calc($avatar-size-xl + 2 * $border); - width: calc($avatar-size-xl + 2 * $border); + height: calc($avatar-size-xl + 2 * $border-size-xl); + width: calc($avatar-size-xl + 2 * $border-size-xl); } .avatar { background-color: $tc-white; border-radius: 50%; - border: 2px solid $tc-white; + border: solid $tc-white; &.sm { + border-width: $border-size-sm; height: $avatar-size-sm; width: $avatar-size-sm; } &.xl { + border-width: $border-size-xl; height: $avatar-size-xl; width: $avatar-size-xl; } @@ -48,7 +52,8 @@ $avatar-size-xl: 120px; } &.xl { - font-size: 48px; + @extend h1; + padding: 0; } } } diff --git a/src/lib/button/Button.module.scss b/src/lib/button/Button.module.scss index 42b220c24..a2fcfa0d7 100644 --- a/src/lib/button/Button.module.scss +++ b/src/lib/button/Button.module.scss @@ -13,7 +13,7 @@ line-height: 24px; &:focus { - outline: 2px solid $turq-140; + outline: $border solid $turq-140; } &.button-sm { @@ -98,6 +98,43 @@ } } + &.link { + margin: 0; + padding: 0; + display: flex; + align-items: center; + font-size: 16px; + color: $turq-160; + background-color: $tc-white; + border: none; + outline: none; + border-radius: 0; + + &:focus { + outline: $border solid $turq-140; + } + + &:hover { + color: $turq-120; + } + + &:active { + color: $turq-180; + } + + &.disabled { + color: $black-60; + background-color: $tc-white; + border-color: $black-5; + } + + svg { + margin-left: $pad-xs; + height: $pad-lg; + width: $pad-lg; + } + } + &.text { border-color: transparent; } diff --git a/src/lib/button/Button.tsx b/src/lib/button/Button.tsx index c2d406360..012614da2 100644 --- a/src/lib/button/Button.tsx +++ b/src/lib/button/Button.tsx @@ -2,10 +2,12 @@ import classNames from 'classnames' import { FC } from 'react' import { Link } from 'react-router-dom' +import { IconOutline } from '../svgs' + import styles from './Button.module.scss' export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl' -export type ButtonStyle = 'primary' | 'secondary' | 'tertiary' | 'text' +export type ButtonStyle = 'link' | 'primary' | 'secondary' | 'tertiary' | 'text' export type ButtonType = 'button' | 'submit' export interface ButtonProps { @@ -61,6 +63,7 @@ const Button: FC = (props: ButtonProps) => { type={props.type || 'button'} > {props.label} + {props.buttonStyle === 'link' && } ) } diff --git a/src/lib/content-layout/ContentLayout.module.scss b/src/lib/content-layout/ContentLayout.module.scss index 758a18b42..e728a5f83 100644 --- a/src/lib/content-layout/ContentLayout.module.scss +++ b/src/lib/content-layout/ContentLayout.module.scss @@ -2,19 +2,33 @@ .content { @include content-height; - padding: 0; + padding: 0 $pad-content-lg; display: grid; grid-template-columns: 1fr; justify-content: center; + @include xxl { + padding: 0; + } + + @include md { + padding: 0 $pad-xxl; + } + + @include ltesm { + padding: 0; + } + .content-outer { display: flex; justify-content: center; .content-inner { flex: 1; - max-width: $xl-max-content; + max-width: $xl-max; padding: 0; + display: flex; + flex-direction: column; } } } diff --git a/src/lib/form/Form.module.scss b/src/lib/form/Form.module.scss index e9f39b880..50467e712 100644 --- a/src/lib/form/Form.module.scss +++ b/src/lib/form/Form.module.scss @@ -1,11 +1,12 @@ @import '../../lib/styles'; -.form-fields { - display: grid; - grid-template-columns: .5fr; - justify-content: flex-end; +form { + flex: 1; + display: flex; + flex-direction: column; - @include ltemd { + .form-fields { + display: grid; grid-template-columns: 1fr; justify-content: center; } diff --git a/src/lib/form/Form.tsx b/src/lib/form/Form.tsx index b50d16c91..0ad33b689 100644 --- a/src/lib/form/Form.tsx +++ b/src/lib/form/Form.tsx @@ -8,9 +8,9 @@ import { FormErrorMessage, formGetInputModel, formInitializeValues, + formOnChange, formReset, formSubmitAsync, - formValidateAndUpdateAsync, } from './form-functions' import { InputText, InputTextarea } from './form-input' import { FormInputModel } from './form-input.model' @@ -22,13 +22,14 @@ interface FormProps { readonly requestGenerator: (inputs: ReadonlyArray) => RequestType readonly resetOnError: boolean readonly save: (value: RequestType) => Promise + readonly succeeded?: () => void } const Form: (props: FormProps) => JSX.Element = (props: FormProps) => { const [disableSave, setDisableSave]: [boolean, Dispatch>] - = useState(true) + = useState(false) const [formDef, setFormDef]: [FormDefinition, Dispatch>] = useState({ ...props.formDef }) @@ -48,14 +49,14 @@ const Form: (props: FormProps): Promise { - const isValid: boolean = await formValidateAndUpdateAsync(event, formDef.inputs) + const isValid: boolean = await formOnChange(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 + inputDef.touched = true setFormDef({ ...formDef }) } @@ -67,7 +68,7 @@ const Form: (props: FormProps): Promise { const values: RequestType = props.requestGenerator(formDef.inputs) - formSubmitAsync(event, formDef.inputs, props.formDef.title, values, props.save, setDisableSave) + formSubmitAsync(event, formDef.inputs, props.formDef.title, values, props.save, setDisableSave, props.succeeded) .then(() => { setFormKey(Date.now()) formReset(formDef.inputs, props.formValues) @@ -85,9 +86,10 @@ const Form: (props: FormProps = props.formDef.inputs - .map(input => formGetInputModel(props.formDef.inputs, input.name)) + const formInputs: Array = formDef.inputs + .map(input => formGetInputModel(formDef.inputs, input.name)) .map((inputModel, index) => { + const tabIndex: number = inputModel.notTabbable ? -1 : index + 1 + (formDef.tabIndexStart || 0) switch (inputModel.type) { case 'textarea': return ( @@ -96,7 +98,7 @@ const Form: (props: FormProps ) @@ -107,7 +109,7 @@ const Form: (props: FormProps @@ -116,7 +118,7 @@ const Form: (props: FormProps = props.formDef.buttons + const buttons: Array = formDef.buttons .map((button, index) => { // if this is a reset button, set its onclick to reset if (!!button.isReset) { @@ -130,7 +132,7 @@ const Form: (props: FormProps ) }) @@ -145,12 +147,16 @@ const Form: (props: FormProps{props.formDef.title} +
+
{formInputs}
-
- {buttons} +
+
+ {buttons} +
diff --git a/src/lib/form/form-button.model.ts b/src/lib/form/form-button.model.ts index 0a36104b5..49918ca7e 100644 --- a/src/lib/form/form-button.model.ts +++ b/src/lib/form/form-button.model.ts @@ -7,7 +7,7 @@ export interface FormButton { readonly label: string readonly notTabble?: boolean readonly onClick?: (event?: any) => void - route?: string + readonly route?: string readonly size?: ButtonSize readonly type?: ButtonType readonly url?: string diff --git a/src/lib/form/form-definition.model.ts b/src/lib/form/form-definition.model.ts index 101502547..79eff6228 100644 --- a/src/lib/form/form-definition.model.ts +++ b/src/lib/form/form-definition.model.ts @@ -4,5 +4,6 @@ import { FormInputModel } from './form-input.model' export interface FormDefinition { readonly buttons: ReadonlyArray readonly inputs: ReadonlyArray + readonly tabIndexStart?: number readonly title: string } diff --git a/src/lib/form/form-functions/form.functions.ts b/src/lib/form/form-functions/form.functions.ts index 3f701c30e..5f8f9ba1a 100644 --- a/src/lib/form/form-functions/form.functions.ts +++ b/src/lib/form/form-functions/form.functions.ts @@ -26,7 +26,7 @@ export function getInputModel(inputs: ReadonlyArray, fieldName: export function initializeValues(inputs: ReadonlyArray, formValues?: T): void { inputs - .filter(input => !input.dirtyOrTouched) + .filter(input => !input.dirty && !input.touched) .forEach(input => { input.value = !!(formValues as any)?.hasOwnProperty(input.name) ? (formValues as any)[input.name] @@ -34,10 +34,26 @@ export function initializeValues(inputs: ReadonlyArray, formV }) } +export function onChange(event: FormEvent, inputs: ReadonlyArray): boolean { + + const input: HTMLInputElement = (event.target as HTMLInputElement) + // set the input def info + const inputDef: FormInputModel = getInputModel(inputs, input.name) + inputDef.dirty = true + inputDef.value = input.value + + // validate the form + const formElements: HTMLFormControlsCollection = (input.form as HTMLFormElement).elements + const isValid: boolean = validate(inputs, formElements) + + return isValid +} + export function reset(inputs: ReadonlyArray, formValue?: any): void { inputs .forEach(inputDef => { - inputDef.dirtyOrTouched = false + inputDef.dirty = false + inputDef.touched = false inputDef.error = undefined inputDef.value = formValue?.[inputDef.name] }) @@ -50,25 +66,25 @@ export async function submitAsync( formValue: T, save: (value: T) => Promise, setDisableButton: Dispatch>, + succeeded?: () => void, ): Promise { event.preventDefault() setDisableButton(true) - // if there are no dirty fields, display a message and stop submitting - const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirtyOrTouched) + // if there are no dirty fields, just run the succeeded method + const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirty) if (!dirty) { - toast.info('No changes detected.') - return + succeeded?.() + return Promise.resolve() } // get the form values so we can validate them const formValues: HTMLFormControlsCollection = (event.target as HTMLFormElement).elements // if there are any validation errors, display a message and stop submitting - const isValid: boolean = await validateAsync(inputs, formValues, true) + const isValid: boolean = validate(inputs, formValues, true) if (!isValid) { - toast.error('Changes could not be saved. Please resolve errors.') return Promise.reject(ErrorMessage.submit) } @@ -85,30 +101,15 @@ export async function submitAsync( }) } -export async function validateAndUpdateAsync(event: FormEvent, inputs: ReadonlyArray): Promise { - - const input: HTMLInputElement = (event.target as HTMLInputElement) - // set the input def info - const inputDef: FormInputModel = getInputModel(inputs, input.name) - inputDef.dirtyOrTouched = true - inputDef.value = input.value - - // validate the form - const formElements: HTMLFormControlsCollection = (input.form as HTMLFormElement).elements - const isValid: boolean = await validateAsync(inputs, formElements) - - return isValid -} - -async function validateAsync(inputs: ReadonlyArray, formElements: HTMLFormControlsCollection, formDirty?: boolean): Promise { +function validate(inputs: ReadonlyArray, formElements: HTMLFormControlsCollection, formDirty?: boolean): boolean { const errors: ReadonlyArray = inputs .filter(formInputDef => { formInputDef.error = undefined - formInputDef.dirtyOrTouched = formInputDef.dirtyOrTouched || !!formDirty + formInputDef.dirty = formInputDef.dirty || !!formDirty formInputDef.validateOnChange - ?.forEach(async validator => { + ?.forEach(validator => { if (!formInputDef.error) { - formInputDef.error = await validator(formInputDef.value, formElements, formInputDef.dependentField) + formInputDef.error = 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 fbce4c278..f0979b852 100644 --- a/src/lib/form/form-functions/index.ts +++ b/src/lib/form/form-functions/index.ts @@ -3,7 +3,7 @@ export { getInputElement as formGetInput, getInputModel as formGetInputModel, initializeValues as formInitializeValues, + onChange as formOnChange, reset as formReset, submitAsync as formSubmitAsync, - 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 2d4c091c5..7e9836b8e 100644 --- a/src/lib/form/form-input.model.ts +++ b/src/lib/form/form-input.model.ts @@ -2,7 +2,7 @@ import { ValidatorFn } from './validator-functions' export interface FormInputModel { readonly dependentField?: string - dirtyOrTouched?: boolean + dirty?: boolean disabled?: boolean error?: string readonly hint?: string @@ -11,6 +11,7 @@ export interface FormInputModel { readonly notTabbable?: boolean readonly placeholder?: string readonly preventAutocomplete?: boolean + touched?: boolean readonly type: 'password' | 'text' | 'textarea' readonly validateOnBlur?: ValidatorFn readonly validateOnChange?: ValidatorFn 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 f5f163c01..ffc8c238f 100644 --- a/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx +++ b/src/lib/form/form-input/form-field-wrapper/FormFieldWrapper.tsx @@ -9,19 +9,20 @@ export const optionalHint: string = '(optional)' interface FormFieldWrapperProps { readonly children: ReactNode - readonly dirtyOrTouched?: boolean + readonly dirty?: boolean readonly disabled: boolean readonly error?: string readonly hint?: string readonly label: string readonly name: string + readonly touched?: boolean } const FormFieldWrapper: FC = (props: FormFieldWrapperProps) => { const [focusStyle, setFocusStyle]: [string | undefined, Dispatch>] = useState() - const showError: boolean = !!props.error && !!props.dirtyOrTouched + const showError: boolean = !!props.error && (!!props.dirty || !!props.touched) 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 3cad3ccc3..aa19b14f9 100644 --- a/src/lib/form/form-input/input-text/InputText.tsx +++ b/src/lib/form/form-input/input-text/InputText.tsx @@ -8,7 +8,7 @@ import styles from './InputText.module.scss' export const optionalHint: string = '(optional)' interface InputTextProps { - readonly dirtyOrTouched?: boolean + readonly dirty?: boolean readonly disabled?: boolean readonly error?: string readonly hint?: string @@ -19,6 +19,7 @@ interface InputTextProps { readonly placeholder?: string readonly preventAutocomplete?: boolean readonly tabIndex: number + readonly touched?: boolean readonly type: 'password' | 'text' readonly validateOnBlur?: ValidatorFn readonly value?: string | number @@ -28,12 +29,13 @@ const InputText: FC = (props: InputTextProps) => { return ( = (props: InputTextareaProps) => { return (