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