From 3a1a243684bda298721da34b70d9da608a354237 Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:53:18 +0300 Subject: [PATCH] feat(RSS-ECOMM-2_01): implement client side validation for login form (#112) * feat: add InputField component * feat: add InputFieldValidator component * feat: add types for InputField component * feat: add LoginForm component * feat: add the necessary validators to check input fields * feat: add password display functionality * fix: changes from @stardustmeg Co-authored-by: Meg G. <146496794+stardustmeg@users.noreply.github.com> * fix: changes from @stardustmeg Co-authored-by: Meg G. <146496794+stardustmeg@users.noreply.github.com> --------- Co-authored-by: Meg G. <146496794+stardustmeg@users.noreply.github.com> --- .../InputField/model/InputFieldModel.ts | 76 ++++++++++++ .../InputField/view/InputFieldView.ts | 115 ++++++++++++++++++ src/features/InputFieldValidator/.gitkeep | 0 .../model/InputFieldValidatorModel.ts | 103 ++++++++++++++++ src/pages/LoginPage/model/LoginPageModel.ts | 10 ++ src/shared/constants/enums.ts | 71 +++++++++++ src/shared/img/svg/closeEye.svg | 3 + src/shared/img/svg/openEye.svg | 4 + src/shared/types/interfaces.ts | 40 ++++++ src/widgets/LoginForm/.gitkeep | 0 src/widgets/LoginForm/model/LoginFormModel.ts | 63 ++++++++++ src/widgets/LoginForm/view/LoginFormView.ts | 90 ++++++++++++++ .../LoginForm/view/loginForm.module.scss} | 0 13 files changed, 575 insertions(+) create mode 100644 src/entities/InputField/model/InputFieldModel.ts create mode 100644 src/entities/InputField/view/InputFieldView.ts delete mode 100644 src/features/InputFieldValidator/.gitkeep create mode 100644 src/features/InputFieldValidator/model/InputFieldValidatorModel.ts create mode 100644 src/shared/img/svg/closeEye.svg create mode 100644 src/shared/img/svg/openEye.svg delete mode 100644 src/widgets/LoginForm/.gitkeep create mode 100644 src/widgets/LoginForm/model/LoginFormModel.ts create mode 100644 src/widgets/LoginForm/view/LoginFormView.ts rename src/{entities/InputField/.gitkeep => widgets/LoginForm/view/loginForm.module.scss} (100%) diff --git a/src/entities/InputField/model/InputFieldModel.ts b/src/entities/InputField/model/InputFieldModel.ts new file mode 100644 index 00000000..8321c177 --- /dev/null +++ b/src/entities/InputField/model/InputFieldModel.ts @@ -0,0 +1,76 @@ +import type { InputFieldParams, InputFieldValidatorParams } from '@/shared/types/interfaces.ts'; + +import InputFieldValidatorModel from '@/features/InputFieldValidator/model/InputFieldValidatorModel.ts'; +import { EVENT_NAMES, INPUT_TYPES } from '@/shared/constants/enums.ts'; + +import InputFieldView from '../view/InputFieldView.ts'; + +class InputFieldModel { + private isValid = false; + + private validator: InputFieldValidatorModel | null = null; + + private view: InputFieldView; + + constructor(inputFieldParams: InputFieldParams, validParams: InputFieldValidatorParams | null) { + this.view = new InputFieldView(inputFieldParams); + + if (validParams) { + this.validator = new InputFieldValidatorModel(validParams, this.isValid); + this.setInputHandler(); + } + + this.setShowPasswordHandler(); + } + + private inputHandler(): boolean { + const errorField = this.view.getErrorField(); + const errors = this.validator?.validate(this.view.getValue()); + if (errors === true) { + if (errorField) { + errorField.textContent = ''; + } + this.isValid = true; + } else { + if (errorField && errors) { + const [firstError] = errors; + errorField.textContent = firstError; + } + this.isValid = false; + } + + return true; + } + + private setInputHandler(): boolean { + const input = this.view.getInput().getHTML(); + input.addEventListener(EVENT_NAMES.INPUT, () => { + this.inputHandler(); + }); + + return true; + } + + private setShowPasswordHandler(): boolean { + const button = this.view.getShowPasswordButton().getHTML(); + button.addEventListener(EVENT_NAMES.CLICK, () => this.showPasswordHandler()); + return true; + } + + private showPasswordHandler(): boolean { + const input = this.view.getInput().getHTML(); + input.type = input.type === INPUT_TYPES.PASSWORD ? INPUT_TYPES.TEXT : INPUT_TYPES.PASSWORD; + this.view.switchPasswordButtonSVG(input.type); + return true; + } + + public getIsValid(): boolean { + return this.isValid; + } + + public getView(): InputFieldView { + return this.view; + } +} + +export default InputFieldModel; diff --git a/src/entities/InputField/view/InputFieldView.ts b/src/entities/InputField/view/InputFieldView.ts new file mode 100644 index 00000000..531b03f9 --- /dev/null +++ b/src/entities/InputField/view/InputFieldView.ts @@ -0,0 +1,115 @@ +import type { InputFieldParams, InputParams, LabelParams } from '@/shared/types/interfaces.ts'; + +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import { INPUT_TYPES, SVG_DETAILS, TAG_NAMES } from '@/shared/constants/enums.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; + +import InputModel from '@/shared/Input/model/InputModel.ts'; + +class InputFieldView { + private errorField: HTMLSpanElement | null = null; + + private input: InputModel; + + private inputField: HTMLLabelElement | InputModel; + + private label: HTMLLabelElement | null = null; + + private showPasswordButton: ButtonModel; + + constructor(params: InputFieldParams) { + this.input = this.createInput(params.inputParams); + this.showPasswordButton = this.createShowPasswordButton(); + this.inputField = this.createHTML(params); + } + + private createErrorField(): HTMLSpanElement { + this.errorField = createBaseElement({ + tag: TAG_NAMES.SPAN, + }); + + return this.errorField; + } + + private createHTML(params: InputFieldParams): HTMLLabelElement | InputModel { + const { labelParams } = params; + if (labelParams) { + this.inputField = this.createLabel(labelParams); + this.errorField = this.createErrorField(); + this.label?.append(this.input.getHTML(), this.errorField); + } else { + this.inputField = this.input; + } + + if (this.getInput().getHTML().type === INPUT_TYPES.PASSWORD) { + this.label?.append(this.showPasswordButton.getHTML()); + } + + return this.inputField; + } + + private createInput(inputParams: InputParams): InputModel { + const { autocomplete, id, placeholder, type } = inputParams; + this.input = new InputModel({ + autocomplete, + id, + placeholder: placeholder || '', + type, + }); + + return this.input; + } + + private createLabel(labelParams: LabelParams): HTMLLabelElement { + const { for: htmlFor, text } = labelParams; + this.label = createBaseElement({ + attributes: { + for: htmlFor, + }, + innerContent: text || '', + tag: TAG_NAMES.LABEL, + }); + + return this.label; + } + + private createShowPasswordButton(): ButtonModel { + this.showPasswordButton = new ButtonModel({}); + this.switchPasswordButtonSVG(INPUT_TYPES.PASSWORD); + return this.showPasswordButton; + } + + public getErrorField(): HTMLSpanElement | null { + return this.errorField; + } + + public getHTML(): HTMLLabelElement | InputModel { + return this.inputField; + } + + public getInput(): InputModel { + return this.input; + } + + public getShowPasswordButton(): ButtonModel { + return this.showPasswordButton; + } + + public getValue(): string { + if (this.inputField instanceof InputModel) { + return this.inputField.getValue(); + } + return this.input.getValue(); + } + + public switchPasswordButtonSVG(type: string): SVGSVGElement { + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, TAG_NAMES.SVG); + this.showPasswordButton.getHTML().innerHTML = ''; + svg.append(createSVGUse(type === INPUT_TYPES.PASSWORD ? SVG_DETAILS.CLOSE_EYE : SVG_DETAILS.OPEN_EYE)); + this.showPasswordButton.getHTML().append(svg); + return svg; + } +} + +export default InputFieldView; diff --git a/src/features/InputFieldValidator/.gitkeep b/src/features/InputFieldValidator/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/features/InputFieldValidator/model/InputFieldValidatorModel.ts b/src/features/InputFieldValidator/model/InputFieldValidatorModel.ts new file mode 100644 index 00000000..0ab94d8e --- /dev/null +++ b/src/features/InputFieldValidator/model/InputFieldValidatorModel.ts @@ -0,0 +1,103 @@ +import type { InputFieldValidatorParams } from '@/shared/types/interfaces'; + +class InputFieldValidatorModel { + private isValid: boolean; + + private validParams; + + constructor(validParams: InputFieldValidatorParams, isValid: boolean) { + this.validParams = validParams; + this.isValid = isValid; + } + + private checkMaxLength(value: string): boolean | string { + if (this.validParams.maxLength && value.length > this.validParams.maxLength) { + const errorMessage = `Max length should not exceed ${this.validParams.maxLength}`; + return errorMessage; + } + + return true; + } + + private checkMinLength(value: string): boolean | string { + if (this.validParams.minLength && value.length < this.validParams.minLength) { + const errorMessage = `Min length should be at least ${this.validParams.minLength}`; + return errorMessage; + } + + return true; + } + + private checkNotSpecialSymbols(value: string): boolean | string { + if (this.validParams.notSpecialSymbols && !this.validParams.notSpecialSymbols.pattern.test(value)) { + const errorMessage = this.validParams.notSpecialSymbols.message; + return errorMessage; + } + + return true; + } + + private checkRequired(value: string): boolean | string { + if (this.validParams.required && value.trim() === '') { + const errorMessage = 'Field is required'; + return errorMessage; + } + + return true; + } + + private checkRequiredSymbols(value: string): boolean | string { + if (this.validParams.requiredSymbols && !this.validParams.requiredSymbols.pattern.test(value)) { + const errorMessage = this.validParams.requiredSymbols.message; + return errorMessage; + } + + return true; + } + + private checkValidMail(value: string): boolean | string { + if (this.validParams.validMail && !this.validParams.validMail.pattern.test(value)) { + const errorMessage = this.validParams.validMail.message; + return errorMessage; + } + + return true; + } + + private checkWhitespace(value: string): boolean | string { + if (this.validParams.notWhitespace && !this.validParams.notWhitespace.pattern.test(value) && value.trim() !== '') { + const errorMessage = this.validParams.notWhitespace.message; + return errorMessage; + } + + return true; + } + + public validate(value: string): boolean | string[] { + const errors = [ + this.checkWhitespace(value), + this.checkRequired(value), + this.checkNotSpecialSymbols(value), + this.checkMinLength(value), + this.checkMaxLength(value), + this.checkRequiredSymbols(value), + this.checkValidMail(value), + ]; + + const errorMessages: string[] = []; + errors.forEach((error) => { + if (typeof error === 'string') { + errorMessages.push(error); + } + }); + + if (errorMessages.length) { + return errorMessages; + } + + this.isValid = true; + return this.isValid; + } +} + +export default InputFieldValidatorModel; diff --git a/src/pages/LoginPage/model/LoginPageModel.ts b/src/pages/LoginPage/model/LoginPageModel.ts index 48e8ecb1..6dd5d577 100644 --- a/src/pages/LoginPage/model/LoginPageModel.ts +++ b/src/pages/LoginPage/model/LoginPageModel.ts @@ -1,12 +1,22 @@ import type { PageInterface } from '@/shared/types/interfaces.ts'; +import LoginFormModel from '@/widgets/LoginForm/model/LoginFormModel.ts'; + import LoginPageView from '../view/LoginPageView.ts'; class LoginPageModel implements PageInterface { + private loginForm = new LoginFormModel(); + private view: LoginPageView; constructor(parent: HTMLDivElement) { this.view = new LoginPageView(parent); + this.init(); + } + + private init(): boolean { + this.getHTML().append(this.loginForm.getHTML()); + return true; } public getHTML(): HTMLDivElement { diff --git a/src/shared/constants/enums.ts b/src/shared/constants/enums.ts index 05b93b7e..d8b9e6dc 100644 --- a/src/shared/constants/enums.ts +++ b/src/shared/constants/enums.ts @@ -113,3 +113,74 @@ export const PAGES_IDS = { export const MEDIATOR_EVENTS = { CHANGE_PAGE: 'changePage', } as const; + +export const LOGIN_FORM_EMAIL_FIELD_PARAMS = { + inputParams: { + autocomplete: 'off', + id: 'email', + placeholder: 'user@example.com', + type: 'text', + }, + labelParams: { + for: 'email', + text: 'Enter your email', + }, +} as const; + +export const LOGIN_FORM_PASSWORD_FIELD_PARAMS = { + inputParams: { + autocomplete: 'off', + id: 'password', + placeholder: '***********', + type: 'password', + }, + labelParams: { + for: 'password', + text: 'Enter your password', + }, +} as const; + +export const LOGIN_FORM_INPUT_FIELD_PARAMS = [LOGIN_FORM_EMAIL_FIELD_PARAMS, LOGIN_FORM_PASSWORD_FIELD_PARAMS]; + +const LOGIN_FORM_EMAIL_FIELD_VALIDATE_PARAMS = { + key: 'email', + notWhitespace: { + message: 'Email must not contain whitespaces', + pattern: /^\S+$/, + }, + required: true, + validMail: { + message: 'Enter correct email (user@example.com)', + pattern: /^([a-z0-9_-]+\.)*[a-z0-9_-]+@[a-z0-9_-]+(\.[a-z0-9_-]+)*\.[a-z]{2,6}$/, + }, +} as const; + +const LOGIN_FORM_PASSWORD_FIELD_VALIDATE_PARAMS = { + key: 'password', + minLength: 8, + notWhitespace: { + message: 'Password must not contain whitespaces', + pattern: /^\S+$/, + }, + required: true, + requiredSymbols: { + message: 'Password must contain English letters, at least 1 letter in upper and lower case and at least 1 number', + pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+/, + }, +} as const; + +export const LOGIN_FORM_INPUT_FIELD_VALIDATION_PARAMS = [ + LOGIN_FORM_EMAIL_FIELD_VALIDATE_PARAMS, + LOGIN_FORM_PASSWORD_FIELD_VALIDATE_PARAMS, +]; + +export const FORM_SUBMIT_BUTTON_TEXT = { + LOGIN: 'Login', + REGISTRATION: 'Register', +} as const; + +export const SVG_DETAILS = { + CLOSE_EYE: 'closeEye', + OPEN_EYE: 'openEye', + SVG_URL: 'http://www.w3.org/2000/svg', +} as const; diff --git a/src/shared/img/svg/closeEye.svg b/src/shared/img/svg/closeEye.svg new file mode 100644 index 00000000..3462ccbe --- /dev/null +++ b/src/shared/img/svg/closeEye.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/img/svg/openEye.svg b/src/shared/img/svg/openEye.svg new file mode 100644 index 00000000..6d8f9724 --- /dev/null +++ b/src/shared/img/svg/openEye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/types/interfaces.ts b/src/shared/types/interfaces.ts index f543db24..73b7a15f 100644 --- a/src/shared/types/interfaces.ts +++ b/src/shared/types/interfaces.ts @@ -10,3 +10,43 @@ export interface ButtonAttributesInterface { export interface PageInterface { getHTML(): HTMLDivElement; } + +export interface InputParams { + autocomplete: 'off' | 'on'; + id: string; + placeholder: null | string; + type: 'color' | 'date' | 'email' | 'number' | 'password' | 'range' | 'text'; +} + +export interface LabelParams { + for: string; + text: null | string; +} + +export interface InputFieldParams { + inputParams: InputParams; + labelParams?: LabelParams | null; +} + +export interface InputFieldValidatorParams { + key: string; + maxLength?: null | number; + minLength?: null | number; + notSpecialSymbols?: { + message: string; + pattern: RegExp; + } | null; + notWhitespace?: { + message: string; + pattern: RegExp; + } | null; + required?: boolean | null; + requiredSymbols?: { + message: string; + pattern: RegExp; + } | null; + validMail?: { + message: string; + pattern: RegExp; + } | null; +} diff --git a/src/widgets/LoginForm/.gitkeep b/src/widgets/LoginForm/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/widgets/LoginForm/model/LoginFormModel.ts b/src/widgets/LoginForm/model/LoginFormModel.ts new file mode 100644 index 00000000..68566c72 --- /dev/null +++ b/src/widgets/LoginForm/model/LoginFormModel.ts @@ -0,0 +1,63 @@ +import { EVENT_NAMES } from '@/shared/constants/enums.ts'; + +import type InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; + +import LoginFormView from '../view/LoginFormView.ts'; + +class LoginFormModel { + private inputFields: InputFieldModel[] = []; + + private isValidInputFields: Record = {}; + + private view: LoginFormView = new LoginFormView(); + + constructor() { + this.init(); + } + + private init(): boolean { + this.inputFields = this.view.getInputFields(); + this.inputFields.forEach((inputField) => this.setInputFieldHandlers(inputField)); + this.setPreventDefaultToForm(); + + return true; + } + + private setInputFieldHandlers(inputField: InputFieldModel): boolean { + const inputHTML = inputField.getView().getInput().getHTML(); + this.isValidInputFields[inputHTML.id] = false; + inputHTML.addEventListener(EVENT_NAMES.INPUT, () => { + this.isValidInputFields[inputHTML.id] = inputField.getIsValid(); + this.switchSubmitFormButtonAccess(); + }); + return true; + } + + private setPreventDefaultToForm(): boolean { + this.getHTML().addEventListener(EVENT_NAMES.SUBMIT, (event) => { + event.preventDefault(); + }); + + return true; + } + + private switchSubmitFormButtonAccess(): boolean { + if (Object.values(this.isValidInputFields).every((value) => value)) { + this.view.getSubmitFormButton().setEnabled(); + } else { + this.view.getSubmitFormButton().setDisabled(); + } + + return true; + } + + public getFirstInputField(): InputFieldModel { + return this.inputFields[0]; + } + + public getHTML(): HTMLFormElement { + return this.view.getHTML(); + } +} + +export default LoginFormModel; diff --git a/src/widgets/LoginForm/view/LoginFormView.ts b/src/widgets/LoginForm/view/LoginFormView.ts new file mode 100644 index 00000000..57e3f5e8 --- /dev/null +++ b/src/widgets/LoginForm/view/LoginFormView.ts @@ -0,0 +1,90 @@ +import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import { + BUTTON_TYPES, + FORM_SUBMIT_BUTTON_TEXT, + LOGIN_FORM_INPUT_FIELD_PARAMS, + LOGIN_FORM_INPUT_FIELD_VALIDATION_PARAMS, + TAG_NAMES, +} from '@/shared/constants/enums.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import LOGIN_FORM_STYLES from './loginForm.module.scss'; + +class LoginFormView { + private form: HTMLFormElement; + + private inputFields: InputFieldModel[] = []; + + private submitFormButton: ButtonModel; + + constructor() { + this.inputFields = this.createInputFields(); + this.submitFormButton = this.createSubmitFormButton(); + this.form = this.createHTML(); + } + + private createHTML(): HTMLFormElement { + this.form = createBaseElement({ + cssClasses: [LOGIN_FORM_STYLES.loginForm], + tag: TAG_NAMES.FORM, + }); + + this.inputFields.forEach((inputField) => { + const inputFieldElement = inputField.getView().getHTML(); + + if (inputFieldElement instanceof HTMLLabelElement) { + this.form.append(inputFieldElement); + } else { + this.form.append(inputFieldElement.getHTML()); + } + }); + + this.form.append(this.submitFormButton.getHTML()); + return this.form; + } + + private createInputFields(): InputFieldModel[] { + LOGIN_FORM_INPUT_FIELD_PARAMS.forEach((inputFieldParams) => { + const currentValidateParams = LOGIN_FORM_INPUT_FIELD_VALIDATION_PARAMS.find( + (validParams) => validParams.key === inputFieldParams.inputParams.id, + ); + + if (currentValidateParams) { + const inputField = new InputFieldModel(inputFieldParams, currentValidateParams); + this.inputFields.push(inputField); + } else { + this.inputFields.push(new InputFieldModel(inputFieldParams, null)); + } + }); + + return this.inputFields; + } + + private createSubmitFormButton(): ButtonModel { + this.submitFormButton = new ButtonModel({ + attrs: { + type: BUTTON_TYPES.SUBMIT, + }, + text: FORM_SUBMIT_BUTTON_TEXT.LOGIN, + }); + + this.submitFormButton.setDisabled(); + + return this.submitFormButton; + } + + public getHTML(): HTMLFormElement { + return this.form; + } + + public getInputFields(): InputFieldModel[] { + return this.inputFields; + } + + public getSubmitFormButton(): ButtonModel { + return this.submitFormButton; + } +} + +export default LoginFormView; diff --git a/src/entities/InputField/.gitkeep b/src/widgets/LoginForm/view/loginForm.module.scss similarity index 100% rename from src/entities/InputField/.gitkeep rename to src/widgets/LoginForm/view/loginForm.module.scss