From 7754139c2912837eabb300f87e7f65302bd184f3 Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Fri, 11 Apr 2025 13:00:24 +0200 Subject: [PATCH 1/3] feat(core): Add validate function based rule condition Add a new rule condition `ValidateFunctionCondition` using a given `validate` function to evaluate the condition result. This allows using arbitrary custom logic to evaluate condition results. This facilitates not using schema-conditions to be able to only use one pre-compiled AJV for the data schema at a later stage. --- packages/core/src/models/uischema.ts | 13 +++- packages/core/src/util/runtime.ts | 10 +++ packages/core/test/util/runtime.test.ts | 85 +++++++++++++++++++++++++ packages/examples/src/examples/rule.ts | 18 ++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/core/src/models/uischema.ts b/packages/core/src/models/uischema.ts index 205d7c585..fa31b9a57 100644 --- a/packages/core/src/models/uischema.ts +++ b/packages/core/src/models/uischema.ts @@ -150,6 +150,16 @@ 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: (data: unknown) => boolean; +} + /** * A composable condition. */ @@ -179,7 +189,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..4fb054067 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,6 +52,12 @@ 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); }; @@ -80,6 +87,9 @@ const evaluateCondition = ( return false; } return ajv.validate(condition.schema, value) as boolean; + } else if (isValidateFunctionCondition(condition)) { + const value = resolveData(data, getConditionScope(condition, path)); + return condition.validate(value); } else { // unknown condition return true; diff --git a/packages/core/test/util/runtime.test.ts b/packages/core/test/util/runtime.test.ts index 434ba9827..cca734864 100644 --- a/packages/core/test/util/runtime.test.ts +++ b/packages/core/test/util/runtime.test.ts @@ -33,6 +33,7 @@ import { OrCondition, RuleEffect, SchemaBasedCondition, + ValidateFunctionCondition, } from '../../src'; import { evalEnablement, evalVisibility } from '../../src/util/runtime'; @@ -491,6 +492,90 @@ 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: (data) => 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: (data) => 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: (data) => 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: (data) => 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('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..00037a686 100644 --- a/packages/examples/src/examples/rule.ts +++ b/packages/examples/src/examples/rule.ts @@ -44,6 +44,10 @@ export const schema = { type: 'string', enum: ['All', 'Some', 'Only potatoes'], }, + vitaminDeficiency: { + type: 'string', + enum: ['None', 'Vitamin A', 'Vitamin B', 'Vitamin C'], + }, }, }; @@ -101,6 +105,20 @@ export const uischema = { }, }, }, + { + type: 'Control', + label: 'Vitamin deficiency?', + scope: '#/properties/vitaminDeficiency', + rule: { + effect: 'SHOW', + condition: { + scope: '#', + validate: (data: any) => { + return !data.dead && data.kindOfVegetables !== 'All'; + }, + }, + }, + }, ], }, ], From 0632025161af8363525fcaf7e6874a5ef6035f4b Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Thu, 24 Apr 2025 12:27:32 +0200 Subject: [PATCH 2/3] review: hand in as much context as possible to validate function --- packages/core/src/models/uischema.ts | 13 ++++++++- packages/core/src/util/runtime.ts | 15 +++++++--- packages/core/test/util/runtime.test.ts | 38 ++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/core/src/models/uischema.ts b/packages/core/src/models/uischema.ts index fa31b9a57..229cbe524 100644 --- a/packages/core/src/models/uischema.ts +++ b/packages/core/src/models/uischema.ts @@ -157,7 +157,18 @@ export interface ValidateFunctionCondition extends BaseCondition, Scoped { * * @param data The data as resolved via the scope. * @returns `true` if the condition is fulfilled */ - validate: (data: unknown) => boolean; + 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; } /** diff --git a/packages/core/src/util/runtime.ts b/packages/core/src/util/runtime.ts index 4fb054067..540bf9d7b 100644 --- a/packages/core/src/util/runtime.ts +++ b/packages/core/src/util/runtime.ts @@ -64,18 +64,19 @@ const getConditionScope = (condition: Scopable, path: string): string => { 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)) { @@ -89,7 +90,13 @@ const evaluateCondition = ( return ajv.validate(condition.schema, value) as boolean; } else if (isValidateFunctionCondition(condition)) { const value = resolveData(data, getConditionScope(condition, path)); - return condition.validate(value); + const context = { + data: value, + fullData: data, + path, + uischemaElement: uischema, + }; + return condition.validate(context); } else { // unknown condition return true; @@ -103,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 cca734864..cfb4c745c 100644 --- a/packages/core/test/util/runtime.test.ts +++ b/packages/core/test/util/runtime.test.ts @@ -34,6 +34,7 @@ import { RuleEffect, SchemaBasedCondition, ValidateFunctionCondition, + ValidateFunctionContext, } from '../../src'; import { evalEnablement, evalVisibility } from '../../src/util/runtime'; @@ -496,7 +497,7 @@ test('evalEnablement disable valid case', (t) => { test('evalEnablement enable valid case based on ValidateFunctionCondition', (t) => { const condition: ValidateFunctionCondition = { scope: '#/properties/ruleValue', - validate: (data) => data === 'bar', + validate: (context: ValidateFunctionContext) => context.data === 'bar', }; const uischema: ControlElement = { type: 'Control', @@ -517,7 +518,7 @@ test('evalEnablement enable valid case based on ValidateFunctionCondition', (t) test('evalEnablement enable invalid case based on ValidateFunctionCondition', (t) => { const condition: ValidateFunctionCondition = { scope: '#/properties/ruleValue', - validate: (data) => data === 'bar', + validate: (context: ValidateFunctionContext) => context.data === 'bar', }; const uischema: ControlElement = { type: 'Control', @@ -538,7 +539,7 @@ test('evalEnablement enable invalid case based on ValidateFunctionCondition', (t test('evalEnablement disable valid case based on ValidateFunctionCondition', (t) => { const condition: ValidateFunctionCondition = { scope: '#/properties/ruleValue', - validate: (data) => data === 'bar', + validate: (context: ValidateFunctionContext) => context.data === 'bar', }; const uischema: ControlElement = { type: 'Control', @@ -559,7 +560,7 @@ test('evalEnablement disable valid case based on ValidateFunctionCondition', (t) test('evalEnablement disable invalid case based on ValidateFunctionCondition', (t) => { const condition: ValidateFunctionCondition = { scope: '#/properties/ruleValue', - validate: (data) => data === 'bar', + validate: (context: ValidateFunctionContext) => context.data === 'bar', }; const uischema: ControlElement = { type: 'Control', @@ -576,6 +577,35 @@ test('evalEnablement disable invalid case based on ValidateFunctionCondition', ( 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', From f5026342fd95923c95459553815eaf3d8323b3a9 Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Wed, 21 May 2025 09:42:56 +0200 Subject: [PATCH 3/3] Fix example --- packages/examples/src/examples/rule.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/examples/src/examples/rule.ts b/packages/examples/src/examples/rule.ts index 00037a686..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 = { @@ -113,8 +114,11 @@ export const uischema = { effect: 'SHOW', condition: { scope: '#', - validate: (data: any) => { - return !data.dead && data.kindOfVegetables !== 'All'; + validate: (context: ValidateFunctionContext) => { + return ( + !(context.data as any).dead && + (context.data as any).kindOfVegetables !== 'All' + ); }, }, },