Skip to content

Commit d0b1dfa

Browse files
feat(core): Add validate function based rule condition (#2441)
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.
1 parent fa546b5 commit d0b1dfa

File tree

4 files changed

+180
-4
lines changed

4 files changed

+180
-4
lines changed

packages/core/src/models/uischema.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,27 @@ export interface SchemaBasedCondition extends BaseCondition, Scoped {
150150
failWhenUndefined?: boolean;
151151
}
152152

153+
/** A condition using a validation function to determine its fulfillment. */
154+
export interface ValidateFunctionCondition extends BaseCondition, Scoped {
155+
/**
156+
* Validates whether the condition is fulfilled.
157+
*
158+
* @param data The data as resolved via the scope.
159+
* @returns `true` if the condition is fulfilled */
160+
validate: (context: ValidateFunctionContext) => boolean;
161+
}
162+
163+
export interface ValidateFunctionContext {
164+
/** The resolved data scoped to the `ValidateFunctionCondition`'s scope. */
165+
data: unknown;
166+
/** The full data of the form. */
167+
fullData: unknown;
168+
/** 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. */
169+
path: string | undefined;
170+
/** The `UISchemaElement` containing the rule that uses the ValidateFunctionCondition, e.g. a `ControlElement` */
171+
uischemaElement: UISchemaElement;
172+
}
173+
153174
/**
154175
* A composable condition.
155176
*/
@@ -179,7 +200,8 @@ export type Condition =
179200
| LeafCondition
180201
| OrCondition
181202
| AndCondition
182-
| SchemaBasedCondition;
203+
| SchemaBasedCondition
204+
| ValidateFunctionCondition;
183205

184206
/**
185207
* Common base interface for any UI schema element.

packages/core/src/util/runtime.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
SchemaBasedCondition,
3434
Scopable,
3535
UISchemaElement,
36+
ValidateFunctionCondition,
3637
} from '../models';
3738
import { resolveData } from './resolvers';
3839
import type Ajv from 'ajv';
@@ -51,24 +52,31 @@ const isSchemaCondition = (
5152
condition: Condition
5253
): condition is SchemaBasedCondition => has(condition, 'schema');
5354

55+
const isValidateFunctionCondition = (
56+
condition: Condition
57+
): condition is ValidateFunctionCondition =>
58+
has(condition, 'validate') &&
59+
typeof (condition as ValidateFunctionCondition).validate === 'function';
60+
5461
const getConditionScope = (condition: Scopable, path: string): string => {
5562
return composeWithUi(condition, path);
5663
};
5764

5865
const evaluateCondition = (
5966
data: any,
67+
uischema: UISchemaElement,
6068
condition: Condition,
6169
path: string,
6270
ajv: Ajv
6371
): boolean => {
6472
if (isAndCondition(condition)) {
6573
return condition.conditions.reduce(
66-
(acc, cur) => acc && evaluateCondition(data, cur, path, ajv),
74+
(acc, cur) => acc && evaluateCondition(data, uischema, cur, path, ajv),
6775
true
6876
);
6977
} else if (isOrCondition(condition)) {
7078
return condition.conditions.reduce(
71-
(acc, cur) => acc || evaluateCondition(data, cur, path, ajv),
79+
(acc, cur) => acc || evaluateCondition(data, uischema, cur, path, ajv),
7280
false
7381
);
7482
} else if (isLeafCondition(condition)) {
@@ -80,6 +88,15 @@ const evaluateCondition = (
8088
return false;
8189
}
8290
return ajv.validate(condition.schema, value) as boolean;
91+
} else if (isValidateFunctionCondition(condition)) {
92+
const value = resolveData(data, getConditionScope(condition, path));
93+
const context = {
94+
data: value,
95+
fullData: data,
96+
path,
97+
uischemaElement: uischema,
98+
};
99+
return condition.validate(context);
83100
} else {
84101
// unknown condition
85102
return true;
@@ -93,7 +110,7 @@ const isRuleFulfilled = (
93110
ajv: Ajv
94111
): boolean => {
95112
const condition = uischema.rule.condition;
96-
return evaluateCondition(data, condition, path, ajv);
113+
return evaluateCondition(data, uischema, condition, path, ajv);
97114
};
98115

99116
export const evalVisibility = (

packages/core/test/util/runtime.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
OrCondition,
3434
RuleEffect,
3535
SchemaBasedCondition,
36+
ValidateFunctionCondition,
37+
ValidateFunctionContext,
3638
} from '../../src';
3739
import { evalEnablement, evalVisibility } from '../../src/util/runtime';
3840

@@ -491,6 +493,119 @@ test('evalEnablement disable valid case', (t) => {
491493
t.is(evalEnablement(uischema, data, undefined, createAjv()), false);
492494
});
493495

496+
// Add test case for ValidateFunctionCondition with evalEnablement (valid enable case)
497+
test('evalEnablement enable valid case based on ValidateFunctionCondition', (t) => {
498+
const condition: ValidateFunctionCondition = {
499+
scope: '#/properties/ruleValue',
500+
validate: (context: ValidateFunctionContext) => context.data === 'bar',
501+
};
502+
const uischema: ControlElement = {
503+
type: 'Control',
504+
scope: '#/properties/value',
505+
rule: {
506+
effect: RuleEffect.ENABLE,
507+
condition: condition,
508+
},
509+
};
510+
const data = {
511+
value: 'foo',
512+
ruleValue: 'bar',
513+
};
514+
t.is(evalEnablement(uischema, data, undefined, createAjv()), true);
515+
});
516+
517+
// Add test case for ValidateFunctionCondition with evalEnablement (invalid enable case)
518+
test('evalEnablement enable invalid case based on ValidateFunctionCondition', (t) => {
519+
const condition: ValidateFunctionCondition = {
520+
scope: '#/properties/ruleValue',
521+
validate: (context: ValidateFunctionContext) => context.data === 'bar',
522+
};
523+
const uischema: ControlElement = {
524+
type: 'Control',
525+
scope: '#/properties/value',
526+
rule: {
527+
effect: RuleEffect.ENABLE,
528+
condition: condition,
529+
},
530+
};
531+
const data = {
532+
value: 'foo',
533+
ruleValue: 'foobar',
534+
};
535+
t.is(evalEnablement(uischema, data, undefined, createAjv()), false);
536+
});
537+
538+
// Add test case for ValidateFunctionCondition with evalEnablement (valid disable case)
539+
test('evalEnablement disable valid case based on ValidateFunctionCondition', (t) => {
540+
const condition: ValidateFunctionCondition = {
541+
scope: '#/properties/ruleValue',
542+
validate: (context: ValidateFunctionContext) => context.data === 'bar',
543+
};
544+
const uischema: ControlElement = {
545+
type: 'Control',
546+
scope: '#/properties/value',
547+
rule: {
548+
effect: RuleEffect.DISABLE,
549+
condition: condition,
550+
},
551+
};
552+
const data = {
553+
value: 'foo',
554+
ruleValue: 'bar',
555+
};
556+
t.is(evalEnablement(uischema, data, undefined, createAjv()), false);
557+
});
558+
559+
// Add test case for ValidateFunctionCondition with evalEnablement (invalid disable case)
560+
test('evalEnablement disable invalid case based on ValidateFunctionCondition', (t) => {
561+
const condition: ValidateFunctionCondition = {
562+
scope: '#/properties/ruleValue',
563+
validate: (context: ValidateFunctionContext) => context.data === 'bar',
564+
};
565+
const uischema: ControlElement = {
566+
type: 'Control',
567+
scope: '#/properties/value',
568+
rule: {
569+
effect: RuleEffect.DISABLE,
570+
condition: condition,
571+
},
572+
};
573+
const data = {
574+
value: 'foo',
575+
ruleValue: 'foobar',
576+
};
577+
t.is(evalEnablement(uischema, data, undefined, createAjv()), true);
578+
});
579+
580+
// Test context properties for ValidateFunctionCondition
581+
test('ValidateFunctionCondition correctly passes context parameters', (t) => {
582+
const condition: ValidateFunctionCondition = {
583+
scope: '#/properties/ruleValue',
584+
validate: (context: ValidateFunctionContext) => {
585+
// Verify all context properties are passed correctly
586+
return (
587+
context.data === 'bar' &&
588+
(context.fullData as any).value === 'foo' &&
589+
context.path === undefined &&
590+
(context.uischemaElement as any).scope === '#/properties/value'
591+
);
592+
},
593+
};
594+
const uischema: ControlElement = {
595+
type: 'Control',
596+
scope: '#/properties/value',
597+
rule: {
598+
effect: RuleEffect.ENABLE,
599+
condition: condition,
600+
},
601+
};
602+
const data = {
603+
value: 'foo',
604+
ruleValue: 'bar',
605+
};
606+
t.is(evalEnablement(uischema, data, undefined, createAjv()), true);
607+
});
608+
494609
test('evalEnablement disable invalid case', (t) => {
495610
const leafCondition: LeafCondition = {
496611
type: 'LEAF',

packages/examples/src/examples/rule.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2323
THE SOFTWARE.
2424
*/
25+
import { ValidateFunctionContext } from '@jsonforms/core';
2526
import { registerExamples } from '../register';
2627

2728
export const schema = {
@@ -44,6 +45,10 @@ export const schema = {
4445
type: 'string',
4546
enum: ['All', 'Some', 'Only potatoes'],
4647
},
48+
vitaminDeficiency: {
49+
type: 'string',
50+
enum: ['None', 'Vitamin A', 'Vitamin B', 'Vitamin C'],
51+
},
4752
},
4853
};
4954

@@ -101,6 +106,23 @@ export const uischema = {
101106
},
102107
},
103108
},
109+
{
110+
type: 'Control',
111+
label: 'Vitamin deficiency?',
112+
scope: '#/properties/vitaminDeficiency',
113+
rule: {
114+
effect: 'SHOW',
115+
condition: {
116+
scope: '#',
117+
validate: (context: ValidateFunctionContext) => {
118+
return (
119+
!(context.data as any).dead &&
120+
(context.data as any).kindOfVegetables !== 'All'
121+
);
122+
},
123+
},
124+
},
125+
},
104126
],
105127
},
106128
],

0 commit comments

Comments
 (0)