diff --git a/packages/core/src/configDefault.ts b/packages/core/src/configDefault.ts index 3761c314e..f75b95611 100644 --- a/packages/core/src/configDefault.ts +++ b/packages/core/src/configDefault.ts @@ -1,19 +1,19 @@ /* The MIT License - + Copyright (c) 2017-2019 EclipseSource Munich https://github.com/eclipsesource/jsonforms - + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -45,4 +45,10 @@ export const configDefault = { * [text] if asterisks in labels for required fields should be hidden */ hideRequiredAsterisk: false, + + /** + * [text] if dynamic checks for conditional application of properties + * should be performed (e.g. check for conditional required) + */ + allowDynamicCheck: false, }; diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index 2a80de8e6..0554f0b78 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -27,6 +27,8 @@ import get from 'lodash/get'; import { ControlElement, JsonSchema, + JsonSchema4, + JsonSchema7, LabelElement, UISchemaElement, } from '../models'; @@ -57,6 +59,10 @@ import { ArrayTranslations, } from '../i18n'; import cloneDeep from 'lodash/cloneDeep'; +import isEqual from 'lodash/isEqual'; +import has from 'lodash/has'; +import any from 'lodash/fp/any'; +import all from 'lodash/fp/all'; import { composePaths, composeWithUi, @@ -90,7 +96,6 @@ import { } from '../store'; import { isInherentlyEnabled } from './util'; import { CombinatorKeyword } from './combinators'; -import { isEqual } from 'lodash'; const move = (array: any[], index: number, delta: number) => { const newIndex: number = index + delta; @@ -109,6 +114,324 @@ export const moveDown = (array: any[], toMove: number) => { move(array, toMove, 1); }; +const dataPathToJsonPointer = (dataPath: string): string => { + const parts = dataPath.split('.'); + let jsonPointer = '#'; + + parts.forEach((part) => { + if (part.match(/^\d+$/)) { + jsonPointer += '/items'; + } else { + jsonPointer += `/properties/${part}`; + } + }); + + return jsonPointer; +}; + +const checkDataCondition = ( + propertyCondition: unknown, + property: string, + data: Record +) => { + if (has(propertyCondition, 'const')) { + return ( + has(data, property) && + isEqual(data[property], get(propertyCondition, 'const')) + ); + } else if (has(propertyCondition, 'enum')) { + return ( + has(data, property) && + (get(propertyCondition, 'enum') as unknown[]).find((value) => + isEqual(value, data[property]) + ) !== undefined + ); + } else if (has(propertyCondition, 'pattern')) { + const pattern = new RegExp(get(propertyCondition, 'pattern')); + + return ( + has(data, property) && + typeof data[property] === 'string' && + pattern.test(data[property] as string) + ); + } + + return false; +}; + +const checkPropertyCondition = ( + propertiesCondition: Record, + property: string, + data: Record +): boolean => { + if (has(propertiesCondition[property], 'not')) { + return !checkDataCondition( + get(propertiesCondition[property], 'not'), + property, + data + ); + } + + if (has(propertiesCondition[property], 'properties') && has(data, property)) { + const nextPropertyConditions = get( + propertiesCondition[property], + 'properties' + ); + + return all( + (prop) => + checkPropertyCondition( + nextPropertyConditions, + prop, + data[property] as Record + ), + Object.keys(nextPropertyConditions) + ); + } + + return checkDataCondition(propertiesCondition[property], property, data); +}; + +const evaluateCondition = ( + schema: JsonSchema, + data: Record +): boolean => { + if (has(schema, 'allOf')) { + return all( + (subschema: JsonSchema) => evaluateCondition(subschema, data), + get(schema, 'allOf') + ); + } + + if (has(schema, 'anyOf')) { + return any( + (subschema: JsonSchema) => evaluateCondition(subschema, data), + get(schema, 'anyOf') + ); + } + + if (has(schema, 'oneOf')) { + const subschemas = get(schema, 'oneOf'); + + let satisfied = false; + + for (let i = 0; i < subschemas.length; i++) { + const current = evaluateCondition(subschemas[i], data); + if (current && satisfied) { + return false; + } + + if (current && !satisfied) { + satisfied = true; + } + } + + return satisfied; + } + + let requiredProperties: string[] = []; + if (has(schema, 'required')) { + requiredProperties = get(schema, 'required'); + } + + const requiredCondition = all( + (property) => has(data, property), + requiredProperties + ); + + if (has(schema, 'properties')) { + const propertiesCondition = get(schema, 'properties') as Record< + string, + unknown + >; + + const valueCondition = all( + (property) => checkPropertyCondition(propertiesCondition, property, data), + Object.keys(propertiesCondition) + ); + + return requiredCondition && valueCondition; + } + + return requiredCondition; +}; + +/** + * Go through parent's properties until the segment is found at the exact level it is defined and check if it is required + */ +const extractRequired = ( + schema: JsonSchema, + segment: string, + prevSegments: string[] +) => { + let segmentIndex = 0; + let currentSchema = schema; + while ( + segmentIndex < prevSegments.length && + (has(currentSchema, prevSegments[segmentIndex]) || + (has(currentSchema, 'properties') && + has(get(currentSchema, 'properties'), prevSegments[segmentIndex]))) + ) { + if (has(currentSchema, 'properties')) { + currentSchema = get(currentSchema, 'properties'); + } + currentSchema = get(currentSchema, prevSegments[segmentIndex]); + ++segmentIndex; + } + + if (segmentIndex < prevSegments.length) { + return false; + } + + return ( + has(currentSchema, 'required') && + (get(currentSchema, 'required') as string[]).includes(segment) + ); +}; + +/** + * Check if property's required attribute is set based on if-then-else condition + */ +const checkRequiredInIf = ( + schema: JsonSchema, + segment: string, + prevSegments: string[], + data: Record +): boolean => { + const propertiesConditionSchema = get(schema, 'if'); + + const condition = evaluateCondition(propertiesConditionSchema, data); + + const ifInThen = has(get(schema, 'then'), 'if'); + const ifInElse = has(get(schema, 'else'), 'if'); + const allOfInThen = has(get(schema, 'then'), 'allOf'); + const allOfInElse = has(get(schema, 'else'), 'allOf'); + + return ( + (has(schema, 'then') && + condition && + extractRequired(get(schema, 'then'), segment, prevSegments)) || + (has(schema, 'else') && + !condition && + extractRequired(get(schema, 'else'), segment, prevSegments)) || + (ifInThen && + condition && + checkRequiredInIf(get(schema, 'then'), segment, prevSegments, data)) || + (ifInElse && + !condition && + checkRequiredInIf(get(schema, 'else'), segment, prevSegments, data)) || + (allOfInThen && + condition && + conditionallyRequired( + get(schema, 'then'), + segment, + prevSegments, + data + )) || + (allOfInElse && + !condition && + conditionallyRequired(get(schema, 'else'), segment, prevSegments, data)) + ); +}; + +/** + * Check if property becomes required based on some if-then-else condition + * that is part of allOf combinator + */ +const conditionallyRequired = ( + schema: JsonSchema, + segment: string, + prevSegments: string[], + data: any +) => { + const nestedAllOfSchema = get(schema, 'allOf'); + + return any((subschema: JsonSchema4 | JsonSchema7): boolean => { + return ( + (has(subschema, 'if') && + checkRequiredInIf(subschema, segment, prevSegments, data)) || + conditionallyRequired(subschema, segment, prevSegments, data) + ); + }, nestedAllOfSchema); +}; + +const getNextHigherSchemaPath = (schemaPath: string): string => { + const pathSegments = schemaPath.split('/'); + const lastSegment = pathSegments[pathSegments.length - 1]; + + // We'd normally jump two segments back, but if we're in an `items` key, we want to check its parent + // Example path: '#/properties/anotherObject/properties/myArray/items/properties/propertyName' + const nextHigherSegmentIndexDifference = lastSegment === 'items' ? 1 : 2; + const nextHigherSchemaSegments = pathSegments.slice( + 0, + pathSegments.length - nextHigherSegmentIndexDifference + ); + + return nextHigherSchemaSegments.join('/'); +}; + +const getNextHigherDataPath = (dataPath: string): string => { + const dataPathSegments = dataPath.split('.'); + return dataPathSegments.slice(0, dataPathSegments.length - 1).join('.'); +}; + +/** + * Check if property is being required in the parent schema + */ +const isRequiredInParent = ( + schema: JsonSchema, + schemaPath: string, + segment: string, + prevSegments: string[], + data: Record, + dataPath: string +): boolean => { + const pathSegments = schemaPath.split('/'); + const lastSegment = pathSegments[pathSegments.length - 1]; + const nextHigherSchemaPath = getNextHigherSchemaPath(schemaPath); + + if (!nextHigherSchemaPath) { + return false; + } + + const nextHigherSchema = Resolve.schema(schema, nextHigherSchemaPath, schema); + + const nextHigherDataPath = getNextHigherDataPath(dataPath); + const currentData = Resolve.data(data, nextHigherDataPath); + + return ( + conditionallyRequired( + nextHigherSchema, + segment, + [lastSegment, ...prevSegments], + currentData + ) || + (has(nextHigherSchema, 'if') && + checkRequiredInIf( + nextHigherSchema, + segment, + [lastSegment, ...prevSegments], + currentData + )) || + isRequiredInParent( + schema, + nextHigherSchemaPath, + segment, + [lastSegment, ...prevSegments], + data, + nextHigherDataPath + ) + ); +}; + +const isRequiredInSchema = (schema: JsonSchema, segment: string): boolean => { + return ( + schema !== undefined && + schema.required !== undefined && + schema.required.indexOf(segment) !== -1 + ); +}; + const isRequired = ( schema: JsonSchema, schemaPath: string, @@ -127,12 +450,52 @@ const isRequired = ( nextHigherSchemaPath, rootSchema ); + return isRequiredInSchema(nextHigherSchema, lastSegment); +}; - return ( - nextHigherSchema !== undefined && - nextHigherSchema.required !== undefined && - nextHigherSchema.required.indexOf(lastSegment) !== -1 +const isConditionallyRequired = ( + rootSchema: JsonSchema, + schemaPath: string, + data: any, + dataPath: string +): boolean => { + const pathSegments = schemaPath.split('/'); + const lastSegment = pathSegments[pathSegments.length - 1]; + + const nextHigherSchemaPath = getNextHigherSchemaPath(schemaPath); + const nextHigherSchema = Resolve.schema( + rootSchema, + nextHigherSchemaPath, + rootSchema + ); + + // We need the `dataPath` to be able to resolve data in arrays, + // for example `myObject.myArray.0.myProperty` has no + // equivalent for the index in the schema syntax + const nextHigherDataPath = getNextHigherDataPath(dataPath); + const currentData = Resolve.data(data, nextHigherDataPath); + + const requiredInIf = + has(nextHigherSchema, 'if') && + checkRequiredInIf(nextHigherSchema, lastSegment, [], currentData); + + const requiredConditionally = conditionallyRequired( + nextHigherSchema, + lastSegment, + [], + currentData ); + + const requiredConditionallyInParent = isRequiredInParent( + rootSchema, + nextHigherSchemaPath, + lastSegment, + [], + data, + nextHigherDataPath + ); + + return requiredInIf || requiredConditionally || requiredConditionallyInParent; }; /** @@ -526,9 +889,19 @@ export const mapStateToControlProps = ( const controlElement = uischema as ControlElement; const id = ownProps.id; const rootSchema = getSchema(state); + const config = getConfig(state); const required = controlElement.scope !== undefined && - isRequired(ownProps.schema, controlElement.scope, rootSchema); + !!( + isRequired(ownProps.schema, controlElement.scope, rootSchema) || + (config?.allowDynamicCheck && + isConditionallyRequired( + rootSchema, + dataPathToJsonPointer(path), + rootData, + path + )) + ); const resolvedSchema = Resolve.schema( ownProps.schema || rootSchema, controlElement.scope, @@ -541,7 +914,6 @@ export const mapStateToControlProps = ( const data = Resolve.data(rootData, path); const labelDesc = createLabelDescriptionFrom(uischema, resolvedSchema); const label = labelDesc.show ? labelDesc.text : ''; - const config = getConfig(state); const enabled: boolean = isInherentlyEnabled( state, ownProps, diff --git a/packages/core/test/mappers/renderer.test.ts b/packages/core/test/mappers/renderer.test.ts index 8e686e884..f46f5388a 100644 --- a/packages/core/test/mappers/renderer.test.ts +++ b/packages/core/test/mappers/renderer.test.ts @@ -1,19 +1,19 @@ /* The MIT License - + Copyright (c) 2017-2019 EclipseSource Munich https://github.com/eclipsesource/jsonforms - + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -29,7 +29,11 @@ import test from 'ava'; import { ErrorObject } from 'ajv'; import { JsonFormsCore, JsonFormsState, i18nJsonSchema } from '../../src/store'; -import { coreReducer, defaultJsonFormsI18nState } from '../../src/reducers'; +import { + configReducer, + coreReducer, + defaultJsonFormsI18nState, +} from '../../src/reducers'; import { CoreActions, init, @@ -37,6 +41,7 @@ import { update, UpdateAction, UPDATE_DATA, + setConfig, } from '../../src/actions'; import { ControlElement, @@ -67,6 +72,8 @@ import { import { clearAllIds, convertDateToString, createAjv } from '../../src/util'; import { rankWith } from '../../src'; +type Combinator = 'allOf' | 'anyOf' | 'oneOf'; + const middlewares: Redux.Middleware[] = []; const mockStore = configureStore(middlewares); @@ -438,6 +445,725 @@ test('mapStateToControlProps - id', (t) => { t.is(props.id, '#/properties/firstName'); }); +test('mapStateToControlProps - required not checked dynamically if not explicitly specified in config', (t) => { + const schema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + required: ['firstName'], + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { + notifications: boolean; + firstName: string; + }) => ({ + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + }, + }); + + const stateTrue = createState({ notifications: true, firstName: 'Bart' }); + const stateFalse = createState({ notifications: false, firstName: 'Bart' }); + + const propsTrue = mapStateToControlProps(stateTrue, ownProps); + const propsFalse = mapStateToControlProps(stateFalse, ownProps); + t.false(propsTrue.required); + t.false(propsFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on single condition', (t) => { + const schema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + required: ['firstName'], + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const config = setConfig({ + allowDynamicCheck: true, + }); + + const createState = (data: { + notifications: boolean; + firstName: string; + }) => ({ + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }); + + const stateTrue = createState({ notifications: true, firstName: 'Bart' }); + const stateFalse = createState({ notifications: false, firstName: 'Bart' }); + + const propsTrue = mapStateToControlProps(stateTrue, ownProps); + const propsFalse = mapStateToControlProps(stateFalse, ownProps); + t.true(propsTrue.required); + t.false(propsFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on condition with missing data', (t) => { + const schema = { + type: 'object', + properties: { + role: { type: 'string' }, + firstName: { type: 'string' }, + }, + required: ['role'], + if: { + properties: { + role: { + not: { + const: 'manager', + }, + }, + }, + }, + then: { + required: ['firstName'], + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { role?: string; firstName: string }) => { + const { role, ...rest } = data; + + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data: role ? data : rest, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateMissing = createState({ firstName: 'Bart' }); + const stateTrue = createState({ role: 'lead', firstName: 'Bart' }); + const stateFalse = createState({ role: 'manager', firstName: 'Bart' }); + + const propsMissing = mapStateToControlProps(stateMissing, ownProps); + const propsTrue = mapStateToControlProps(stateTrue, ownProps); + const propsFalse = mapStateToControlProps(stateFalse, ownProps); + + t.true(propsMissing.required); + t.true(propsTrue.required); + t.false(propsFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on condition with combinators', (t) => { + const createSchema = (combinator: Combinator) => ({ + type: 'object', + properties: { + notifications: { type: 'boolean' }, + email: { type: 'string' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + if: { + [combinator]: [ + { + properties: { + notifications: { const: true }, + }, + }, + { + required: ['email'], + }, + ], + }, + then: { + required: ['firstName'], + }, + }); + + const createState = (data: { + combinator: Combinator; + notifications: boolean; + email?: string; + firstName: string; + }) => { + const { email, ...rest } = data; + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema: createSchema(data.combinator), + uischema: coreUISchema, + data: email ? data : rest, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateAnyOfTrue = createState({ + combinator: 'anyOf', + notifications: false, + email: 'bart@email.com', + firstName: 'Bart', + }); + + const stateAllOfTrue = createState({ + combinator: 'allOf', + notifications: true, + email: 'bart@email.com', + firstName: 'Bart', + }); + + const stateOneOfTrue = createState({ + combinator: 'oneOf', + notifications: true, + firstName: 'Bart', + }); + + const stateAnyOfFalse = createState({ + combinator: 'anyOf', + notifications: false, + firstName: 'Bart', + }); + + const stateAllOfFalse = createState({ + combinator: 'allOf', + notifications: true, + firstName: 'Bart', + }); + + const stateOneOfFalse = createState({ + combinator: 'oneOf', + notifications: true, + email: 'bart@email.com', + firstName: 'Bart', + }); + + const ownPropsAnyOf: OwnPropsOfControl = { + uischema: coreUISchema, + schema: createSchema('anyOf'), + }; + + const ownPropsAllOf: OwnPropsOfControl = { + uischema: coreUISchema, + schema: createSchema('allOf'), + }; + + const ownPropsOneOf: OwnPropsOfControl = { + uischema: coreUISchema, + schema: createSchema('oneOf'), + }; + + const propsAnyOfTrue = mapStateToControlProps(stateAnyOfTrue, ownPropsAnyOf); + const propsAllOfTrue = mapStateToControlProps(stateAllOfTrue, ownPropsAllOf); + const propsOneOfTrue = mapStateToControlProps(stateOneOfTrue, ownPropsOneOf); + const propsAnyOfFalse = mapStateToControlProps( + stateAnyOfFalse, + ownPropsAnyOf + ); + const propsAllOfFalse = mapStateToControlProps( + stateAllOfFalse, + ownPropsAllOf + ); + const propsOneOfFalse = mapStateToControlProps( + stateOneOfFalse, + ownPropsOneOf + ); + + t.true(propsAnyOfTrue.required); + t.true(propsAllOfTrue.required); + t.true(propsOneOfTrue.required); + t.false(propsAnyOfFalse.required); + t.false(propsAllOfFalse.required); + t.false(propsOneOfFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on multiple conditions', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + role: { type: 'string' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + allOf: [ + { + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + required: ['firstName'], + }, + }, + { + if: { + properties: { + role: { const: 'manager' }, + }, + }, + then: { + required: ['firstName'], + }, + }, + ], + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { + notifications?: boolean; + role: string; + firstName: string; + }) => { + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateFirstMatches = createState({ + notifications: true, + role: 'employee', + firstName: 'Bart', + }); + + const stateSecondMatches = createState({ + notifications: false, + role: 'manager', + firstName: 'Bart', + }); + + const propsFirstMatches = mapStateToControlProps(stateFirstMatches, ownProps); + const propsSecondMatcher = mapStateToControlProps( + stateSecondMatches, + ownProps + ); + t.true(propsFirstMatches.required); + t.true(propsSecondMatcher.required); +}); + +test('mapStateToControlProps - required with dynamic check based on nested conditions', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + role: { type: 'string' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + if: { + properties: { + role: { + pattern: '[a-zA-Z]+', + }, + }, + }, + then: { + required: ['firstName'], + }, + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { + notifications?: boolean; + role: string; + firstName: string; + }) => { + const { notifications, ...rest } = data; + + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data: notifications !== undefined ? data : rest, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateAllTrue = createState({ + notifications: true, + role: 'manager', + firstName: 'Bart', + }); + const stateInnerFalse = createState({ + notifications: true, + role: '398', + firstName: 'Bart', + }); + const stateOuterFalse = createState({ + role: 'manager', + firstName: 'Bart', + }); + + const propsAllTrue = mapStateToControlProps(stateAllTrue, ownProps); + const propsInnerFalse = mapStateToControlProps(stateInnerFalse, ownProps); + const propsOuterFalse = mapStateToControlProps(stateOuterFalse, ownProps); + + t.true(propsAllTrue.required); + t.false(propsInnerFalse.required); + t.false(propsOuterFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on multiple conditions in nested "allOf"-s', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + role: { type: 'string' }, + qualifies: { type: 'boolean' }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + allOf: [ + { + allOf: [ + { + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + required: ['firstName'], + }, + }, + { + if: { + properties: { + role: { enum: ['manager', 'lead'] }, + }, + }, + then: { + required: ['firstName'], + }, + }, + ], + }, + { + if: { + properties: { + qualified: { const: true }, + }, + }, + then: { + required: ['firstName'], + }, + }, + ], + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { + notifications: boolean; + qualified: boolean; + role: string; + firstName: string; + }) => { + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateFirstMatches = createState({ + notifications: true, + qualified: false, + role: 'employee', + firstName: 'Bart', + }); + + const stateSecondMatches = createState({ + notifications: false, + qualified: false, + role: 'manager', + firstName: 'Bart', + }); + + const stateThirdMatches = createState({ + notifications: false, + qualified: true, + role: 'employee', + firstName: 'Bart', + }); + + const propsFirstMatches = mapStateToControlProps(stateFirstMatches, ownProps); + const propsSecondMatcher = mapStateToControlProps( + stateSecondMatches, + ownProps + ); + const propsThirdMatcher = mapStateToControlProps(stateThirdMatches, ownProps); + + t.true(propsFirstMatches.required); + t.true(propsSecondMatcher.required); + t.true(propsThirdMatcher.required); +}); + +test('mapStateToControlProps - required with dynamic check based on condition for nested object', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + role: { + type: 'object', + properties: { + position: { type: 'string' }, + experience: { type: 'number' }, + }, + }, + firstName: { type: 'string' }, + }, + required: ['notifications'], + if: { + properties: { + role: { + properties: { + position: { enum: ['manager', 'lead'] }, + }, + }, + }, + }, + then: { + required: ['firstName'], + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: coreUISchema, + schema, + }; + + const createState = (data: { + role: { + position: string; + experience: number; + }; + firstName: string; + }) => { + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateTrue = createState({ + role: { + position: 'lead', + experience: 5, + }, + firstName: 'Bart', + }); + + const stateFalse = createState({ + role: { + position: 'employee', + experience: 1, + }, + firstName: 'Bart', + }); + + const propsTrue = mapStateToControlProps(stateTrue, ownProps); + const propsFalse = mapStateToControlProps(stateFalse, ownProps); + + t.true(propsTrue.required); + t.false(propsFalse.required); +}); + +test('mapStateToControlProps - required with dynamic check based on parent condition', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + notifications: { type: 'boolean' }, + role: { + type: 'object', + properties: { + position: { type: 'string' }, + firstName: { type: 'string' }, + }, + }, + }, + required: ['notifications'], + if: { + properties: { + notifications: { const: true }, + }, + }, + then: { + properties: { + role: { + required: ['firstName'], + }, + }, + }, + }; + + const ownProps: OwnPropsOfControl = { + uischema: { + type: 'Control', + scope: '#/properties/role/properties/firstName', + }, + schema, + }; + + const createState = (data: { + notifications: boolean; + role: { + position: string; + firstName: string; + }; + }) => { + const config = setConfig({ + allowDynamicCheck: true, + }); + + return { + jsonforms: { + core: { + schema, + uischema: coreUISchema, + data, + errors: [] as ErrorObject[], + }, + config: configReducer(undefined, config), + }, + }; + }; + + const stateTrue = createState({ + notifications: true, + role: { + position: 'lead', + firstName: 'Bart', + }, + }); + + const stateFalse = createState({ + notifications: false, + role: { + position: 'lead', + firstName: 'Bart', + }, + }); + + const propsTrue = mapStateToControlProps(stateTrue, ownProps); + const propsFalse = mapStateToControlProps(stateFalse, ownProps); + + t.true(propsTrue.required); + t.false(propsFalse.required); +}); + test('mapStateToControlProps - hide errors in hide validation mode', (t) => { const schema = { type: 'object', diff --git a/packages/examples/src/examples/if-allOf.ts b/packages/examples/src/examples/if-allOf.ts new file mode 100644 index 000000000..13102d006 --- /dev/null +++ b/packages/examples/src/examples/if-allOf.ts @@ -0,0 +1,126 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'string' }, + baz: { type: 'string' }, + nested: { + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'string' }, + }, + allOf: [ + { + if: { + properties: { + foo: { const: 'bar' }, + }, + }, + then: { required: ['bar'] }, + }, + ], + }, + }, + allOf: [ + { + if: { + properties: { + foo: { const: 'bar' }, + }, + }, + then: { required: ['bar'] }, + }, + { + if: { + properties: { + foo: { const: 'baz' }, + }, + }, + then: { required: ['baz'] }, + }, + { + allOf: [ + { + if: { + properties: { + foo: { pattern: 'foo.' }, + }, + }, + then: { + required: ['baz', 'bar'], + }, + }, + ], + }, + ], +}; + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + label: 'Foo', + type: 'Control', + scope: '#/properties/foo', + }, + { + type: 'Control', + label: 'bar', + scope: '#/properties/bar', + }, + { + type: 'Control', + label: 'baz', + scope: '#/properties/baz', + }, + { + type: 'Control', + label: 'foo1', + scope: '#/properties/nested/properties/foo', + }, + { + type: 'Control', + label: 'bar1', + scope: '#/properties/nested/properties/bar', + }, + ], +}; + +const data = {}; + +registerExamples([ + { + name: 'if-allOf', + label: 'If AllOf', + data, + schema, + uischema, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index c50414362..2711dd304 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -80,11 +80,13 @@ export * from './register'; export * from './example'; import * as ifThenElse from './examples/if_then_else'; +import * as allOfIf from './examples/if-allOf'; export { issue_1948, defaultExample, allOf, + allOfIf, anyOf, oneOf, oneOfArray,