Skip to content

Commit

Permalink
feat: implemented conditional validation rule apply & custom validators
Browse files Browse the repository at this point in the history
  • Loading branch information
chesterkmr committed Dec 4, 2024
1 parent e471f81 commit 7cb51fe
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 18 deletions.
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"dayjs": "^1.11.6",
"email-validator": "^2.0.4",
"i18n-iso-countries": "^7.6.0",
"json-logic-js": "^2.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.144.0",
"react": "^18.0.37",
Expand All @@ -79,6 +80,7 @@
"@storybook/testing-library": "^0.0.14-next.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^13.3.0",
"@types/json-logic-js": "^2.0.1",
"@types/lodash": "^4.14.191",
"@types/node": "^20.4.1",
"@types/react": "^18.0.37",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@ export type TBaseValidators =
| 'minimum'
| 'maximum';

export interface ICommonValidator<T = object, TValidatorType = TBaseValidators> {
export interface ICommonValidator<T = object, TValidatorType extends string = TBaseValidators> {
type: TValidatorType;
value: T;
message?: string;
applyWhen?: IValidationRule;
}

export interface IValidationSchema {
export interface IValidationSchema<
TValidatorTypeExtends extends string = TBaseValidators,
TValue = object,
> {
id: string;
valueDestination?: string;
validators: ICommonValidator[];
validators: Array<ICommonValidator<TValue, TValidatorTypeExtends>>;
children?: IValidationSchema[];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { ICommonValidator } from '../../types';
import { ICommonValidator, TBaseValidators } from '../../types';
import { baseValidatorsMap, validatorsExtends } from '../../validators';

export const getValidator = (validator: ICommonValidator) => {
const validatorFn = baseValidatorsMap[validator.type] || validatorsExtends[validator.type];
export const getValidator = <TValidatorTypeExtends extends string = TBaseValidators>(
validator: ICommonValidator<object, TValidatorTypeExtends>,
) => {
const validatorFn =
baseValidatorsMap[validator.type as unknown as TBaseValidators] ||
validatorsExtends[validator.type];

if (!validatorFn) {
throw new Error(`Validator ${validator.type} not found.`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './remove-validator';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { validatorsExtends } from '../../validators';

export const removeValidator = (type: string) => {
delete validatorsExtends[type];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { removeValidator } from './remove-validator';

vi.mock('../../validators', () => ({
validatorsExtends: {},
}));

describe('removeValidator', async () => {
const validatorsExtends = vi.mocked(await import('../../validators')).validatorsExtends;

beforeEach(() => {
// Clear validators before each test
Object.keys(validatorsExtends).forEach(key => {
delete validatorsExtends[key];
});
});

it('should remove validator from validatorsExtends', () => {
// Setup
const mockValidator = vi.fn();
validatorsExtends['test'] = mockValidator;
expect(validatorsExtends['test']).toBe(mockValidator);

// Execute
removeValidator('test');

// Verify
expect(validatorsExtends['test']).toBeUndefined();
});

it('should not throw error when removing non-existent validator', () => {
expect(() => {
removeValidator('nonexistent');
}).not.toThrow();
});

it('should only remove specified validator', () => {
// Setup
const mockValidator1 = vi.fn();
const mockValidator2 = vi.fn();
validatorsExtends['test1'] = mockValidator1;
validatorsExtends['test2'] = mockValidator2;

// Execute
removeValidator('test1');

// Verify
expect(validatorsExtends['test1']).toBeUndefined();
expect(validatorsExtends['test2']).toBe(mockValidator2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import jsonLogic from 'json-logic-js';
import { IValidationRule } from '../../types';

export const isShouldApplyValidation = (rule: IValidationRule, context: object) => {
return Boolean(jsonLogic.apply(rule.value, context));
};
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import get from 'lodash/get';
import { IValidationError, IValidationSchema } from '../../types';
import {
ICommonValidator,
IValidationError,
IValidationSchema,
TBaseValidators,
} from '../../types';
import { createValidationError } from '../create-validation-error';
import { formatValueDestination } from '../format-value-destination';
import { getValidator } from '../get-validator';
import { isShouldApplyValidation } from './helpers';
import { IValidateParams } from './types';

// TODO: Codnitional Apply
// TODO: Test coverage ror custom validators

export const validate = <TValues extends object>(
export const validate = <
TValues extends object,
TValidatorTypeExtends extends string = TBaseValidators,
>(
context: TValues,
schema: IValidationSchema[],
schema: Array<IValidationSchema<TValidatorTypeExtends>>,
params: IValidateParams = {},
): IValidationError[] => {
const { abortEarly = false } = params;

const validationErrors: IValidationError[] = [];

const run = (schema: IValidationSchema[], stack: number[] = []) => {
const run = (schema: Array<IValidationSchema<TValidatorTypeExtends>>, stack: number[] = []) => {
schema.forEach(schema => {
const { validators = [], children, valueDestination, id } = schema;
const formattedValueDestination = valueDestination
Expand All @@ -27,10 +33,14 @@ export const validate = <TValues extends object>(
const value = formattedValueDestination ? get(context, formattedValueDestination) : context;

for (const validator of validators) {
if (validator.applyWhen && !isShouldApplyValidation(validator.applyWhen, context)) {
continue;
}

const validate = getValidator(validator);

try {
validate(value, validator);
validate(value, validator as unknown as ICommonValidator);
} catch (exception) {
const error = createValidationError({
id,
Expand All @@ -49,7 +59,7 @@ export const validate = <TValues extends object>(

if (children?.length && Array.isArray(value)) {
value.forEach((_, index) => {
run(children, [...stack, index]);
run(children as Array<IValidationSchema<TValidatorTypeExtends>>, [...stack, index]);
});
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, test } from 'vitest';
import { IValidationError, IValidationSchema } from '../../types';
import { beforeEach, describe, expect, it, test, vi } from 'vitest';
import { ICommonValidator, IValidationError, IValidationSchema } from '../../types';
import { registerValidator } from '../register-validator';
import { validate } from './validate';

describe('validate', () => {
Expand Down Expand Up @@ -726,5 +727,134 @@ describe('validate', () => {
});
});
});

describe('conditional validation', () => {
const case1 = [
{
firstName: 'John',
lastName: undefined,
},
[
{
id: 'name',
valueDestination: 'firstName',
validators: [{ type: 'required', message: 'Field is required.', value: {} }],
},
{
id: 'lastName',
valueDestination: 'lastName',
validators: [
{
type: 'required',
message: 'Field is required.',
value: {},
applyWhen: {
type: 'json-logic',
value: {
var: 'firstName',
},
},
},
],
},
] as IValidationSchema[],
{
id: 'lastName',
originId: 'lastName',
invalidValue: undefined,
message: ['Field is required.'],
} as IValidationError,
] as const;

const case2 = [
{
firstName: 'Banana',
lastName: undefined,
},
[
{
id: 'lastName',
valueDestination: 'lastName',
validators: [
{
type: 'required',
message: 'Field is required.',
value: {},
applyWhen: {
type: 'json-logic',
value: {
'==': [{ var: 'firstName' }, 'Banana'],
},
},
},
],
},
] as IValidationSchema[],
{
id: 'lastName',
originId: 'lastName',
invalidValue: undefined,
message: ['Field is required.'],
} as IValidationError,
] as const;

const cases = [case1, case2];

test.each(cases)(
'should be applied when the condition is truthy',
(testData, schema, expectedErrors) => {
const errors = validate(testData, schema);

expect(errors).toEqual([expectedErrors]);
},
);
});

describe('custom validators', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('even number validator', () => {
const evenNumberValidator = (value: number, _: ICommonValidator) => {
if (typeof value !== 'number') return true;

if (value % 2 !== 0) {
throw new Error('Number is not even');
}
};

registerValidator('evenNumber', evenNumberValidator);

const data = {
odd: 19,
even: 20,
};

const schema = [
{
id: 'odd',
valueDestination: 'odd',
validators: [{ type: 'evenNumber', value: {} }],
},
{
id: 'even',
valueDestination: 'even',
validators: [{ type: 'evenNumber', value: {} }],
},
] as Array<IValidationSchema<'evenNumber'>>;

const errors = validate(data, schema);

expect(errors).toEqual([
{
id: 'odd',
originId: 'odd',
invalidValue: 19,
message: ['Number is not even'],
},
]);
});
});
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion services/workflows-service/prisma/data-migrations

0 comments on commit 7cb51fe

Please sign in to comment.