diff --git a/next/package.json b/next/package.json index 075f80c9..0bdd1333 100644 --- a/next/package.json +++ b/next/package.json @@ -41,6 +41,9 @@ "release:dev": "cd .. && npm run release:v1:dev", "release:beta": "cd .. && npm run release:v1:beta" }, + "dependencies": { + "json-logic-js": "^2.0.5" + }, "devDependencies": { "@antfu/eslint-config": "^3.14.0", "@babel/core": "^7.23.7", @@ -48,6 +51,7 @@ "@babel/preset-typescript": "^7.26.0", "@jest/globals": "^29.7.0", "@jest/reporters": "^29.7.0", + "@types/json-logic-js": "^2.0.8", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "babel-jest": "^29.7.0", diff --git a/next/pnpm-lock.yaml b/next/pnpm-lock.yaml index 03744ce6..4ac1bed6 100644 --- a/next/pnpm-lock.yaml +++ b/next/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + json-logic-js: + specifier: ^2.0.5 + version: 2.0.5 devDependencies: '@antfu/eslint-config': specifier: ^3.14.0 @@ -26,6 +30,9 @@ importers: '@jest/reporters': specifier: ^29.7.0 version: 29.7.0 + '@types/json-logic-js': + specifier: ^2.0.8 + version: 2.0.8 '@types/lodash': specifier: ^4.17.16 version: 4.17.16 @@ -1221,6 +1228,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/json-logic-js@2.0.8': + resolution: {integrity: sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2314,6 +2324,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-logic-js@2.0.5: + resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -4588,6 +4601,8 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/json-logic-js@2.0.8': {} + '@types/json-schema@7.0.15': {} '@types/lodash@4.17.16': {} @@ -5970,6 +5985,8 @@ snapshots: json-buffer@3.0.1: {} + json-logic-js@2.0.5: {} + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} diff --git a/next/src/errors/index.ts b/next/src/errors/index.ts index ffd9d94d..45c03f56 100644 --- a/next/src/errors/index.ts +++ b/next/src/errors/index.ts @@ -29,6 +29,10 @@ export type SchemaValidationErrorType = * Array validation keywords */ | 'minItems' | 'maxItems' | 'uniqueItems' | 'contains' | 'maxContains' | 'minContains' + /** + * Custom validation keywords + */ + | 'json-logic' export type ValidationErrorPath = Array @@ -55,4 +59,10 @@ export interface ValidationError { * 'required' */ validation: SchemaValidationErrorType + /** + * The custom error message to display + * @example + * 'The value is not valid' + */ + customErrorMessage?: string } diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index f3b2f9e2..e1fde0e7 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -7,6 +7,7 @@ export function getErrorMessage( schema: NonBooleanJsfSchema, value: SchemaValue, validation: SchemaValidationErrorType, + customErrorMessage?: string, ): string { switch (validation) { // Core validation @@ -76,6 +77,8 @@ export function getErrorMessage( throw new Error('Array support is not implemented yet') case 'maxContains': throw new Error('Array support is not implemented yet') + case 'json-logic': + return customErrorMessage || 'The value is not valid' } } diff --git a/next/src/form.ts b/next/src/form.ts index fc34003a..99a2ec8e 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -186,7 +186,7 @@ function addErrorMessages(rootValue: SchemaValue, rootSchema: JsfSchema, errors: return { ...error, - message: getErrorMessage(errorSchema, errorValue, error.validation), + message: getErrorMessage(errorSchema, errorValue, error.validation, error.customErrorMessage), } }) } diff --git a/next/src/types.ts b/next/src/types.ts index fbc88810..7877baea 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -1,6 +1,6 @@ +import type { RulesLogic } from 'json-logic-js' import type { JSONSchema } from 'json-schema-typed/draft-2020-12' import type { FieldType } from './field/type' - /** * Defines the type of a `Field` in the form. */ @@ -29,6 +29,21 @@ export type JsfPresentation = { [key: string]: unknown } +export interface JsonLogicBag { + schema: JsonLogicSchema + value: SchemaValue +} + +export interface JsonLogicSchema { + validations?: Record + computedValues?: Record +} + /** * JSON Schema Form extending JSON Schema with additional JSON Schema Form properties. */ @@ -42,17 +57,21 @@ export type JsfSchema = JSONSchema & { 'if'?: JsfSchema 'then'?: JsfSchema 'else'?: JsfSchema - 'x-jsf-logic'?: { - validations: Record - computedValues: Record - } // Note: if we don't have this property here, when inspecting any recursive // schema (like an if inside another schema), the required property won't be // present in the type 'required'?: string[] + // Defines the order of the fields in the form. 'x-jsf-order'?: string[] + // Defines the presentation of the field in the form. 'x-jsf-presentation'?: JsfPresentation + // Defines the error message of the field in the form. 'x-jsf-errorMessage'?: Record + 'x-jsf-logic'?: JsonLogicSchema + // Extra validations to run. References validations in the `x-jsf-logic` root property. + 'x-jsf-logic-validations'?: string[] + // Extra attributes to add to the schema. References computedValues in the `x-jsf-logic` root property. + 'x-jsf-logic-computedAttrs'?: Record } /** diff --git a/next/src/validation/array.ts b/next/src/validation/array.ts index e90553dd..1b470f7e 100644 --- a/next/src/validation/array.ts +++ b/next/src/validation/array.ts @@ -1,5 +1,5 @@ import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfSchema, NonBooleanJsfSchema, SchemaValue } from '../types' +import type { JsfSchema, JsonLogicBag, NonBooleanJsfSchema, SchemaValue } from '../types' import { validateSchema, type ValidationOptions } from './schema' import { deepEqual } from './util' @@ -8,6 +8,7 @@ import { deepEqual } from './util' * @param value - The value to validate * @param schema - The schema to validate against * @param options - The validation options + * @param jsonLogicBag - The JSON logic bag * @param path - The path to the current field being validated * @returns An array of validation errors * @description @@ -18,6 +19,7 @@ export function validateArray( value: SchemaValue, schema: JsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath, ): ValidationError[] { if (!Array.isArray(value)) { @@ -27,9 +29,9 @@ export function validateArray( return [ ...validateLength(schema, value, path), ...validateUniqueItems(schema, value, path), - ...validateContains(value, schema, options, path), - ...validatePrefixItems(schema, value, options, path), - ...validateItems(schema, value, options, path), + ...validateContains(value, schema, options, jsonLogicBag, path), + ...validatePrefixItems(schema, value, options, jsonLogicBag, path), + ...validateItems(schema, value, options, jsonLogicBag, path), ] } @@ -44,7 +46,11 @@ export function validateArray( * If the `maxItems` keyword is defined, the array must contain at most `maxItems` items. * If the `minItems` keyword is defined, the array must contain at least `minItems` items. */ -function validateLength(schema: NonBooleanJsfSchema, value: SchemaValue[], path: ValidationErrorPath): ValidationError[] { +function validateLength( + schema: NonBooleanJsfSchema, + value: SchemaValue[], + path: ValidationErrorPath, +): ValidationError[] { const errors: ValidationError[] = [] const itemsLength = value.length @@ -65,6 +71,7 @@ function validateLength(schema: NonBooleanJsfSchema, value: SchemaValue[], path: * @param schema - The schema to validate against * @param values - The array value to validate * @param options - The validation options + * @param jsonLogicBag - The JSON logic bag * @param path - The path to the current field being validated * @returns An array of validation errors * @description @@ -72,7 +79,13 @@ function validateLength(schema: NonBooleanJsfSchema, value: SchemaValue[], path: * If the `items` keyword is defined, each item in the array must match the schema of the `items` keyword. * When the `prefixItems` keyword is defined, the items constraint is validated only for the items after the prefix items. */ -function validateItems(schema: NonBooleanJsfSchema, values: SchemaValue[], options: ValidationOptions, path: ValidationErrorPath): ValidationError[] { +function validateItems( + schema: NonBooleanJsfSchema, + values: SchemaValue[], + options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, + path: ValidationErrorPath, +): ValidationError[] { if (schema.items === undefined) { return [] } @@ -81,7 +94,15 @@ function validateItems(schema: NonBooleanJsfSchema, values: SchemaValue[], optio const startIndex = Array.isArray(schema.prefixItems) ? schema.prefixItems.length : 0 for (const [i, item] of values.slice(startIndex).entries()) { - errors.push(...validateSchema(item, schema.items, options, [...path, 'items', i + startIndex])) + errors.push( + ...validateSchema( + item, + schema.items, + options, + [...path, 'items', i + startIndex], + jsonLogicBag, + ), + ) } return errors @@ -92,13 +113,20 @@ function validateItems(schema: NonBooleanJsfSchema, values: SchemaValue[], optio * @param schema - The schema to validate against * @param values - The array value to validate * @param options - The validation options + * @param jsonLogicBag - The JSON logic bag * @param path - The path to the current field being validated * @returns An array of validation errors * @description * Validates the prefixItems constraint of an array. * If the `prefixItems` keyword is defined, each item in the array must match the schema of the corresponding prefix item. */ -function validatePrefixItems(schema: NonBooleanJsfSchema, values: SchemaValue[], options: ValidationOptions, path: ValidationErrorPath): ValidationError[] { +function validatePrefixItems( + schema: NonBooleanJsfSchema, + values: SchemaValue[], + options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, + path: ValidationErrorPath, +): ValidationError[] { if (!Array.isArray(schema.prefixItems)) { return [] } @@ -106,7 +134,15 @@ function validatePrefixItems(schema: NonBooleanJsfSchema, values: SchemaValue[], const errors: ValidationError[] = [] for (const [i, item] of values.entries()) { if (i < schema.prefixItems.length) { - errors.push(...validateSchema(item, schema.prefixItems[i] as JsfSchema, options, [...path, 'prefixItems', i])) + errors.push( + ...validateSchema( + item, + schema.prefixItems[i] as JsfSchema, + options, + [...path, 'prefixItems', i], + jsonLogicBag, + ), + ) } } @@ -118,6 +154,7 @@ function validatePrefixItems(schema: NonBooleanJsfSchema, values: SchemaValue[], * @param value - The array value to validate * @param schema - The schema to validate against * @param options - The validation options + * @param jsonLogicBag - The JSON logic bag * @param path - The path to the current field being validated * @returns An array of validation errors * @description @@ -128,6 +165,7 @@ function validateContains( value: SchemaValue[], schema: NonBooleanJsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath, ): ValidationError[] { if (!('contains' in schema)) { @@ -137,8 +175,15 @@ function validateContains( const errors: ValidationError[] = [] // How many items in the array are valid against the contains schema? - const contains = value.filter(item => - validateSchema(item, schema.contains as JsfSchema, options, [...path, 'contains']).length === 0, + const contains = value.filter( + item => + validateSchema( + item, + schema.contains as JsfSchema, + options, + [...path, 'contains'], + jsonLogicBag, + ).length === 0, ).length if (schema.minContains === undefined && schema.maxContains === undefined) { @@ -168,7 +213,11 @@ function validateContains( * @description * Validates the uniqueItems constraint of an array when the `uniqueItems` keyword is defined as `true`. */ -function validateUniqueItems(schema: NonBooleanJsfSchema, values: SchemaValue[], path: ValidationErrorPath): ValidationError[] { +function validateUniqueItems( + schema: NonBooleanJsfSchema, + values: SchemaValue[], + path: ValidationErrorPath, +): ValidationError[] { if (schema.uniqueItems !== true) { return [] } diff --git a/next/src/validation/composition.ts b/next/src/validation/composition.ts index 30ab31de..f91ac242 100644 --- a/next/src/validation/composition.ts +++ b/next/src/validation/composition.ts @@ -7,7 +7,7 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { ValidationOptions } from '../form' -import type { JsfSchema, SchemaValue } from '../types' +import type { JsfSchema, JsonLogicBag, SchemaValue } from '../types' import { validateSchema } from './schema' /** @@ -30,6 +30,7 @@ export function validateAllOf( value: SchemaValue, schema: JsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (!schema.allOf) { @@ -38,7 +39,7 @@ export function validateAllOf( for (let i = 0; i < schema.allOf.length; i++) { const subSchema = schema.allOf[i] - const errors = validateSchema(value, subSchema, options, [...path, 'allOf', i]) + const errors = validateSchema(value, subSchema, options, [...path, 'allOf', i], jsonLogicBag) if (errors.length > 0) { return errors } @@ -67,6 +68,7 @@ export function validateAnyOf( value: SchemaValue, schema: JsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (!schema.anyOf) { @@ -74,7 +76,7 @@ export function validateAnyOf( } for (const subSchema of schema.anyOf) { - const errors = validateSchema(value, subSchema, options, path) + const errors = validateSchema(value, subSchema, options, path, jsonLogicBag) if (errors.length === 0) { return [] } @@ -108,6 +110,7 @@ export function validateOneOf( value: SchemaValue, schema: JsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (!schema.oneOf) { @@ -117,7 +120,7 @@ export function validateOneOf( let validCount = 0 for (let i = 0; i < schema.oneOf.length; i++) { - const errors = validateSchema(value, schema.oneOf[i], options, path) + const errors = validateSchema(value, schema.oneOf[i], options, path, jsonLogicBag) if (errors.length === 0) { validCount++ if (validCount > 1) { @@ -168,6 +171,7 @@ export function validateNot( value: SchemaValue, schema: JsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (schema.not === undefined) { @@ -175,13 +179,9 @@ export function validateNot( } if (typeof schema.not === 'boolean') { - return schema.not - ? [{ path, validation: 'not' }] - : [] + return schema.not ? [{ path, validation: 'not' }] : [] } - const notErrors = validateSchema(value, schema.not, options, path) - return notErrors.length === 0 - ? [{ path, validation: 'not' }] - : [] + const notErrors = validateSchema(value, schema.not, options, path, jsonLogicBag) + return notErrors.length === 0 ? [{ path, validation: 'not' }] : [] } diff --git a/next/src/validation/conditions.ts b/next/src/validation/conditions.ts index 8308f71a..dec6078e 100644 --- a/next/src/validation/conditions.ts +++ b/next/src/validation/conditions.ts @@ -1,26 +1,27 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { ValidationOptions } from '../form' -import type { NonBooleanJsfSchema, SchemaValue } from '../types' +import type { JsonLogicBag, NonBooleanJsfSchema, SchemaValue } from '../types' import { validateSchema } from './schema' export function validateCondition( value: SchemaValue, schema: NonBooleanJsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (schema.if === undefined) { return [] } - const conditionIsTrue = validateSchema(value, schema.if, options, path).length === 0 + const conditionIsTrue = validateSchema(value, schema.if, options, path, jsonLogicBag).length === 0 if (conditionIsTrue && schema.then !== undefined) { - return validateSchema(value, schema.then, options, [...path, 'then']) + return validateSchema(value, schema.then, options, [...path, 'then'], jsonLogicBag) } if (!conditionIsTrue && schema.else !== undefined) { - return validateSchema(value, schema.else, options, [...path, 'else']) + return validateSchema(value, schema.else, options, [...path, 'else'], jsonLogicBag) } return [] diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts new file mode 100644 index 00000000..de8d3b27 --- /dev/null +++ b/next/src/validation/json-logic.ts @@ -0,0 +1,54 @@ +import type { ValidationError, ValidationErrorPath } from '../errors' +import type { JsonLogicBag, NonBooleanJsfSchema } from '../types' +import jsonLogic from 'json-logic-js' + +/** + * (Ported from v0. TODO: check why we need it and if the name is correct) + * We removed undefined values in this function as `json-logic` ignores them. + * Means we will always check against a value for validations. + * + * @param {object} values - a set of values from a form + * @returns {object} values object without any undefined + */ +function replaceUndefinedValuesWithNulls(values: any = {}) { + return Object.entries(values).reduce((prev, [key, value]) => { + return { ...prev, [key]: value === undefined || value === null ? Number.NaN : value } + }, {}) +} + +/** + * Validates the JSON Logic for a given schema. + * + * @param {NonBooleanJsfSchema} schema - The JSON Schema to validate. + * @param {JsonLogicBag | undefined} jsonLogicBag - The JSON Logic bag. + */ +export function validateJsonLogic( + schema: NonBooleanJsfSchema, + jsonLogicBag: JsonLogicBag | undefined, + path: ValidationErrorPath = [], +): ValidationError[] { + const validations = schema['x-jsf-logic-validations'] + + // if the current schema has no validations, we skip the validation + if (!validations || validations.length === 0) { + return [] + } + + return validations.map((validation: string) => { + const validationData = jsonLogicBag?.schema?.validations?.[validation] + const formValue = jsonLogicBag?.value + + if (!validationData) { + return [] + } + + const result: any = jsonLogic.apply(validationData.rule, replaceUndefinedValuesWithNulls(formValue)) + + // If the condition is false, we return a validation error + if (result === false) { + return [{ path, validation: 'json-logic', customErrorMessage: validationData.errorMessage } as ValidationError] + } + + return [] + }).flat() +} diff --git a/next/src/validation/object.ts b/next/src/validation/object.ts index 713ef50d..2fb52105 100644 --- a/next/src/validation/object.ts +++ b/next/src/validation/object.ts @@ -1,6 +1,6 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { ValidationOptions } from '../form' -import type { NonBooleanJsfSchema, SchemaValue } from '../types' +import type { JsonLogicBag, NonBooleanJsfSchema, SchemaValue } from '../types' import { validateSchema } from './schema' import { isObjectValue } from './util' @@ -9,6 +9,7 @@ import { isObjectValue } from './util' * @param value - The value to validate * @param schema - The schema to validate against * @param options - The validation options + * @param jsonLogicBag - The JSON Logic bag * @param path - The path to the current field being validated * @returns An array of validation errors * @description @@ -19,12 +20,13 @@ export function validateObject( value: SchemaValue, schema: NonBooleanJsfSchema, options: ValidationOptions, + jsonLogicBag: JsonLogicBag | undefined, path: ValidationErrorPath = [], ): ValidationError[] { if (typeof schema === 'object' && schema.properties && isObjectValue(value)) { const errors = [] for (const [key, propertySchema] of Object.entries(schema.properties)) { - errors.push(...validateSchema(value[key], propertySchema, options, [...path, key])) + errors.push(...validateSchema(value[key], propertySchema, options, [...path, key], jsonLogicBag)) } return errors } diff --git a/next/src/validation/schema.ts b/next/src/validation/schema.ts index bb5c3a31..2e86cd28 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -1,11 +1,12 @@ import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types' +import type { JsfSchema, JsfSchemaType, JsonLogicBag, SchemaValue } from '../types' import { validateArray } from './array' import { validateAllOf, validateAnyOf, validateNot, validateOneOf } from './composition' import { validateCondition } from './conditions' import { validateConst } from './const' import { validateDate } from './custom/date' import { validateEnum } from './enum' +import { validateJsonLogic } from './json-logic' import { validateNumber } from './number' import { validateObject } from './object' import { validateString } from './string' @@ -71,11 +72,7 @@ function validateType( return [] } - const valueType = value === null - ? 'null' - : Array.isArray(value) - ? 'array' - : typeof value + const valueType = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value if (Array.isArray(schemaType)) { if (value === null && schemaType.includes('null')) { @@ -138,7 +135,17 @@ export function validateSchema( schema: JsfSchema, options: ValidationOptions = {}, path: ValidationErrorPath = [], + rootJsonLogicBag?: JsonLogicBag, ): ValidationError[] { + // If we have a rootJsonLogicBag, we shoud use that. If not, we try to check for the 'x-jsf-logic' property in the schema. + let jsonLogicBag = rootJsonLogicBag + if (!rootJsonLogicBag && schema['x-jsf-logic']) { + jsonLogicBag = { + schema: schema['x-jsf-logic'], + value, + } + } + const valueIsUndefined = value === undefined || (value === null && options.treatNullAsUndefined) const errors: ValidationError[] = [] @@ -163,9 +170,7 @@ export function validateSchema( } // If the schema defines "required", run required checks even when type is undefined. - if ( - schema.required && isObjectValue(value) - ) { + if (schema.required && isObjectValue(value)) { const missingKeys = schema.required.filter((key: string) => { const fieldValue = value[key] return fieldValue === undefined || (fieldValue === null && options.treatNullAsUndefined) @@ -184,16 +189,17 @@ export function validateSchema( // JSON-schema spec validations ...validateConst(value, schema, path), ...validateEnum(value, schema, path), - ...validateObject(value, schema, options, path), - ...validateArray(value, schema, options, path), + ...validateObject(value, schema, options, jsonLogicBag, path), + ...validateArray(value, schema, options, jsonLogicBag, path), ...validateString(value, schema, path), ...validateNumber(value, schema, path), - ...validateNot(value, schema, options, path), - ...validateAllOf(value, schema, options, path), - ...validateAnyOf(value, schema, options, path), - ...validateOneOf(value, schema, options, path), - ...validateCondition(value, schema, options, path), + ...validateNot(value, schema, options, jsonLogicBag, path), + ...validateAllOf(value, schema, options, jsonLogicBag, path), + ...validateAnyOf(value, schema, options, jsonLogicBag, path), + ...validateOneOf(value, schema, options, jsonLogicBag, path), + ...validateCondition(value, schema, options, jsonLogicBag, path), // Custom validations ...validateDate(value, schema, options, path), + ...validateJsonLogic(schema, jsonLogicBag, path), ] } diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts new file mode 100644 index 00000000..eb714107 --- /dev/null +++ b/next/test/validation/json-logic.test.ts @@ -0,0 +1,188 @@ +import type { JsonLogicBag, NonBooleanJsfSchema } from '../../src/types' +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import jsonLogic from 'json-logic-js' +import { validateJsonLogic } from '../../src/validation/json-logic' + +// Mock json-logic-js +jest.mock('json-logic-js', () => ({ + apply: jest.fn(), +})) + +describe('validateJsonLogic', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns empty array when no validations exist', () => { + const schema: NonBooleanJsfSchema = { + type: 'object', + properties: {}, + } + + const result = validateJsonLogic(schema, undefined) + expect(result).toEqual([]) + }) + + it('returns empty array when validations is empty array', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': [], + } + + const result = validateJsonLogic(schema, undefined) + expect(result).toEqual([]) + }) + + it('returns empty array when validation data is not found', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': ['someValidation'], + } + + const jsonLogicBag: JsonLogicBag = { + schema: { + validations: {}, + }, + value: {}, + } + + const result = validateJsonLogic(schema, jsonLogicBag) + expect(result).toEqual([]) + }) + + it('returns validation error when rule evaluates to false', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': ['ageCheck'], + } + + const jsonLogicBag: JsonLogicBag = { + schema: { + validations: { + ageCheck: { + rule: { '>': [{ var: 'age' }, 18] }, + errorMessage: 'Must be over 18', + }, + }, + }, + value: { age: 16 }, + }; + + // Mock the jsonLogic.apply to return false (false is the return value for invalid logic) + (jsonLogic.apply as jest.Mock).mockReturnValue(false) + + const result = validateJsonLogic(schema, jsonLogicBag) + + expect(result).toEqual([ + { + path: [], + validation: 'json-logic', + customErrorMessage: 'Must be over 18', + }, + ]) + + expect(jsonLogic.apply).toHaveBeenCalledWith( + { '>': [{ var: 'age' }, 18] }, + { age: 16 }, + ) + }) + + it('returns empty array when rule evaluates to true', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': ['ageCheck'], + } + + const jsonLogicBag: JsonLogicBag = { + schema: { + validations: { + ageCheck: { + rule: { '>': [{ var: 'age' }, 18] }, + errorMessage: 'Must be over 18', + }, + }, + }, + value: { age: 20 }, + } + + // Mock the jsonLogic.apply to return true + ;(jsonLogic.apply as jest.Mock).mockReturnValue(true) + + const result = validateJsonLogic(schema, jsonLogicBag) + expect(result).toEqual([]) + }) + + it('handles undefined and null values by converting them to NaN', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': ['check'], + } + + const jsonLogicBag: JsonLogicBag = { + schema: { + validations: { + check: { + rule: { '==': [{ var: 'field' }, null] }, + errorMessage: 'Error', + }, + }, + }, + value: { field: undefined }, + } + + ;(jsonLogic.apply as jest.Mock).mockReturnValue(true) + + validateJsonLogic(schema, jsonLogicBag) + + expect(jsonLogic.apply).toHaveBeenCalledWith( + { '==': [{ var: 'field' }, null] }, + { field: Number.NaN }, + ) + }) + + it('handles multiple validations', () => { + const schema: NonBooleanJsfSchema = { + 'type': 'object', + 'properties': {}, + 'x-jsf-logic-validations': ['check1', 'check2'], + } + + const jsonLogicBag: JsonLogicBag = { + schema: { + validations: { + check1: { + rule: { '>': [{ var: 'age' }, 18] }, + errorMessage: 'Must be over 18', + }, + check2: { + rule: { '<': [{ var: 'age' }, 100] }, + errorMessage: 'Must be under 100', + }, + }, + }, + value: { age: 16 }, + } + + // First validation fails, second passes + ;(jsonLogic.apply as jest.Mock) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + + const result = validateJsonLogic(schema, jsonLogicBag) + + expect(result).toEqual([ + { + path: [], + validation: 'json-logic', + customErrorMessage: 'Must be over 18', + }, + ]) + + expect(jsonLogic.apply).toHaveBeenCalledTimes(2) + }) +}) diff --git a/package.json b/package.json index d2b71d25..f3153a2a 100644 --- a/package.json +++ b/package.json @@ -85,4 +85,4 @@ "engines": { "node": ">=18.14.0" } -} +} \ No newline at end of file