diff --git a/app/frontend/src/javascript/components/form/abstract-form-item.tsx b/app/frontend/src/javascript/components/form/abstract-form-item.tsx new file mode 100644 index 000000000..2c4d2343f --- /dev/null +++ b/app/frontend/src/javascript/components/form/abstract-form-item.tsx @@ -0,0 +1,59 @@ +import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react'; +import { AbstractFormComponent } from '../../models/form-component'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { get as _get } from 'lodash'; + +export interface AbstractFormItemProps extends PropsWithChildren> { + id: string, + label?: string, + tooltip?: ReactNode, + className?: string, + disabled?: boolean, + readOnly?: boolean +} + +/** + * This abstract component should not be used directly. + * Other forms components that are intended to be used with react-hook-form must extend this component. + */ +export const AbstractFormItem = ({ id, label, tooltip, className, disabled, readOnly, error, warning, rules, formState, children }: AbstractFormItemProps) => { + const [isDirty, setIsDirty] = useState(false); + const [fieldError, setFieldError] = useState(error); + + useEffect(() => { + setIsDirty(_get(formState?.dirtyFields, id)); + setFieldError(_get(formState?.errors, id)); + }, [formState]); + + useEffect(() => { + setFieldError(error); + }, [error]); + + // Compose classnames from props + const classNames = [ + 'form-item', + `${className || ''}`, + `${isDirty && fieldError ? 'is-incorrect' : ''}`, + `${isDirty && warning ? 'is-warned' : ''}`, + `${rules && rules.required ? 'is-required' : ''}`, + `${readOnly ? 'is-readonly' : ''}`, + `${disabled ? 'is-disabled' : ''}` + ].join(' '); + + return ( + + ); +}; diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 09260308e..cdfa9a495 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -1,36 +1,27 @@ -import React, { InputHTMLAttributes, ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { ReactNode, useCallback } from 'react'; import { FieldPathValue } from 'react-hook-form'; -import { debounce as _debounce, get as _get } from 'lodash'; +import { debounce as _debounce } from 'lodash'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; import { FormComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; -interface FormInputProps extends InputHTMLAttributes, FormComponent{ - id: string, - label?: string, - tooltip?: ReactNode, +interface FormInputProps extends FormComponent, AbstractFormItemProps { icon?: ReactNode, addOn?: ReactNode, addOnClassName?: string, debounce?: number, + type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week' | 'hidden', + defaultValue?: TInputType, + placeholder?: string, + step?: number | 'any', + onChange?: (event: React.ChangeEvent) => void, } /** * This component is a template for an input component to use within React Hook Form */ -export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps) => { - const [isDirty, setIsDirty] = useState(false); - const [fieldError, setFieldError] = useState(error); - - useEffect(() => { - setIsDirty(_get(formState?.dirtyFields, id)); - setFieldError(_get(formState?.errors, id)); - }, [formState]); - - useEffect(() => { - setFieldError(error); - }, [error]); - +export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps) => { /** * Debounced (ie. temporised) version of the 'on change' callback. */ @@ -51,26 +42,16 @@ export const FormInput = ({ id, register, labe // Compose classnames from props const classNames = [ - 'form-input form-item', + 'form-input', `${className || ''}`, - `${type === 'hidden' ? 'is-hidden' : ''}`, - `${isDirty && fieldError ? 'is-incorrect' : ''}`, - `${isDirty && warning ? 'is-warned' : ''}`, - `${rules && rules.required ? 'is-required' : ''}`, - `${readOnly ? 'is-readonly' : ''}`, - `${disabled ? 'is-disabled' : ''}` + `${type === 'hidden' ? 'is-hidden' : ''}` ].join(' '); return ( - + ); }; diff --git a/app/frontend/src/javascript/components/form/form-multi-select.tsx b/app/frontend/src/javascript/components/form/form-multi-select.tsx index 61ec8514e..1171f16cc 100644 --- a/app/frontend/src/javascript/components/form/form-multi-select.tsx +++ b/app/frontend/src/javascript/components/form/form-multi-select.tsx @@ -1,21 +1,17 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import Select from 'react-select'; import { Controller, Path } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; -interface FormSelectProps extends FormControlledComponent { - id: string, - label?: string, - tooltip?: ReactNode, +interface FormSelectProps extends FormControlledComponent, AbstractFormItemProps { options: Array>, valuesDefault?: Array, onChange?: (values: Array) => void, - className?: string, placeholder?: string, - disabled?: boolean, } /** @@ -28,15 +24,7 @@ type selectOption = { value: TOptionValue, label: string }; * This component is a wrapper around react-select to use with react-hook-form. * It is a multi-select component. */ -export const FormMultiSelect = ({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange }: FormSelectProps) => { - const classNames = [ - 'form-multi-select form-item', - `${className || ''}`, - `${error ? 'is-incorrect' : ''}`, - `${rules && rules.required ? 'is-required' : ''}`, - `${disabled ? 'is-disabled' : ''}` - ].join(' '); - +export const FormMultiSelect = ({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange, formState, readOnly, warning }: FormSelectProps) => { /** * The following callback will trigger the onChange callback, if it was passed to this component, * when the selected option changes. @@ -48,15 +36,10 @@ export const FormMultiSelect = - {label &&
-

{label}

- {tooltip &&
- -
{tooltip}
-
} -
} -
+ } control={control} defaultValue={valuesDefault as UnpackNestedValue>>} @@ -74,8 +57,6 @@ export const FormMultiSelect = } /> -
- {(error) &&
{error.message}
} - + ); }; diff --git a/app/frontend/src/javascript/components/form/form-select.tsx b/app/frontend/src/javascript/components/form/form-select.tsx index f15e94f85..3220190f9 100644 --- a/app/frontend/src/javascript/components/form/form-select.tsx +++ b/app/frontend/src/javascript/components/form/form-select.tsx @@ -1,22 +1,17 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import Select from 'react-select'; import { Controller, Path } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; -interface FormSelectProps extends FormControlledComponent { - id: string, - label?: string, - tooltip?: ReactNode, +interface FormSelectProps extends FormControlledComponent, AbstractFormItemProps { options: Array>, valueDefault?: TOptionValue, onChange?: (value: TOptionValue) => void, - className?: string, placeholder?: string, - disabled?: boolean, - readOnly?: boolean, clearable?: boolean, } @@ -29,15 +24,7 @@ type selectOption = { value: TOptionValue, label: string }; /** * This component is a wrapper for react-select to use with react-hook-form */ -export const FormSelect = ({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, rules, disabled, onChange, readOnly, clearable }: FormSelectProps) => { - const classNames = [ - 'form-select form-item', - `${className || ''}`, - `${error ? 'is-incorrect' : ''}`, - `${rules && rules.required ? 'is-required' : ''}`, - `${disabled ? 'is-disabled' : ''}` - ].join(' '); - +export const FormSelect = ({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, warning, rules, disabled, onChange, readOnly, clearable, formState }: FormSelectProps) => { /** * The following callback will trigger the onChange callback, if it was passed to this component, * when the selected option changes. @@ -49,34 +36,27 @@ export const FormSelect = - {label &&
-

{label}

- {tooltip &&
- -
{tooltip}
-
} -
} -
- } - control={control} - defaultValue={valueDefault as UnpackNestedValue>>} - render={({ field: { onChange, value, ref } }) => - c.value === value)} + onChange={val => { + onChangeCb(val.value); + onChange(val.value); + }} + placeholder={placeholder} + isDisabled={readOnly} + isClearable={clearable} + options={options} /> + } /> + ); }; diff --git a/app/frontend/src/javascript/components/form/form-switch.tsx b/app/frontend/src/javascript/components/form/form-switch.tsx new file mode 100644 index 000000000..91bb9a0af --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-switch.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { FormControlledComponent } from '../../models/form-component'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types'; +import { Controller, Path } from 'react-hook-form'; +import Switch from 'react-switch'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; + +interface FormSwitchProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: boolean, +} + +/** + * This component is a wrapper for react-switch, to use with react-hook-form. + */ +export const FormSwitch = ({ id, label, tooltip, className, error, rules, disabled, control, defaultValue, formState, readOnly, warning }: FormSwitchProps) => { + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + render={({ field: { onChange, value, ref } }) => + + } /> + + ); +}; diff --git a/app/frontend/src/javascript/models/form-component.ts b/app/frontend/src/javascript/models/form-component.ts index a611773eb..127c86e5f 100644 --- a/app/frontend/src/javascript/models/form-component.ts +++ b/app/frontend/src/javascript/models/form-component.ts @@ -16,18 +16,17 @@ export type ruleTypes = { * Automatic error handling is done through the `formState` prop. * Even for manual error/warning, the `formState` prop is required, because it is used to determine is the field is dirty. */ -export interface FormComponent { - register: UseFormRegister, +export interface AbstractFormComponent { error?: { message: string }, warning?: { message: string }, rules?: ruleTypes, formState?: FormState; } -export interface FormControlledComponent { - control: Control, - error?: { message: string }, - warning?: { message: string }, - rules?: ruleTypes, - formState?: FormState; +export interface FormComponent extends AbstractFormComponent { + register: UseFormRegister, +} + +export interface FormControlledComponent extends AbstractFormComponent { + control: Control } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 4652052fb..21d46212e 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -29,7 +29,9 @@ @import "modules/base/fab-text-editor"; @import "modules/base/labelled-input"; @import "modules/calendar/calendar"; +@import "modules/form/form-input"; @import "modules/form/form-item"; +@import "modules/form/form-switch"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; @import "modules/machines/machines-list"; diff --git a/app/frontend/src/stylesheets/modules/form/form-input.scss b/app/frontend/src/stylesheets/modules/form/form-input.scss new file mode 100644 index 000000000..6a823bfca --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-input.scss @@ -0,0 +1,5 @@ +.form-input { + &.is-hidden { + display: none; + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-item.scss b/app/frontend/src/stylesheets/modules/form/form-item.scss index 9d375515c..412723f17 100644 --- a/app/frontend/src/stylesheets/modules/form/form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/form-item.scss @@ -44,9 +44,6 @@ &:hover .content { display: block; } } } - &.is-hidden { - display: none; - } &.is-required &-header p::after { content: "*"; margin-left: 0.5ch; diff --git a/app/frontend/src/stylesheets/modules/form/form-switch.scss b/app/frontend/src/stylesheets/modules/form/form-switch.scss new file mode 100644 index 000000000..6c58a4b11 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-switch.scss @@ -0,0 +1,3 @@ +.form-switch { + +}