Skip to content

feat(next): Custom user defined validations #196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions next/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ValidationError, ValidationErrorPath } from './errors'
import type { Field } from './field/type'
import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types'
import type { ValidationOptions } from './validation/schema'
import jsonLogic from 'json-logic-js'
import { getErrorMessage } from './errors/messages'
import { buildFieldSchema } from './field/schema'
import { calculateFinalSchema, updateFieldProperties } from './mutations'
Expand Down Expand Up @@ -192,6 +193,30 @@ function applyCustomErrorMessages(errors: ValidationErrorWithMessage[], schema:
})
}

/**
* Register custom used defined JSON Logic operations to the jsonLogic instance
* @param customJsonLogicOps - The custom JSON Logic operations
*/
function addCustomJsonLogicOperations(customJsonLogicOps: Record<string, (...args: any[]) => any> | undefined) {
if (customJsonLogicOps) {
for (const [name, func] of Object.entries(customJsonLogicOps)) {
jsonLogic.add_operation(name, func)
}
}
}

/**
* Remove custom JSON Logic operations from the jsonLogic instance
* @param customJsonLogicOps - The custom JSON Logic operations
*/
function removeJsonLogicCustomOperations(customJsonLogicOps: Record<string, (...args: any[]) => any> | undefined) {
if (customJsonLogicOps) {
for (const name of Object.keys(customJsonLogicOps)) {
jsonLogic.rm_operation(name)
}
}
}

/**
* Validate a value against a schema
* @param value - The value to validate
Expand Down Expand Up @@ -251,6 +276,22 @@ function validateOptions(options: CreateHeadlessFormOptions) {
if (Object.prototype.hasOwnProperty.call(options, 'modifyConfig')) {
throw new Error('`modifyConfig` is a deprecated option and it\'s not supported on json-schema-form v1')
}

const customJsonLogicOps = options?.validationOptions?.customJsonLogicOps

if (customJsonLogicOps) {
if (typeof customJsonLogicOps !== 'object' || customJsonLogicOps === null) {
throw new TypeError('validationOptions.customJsonLogicOps must be an object.')
}

for (const [name, func] of Object.entries(customJsonLogicOps)) {
if (typeof func !== 'function') {
throw new TypeError(
`Custom JSON Logic operator '${name}' must be a function, but received type '${typeof func}'.`,
)
}
}
Comment on lines +282 to +293
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion

Moving this block to a validateCustomJsonLogicOps function would make things easier to read 🙂

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antoniocapelo There was a validateOptions function, I moved it there

}
}

export function createHeadlessForm(
Expand All @@ -260,6 +301,7 @@ export function createHeadlessForm(
validateOptions(options)
const initialValues = options.initialValues || {}
const strictInputType = options.strictInputType || false

// Make a new version of the schema with all the computed attrs applied, as well as the final version of each property (taking into account conditional rules)
const updatedSchema = calculateFinalSchema({
schema,
Expand All @@ -271,20 +313,27 @@ export function createHeadlessForm(

// TODO: check if we need this isError variable exposed
const isError = false
const customJsonLogicOps = options?.validationOptions?.customJsonLogicOps

const handleValidation = (value: SchemaValue) => {
const updatedSchema = calculateFinalSchema({
schema,
values: value,
options: options.validationOptions,
})
try {
addCustomJsonLogicOperations(customJsonLogicOps)

const updatedSchema = calculateFinalSchema({
schema,
values: value,
options: options.validationOptions,
})

const result = validate(value, updatedSchema, options.validationOptions)
const result = validate(value, updatedSchema, options.validationOptions)

// Fields properties might have changed, so we need to reset the fields by updating them in place
updateFieldProperties(fields, updatedSchema, schema)
updateFieldProperties(fields, updatedSchema, schema)

return result
return result
}
finally {
removeJsonLogicCustomOperations(customJsonLogicOps)
}
}

return {
Expand Down
6 changes: 6 additions & 0 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export interface ValidationOptions {
* @default false
*/
allowForbiddenValues?: boolean

/**
* Custom jsonLogic operations to register (only applies once at setup)
* Format: { [operationName]: (...args: any[]) => any }
*/
customJsonLogicOps?: Record<string, (...args: any[]) => any>
}

/**
Expand Down
37 changes: 37 additions & 0 deletions next/test/validation/json-logic-v0.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createHeadlessForm } from '@/createHeadlessForm'

import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'

import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from '../test-utils'
import {
badSchemaThatWillNotSetAForcedValue,
Expand All @@ -15,6 +17,7 @@ import {
schemaWithComputedAttributeThatDoesntExist,
schemaWithComputedAttributeThatDoesntExistDescription,
schemaWithComputedAttributeThatDoesntExistTitle,
schemaWithCustomValidationFunction,
schemaWithDeepVarThatDoesNotExist,
schemaWithDeepVarThatDoesNotExistOnFieldset,
schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar,
Expand Down Expand Up @@ -446,4 +449,38 @@ describe('jsonLogic: cross-values validations', () => {
expect(handleValidation({ field_a: 20, field_b: 41 }).formErrors).toEqual()
})
})

describe('custom operators', () => {
it('custom function', () => {
const { handleValidation } = createHeadlessForm(schemaWithCustomValidationFunction, { strictInputType: false, validationOptions: { customJsonLogicOps: { is_hello: a => a === 'hello world' } } })
expect(handleValidation({ field_a: 'hello world' }).formErrors).toEqual(undefined)
const { formErrors } = handleValidation({ field_a: 'wrong text' })
expect(formErrors?.field_a).toEqual('Invalid hello world')
})

it('custom function are form specific', () => {
const { handleValidation } = createHeadlessForm(schemaWithCustomValidationFunction, { strictInputType: false, validationOptions: { customJsonLogicOps: { is_hello: a => a === 'hello world' } } })
expect(handleValidation({ field_a: 'hello world' }).formErrors).toEqual(undefined)
const { formErrors } = handleValidation({ field_a: 'wrong text' })
expect(formErrors?.field_a).toEqual('Invalid hello world')

const { handleValidation: handleValidation2 } = createHeadlessForm(schemaWithCustomValidationFunction, { strictInputType: false, validationOptions: { customJsonLogicOps: { is_hello: a => a === 'hello world!' } } })
expect(handleValidation2({ field_a: 'hello world!' }).formErrors).toEqual(undefined)

const { handleValidation: handleValidation3 } = createHeadlessForm(schemaWithCustomValidationFunction, { strictInputType: false })
const actionThatWillThrow = () => {
handleValidation3({ field_a: 'hello world' })
}

expect(actionThatWillThrow).toThrow('Unrecognized operation is_hello')
})

it('validation on custom functions', () => {
const actionThatWillThrow = () => {
createHeadlessForm(schemaWithCustomValidationFunction, { strictInputType: false, validationOptions: { customJsonLogicOps: { is_hello: 'not a funcion' } } })
}

expect(actionThatWillThrow).toThrow('Custom JSON Logic operator \'is_hello\' must be a function, but received type \'string\'.')
})
})
})
19 changes: 19 additions & 0 deletions next/test/validation/json-logic.fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -744,3 +744,22 @@ export const schemaWithReduceAccumulator = {
},
},
}

export const schemaWithCustomValidationFunction = {
'properties': {
field_a: {
'type': 'string',
'x-jsf-logic-validations': ['hello_world'],
},
},
'x-jsf-logic': {
validations: {
hello_world: {
errorMessage: 'Invalid hello world',
rule: {
is_hello: { var: 'field_a' },
},
},
},
},
}