diff --git a/packages/core/src/models/uischema.ts b/packages/core/src/models/uischema.ts index 205d7c585..229cbe524 100644 --- a/packages/core/src/models/uischema.ts +++ b/packages/core/src/models/uischema.ts @@ -150,6 +150,27 @@ export interface SchemaBasedCondition extends BaseCondition, Scoped { failWhenUndefined?: boolean; } +/** A condition using a validation function to determine its fulfillment. */ +export interface ValidateFunctionCondition extends BaseCondition, Scoped { + /** + * Validates whether the condition is fulfilled. + * + * @param data The data as resolved via the scope. + * @returns `true` if the condition is fulfilled */ + validate: (context: ValidateFunctionContext) => boolean; +} + +export interface ValidateFunctionContext { + /** The resolved data scoped to the `ValidateFunctionCondition`'s scope. */ + data: unknown; + /** The full data of the form. */ + fullData: unknown; + /** Optional instance path. Necessary when the actual data path can not be inferred via the scope alone as it is the case with nested controls. */ + path: string | undefined; + /** The `UISchemaElement` containing the rule that uses the ValidateFunctionCondition, e.g. a `ControlElement` */ + uischemaElement: UISchemaElement; +} + /** * A composable condition. */ @@ -179,7 +200,8 @@ export type Condition = | LeafCondition | OrCondition | AndCondition - | SchemaBasedCondition; + | SchemaBasedCondition + | ValidateFunctionCondition; /** * Common base interface for any UI schema element. diff --git a/packages/core/src/util/runtime.ts b/packages/core/src/util/runtime.ts index 1f6ddd254..540bf9d7b 100644 --- a/packages/core/src/util/runtime.ts +++ b/packages/core/src/util/runtime.ts @@ -33,6 +33,7 @@ import { SchemaBasedCondition, Scopable, UISchemaElement, + ValidateFunctionCondition, } from '../models'; import { resolveData } from './resolvers'; import type Ajv from 'ajv'; @@ -51,24 +52,31 @@ const isSchemaCondition = ( condition: Condition ): condition is SchemaBasedCondition => has(condition, 'schema'); +const isValidateFunctionCondition = ( + condition: Condition +): condition is ValidateFunctionCondition => + has(condition, 'validate') && + typeof (condition as ValidateFunctionCondition).validate === 'function'; + const getConditionScope = (condition: Scopable, path: string): string => { return composeWithUi(condition, path); }; const evaluateCondition = ( data: any, + uischema: UISchemaElement, condition: Condition, path: string, ajv: Ajv ): boolean => { if (isAndCondition(condition)) { return condition.conditions.reduce( - (acc, cur) => acc && evaluateCondition(data, cur, path, ajv), + (acc, cur) => acc && evaluateCondition(data, uischema, cur, path, ajv), true ); } else if (isOrCondition(condition)) { return condition.conditions.reduce( - (acc, cur) => acc || evaluateCondition(data, cur, path, ajv), + (acc, cur) => acc || evaluateCondition(data, uischema, cur, path, ajv), false ); } else if (isLeafCondition(condition)) { @@ -80,6 +88,15 @@ const evaluateCondition = ( return false; } return ajv.validate(condition.schema, value) as boolean; + } else if (isValidateFunctionCondition(condition)) { + const value = resolveData(data, getConditionScope(condition, path)); + const context = { + data: value, + fullData: data, + path, + uischemaElement: uischema, + }; + return condition.validate(context); } else { // unknown condition return true; @@ -93,7 +110,7 @@ const isRuleFulfilled = ( ajv: Ajv ): boolean => { const condition = uischema.rule.condition; - return evaluateCondition(data, condition, path, ajv); + return evaluateCondition(data, uischema, condition, path, ajv); }; export const evalVisibility = ( diff --git a/packages/core/test/util/runtime.test.ts b/packages/core/test/util/runtime.test.ts index 434ba9827..cfb4c745c 100644 --- a/packages/core/test/util/runtime.test.ts +++ b/packages/core/test/util/runtime.test.ts @@ -33,6 +33,8 @@ import { OrCondition, RuleEffect, SchemaBasedCondition, + ValidateFunctionCondition, + ValidateFunctionContext, } from '../../src'; import { evalEnablement, evalVisibility } from '../../src/util/runtime'; @@ -491,6 +493,119 @@ test('evalEnablement disable valid case', (t) => { t.is(evalEnablement(uischema, data, undefined, createAjv()), false); }); +// Add test case for ValidateFunctionCondition with evalEnablement (valid enable case) +test('evalEnablement enable valid case based on ValidateFunctionCondition', (t) => { + const condition: ValidateFunctionCondition = { + scope: '#/properties/ruleValue', + validate: (context: ValidateFunctionContext) => context.data === 'bar', + }; + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { + effect: RuleEffect.ENABLE, + condition: condition, + }, + }; + const data = { + value: 'foo', + ruleValue: 'bar', + }; + t.is(evalEnablement(uischema, data, undefined, createAjv()), true); +}); + +// Add test case for ValidateFunctionCondition with evalEnablement (invalid enable case) +test('evalEnablement enable invalid case based on ValidateFunctionCondition', (t) => { + const condition: ValidateFunctionCondition = { + scope: '#/properties/ruleValue', + validate: (context: ValidateFunctionContext) => context.data === 'bar', + }; + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { + effect: RuleEffect.ENABLE, + condition: condition, + }, + }; + const data = { + value: 'foo', + ruleValue: 'foobar', + }; + t.is(evalEnablement(uischema, data, undefined, createAjv()), false); +}); + +// Add test case for ValidateFunctionCondition with evalEnablement (valid disable case) +test('evalEnablement disable valid case based on ValidateFunctionCondition', (t) => { + const condition: ValidateFunctionCondition = { + scope: '#/properties/ruleValue', + validate: (context: ValidateFunctionContext) => context.data === 'bar', + }; + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { + effect: RuleEffect.DISABLE, + condition: condition, + }, + }; + const data = { + value: 'foo', + ruleValue: 'bar', + }; + t.is(evalEnablement(uischema, data, undefined, createAjv()), false); +}); + +// Add test case for ValidateFunctionCondition with evalEnablement (invalid disable case) +test('evalEnablement disable invalid case based on ValidateFunctionCondition', (t) => { + const condition: ValidateFunctionCondition = { + scope: '#/properties/ruleValue', + validate: (context: ValidateFunctionContext) => context.data === 'bar', + }; + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { + effect: RuleEffect.DISABLE, + condition: condition, + }, + }; + const data = { + value: 'foo', + ruleValue: 'foobar', + }; + t.is(evalEnablement(uischema, data, undefined, createAjv()), true); +}); + +// Test context properties for ValidateFunctionCondition +test('ValidateFunctionCondition correctly passes context parameters', (t) => { + const condition: ValidateFunctionCondition = { + scope: '#/properties/ruleValue', + validate: (context: ValidateFunctionContext) => { + // Verify all context properties are passed correctly + return ( + context.data === 'bar' && + (context.fullData as any).value === 'foo' && + context.path === undefined && + (context.uischemaElement as any).scope === '#/properties/value' + ); + }, + }; + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { + effect: RuleEffect.ENABLE, + condition: condition, + }, + }; + const data = { + value: 'foo', + ruleValue: 'bar', + }; + t.is(evalEnablement(uischema, data, undefined, createAjv()), true); +}); + test('evalEnablement disable invalid case', (t) => { const leafCondition: LeafCondition = { type: 'LEAF', diff --git a/packages/examples/src/examples/rule.ts b/packages/examples/src/examples/rule.ts index 58d2b9b2a..1407278f1 100644 --- a/packages/examples/src/examples/rule.ts +++ b/packages/examples/src/examples/rule.ts @@ -22,6 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { ValidateFunctionContext } from '@jsonforms/core'; import { registerExamples } from '../register'; export const schema = { @@ -44,6 +45,10 @@ export const schema = { type: 'string', enum: ['All', 'Some', 'Only potatoes'], }, + vitaminDeficiency: { + type: 'string', + enum: ['None', 'Vitamin A', 'Vitamin B', 'Vitamin C'], + }, }, }; @@ -101,6 +106,23 @@ export const uischema = { }, }, }, + { + type: 'Control', + label: 'Vitamin deficiency?', + scope: '#/properties/vitaminDeficiency', + rule: { + effect: 'SHOW', + condition: { + scope: '#', + validate: (context: ValidateFunctionContext) => { + return ( + !(context.data as any).dead && + (context.data as any).kindOfVegetables !== 'All' + ); + }, + }, + }, + }, ], }, ],