diff --git a/.eslintrc b/.eslintrc index 32b18a22..b69adc3a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -82,6 +82,7 @@ "import/no-unresolved": "off", "import/prefer-default-export": "off", "indent": "off", + "no-plusplus": "off", "no-underscore-dangle": "off", "no-unused-vars": "off", "react/jsx-one-expression-per-line": "off", diff --git a/src/config/lexics/public.tsx b/src/config/lexics/public.tsx index d92ee766..71cabadf 100644 --- a/src/config/lexics/public.tsx +++ b/src/config/lexics/public.tsx @@ -1,4 +1,10 @@ export const publicLexics = { + error_empty_email: 2498, + error_fill_out_required_fields: 12911, + error_fill_out_this_field: 12933, + error_invalid_email_format: 12908, + error_select_country_first: 12910, + error_simple_password: 12940, form_address1: 12914, form_address2: 12915, form_card_code: 12918, @@ -15,7 +21,6 @@ export const publicLexics = { form_region: 12932, login: 1367, next: 12916, - please_fill_out_this_field: 12933, register: 1305, select_language: 1005, step_title_card: 12917, diff --git a/src/features/Combobox/index.tsx b/src/features/Combobox/index.tsx index 515eebe1..cf5892a1 100644 --- a/src/features/Combobox/index.tsx +++ b/src/features/Combobox/index.tsx @@ -5,7 +5,11 @@ import map from 'lodash/map' import '@reach/combobox/styles.css' import { T9n } from 'features/T9n' -import { Label } from 'features/Common/Input/styled' +import { + Label, + Column, + Error, +} from 'features/Common/Input/styled' import { Props, Option } from './types' import { useCombobox } from './hooks' @@ -21,6 +25,7 @@ import { export const Combobox = (props: Props) => { const { disabled, + error, id, label, labelLexic, @@ -42,41 +47,48 @@ export const Combobox = (props: Props) => { } = useCombobox(props) return ( - - - - {!isEmpty(options) && ( - - - {map(options, ({ id: optionId, name }) => ( - - ))} - - - )} - + + + {!isEmpty(options) && ( + + + {map(options, ({ id: optionId, name }) => ( + + ))} + + + )} + + {error && } + ) } diff --git a/src/features/Combobox/types.tsx b/src/features/Combobox/types.tsx index 52c46e71..9da8f896 100644 --- a/src/features/Combobox/types.tsx +++ b/src/features/Combobox/types.tsx @@ -13,6 +13,7 @@ export type Props = Pick, ( | 'pattern' | 'title' )> & { + error?: string | null, label?: string, labelLexic?: string, labelWidth?: number, diff --git a/src/features/Common/Input/index.tsx b/src/features/Common/Input/index.tsx index cd27b715..793d2bed 100644 --- a/src/features/Common/Input/index.tsx +++ b/src/features/Common/Input/index.tsx @@ -1,15 +1,18 @@ -import React, { ChangeEvent } from 'react' +import type { ChangeEvent, FocusEvent } from 'react' +import React from 'react' import { T9n } from 'features/T9n' import { - TInputWrapper, + WrapperProps, InputWrapper, InputStyled, Label, + Error, + Column, } from './styled' -type TInput = { +type Props = { defaultValue?: string, id: string, inputWidth?: number, @@ -17,22 +20,25 @@ type TInput = { labelLexic?: string, labelWidth?: number, maxLength?: number, + onBlur?: (event: FocusEvent) => void, onChange?: (event: ChangeEvent) => void, pattern?: string, required?: boolean, title?: string, type?: string, value?: string, -} & TInputWrapper +} & WrapperProps export const Input = ({ defaultValue, + error, id, inputWidth, label, labelLexic, labelWidth, maxLength, + onBlur, onChange, paddingX, pattern, @@ -41,32 +47,37 @@ export const Input = ({ type, value, wrapperWidth, -}: TInput) => ( - - - - + + + + {error && } + ) diff --git a/src/features/Common/Input/stories.tsx b/src/features/Common/Input/stories.tsx index 51bb517e..75144628 100644 --- a/src/features/Common/Input/stories.tsx +++ b/src/features/Common/Input/stories.tsx @@ -33,3 +33,12 @@ export const EmailPassword = () => ( /> ) + +export const WithError = () => ( + +) diff --git a/src/features/Common/Input/styled.tsx b/src/features/Common/Input/styled.tsx index 873080ba..cdbd4b36 100644 --- a/src/features/Common/Input/styled.tsx +++ b/src/features/Common/Input/styled.tsx @@ -1,14 +1,17 @@ import styled, { css } from 'styled-components/macro' -export type TInputWrapper = { +import isNil from 'lodash/isNil' + +export type WrapperProps = { + error?: string | null, paddingX?: number, wrapperWidth?: number, } -export const wrapperStyles = css` +export const wrapperStyles = css` width: ${({ wrapperWidth }) => (wrapperWidth ? `${wrapperWidth}px` : '100%')}; height: 48px; - margin: 20px 0; + margin-top: 20px; padding-left: ${({ paddingX = 24 }) => (paddingX ? `${paddingX}px` : '')}; padding-right: ${({ paddingX = 24 }) => (paddingX ? `${paddingX}px` : '')}; padding-top: 13px; @@ -18,17 +21,19 @@ export const wrapperStyles = css` background-color: #3F3F3F; box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); border-radius: 2px; + border: 1px solid ${(({ error }) => (isNil(error) ? 'transparent' : '#E64646'))}; + border-width: 1px; ` -export const InputWrapper = styled.div` +export const InputWrapper = styled.div` ${wrapperStyles} ` -type TLabel = { +type LabelProps = { labelWidth?: number, } -export const Label = styled.label` +export const Label = styled.label` font-style: normal; font-weight: normal; font-size: 16px; @@ -39,11 +44,11 @@ export const Label = styled.label` width: ${({ labelWidth }) => (labelWidth ? `${labelWidth}px` : '')}; ` -type TInputStyled = { +type InputProps = { inputWidth?: number, } -export const inputStyles = css` +export const inputStyles = css` flex-grow: 1; font-weight: bold; font-size: 20px; @@ -73,6 +78,23 @@ export const inputStyles = css` } ` -export const InputStyled = styled.input` +export const InputStyled = styled.input` ${inputStyles} ` + +export const Column = styled.div` + width: 100%; + display: flex; + flex-direction: column; +` + +export const Error = styled.span` + min-height: 16px; + margin-top: 5px; + font-style: normal; + font-weight: normal; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.078px; + color: #E64646; +` diff --git a/src/features/FormStore/hooks/useFormState.tsx b/src/features/FormStore/hooks/useFormState.tsx new file mode 100644 index 00000000..ff74535a --- /dev/null +++ b/src/features/FormStore/hooks/useFormState.tsx @@ -0,0 +1,64 @@ +import type { ChangeEvent } from 'react' +import { useState, useCallback } from 'react' + +import isString from 'lodash/isString' + +type FieldState = { + error: string | null, + value: string, +} + +type FormState = {[formId: string]: FieldState} + +export const useFormState = () => { + const [formState, setFormState] = useState({}) + const readFormValue = useCallback( + (fieldName: string) => formState[fieldName]?.value ?? '', + [formState], + ) + const readFormError = useCallback( + (fieldName: string) => formState[fieldName]?.error ?? null, + [formState], + ) + + const updateFormValue = useCallback( + (fieldName: string) => ( + (event: ChangeEvent | string) => { + const value = isString(event) ? event : event.target.value + setFormState(((state) => { + const newState = { + ...state, + [fieldName]: { + error: null, + value, + }, + } + return newState + })) + } + ), + [], + ) + + const updateFormError = useCallback( + (fieldName: string, error: string) => { + setFormState(((state) => { + const newState = { + ...state, + [fieldName]: { + error, + value: state[fieldName]?.value, + }, + } + return newState + })) + }, + [], + ) + return { + readFormError, + readFormValue, + updateFormError, + updateFormValue, + } +} diff --git a/src/features/FormStore/hooks/useFormValidators.tsx b/src/features/FormStore/hooks/useFormValidators.tsx new file mode 100644 index 00000000..9ca1db88 --- /dev/null +++ b/src/features/FormStore/hooks/useFormValidators.tsx @@ -0,0 +1,23 @@ +import trim from 'lodash/trim' +import every from 'lodash/every' +import isEmpty from 'lodash/isEmpty' + +import { useFormState } from './useFormState' + +type Args = ReturnType + +export const useFormValidators = ({ + readFormValue, + updateFormError, +}: Args) => { + const isFieldEmpty = (fieldName: string) => isEmpty(trim(readFormValue(fieldName))) + + const allFieldsEmpty = (fieldNames: Array) => ( + every(fieldNames, isFieldEmpty) + ) + + return { + allFieldsEmpty, + isFieldEmpty, + } +} diff --git a/src/features/FormStore/index.tsx b/src/features/FormStore/index.tsx new file mode 100644 index 00000000..bf40ee21 --- /dev/null +++ b/src/features/FormStore/index.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import React, { + createContext, + useContext, + useMemo, +} from 'react' + +import { useFormState } from './hooks/useFormState' +import { useFormValidators } from './hooks/useFormValidators' + +type FormStore = ( + ReturnType + & ReturnType +) +type Props = { + children: ReactNode, +} + +const FormContext = createContext({} as FormStore) + +/** + * стор формы, содержит состояние формы[значение полей, ошибки] + * дает доступ к функциям чтения и обновления состояния + * также часто используемым функциям для валидации полей + */ +export const FormStore = ({ + children, +}: Props) => { + const formState = useFormState() + const validators = useFormValidators(formState) + const value = useMemo( + () => ({ ...formState, ...validators }), + [formState, validators], + ) + return ( + + {children} + + ) +} + +export const useForm = () => useContext(FormContext) diff --git a/src/features/Login/styled.tsx b/src/features/Login/styled.tsx index bc2837a3..64e312d9 100644 --- a/src/features/Login/styled.tsx +++ b/src/features/Login/styled.tsx @@ -33,6 +33,7 @@ export const BlockTitle = styled.span` export const ButtonsBlock = styled.div<{forSubsPage?: boolean}>` display: flex; flex-direction: column; + align-items: center; margin-top: ${({ forSubsPage }) => (forSubsPage ? '80px' : '60px')}; margin-bottom: ${({ forSubsPage }) => (forSubsPage ? '96px' : '')}; position: relative; diff --git a/src/features/Register/components/RegistrationStep/config.tsx b/src/features/Register/components/RegistrationStep/config.tsx index 4dc1de27..15b683c1 100644 --- a/src/features/Register/components/RegistrationStep/config.tsx +++ b/src/features/Register/components/RegistrationStep/config.tsx @@ -5,9 +5,34 @@ export const formIds = { country: 'country', email: 'email', firstname: 'firstname', + formError: 'formError', lastname: 'lastname', password: 'password', phone: 'phone', postalCode: 'postalCode', region: 'region', } + +export const requiredFields = [ + formIds.address1, + formIds.city, + formIds.country, + formIds.email, + formIds.firstname, + formIds.lastname, + formIds.password, + formIds.phone, + formIds.postalCode, + formIds.region, +] + +export const simpleValidationFields = [ + formIds.address1, + formIds.city, + formIds.country, + formIds.firstname, + formIds.lastname, + formIds.phone, + formIds.postalCode, + formIds.region, +] diff --git a/src/features/Register/components/RegistrationStep/hooks/useCities.tsx b/src/features/Register/components/RegistrationStep/hooks/useCities.tsx index 1bd29576..89e1ddd5 100644 --- a/src/features/Register/components/RegistrationStep/hooks/useCities.tsx +++ b/src/features/Register/components/RegistrationStep/hooks/useCities.tsx @@ -1,15 +1,15 @@ -import type { ChangeEvent } from 'react' import { useState, useCallback, - useEffect, } from 'react' import debounce from 'lodash/debounce' -import trim from 'lodash/trim' import type { Cities, City } from 'requests' import { getCountryCities } from 'requests' +import { useForm } from 'features/FormStore' + +import { formIds } from '../config' const useCitiesList = () => { const [cities, setCities] = useState([]) @@ -23,7 +23,7 @@ const useCitiesList = () => { [], ) - const resetCities = useCallback(() => setCities([]), []) + const resetCities = () => setCities([]) return { cities, @@ -32,31 +32,18 @@ const useCitiesList = () => { } } -export const useCities = (selectedCountryId?: number) => { - const [cityQuery, setCityQuery] = useState('') +export const useCities = () => { + const { updateFormValue } = useForm() const [selectedCity, setSelectedCity] = useState(null) + const setCityQuery = useCallback(updateFormValue(formIds.city), []) + const { cities, getCities, resetCities, } = useCitiesList() - const trimmedCity = trim(cityQuery) - useEffect(() => { - if (trimmedCity && selectedCountryId) { - getCities(trimmedCity, selectedCountryId) - } - }, [ - trimmedCity, - selectedCountryId, - getCities, - ]) - - const onCityQueryChange = ({ target }: ChangeEvent) => { - setCityQuery(target.value) - } - const onCitySelect = (newCity: City | null) => { if (newCity) { setCityQuery(newCity.name) @@ -66,18 +53,14 @@ export const useCities = (selectedCountryId?: number) => { } } - const resetSelectedCity = useCallback( - () => { - setCityQuery('') - setSelectedCity(null) - }, - [setCityQuery, setSelectedCity], - ) + const resetSelectedCity = () => { + setCityQuery('') + setSelectedCity(null) + } return { cities, - cityQuery, - onCityQueryChange, + getCities, onCitySelect, resetCities, resetSelectedCity, diff --git a/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx b/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx index 4e919131..856372d6 100644 --- a/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx +++ b/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx @@ -11,7 +11,7 @@ import type { Countries } from 'requests' import { getCountries } from 'requests' import { useLexicsStore } from 'features/LexicsStore' -type Country = { +export type Country = { id: number, name: string, } @@ -48,7 +48,7 @@ export const useCountries = () => { return { countries: transformedCountries, - onCountrySelect: setSelectedCountry, selectedCountry, + setSelectedCountry, } } diff --git a/src/features/Register/components/RegistrationStep/hooks/useForm.tsx b/src/features/Register/components/RegistrationStep/hooks/useForm.tsx index 2363ff3a..62e865db 100644 --- a/src/features/Register/components/RegistrationStep/hooks/useForm.tsx +++ b/src/features/Register/components/RegistrationStep/hooks/useForm.tsx @@ -1,91 +1,88 @@ -import type { FormEvent } from 'react' +import type { ChangeEvent, FocusEvent } from 'react' import { useEffect } from 'react' import trim from 'lodash/trim' -import { useAuthStore } from 'features/AuthStore' +import { useForm } from 'features/FormStore' import { isValidEmail } from 'features/Register/helpers/isValidEmail' import { formIds } from '../config' +import type { Country } from './useCountries' import { useCountries } from './useCountries' import { useCities } from './useCities' +import { useSubmitHandler } from './useSubmitHandler' -const readFormValue = (event: FormEvent) => ( - (fieldName: string) => trim(event.currentTarget[fieldName]?.value) -) - -export const useForm = () => { - const { register } = useAuthStore() +export const useRegistrationForm = () => { + const { + readFormError, + readFormValue, + updateFormError, + updateFormValue, + } = useForm() const { countries, - onCountrySelect, selectedCountry, + setSelectedCountry, } = useCountries() - const { cities, - cityQuery, - onCityQueryChange, + getCities, onCitySelect, resetCities, resetSelectedCity, selectedCity, - } = useCities(selectedCountry?.id) + } = useCities() - useEffect(() => { - resetSelectedCity() - resetCities() - }, [ + const handleSubmit = useSubmitHandler({ + selectedCity, selectedCountry, - resetSelectedCity, - resetCities, - ]) + }) - const getCityParams = () => { - if (selectedCity) return { cityId: selectedCity.id } - return { city: cityQuery } + const onEmailBlur = ({ target }: FocusEvent) => { + const email = trim(target.value) + if (email && !isValidEmail(email)) { + updateFormError(formIds.email, 'error_invalid_email_format') + } } - const handleSubmit = async (event: FormEvent) => { - event.preventDefault() - if (!selectedCountry) return + const onCountrySelect = (country: Country | null) => { + setSelectedCountry(country) + updateFormValue(formIds.country)(country?.name || '') + resetCities() + resetSelectedCity() + } - const readFieldValue = readFormValue(event) - const firstname = readFieldValue(formIds.firstname) - const lastname = readFieldValue(formIds.lastname) - const phone = readFieldValue(formIds.phone) - const email = readFieldValue(formIds.email) - const password = readFieldValue(formIds.password) - const postalCode = Number(readFieldValue(formIds.postalCode)) - const region = readFieldValue(formIds.region) - const address1 = readFieldValue(formIds.address1) - const address2 = readFieldValue(formIds.address2) + const onRegionOrCityChange = (fieldName: string) => ( + ({ target }: ChangeEvent) => { + if (selectedCountry) { + updateFormValue(fieldName)(target.value) + } else { + updateFormError(formIds.country, 'error_select_country_first') + } + } + ) - if (isValidEmail(email)) { - register({ - address1, - address2, - ...getCityParams(), - countryId: selectedCountry.id, - email, - firstname, - lastname, - password, - phone, - postalCode, - region, - }) + const trimmedCity = trim(readFormValue(formIds.city)) + useEffect(() => { + if (trimmedCity && selectedCountry?.id) { + getCities(trimmedCity, selectedCountry.id) } - } + }, [ + trimmedCity, + selectedCountry, + getCities, + ]) return { cities, - cityQuery, countries, handleSubmit, - onCityQueryChange, onCitySelect, onCountrySelect, - selectedCountry, + onEmailBlur, + onRegionOrCityChange, + readFormError, + readFormValue, + updateFormValue, } } diff --git a/src/features/Register/components/RegistrationStep/hooks/useSubmitHandler.tsx b/src/features/Register/components/RegistrationStep/hooks/useSubmitHandler.tsx new file mode 100644 index 00000000..b4d12659 --- /dev/null +++ b/src/features/Register/components/RegistrationStep/hooks/useSubmitHandler.tsx @@ -0,0 +1,65 @@ +import type { FormEvent } from 'react' + +import trim from 'lodash/trim' + +import type { City } from 'requests' + +import { useAuthStore } from 'features/AuthStore' +import { useForm } from 'features/FormStore' + +import { formIds } from '../config' +import type { Country } from './useCountries' +import { useValidateForm } from './useValidateForm' + +type Args = { + selectedCity: City | null, + selectedCountry: Country | null, +} + +export const useSubmitHandler = ({ + selectedCity, + selectedCountry, +}: Args) => { + const { register } = useAuthStore() + const { readFormValue } = useForm() + const validateForm = useValidateForm() + + const readTrimmedValue = (fieldName: string) => trim(readFormValue(fieldName)) + + const getCityParams = () => { + if (selectedCity) return { cityId: selectedCity.id } + return { city: readTrimmedValue(formIds.city) } + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + if (validateForm() && selectedCountry) { + const firstname = readTrimmedValue(formIds.firstname) + const lastname = readTrimmedValue(formIds.lastname) + const phone = readTrimmedValue(formIds.phone) + const email = readTrimmedValue(formIds.email) + const password = readTrimmedValue(formIds.password) + const postalCode = Number(readTrimmedValue(formIds.postalCode)) + const region = readTrimmedValue(formIds.region) + const address1 = readTrimmedValue(formIds.address1) + const address2 = readTrimmedValue(formIds.address2) + + register({ + address1, + address2, + ...getCityParams(), + countryId: selectedCountry.id, + email, + firstname, + lastname, + password, + phone, + postalCode, + region, + }) + } + } + + return handleSubmit +} diff --git a/src/features/Register/components/RegistrationStep/hooks/useValidateForm.tsx b/src/features/Register/components/RegistrationStep/hooks/useValidateForm.tsx new file mode 100644 index 00000000..26bd84b0 --- /dev/null +++ b/src/features/Register/components/RegistrationStep/hooks/useValidateForm.tsx @@ -0,0 +1,62 @@ +import trim from 'lodash/trim' +import reduce from 'lodash/reduce' + +import { useForm } from 'features/FormStore' +import { isValidEmail } from 'features/Register/helpers/isValidEmail' +import { isValidPassword } from 'features/Register/helpers/isValidPassword' + +import { + formIds, + requiredFields, + simpleValidationFields, +} from '../config' + +export const useValidateForm = () => { + const { + allFieldsEmpty, + isFieldEmpty, + readFormValue, + updateFormError, + } = useForm() + + const readTrimmedValue = (fieldName: string) => trim(readFormValue(fieldName)) + + const setErrorOnEmptyFields = (fieldNames: Array, message: string) => ( + reduce( + fieldNames, + (acc, fieldName) => { + if (isFieldEmpty(fieldName)) { + updateFormError(fieldName, message) + return acc + 1 + } + return acc + }, + 0, + ) + ) + + const validateForm = () => { + let errorsCount = 0 + const email = readTrimmedValue(formIds.email) + const password = readTrimmedValue(formIds.password) + if (allFieldsEmpty(requiredFields)) { + updateFormError(formIds.formError, 'error_fill_out_required_fields') + setErrorOnEmptyFields(requiredFields, '') + return false + } + if (isFieldEmpty(formIds.email)) { + updateFormError(formIds.email, 'error_empty_email') + errorsCount++ + } else if (!isValidEmail(email)) { + updateFormError(formIds.email, 'error_invalid_email_format') + } + if (!isValidPassword(password)) { + updateFormError(formIds.password, 'error_simple_password') + errorsCount++ + } + errorsCount += setErrorOnEmptyFields(simpleValidationFields, 'error_fill_out_this_field') + return errorsCount === 0 + } + + return validateForm +} diff --git a/src/features/Register/components/RegistrationStep/index.tsx b/src/features/Register/components/RegistrationStep/index.tsx index 4d9e63d7..949caae2 100644 --- a/src/features/Register/components/RegistrationStep/index.tsx +++ b/src/features/Register/components/RegistrationStep/index.tsx @@ -3,34 +3,32 @@ import React from 'react' import { T9n } from 'features/T9n' import { Combobox } from 'features/Combobox' import { Input, ButtonSolid } from 'features/Common' +import { Error } from 'features/Common/Input/styled' import { BlockTitle, ButtonsBlock, Form, } from 'features/Login/styled' -import { useLexicsStore } from 'features/LexicsStore' +import { FormStore } from 'features/FormStore' import { formIds } from './config' -import { passwordRegex } from '../../helpers/isValidPassword' -import { emailRegex } from '../../helpers/isValidEmail' -import { useForm } from './hooks/useForm' +import { useRegistrationForm } from './hooks/useForm' -const commonFieldRegex = '^.{0,500}$' const labelWidth = 116 -export const RegistrationStep = () => { +const Registration = () => { const { cities, - cityQuery, countries, handleSubmit, - onCityQueryChange, onCitySelect, onCountrySelect, - selectedCountry, - } = useForm() - const { translate } = useLexicsStore() - const defaultMessage = translate('please_fill_out_this_field') + onEmailBlur, + onRegionOrCityChange, + readFormError, + readFormValue, + updateFormValue, + } = useRegistrationForm() return (
@@ -39,107 +37,116 @@ export const RegistrationStep = () => { + ) } + +export const RegistrationStep = () => ( + + + +)