Skip to content

Commit

Permalink
Allow deeply nested if property checks (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
brennj committed Aug 9, 2023
1 parent 0c7ad32 commit e34cfcc
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 65 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

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

70 changes: 70 additions & 0 deletions src/checkIfConditionMatches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { compareFormValueWithSchemaValue, getField, validateFieldSchema } from './helpers';
import { hasProperty } from './utils';

/**
* Checks if a "IF" condition matches given the current form state
* @param {Object} node - JSON schema node
* @param {Object} formValues - form state
* @returns {Boolean}
*/
export function checkIfConditionMatches(node, formValues, formFields) {
return Object.keys(node.if.properties).every((name) => {
const currentProperty = node.if.properties[name];
const value = formValues[name];
const hasEmptyValue =
typeof value === 'undefined' ||
// NOTE: This is a "Remote API" dependency, as empty fields are sent as "null".
value === null;
const hasIfExplicit = node.if.required?.includes(name);

if (hasEmptyValue && !hasIfExplicit) {
// A property with empty value in a "if" will always match (lead to "then"),
// even if the actual conditional isn't true. Unless it's explicit in the if.required.
// WRONG:: if: { properties: { foo: {...} } }
// CORRECT:: if: { properties: { foo: {...} }, required: ['foo'] }
// Check MR !14408 for further explanation about the official specs
// https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
return true;
}

if (hasProperty(currentProperty, 'const')) {
return compareFormValueWithSchemaValue(value, currentProperty.const);
}

if (currentProperty.contains?.pattern) {
// TODO: remove this || after D#4098 is merged and transformValue does not run for the parser anymore
const formValue = value || [];

// Making sure the form value type matches the expected type (array) when theres' a "contains" condition
if (Array.isArray(formValue)) {
const pattern = new RegExp(currentProperty.contains.pattern);
return (value || []).some((item) => pattern.test(item));
}
}

if (currentProperty.enum) {
return currentProperty.enum.includes(value);
}

if (currentProperty.properties) {
return checkIfConditionMatches(
{ if: currentProperty },
formValues[name],
getField(name, formFields).fields
);
}

const field = getField(name, formFields);

return validateFieldSchema(
{
options: field.options,
// @TODO/CODE SMELL. We are passing the property (raw field), but buildYupSchema() expected the output field.
...currentProperty,
inputType: field.inputType,
required: true,
},
value
);
});
}
67 changes: 4 additions & 63 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import omitBy from 'lodash/omitBy';
import set from 'lodash/set';
import { lazy } from 'yup';

import { checkIfConditionMatches } from './checkIfConditionMatches';
import { supportedTypes, getInputType } from './internals/fields';
import { pickXKey } from './internals/helpers';
import { containsHTML, hasProperty, wrapWithSpan } from './utils';
Expand All @@ -29,7 +30,7 @@ function hasType(type, typeName) {
* @param {Object[]} fields - form fields
* @returns
*/
function getField(fieldName, fields) {
export function getField(fieldName, fields) {
return fields.find(({ name }) => name === fieldName);
}

Expand All @@ -39,7 +40,7 @@ function getField(fieldName, fields) {
* @param {any} value
* @returns
*/
function validateFieldSchema(field, value) {
export function validateFieldSchema(field, value) {
const validator = buildYupSchema(field);
return validator().isValidSync(value);
}
Expand All @@ -52,7 +53,7 @@ function validateFieldSchema(field, value) {
* @param {any} schemaValue - value specified in the schema
* @returns {Boolean}
*/
function compareFormValueWithSchemaValue(formValue, schemaValue) {
export function compareFormValueWithSchemaValue(formValue, schemaValue) {
// If the value is a number, we can use it directly, otherwise we need to
// fallback to undefined since JSON-schemas empty values come represented as null
const currentPropertyValue =
Expand All @@ -62,66 +63,6 @@ function compareFormValueWithSchemaValue(formValue, schemaValue) {
return String(formValue) === String(currentPropertyValue);
}

/**
* Checks if a "IF" condition matches given the current form state
* @param {Object} node - JSON schema node
* @param {Object} formValues - form state
* @returns {Boolean}
*/
function checkIfConditionMatches(node, formValues, formFields) {
return Object.keys(node.if.properties).every((name) => {
const currentProperty = node.if.properties[name];
const value = formValues[name];
const hasEmptyValue =
typeof value === 'undefined' ||
// NOTE: This is a "Remote API" dependency, as empty fields are sent as "null".
value === null;
const hasIfExplicit = node.if.required?.includes(name);

if (hasEmptyValue && !hasIfExplicit) {
// A property with empty value in a "if" will always match (lead to "then"),
// even if the actual conditional isn't true. Unless it's explicit in the if.required.
// WRONG:: if: { properties: { foo: {...} } }
// CORRECT:: if: { properties: { foo: {...} }, required: ['foo'] }
// Check MR !14408 for further explanation about the official specs
// https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
return true;
}

if (hasProperty(currentProperty, 'const')) {
return compareFormValueWithSchemaValue(value, currentProperty.const);
}

if (currentProperty.contains?.pattern) {
// TODO: remove this || after D#4098 is merged and transformValue does not run for the parser anymore
const formValue = value || [];

// Making sure the form value type matches the expected type (array) when theres' a "contains" condition
if (Array.isArray(formValue)) {
const pattern = new RegExp(currentProperty.contains.pattern);
return (value || []).some((item) => pattern.test(item));
}
}

if (currentProperty.enum) {
return currentProperty.enum.includes(value);
}

const field = getField(name, formFields);

return validateFieldSchema(
{
options: field.options,
// @TODO/CODE SMELL. We are passing the property (raw field), but buildYupSchema() expected the output field.
...currentProperty,
inputType: field.inputType,
required: true,
},
value
);
});
}

/**
* Checks if the provided field has a value (array with positive length or truthy value)
*
Expand Down
51 changes: 51 additions & 0 deletions src/tests/checkIfConditionMatches.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { checkIfConditionMatches } from '../checkIfConditionMatches';

it('Empty if is always going to be true', () => {
expect(checkIfConditionMatches({ if: { properties: {} } })).toBe(true);
});

it('Basic if check passes with correct value', () => {
expect(
checkIfConditionMatches(
{ if: { properties: { a: { const: 'hello' } } } },
{
a: 'hello',
}
)
).toBe(true);
});

it('Basic if check fails with incorrect value', () => {
expect(
checkIfConditionMatches(
{ if: { properties: { a: { const: 'hello' } } } },
{
a: 'goodbye',
}
)
).toBe(false);
});

it('Nested properties check passes with correct value', () => {
expect(
checkIfConditionMatches(
{ if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } },
{
parent: { child: 'hello from child' },
},
[{ name: 'parent', fields: [] }]
)
).toBe(true);
});

it('Nested properties check passes with correct value', () => {
expect(
checkIfConditionMatches(
{ if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } },
{
parent: { child: 'goodbye from child' },
},
[{ name: 'parent', fields: [] }]
)
).toBe(false);
});
58 changes: 58 additions & 0 deletions src/tests/conditions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createHeadlessForm } from '../createHeadlessForm';

it('Should allow check of a nested property in a conditional', () => {
const { handleValidation } = createHeadlessForm(
{
additionalProperties: false,
allOf: [
{
if: {
properties: {
parent: {
properties: {
child: {
const: 'yes',
},
},
required: ['child'],
},
},
required: ['parent'],
},
then: { required: ['parent_sibling'] },
},
],
properties: {
parent: {
additionalProperties: false,
properties: {
child: {
oneOf: [
{
const: 'yes',
},
{ const: 'no' },
],
type: 'string',
},
},
required: ['child'],
type: 'object',
},
parent_sibling: {
type: 'integer',
},
},
required: ['parent'],
type: 'object',
},
{ strictInputType: false }
);
expect(handleValidation({ parent: { child: 'no' } }).formErrors).toEqual(undefined);
expect(handleValidation({ parent: { child: 'yes' } }).formErrors).toEqual({
parent_sibling: 'Required field',
});
expect(handleValidation({ parent: { child: 'yes' }, parent_sibling: 1 }).formErrors).toEqual(
undefined
);
});

0 comments on commit e34cfcc

Please sign in to comment.