diff --git a/apps/kyb-app/src/App.tsx b/apps/kyb-app/src/App.tsx index e05ea3c363..99959f3cf4 100644 --- a/apps/kyb-app/src/App.tsx +++ b/apps/kyb-app/src/App.tsx @@ -1,15 +1,9 @@ -import { LoadingScreen } from '@/common/components/molecules/LoadingScreen'; import { APP_LANGUAGE_QUERY_KEY } from '@/common/consts/consts'; -import { CustomerProviderFallback } from '@/components/molecules/CustomerProviderFallback'; -import { AppLoadingContainer } from '@/components/organisms/AppLoadingContainer'; -import { CustomerProvider } from '@/components/providers/CustomerProvider'; import { useCustomerQuery } from '@/hooks/useCustomerQuery'; import { useFlowContextQuery } from '@/hooks/useFlowContextQuery'; import { useUISchemasQuery } from '@/hooks/useUISchemasQuery'; -import { router } from '@/router'; +import { ValidatorPOC } from '@/ValidatorPOC'; import '@ballerine/ui/dist/style.css'; -import * as Sentry from '@sentry/react'; -import { RouterProvider } from 'react-router-dom'; export const App = () => { // useLanguage uses react-router context @@ -22,18 +16,20 @@ export const App = () => { useFlowContextQuery(), ] as const; - return ( - - - } - fallback={CustomerProviderFallback} - > - - - - - ); + // return ( + // + // + // } + // fallback={CustomerProviderFallback} + // > + // + // + // + // + // ); + + return ; }; (window as any).toggleDevmode = () => { diff --git a/apps/kyb-app/src/ValidatorPOC.tsx b/apps/kyb-app/src/ValidatorPOC.tsx new file mode 100644 index 0000000000..21e2a79e74 --- /dev/null +++ b/apps/kyb-app/src/ValidatorPOC.tsx @@ -0,0 +1,103 @@ +//@ts-nocheck + +import { Validator } from '@/components/providers/Validator'; +import { pocDefinition } from '@/poc-definition'; +import { FieldList } from '@/poc/FieldList'; +import { SubmitButton } from '@/poc/SubmitButton'; +import { TextInput } from '@/poc/TextInput'; + +const context = { + items: [ + { + subItems: [{}, { subsubItems: [{}] }], + }, + {}, + ], +}; + +export const ValidatorPOC = () => { + return ( + +
{ + e.preventDefault(); + alert('submitted'); + }} + > + + +
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+
+
+ + +
+ ); +}; diff --git a/apps/kyb-app/src/components/providers/Validator/Validator.tsx b/apps/kyb-app/src/components/providers/Validator/Validator.tsx new file mode 100644 index 0000000000..9880cacd50 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/Validator.tsx @@ -0,0 +1,41 @@ +import { useValidate } from '@/components/providers/Validator/hooks/useValidate'; +import { UIElement } from '@/components/providers/Validator/hooks/useValidate/ui-element'; +import { UIElementV2 } from '@/components/providers/Validator/types'; +import React, { FunctionComponent, useCallback, useMemo, useState } from 'react'; +import { TValidationErrors, validatorContext } from './validator.context'; + +const { Provider } = validatorContext; + +export interface IValidatorProps { + children: React.ReactNode | React.ReactNode[]; + context: unknown; + elements: UIElementV2[]; +} + +export const Validator: FunctionComponent = ({ children, elements, context }) => { + const validate = useValidate({ elements, context }); + const [validationErrors, setValiationErrors] = useState({}); + + const onValidate = useCallback(() => { + const errors = validate(); + const validationErrors = errors.reduce((acc, error) => { + const element = new UIElement(error.element, context, error.stack); + acc[element.getId()] = error.message; + + return acc; + }, {} as TValidationErrors); + + setValiationErrors(validationErrors); + + console.log({ errors, validationErrors }); + + return Boolean(errors.length); + }, [validate]); + + const ctx = useMemo( + () => ({ validate: onValidate, errors: validationErrors }), + [validationErrors, onValidate], + ); + + return {children}; +}; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/index.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/index.ts new file mode 100644 index 0000000000..7ea5d57fe6 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/index.ts @@ -0,0 +1 @@ +export * from './useValidate'; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/ui-element.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/ui-element.ts new file mode 100644 index 0000000000..fd081341bc --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/ui-element.ts @@ -0,0 +1,176 @@ +import { UIElementV2 } from '@/components/providers/Validator/types'; +import { + IMaxLengthValueValidatorParams, + MaxLengthValueValidator, +} from '@/components/providers/Validator/value-validators/max-length.value.validator'; +import { + IMinLengthValueValidatorParams, + MinLengthValueValidator, +} from '@/components/providers/Validator/value-validators/min-length.value.validator'; +import { + IPatternValidatorParams, + PatternValueValidator, +} from '@/components/providers/Validator/value-validators/pattern.value.validator'; +import { + IRequiredValueValidatorParams, + RequiredValueValidator, +} from '@/components/providers/Validator/value-validators/required.value-validator'; +import get from 'lodash/get'; + +export class UIElement { + constructor(readonly element: UIElementV2, readonly context: unknown, readonly stack: number[]) {} + + getId() { + return this.formatId(this.element.id); + } + + getOriginId() { + return this.element.id; + } + + private formatId(id: string) { + return `${id}${this.stack.join('.')}`; + } + + getValueDestination() { + return this.formatValueDestination(this.element.valueDestination); + } + + private formatValueDestination(valueDestination: string) { + return this.formatValueAndApplyStackIndexes(valueDestination); + } + + private formatValueAndApplyStackIndexes(value: string) { + this.stack.forEach((stackValue, index) => { + value = value.replace(`$${index}`, String(stackValue)); + }); + + return value; + } + + getValue() { + const valueDestination = this.getValueDestination(); + + return get(this.context, valueDestination); + } + + getValidatorsParams() { + const validatorKeys = Object.keys(this.element.validation); + + return validatorKeys.map(key => ({ + validator: key, + params: this.getValidatorParams(key), + })); + } + + private getValidatorParams(key: string) { + switch (key) { + case 'minLength': + return this.getMinLengthParams(); + case 'required': + return this.getRequiredParams(); + case 'maxLength': + return this.getMaxLengthParams(); + case 'pattern': + return this.getPatternParams(); + default: + throw new Error('Invalid key'); + } + } + + private getMinLengthParams(): IMinLengthValueValidatorParams { + const _params = this.element.validation.minLength; + + if (MinLengthValueValidator.isMinLengthParams(_params)) { + if (Array.isArray(_params)) { + return { + minLength: _params[0], + message: _params[1], + }; + } + + const minLength = _params; + + const params: IMinLengthValueValidatorParams = { + minLength, + message: `Minimum length is ${minLength}.`, + }; + + return params; + } + + throw new Error('Invalid params'); + } + + private getMaxLengthParams(): IMaxLengthValueValidatorParams { + const _params = this.element.validation.maxLength; + + if (MaxLengthValueValidator.isMaxLengthParams(_params)) { + if (Array.isArray(_params)) { + return { + maxLength: _params[0], + message: _params[1], + }; + } + + const maxLength = _params; + + const params: IMaxLengthValueValidatorParams = { + maxLength, + message: `Maximum length is ${maxLength}.`, + }; + + return params; + } + + throw new Error('Invalid params'); + } + + private getRequiredParams(): IRequiredValueValidatorParams { + const _params = this.element.validation.required; + + if (RequiredValueValidator.isRequiredParams(_params)) { + if (Array.isArray(_params)) { + return { + required: _params[0], + message: _params[1], + }; + } + + const isRequired = _params; + + const params: IRequiredValueValidatorParams = { + required: isRequired, + message: `Value is required.`, + }; + + return params; + } + + throw new Error('Invalid params'); + } + + private getPatternParams() { + const _params = this.element.validation.pattern; + + if (PatternValueValidator.isPatternParams(_params)) { + if (Array.isArray(_params)) { + return { + required: _params[0], + message: _params[1], + }; + } + + const pattern = _params; + + const params: IPatternValidatorParams = { + pattern, + message: `Value must match {pattern}.`, + }; + + return params; + } + + throw new Error('Invalid params'); + } +} diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/useValidate.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/useValidate.ts new file mode 100644 index 0000000000..57d80f4fb8 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidate/useValidate.ts @@ -0,0 +1,105 @@ +import { UIElement } from '@/components/providers/Validator/hooks/useValidate/ui-element'; +import { UIElementV2 } from '@/components/providers/Validator/types'; +import { ValueValidatorManager } from '@/components/providers/Validator/value-validator-manager'; +import { useCallback } from 'react'; + +interface IUseValidateParams { + elements: UIElementV2[]; + context: unknown; +} + +export interface IValidationError { + id: string; + message: string; + element: UIElementV2; + valueDestination: string; + stack: number[]; +} + +export const useValidate = ({ elements, context }: IUseValidateParams) => { + const validate = useCallback(() => { + const validatorManager = new ValueValidatorManager(); + let errors: IValidationError[] = []; + + const fieldValidationStrategy = ( + element: UIElement, + stack: number[] = [], + ): IValidationError[] => { + const value = element.getValue(); + + const validationErrors = element.getValidatorsParams().map(({ validator, params }) => { + try { + validatorManager.validate(value, validator as any, params); + } catch (error) { + return { + //@ts-ignore + message: error.message, + element: element.element, + id: element.getId(), + valueDestination: element.getValueDestination(), + stack, + }; + } + }); + + return validationErrors.filter(Boolean) as IValidationError[]; + }; + + const validateFn = (elements: UIElementV2[], context: object, stack: number[] = []) => { + for (let i = 0; i < elements.length; i++) { + const element = elements[i] as UIElementV2; + const uiElement = new UIElement(element, context, stack); + + if (!element) continue; + + if (element.type === 'field') { + errors = [...errors, ...fieldValidationStrategy(uiElement, stack)]; + continue; + } + + if (element.type === 'field-list') { + const value = uiElement.getValue() as any[]; + + errors = [...errors, ...fieldValidationStrategy(uiElement, stack)]; + + if (!Array.isArray(value)) continue; + + value?.forEach((item, index) => { + validateFn(element.children!, context, [...stack, index]); + }); + } + } + }; + + // const valiateFn = ( + // elements: UIElementV2[], + // meta: { parent: UIElementV2 | null; deepthLevel: number } = { parent: null, deepthLevel: 0 }, + // ) => { + // for (const element of elements) { + // if (element.type === 'ui') continue; + + // if (element.type === 'field') { + // errors = [...errors, ...fieldValidationStrategy(element, meta?.parent || undefined)]; + // } + + // if (element.type === 'field-list') { + // errors = [...errors, ...arrayValidationStrategy(element, meta.deepthLevel)]; + // } + + // if (element.children) { + // valiateFn(element.children, { + // ...meta, + // deepthLevel: meta.deepthLevel + 1, + // parent: element, + // }); + // } + // } + // }; + + validateFn(elements, context as any); + + return errors; + }, [elements, context]); + + return validate; +}; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/index.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/index.ts new file mode 100644 index 0000000000..b6fb03d1bc --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/index.ts @@ -0,0 +1 @@ +export * from './useValidatedInput'; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/useValidatedInput.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/useValidatedInput.ts new file mode 100644 index 0000000000..7daa7ec242 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidatedInput/useValidatedInput.ts @@ -0,0 +1,8 @@ +import { UIElement } from '@/components/providers/Validator/hooks/useValidate/ui-element'; +import { useValidator } from '@/components/providers/Validator/hooks/useValidator'; + +export const useValidatedInput = (element: UIElement) => { + const { errors } = useValidator(); + + return errors[element.getId()]; +}; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/index.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/index.ts new file mode 100644 index 0000000000..df0ef89dfd --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/index.ts @@ -0,0 +1 @@ +export * from './useValidator'; diff --git a/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/useValidator.ts b/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/useValidator.ts new file mode 100644 index 0000000000..f4889599d5 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/hooks/useValidator/useValidator.ts @@ -0,0 +1,4 @@ +import { validatorContext } from '@/components/providers/Validator/validator.context'; +import { useContext } from 'react'; + +export const useValidator = () => useContext(validatorContext); diff --git a/apps/kyb-app/src/components/providers/Validator/index.ts b/apps/kyb-app/src/components/providers/Validator/index.ts new file mode 100644 index 0000000000..3ed72221f5 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/index.ts @@ -0,0 +1 @@ +export * from './Validator'; diff --git a/apps/kyb-app/src/components/providers/Validator/types.ts b/apps/kyb-app/src/components/providers/Validator/types.ts new file mode 100644 index 0000000000..dcb515c2a7 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/types.ts @@ -0,0 +1,37 @@ +export type TValidatorErrorMessage = string; + +export type TRequiredValidationParams = boolean | [boolean, TValidatorErrorMessage]; + +export type TMinLengthValidationParams = number | [number, TValidatorErrorMessage]; + +export type TMaxLengthValidationParams = number | [number, TValidatorErrorMessage]; + +export type TPatternValidationParams = string | [string, TValidatorErrorMessage]; + +export type TValidationParams = + | TRequiredValidationParams + | TMinLengthValidationParams + | TMaxLengthValidationParams + | TPatternValidationParams; + +export type TValidators = 'required' | 'minLength' | 'maxLength' | 'pattern'; + +export interface IBaseFieldParams { + label?: string; + placeholder?: string; + stack?: number[]; +} + +export interface UIElementV2 { + id: string; + element: string; + type: 'ui' | 'field' | 'field-list'; + validation: Partial>; + params?: TFieldParams; + valueDestination: string; + children?: UIElementV2[]; +} + +export interface IBaseValueValidatorParams { + message?: string; +} diff --git a/apps/kyb-app/src/components/providers/Validator/validator.context.ts b/apps/kyb-app/src/components/providers/Validator/validator.context.ts new file mode 100644 index 0000000000..a4e3dd4831 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/validator.context.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +type TIsValid = boolean; +type TFielName = string; + +export type TValidationErrors = Record; +export interface IValidatorContext { + validate: () => TIsValid; + errors: TValidationErrors; +} + +export const validatorContext = createContext({} as IValidatorContext); diff --git a/apps/kyb-app/src/components/providers/Validator/value-validator-manager.ts b/apps/kyb-app/src/components/providers/Validator/value-validator-manager.ts new file mode 100644 index 0000000000..fbb6f5476e --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validator-manager.ts @@ -0,0 +1,25 @@ +import { IBaseValueValidatorParams } from '@/components/providers/Validator/types'; +import { MaxLengthValueValidator } from '@/components/providers/Validator/value-validators/max-length.value.validator'; +import { MinLengthValueValidator } from '@/components/providers/Validator/value-validators/min-length.value.validator'; +import { RequiredValueValidator } from '@/components/providers/Validator/value-validators/required.value-validator'; + +const validatorsMap = { + required: RequiredValueValidator, + minLength: MinLengthValueValidator, + maxLength: MaxLengthValueValidator, +}; + +export type TValidator = keyof typeof validatorsMap; + +export class ValueValidatorManager { + constructor(readonly validators: typeof validatorsMap = validatorsMap) {} + + validate( + value: unknown, + key: TValidator, + params: TValidatorParams, + ) { + const validator = new this.validators[key](params as any); + return validator.validate(value as any); + } +} diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.ts new file mode 100644 index 0000000000..2538ed631c --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.ts @@ -0,0 +1,42 @@ +import { + IBaseValueValidatorParams, + TMaxLengthValidationParams, +} from '@/components/providers/Validator/types'; +import { ValueValidator } from '@/components/providers/Validator/value-validators/value-validator.abstract'; + +export interface IMaxLengthValueValidatorParams extends IBaseValueValidatorParams { + maxLength: number; +} + +export class MaxLengthValueValidator extends ValueValidator { + validate(value: TValue): void { + if (value?.length === undefined || value.length > this.params.maxLength) { + throw new Error(this.getErrorMessage()); + } + } + + private getErrorMessage() { + if (!this.params.message) + return `Maximum length is {maxLength}.`.replace( + '{maxLength}', + this.params.maxLength.toString(), + ); + + return this.params.message.replace('{maxLength}', this.params.maxLength.toString()); + } + + static isMaxLengthParams = (params: unknown): params is TMaxLengthValidationParams => { + if (typeof params === 'number') return true; + + //@ts-ignore + if ( + Array.isArray(params) && + typeof params?.[0] === 'number' && + typeof params?.[1] === 'string' + ) { + return true; + } + + return false; + }; +} diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.unit.test.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.unit.test.ts new file mode 100644 index 0000000000..5f8b758cbb --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/max-length.value.validator.unit.test.ts @@ -0,0 +1,63 @@ +import { MaxLengthValueValidator } from '@/components/providers/Validator/value-validators/max-length.value.validator'; + +describe('Max Length Value Validator', () => { + describe('validation will fail', () => { + test('when value is undefined', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate(undefined as any)).toThrowError('error'); + }); + + test('when value is above maximum length', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate('123456')).toThrowError('error'); + }); + + test('when value is null', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate(null as any)).toThrowError('error'); + }); + }); + + describe('validation will pass', () => { + test('when value is below maximum length', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate('12345')).not.toThrow(); + }); + + test('when value is equal to maximum length', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate('12345')).not.toThrow(); + }); + }); + + describe('validator error messages', () => { + test('should return default error message when message is not provided', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5 }); + + expect(() => validator.validate('123456')).toThrowError('Maximum length is 5.'); + }); + + test('should return custom error message when message is provided', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate('123456')).toThrowError('error'); + }); + + test('should interpolate {maxLength} with the provided value', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error {maxLength}' }); + + expect(() => validator.validate('123456')).toThrowError('error 5'); + }); + + test('error message should stay same if interlopation tag is not present', () => { + const validator = new MaxLengthValueValidator({ maxLength: 5, message: 'error' }); + + expect(() => validator.validate('123456')).toThrowError('error'); + }); + }); +}); diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.ts new file mode 100644 index 0000000000..ace4428a93 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.ts @@ -0,0 +1,39 @@ +import { ValueValidator } from '@/components/providers/Validator/value-validators/value-validator.abstract'; +import { IBaseValueValidatorParams, TMinLengthValidationParams } from '../types'; + +export interface IMinLengthValueValidatorParams extends IBaseValueValidatorParams { + minLength: number; +} + +export class MinLengthValueValidator extends ValueValidator { + validate(value: TValue): void { + if (value?.length === undefined || value.length < this.params.minLength) { + throw new Error(this.getErrorMessage()); + } + } + + private getErrorMessage() { + if (!this.params.message) + return `Minimum length is {minLength}.`.replace( + '{minLength}', + this.params.minLength.toString(), + ); + + return this.params.message.replace('{minLength}', this.params.minLength.toString()); + } + + static isMinLengthParams = (params: unknown): params is TMinLengthValidationParams => { + if (typeof params === 'number') return true; + + //@ts-ignore + if ( + Array.isArray(params) && + typeof params?.[0] === 'number' && + typeof params?.[1] === 'string' + ) { + return true; + } + + return false; + }; +} diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.unit.test.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.unit.test.ts new file mode 100644 index 0000000000..68e7f607de --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/min-length.value.validator.unit.test.ts @@ -0,0 +1,63 @@ +import { MinLengthValueValidator } from '@/components/providers/Validator/value-validators/min-length.value.validator'; + +describe('MinLength Value Validator', () => { + describe('validation will fail', () => { + test('when value is undefined', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate(undefined as any)).toThrowError('error'); + }); + + test('when value is below minimum length', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate('1234')).toThrowError('error'); + }); + + test('when value is null', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate(null as any)).toThrowError('error'); + }); + }); + + describe('validation will pass', () => { + test('when value is above minimum length', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate('12345')).not.toThrow(); + }); + + test('when value is equal to minimum length', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate('12345')).not.toThrow(); + }); + }); + + describe('validator error messages', () => { + test('should return default error message when message is not provided', () => { + const validator = new MinLengthValueValidator({ minLength: 5 }); + + expect(() => validator.validate('1234')).toThrowError('Minimum length is 5.'); + }); + + test('should return custom error message when message is provided', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate('1234')).toThrowError('error'); + }); + + test('should interpolate {minLength} with the provided value', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error {minLength}' }); + + expect(() => validator.validate('1234')).toThrowError('error 5'); + }); + + test('error message should stay same if interlopation tag is not present', () => { + const validator = new MinLengthValueValidator({ minLength: 5, message: 'error' }); + + expect(() => validator.validate('1234')).toThrowError('error'); + }); + }); +}); diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.ts new file mode 100644 index 0000000000..cc7815f7b4 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.ts @@ -0,0 +1,39 @@ +import { + IBaseValueValidatorParams, + TPatternValidationParams, +} from '@/components/providers/Validator/types'; +import { ValueValidator } from '@/components/providers/Validator/value-validators/value-validator.abstract'; + +export interface IPatternValidatorParams extends IBaseValueValidatorParams { + pattern: string; +} + +export class PatternValueValidator extends ValueValidator { + validate(value: unknown) { + if (!new RegExp(this.params.pattern).test(value as string)) { + throw new Error(this.getErrorMessage()); + } + } + + private getErrorMessage() { + if (!this.params.message) + return `Value must match {pattern}.`.replace('{pattern}', this.params.pattern); + + return this.params.message.replace('{pattern}', this.params.pattern); + } + + static isPatternParams = (params: unknown): params is TPatternValidationParams => { + if (typeof params === 'boolean') return true; + + //@ts-ignore + if ( + Array.isArray(params) && + typeof params?.[0] === 'number' && + typeof params?.[1] === 'string' + ) { + return true; + } + + return false; + }; +} diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.unit.test.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.unit.test.ts new file mode 100644 index 0000000000..b305bcb250 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/pattern.value.validator.unit.test.ts @@ -0,0 +1,54 @@ +import { PatternValueValidator } from '@/components/providers/Validator/value-validators/pattern.value.validator'; + +describe('Pattern Value Validator', () => { + describe('validation will fail', () => { + test('when value does not match pattern', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$', message: 'error' }); + + expect(() => validator.validate('abc')).toThrowError('error'); + }); + + test('when value is null', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$', message: 'error' }); + + expect(() => validator.validate(null as any)).toThrowError('error'); + }); + }); + + describe('validation will pass', () => { + test('when value matches pattern', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$', message: 'error' }); + + expect(() => validator.validate('123')).not.toThrow(); + }); + }); + + describe('validator error messages', () => { + test('should return default error message when message is not provided', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$' }); + + expect(() => validator.validate('abc')).toThrowError('Value must match ^[0-9]+$.'); + }); + + test('should return custom error message when message is provided', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$', message: 'error' }); + + expect(() => validator.validate('abc')).toThrowError('error'); + }); + + test('should interpolate {pattern} with the provided value', () => { + const validator = new PatternValueValidator({ + pattern: '^[0-9]+$', + message: 'error {pattern}', + }); + + expect(() => validator.validate('abc')).toThrowError('error ^[0-9]+$'); + }); + + test('error message should stay same if interlopation tag is not present', () => { + const validator = new PatternValueValidator({ pattern: '^[0-9]+$', message: 'error' }); + + expect(() => validator.validate('abc')).toThrowError('error'); + }); + }); +}); diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.ts new file mode 100644 index 0000000000..a4a3f79b22 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.ts @@ -0,0 +1,38 @@ +import { + IBaseValueValidatorParams, + TRequiredValidationParams, +} from '@/components/providers/Validator/types'; +import { ValueValidator } from '@/components/providers/Validator/value-validators/value-validator.abstract'; + +export interface IRequiredValueValidatorParams extends IBaseValueValidatorParams { + required: boolean; +} + +export class RequiredValueValidator extends ValueValidator { + validate(value: unknown) { + if (value === undefined || value === null || value === '') { + throw new Error(this.getErrorMessage()); + } + } + + private getErrorMessage() { + if (!this.params.message) return `Value is required.`; + + return this.params.message; + } + + static isRequiredParams = (params: unknown): params is TRequiredValidationParams => { + if (typeof params === 'boolean') return true; + + //@ts-ignore + if ( + Array.isArray(params) && + typeof params?.[0] === 'number' && + typeof params?.[1] === 'string' + ) { + return true; + } + + return false; + }; +} diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.unit.test.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.unit.test.ts new file mode 100644 index 0000000000..a752045689 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/required.value-validator.unit.test.ts @@ -0,0 +1,57 @@ +import { RequiredValueValidator } from '@/components/providers/Validator/value-validators/required.value-validator'; + +describe('Required Value Validator', () => { + describe('validation will fail', () => { + test('when value is undefined', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate(undefined as any)).toThrowError('error'); + }); + + test('when value is empty string', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate('')).toThrowError('error'); + }); + + test('when value is null', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate(null as any)).toThrowError('error'); + }); + }); + + describe('validation will pass', () => { + test('when value is not empty string', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate('value')).not.toThrow(); + }); + + test('when value is not null', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate(0)).not.toThrow(); + }); + + test('when value is not undefined', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate(false)).not.toThrow(); + }); + }); + + describe('validator error messages', () => { + test('should return default error message when message is not provided', () => { + const validator = new RequiredValueValidator({ required: true }); + + expect(() => validator.validate('')).toThrowError('Value is required.'); + }); + + test('should return custom error message when message is provided', () => { + const validator = new RequiredValueValidator({ message: 'error', required: true }); + + expect(() => validator.validate('')).toThrowError('error'); + }); + }); +}); diff --git a/apps/kyb-app/src/components/providers/Validator/value-validators/value-validator.abstract.ts b/apps/kyb-app/src/components/providers/Validator/value-validators/value-validator.abstract.ts new file mode 100644 index 0000000000..6230afed24 --- /dev/null +++ b/apps/kyb-app/src/components/providers/Validator/value-validators/value-validator.abstract.ts @@ -0,0 +1,5 @@ +export abstract class ValueValidator { + constructor(readonly params: TParams) {} + + abstract validate(value: unknown, errorMessage: string): void; +} diff --git a/apps/kyb-app/src/poc-definition.ts b/apps/kyb-app/src/poc-definition.ts new file mode 100644 index 0000000000..4a32718ce4 --- /dev/null +++ b/apps/kyb-app/src/poc-definition.ts @@ -0,0 +1,170 @@ +import { UIElementV2 } from '@/components/providers/Validator/types'; +import { get } from 'lodash'; + +export const pocDefinition: UIElementV2[] = [ + { + id: 'first-name', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [2, 'Minimum length is 2.'], + }, + params: {}, + valueDestination: 'firstName', + }, + { + id: 'last-name', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [5, 'Minimum length is 5.'], + }, + params: {}, + valueDestination: 'lastName', + }, + { + id: 'names-list', + element: 'array', + type: 'field-list', + validation: { + minLength: [1, 'Minimum "names-list" length is 1.'], + }, + params: {}, + valueDestination: 'items', + children: [ + { + id: 'children-name', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [2, 'Minimum length is 2.'], + }, + params: {}, + valueDestination: 'items[$0].childrenName', + }, + { + id: 'children-last-name', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [5, 'Minimum length is 5.'], + }, + params: {}, + valueDestination: 'items[$0].childrenLastName', + }, + { + id: 'sub-list', + element: 'array', + type: 'field-list', + validation: { + minLength: [1, 'Minimum "sub-list" length is 1.'], + }, + params: {}, + valueDestination: 'items[$0].subItems', + children: [ + { + id: 'first-name-sub', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [2, 'Minimum length is 2.'], + }, + params: {}, + valueDestination: 'items[$0].subItems[$1].firstName', + }, + { + id: 'last-name-sub', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [5, 'Minimum length is 5.'], + }, + params: {}, + valueDestination: 'items[$0].subItems[$1].lastName', + }, + { + id: 'subsub-list', + element: 'array', + type: 'field-list', + validation: { + minLength: [1, 'Minimum "subsub-list" length is 1.'], + }, + params: {}, + valueDestination: 'items[$0].subItems[$1].subsubItems', + children: [ + { + id: 'first-name-sub', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [2, 'Minimum length is 2.'], + }, + params: {}, + valueDestination: 'items[$0].subItems[$1].subsubItems[$2].firstName', + }, + { + id: 'last-name-sub', + element: 'text', + type: 'field', + validation: { + required: true, + minLength: [5, 'Minimum length is 5.'], + }, + params: {}, + valueDestination: 'items[$0].subItems[$1].subsubItems[$2].lastName', + }, + ], + }, + ], + }, + ], + }, +]; + +const context = { + items: [ + { + subItems: [{}, { subsubItems: [{}, {}] }], + }, + {}, + ], +}; + +const formatDestination = (destination: string, stack: number[]) => { + stack.forEach((value, index) => { + destination = destination.replace(`$${index}`, String(value)); + }); + + return destination; +}; + +const iterate = (elements: UIElementV2[], context: object, stack: number[] = []) => { + for (let i = 0; i < elements.length; i++) { + const element = elements[i] as UIElementV2; + + if (!element) continue; + + if (element.type === 'field') { + console.log('valueDestination', formatDestination(element.valueDestination, stack)); + continue; + } + + if (element.type === 'field-list') { + // console.log('valueDestination', element.valueDestination); + const value = get(context, formatDestination(element.valueDestination, stack)) as any[]; + + value?.forEach((item, index) => { + iterate(element.children!, context, [...stack, index]); + }); + } + } +}; + +iterate(pocDefinition, context); diff --git a/apps/kyb-app/src/poc/FieldList/FieldList.tsx b/apps/kyb-app/src/poc/FieldList/FieldList.tsx new file mode 100644 index 0000000000..3edfc5dc3e --- /dev/null +++ b/apps/kyb-app/src/poc/FieldList/FieldList.tsx @@ -0,0 +1,33 @@ +import { UIElement } from '@/components/providers/Validator/hooks/useValidate/ui-element'; +import { useValidatedInput } from '@/components/providers/Validator/hooks/useValidatedInput'; +import { UIElementV2 } from '@/components/providers/Validator/types'; +import { FunctionComponent, useMemo } from 'react'; + +interface IFieldListProps { + element: UIElementV2; + children: any; + context: any; + stack?: number[]; +} + +export const FieldList: FunctionComponent = ({ + element, + context, + children, + stack = [], +}) => { + const uiElement = useMemo( + () => new UIElement(element, context, stack), + [element, context, stack], + ); + const error = useValidatedInput(uiElement); + + return ( +
+ + + {children} + {error && {error}} +
+ ); +}; diff --git a/apps/kyb-app/src/poc/FieldList/index.ts b/apps/kyb-app/src/poc/FieldList/index.ts new file mode 100644 index 0000000000..9fb4cd2987 --- /dev/null +++ b/apps/kyb-app/src/poc/FieldList/index.ts @@ -0,0 +1 @@ +export * from './FieldList'; diff --git a/apps/kyb-app/src/poc/SubmitButton/SubmitButton.tsx b/apps/kyb-app/src/poc/SubmitButton/SubmitButton.tsx new file mode 100644 index 0000000000..59f726aa22 --- /dev/null +++ b/apps/kyb-app/src/poc/SubmitButton/SubmitButton.tsx @@ -0,0 +1,20 @@ +import { useValidator } from '@/components/providers/Validator/hooks/useValidator'; + +export const SubmitButton = () => { + const { validate } = useValidator(); + + const handleSubmit = (e: React.FormEvent) => { + const isValid = validate(); + + if (!isValid) { + e.preventDefault(); + } + }; + + return ( + //@ts-ignore + + ); +}; diff --git a/apps/kyb-app/src/poc/SubmitButton/index.ts b/apps/kyb-app/src/poc/SubmitButton/index.ts new file mode 100644 index 0000000000..bcd94ff82e --- /dev/null +++ b/apps/kyb-app/src/poc/SubmitButton/index.ts @@ -0,0 +1 @@ +export * from './SubmitButton'; diff --git a/apps/kyb-app/src/poc/TextInput/TextInput.tsx b/apps/kyb-app/src/poc/TextInput/TextInput.tsx new file mode 100644 index 0000000000..8590398b7c --- /dev/null +++ b/apps/kyb-app/src/poc/TextInput/TextInput.tsx @@ -0,0 +1,31 @@ +import { UIElement } from '@/components/providers/Validator/hooks/useValidate/ui-element'; +import { useValidatedInput } from '@/components/providers/Validator/hooks/useValidatedInput'; +import { UIElementV2 } from '@/components/providers/Validator/types'; +import { Input } from '@ballerine/ui'; +import { set } from 'lodash'; +import { FunctionComponent, useCallback, useMemo } from 'react'; + +export interface ITextInputProps { + context: object; + element: UIElementV2; + stack?: number[]; +} + +export const TextInput: FunctionComponent = ({ context, element, stack = [] }) => { + const uiElement = useMemo(() => new UIElement(element, context, stack), [element, context]); + const validationError = useValidatedInput(uiElement); + const handleChange = useCallback( + (event: React.ChangeEvent) => { + set(context, uiElement.getValueDestination(), event.target.value); + }, + [context, uiElement], + ); + + return ( +
+ + + {validationError && {validationError}} +
+ ); +}; diff --git a/apps/kyb-app/src/poc/TextInput/index.ts b/apps/kyb-app/src/poc/TextInput/index.ts new file mode 100644 index 0000000000..a7fcf6f39c --- /dev/null +++ b/apps/kyb-app/src/poc/TextInput/index.ts @@ -0,0 +1 @@ +export * from './TextInput'; diff --git a/apps/kyb-app/src/router.tsx b/apps/kyb-app/src/router.tsx index a1f7eb83fc..9595e1df21 100644 --- a/apps/kyb-app/src/router.tsx +++ b/apps/kyb-app/src/router.tsx @@ -3,6 +3,9 @@ import { CollectionFlow } from '@/pages/CollectionFlow'; import { Approved } from '@/pages/CollectionFlow/components/pages/Approved'; import { Rejected } from '@/pages/CollectionFlow/components/pages/Rejected'; import { SignIn } from '@/pages/SignIn'; +import { ValidatorPOC } from '@/ValidatorPOC'; +import * as Sentry from '@sentry/react'; +import React from 'react'; import { createBrowserRouter, createRoutesFromChildren, @@ -10,8 +13,6 @@ import { useLocation, useNavigationType, } from 'react-router-dom'; -import * as Sentry from '@sentry/react'; -import React from 'react'; export const sentyRouterInstrumentation = Sentry.reactRouterV6Instrumentation( React.useEffect, @@ -40,4 +41,8 @@ export const router = sentryCreateBrowserRouter([ path: 'approved', Component: withTokenProtected(Approved), }, + { + path: 'poc', + Component: ValidatorPOC, + }, ]); diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations index 46f7e2631f..b5b8cded73 160000 --- a/services/workflows-service/prisma/data-migrations +++ b/services/workflows-service/prisma/data-migrations @@ -1 +1 @@ -Subproject commit 46f7e2631f901b2746852f49b43ef7c2e5dbe5ea +Subproject commit b5b8cded73bdeafe792b5e5cb51780ae8a2c58a7