11import jsonLogic from 'json-logic-js' ;
22
3+ import { buildYupSchema } from './yupSchema' ;
4+
35/**
46 * Parses the JSON schema to extract the json-logic rules and returns an object
57 * containing the validation scopes, functions to retrieve the scopes, and evaluate the
@@ -9,15 +11,12 @@ import jsonLogic from 'json-logic-js';
911 * @returns {Object } An object containing:
1012 * - scopes {Map} - A Map of the validation scopes (with IDs as keys)
1113 * - getScope {Function} - Function to retrieve a scope by name/ID
12- * - validate {Function} - Function to evaluate a validation rule
13- * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition
14- * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field
15- * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition
1614 */
1715export function createValidationChecker ( schema ) {
1816 const scopes = new Map ( ) ;
1917
2018 function createScopes ( jsonSchema , key = 'root' ) {
19+ const sampleEmptyObject = buildSampleEmptyObject ( schema ) ;
2120 scopes . set ( key , createValidationsScope ( jsonSchema ) ) ;
2221 Object . entries ( jsonSchema ?. properties ?? { } )
2322 . filter ( ( [ , property ] ) => property . type === 'object' || property . type === 'array' )
@@ -28,6 +27,8 @@ export function createValidationChecker(schema) {
2827 createScopes ( property , key ) ;
2928 }
3029 } ) ;
30+
31+ validateInlineRules ( jsonSchema , sampleEmptyObject ) ;
3132 }
3233
3334 createScopes ( schema ) ;
@@ -40,6 +41,21 @@ export function createValidationChecker(schema) {
4041 } ;
4142}
4243
44+ /**
45+ * Creates a validation scope object for a schema.
46+ *
47+ * Builds maps of validations and computed values defined in the schema's
48+ * x-jsf-logic section. Includes functions to evaluate the rules.
49+ *
50+ * @param {Object } schema - The JSON schema
51+ * @returns {Object } The validation scope object containing:
52+ * - validationMap - Map of validation rules
53+ * - computedValuesMap - Map of computed value rules
54+ * - validate {Function} - Function to evaluate a validation rule
55+ * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition
56+ * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field
57+ * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition
58+ */
4359function createValidationsScope ( schema ) {
4460 const validationMap = new Map ( ) ;
4561 const computedValuesMap = new Map ( ) ;
@@ -51,12 +67,25 @@ function createValidationsScope(schema) {
5167
5268 const validations = Object . entries ( logic . validations ?? { } ) ;
5369 const computedValues = Object . entries ( logic . computedValues ?? { } ) ;
70+ const sampleEmptyObject = buildSampleEmptyObject ( schema ) ;
5471
5572 validations . forEach ( ( [ id , validation ] ) => {
73+ if ( ! validation . rule ) {
74+ throw Error ( `[json-schema-form] json-logic error: Validation "${ id } " has missing rule.` ) ;
75+ }
76+
77+ checkRuleIntegrity ( validation . rule , id , sampleEmptyObject ) ;
78+
5679 validationMap . set ( id , validation ) ;
5780 } ) ;
5881
5982 computedValues . forEach ( ( [ id , computedValue ] ) => {
83+ if ( ! computedValue . rule ) {
84+ throw Error ( `[json-schema-form] json-logic error: Computed value "${ id } " has missing rule.` ) ;
85+ }
86+
87+ checkRuleIntegrity ( computedValue . rule , id , sampleEmptyObject ) ;
88+
6089 computedValuesMap . set ( id , computedValue ) ;
6190 } ) ;
6291
@@ -72,8 +101,13 @@ function createValidationsScope(schema) {
72101 const validation = validationMap . get ( id ) ;
73102 return validate ( validation . rule , values ) ;
74103 } ,
75- applyComputedValueInField ( id , values ) {
104+ applyComputedValueInField ( id , values , fieldName ) {
76105 const validation = computedValuesMap . get ( id ) ;
106+ if ( validation === undefined ) {
107+ throw Error (
108+ `[json-schema-form] json-logic error: Computed value "${ id } " doesn't exist in field "${ fieldName } ".`
109+ ) ;
110+ }
77111 return validate ( validation . rule , values ) ;
78112 } ,
79113 applyComputedValueRuleInCondition ( id , values ) {
@@ -112,6 +146,12 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) {
112146 const { parentID = 'root' } = config ;
113147 const validation = logic . getScope ( parentID ) . validationMap . get ( id ) ;
114148
149+ if ( validation === undefined ) {
150+ throw Error (
151+ `[json-schema-form] json-logic error: "${ field . name } " required validation "${ id } " doesn't exist.`
152+ ) ;
153+ }
154+
115155 return ( yupSchema ) =>
116156 yupSchema . test (
117157 `${ field . name } -validation-${ id } ` ,
@@ -123,26 +163,264 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) {
123163 ) ;
124164}
125165
166+ const HANDLEBARS_REGEX = / \{ \{ ( [ ^ { } ] + ) \} \} / g;
167+
168+ /**
169+ * Replaces Handlebars templates in a value with computed values.
170+ *
171+ * Handles recursively replacing Handlebars templates "{{var}}" in strings
172+ * with computed values looked up from the validation logic.
173+ *
174+ * @param {Object } options - Options object
175+ * @param {* } options.value - The value to replace templates in
176+ * @param {Object } options.logic - The validation logic object
177+ * @param {Object } options.formValues - The current form values
178+ * @param {string } options.parentID - The ID of the validation scope
179+ * @param {string } options.name - The name of the field
180+ * @returns {* } The value with templates replaced with computed values
181+ */
182+ function replaceHandlebarsTemplates ( {
183+ value : toReplace ,
184+ logic,
185+ formValues,
186+ parentID,
187+ name : fieldName ,
188+ } ) {
189+ if ( typeof toReplace === 'string' ) {
190+ return toReplace . replace ( HANDLEBARS_REGEX , ( match , key ) => {
191+ return logic . getScope ( parentID ) . applyComputedValueInField ( key . trim ( ) , formValues , fieldName ) ;
192+ } ) ;
193+ } else if ( typeof toReplace === 'object' ) {
194+ const { value, ...rules } = toReplace ;
195+
196+ if ( Object . keys ( rules ) . length > 1 && ! value ) {
197+ throw Error ( 'Cannot define multiple rules without a template string with key `value`.' ) ;
198+ }
199+
200+ const computedTemplateValue = Object . entries ( rules ) . reduce ( ( prev , [ key , rule ] ) => {
201+ const computedValue = logic . getScope ( parentID ) . evaluateValidation ( rule , formValues ) ;
202+ return prev . replaceAll ( `{{${ key } }}` , computedValue ) ;
203+ } , value ) ;
204+
205+ return computedTemplateValue . replace ( / \{ \{ ( [ ^ { } ] + ) \} \} / g, ( match , key ) => {
206+ return logic . getScope ( parentID ) . applyComputedValueInField ( key . trim ( ) , formValues , fieldName ) ;
207+ } ) ;
208+ }
209+ return toReplace ;
210+ }
211+
212+ /**
213+ * Builds computed attributes for a field based on jsonLogic rules.
214+ *
215+ * Processes rules defined in the schema's x-jsf-logic section to build
216+ * computed attributes like label, description, etc.
217+ *
218+ * Handles replacing handlebars templates in strings with computed values.
219+ *
220+ * @param {Object } fieldParams - The field configuration parameters
221+ * @param {Object } options - Options
222+ * @param {string } [options.parentID='root'] - ID of the validation scope
223+ * @returns {Function } A function to build the computed attributes
224+ */
126225export function calculateComputedAttributes ( fieldParams , { parentID = 'root' } = { } ) {
127- return ( { logic, formValues } ) => {
128- const { computedAttributes } = fieldParams ;
226+ return ( { logic, isRequired , config , formValues } ) => {
227+ const { name , computedAttributes } = fieldParams ;
129228 const attributes = Object . fromEntries (
130229 Object . entries ( computedAttributes )
131- . map ( handleComputedAttribute ( logic , formValues , parentID ) )
230+ . map ( handleComputedAttribute ( logic , formValues , parentID , name ) )
132231 . filter ( ( [ , value ] ) => value !== null )
133232 ) ;
134233
135- return attributes ;
234+ return {
235+ ...attributes ,
236+ schema : buildYupSchema (
237+ { ...fieldParams , ...attributes , required : isRequired } ,
238+ config ,
239+ logic
240+ ) ,
241+ } ;
136242 } ;
137243}
138244
139- function handleComputedAttribute ( logic , formValues , parentID ) {
245+ /**
246+ * Handles computing a single attribute value.
247+ *
248+ * Evaluates jsonLogic rules to build the computed value.
249+ *
250+ * @param {Object } logic - Validation logic
251+ * @param {Object } formValues - Current form values
252+ * @param {string } parentID - ID of the validation scope
253+ * @param {string } name - Name of the field
254+ * @returns {Function } Function to compute the attribute value
255+ */
256+ function handleComputedAttribute ( logic , formValues , parentID , name ) {
140257 return ( [ key , value ] ) => {
141- if ( key === 'const' )
142- return [ key , logic . getScope ( parentID ) . applyComputedValueInField ( value , formValues ) ] ;
143-
144- if ( typeof value === 'string' ) {
145- return [ key , logic . getScope ( parentID ) . applyComputedValueInField ( value , formValues ) ] ;
258+ switch ( key ) {
259+ case 'description' :
260+ return [ key , replaceHandlebarsTemplates ( { value, logic, formValues, parentID, name } ) ] ;
261+ case 'title' :
262+ return [ 'label' , replaceHandlebarsTemplates ( { value, logic, formValues, parentID, name } ) ] ;
263+ case 'x-jsf-errorMessage' :
264+ return [
265+ 'errorMessage' ,
266+ handleNestedObjectForComputedValues ( value , formValues , parentID , logic , name ) ,
267+ ] ;
268+ case 'x-jsf-presentation' : {
269+ if ( value . statement ) {
270+ return [
271+ 'statement' ,
272+ handleNestedObjectForComputedValues ( value . statement , formValues , parentID , logic , name ) ,
273+ ] ;
274+ }
275+ return [
276+ key ,
277+ handleNestedObjectForComputedValues ( value . statement , formValues , parentID , logic , name ) ,
278+ ] ;
279+ }
280+ case 'const' :
281+ default :
282+ return [ key , logic . getScope ( parentID ) . applyComputedValueInField ( value , formValues , name ) ] ;
146283 }
147284 } ;
148285}
286+
287+ function handleNestedObjectForComputedValues ( values , formValues , parentID , logic , name ) {
288+ return Object . fromEntries (
289+ Object . entries ( values ) . map ( ( [ key , value ] ) => {
290+ return [ key , replaceHandlebarsTemplates ( { value, logic, formValues, parentID, name } ) ] ;
291+ } )
292+ ) ;
293+ }
294+
295+ /**
296+ * Builds a sample empty object for the given schema.
297+ *
298+ * Recursively builds an object with empty values for each property in the schema.
299+ * Used to provide a valid data structure to test jsonLogic validation rules against.
300+ *
301+ * Handles objects, arrays, and nested schemas.
302+ *
303+ * @param {Object } schema - The JSON schema
304+ * @returns {Object } Sample empty object based on the schema
305+ */
306+ function buildSampleEmptyObject ( schema = { } ) {
307+ const sample = { } ;
308+ if ( typeof schema !== 'object' || ! schema . properties ) {
309+ return schema ;
310+ }
311+
312+ for ( const key in schema . properties ) {
313+ if ( schema . properties [ key ] . type === 'object' ) {
314+ sample [ key ] = buildSampleEmptyObject ( schema . properties [ key ] ) ;
315+ } else if ( schema . properties [ key ] . type === 'array' ) {
316+ const itemSchema = schema . properties [ key ] . items ;
317+ sample [ key ] = buildSampleEmptyObject ( itemSchema ) ;
318+ } else {
319+ sample [ key ] = true ;
320+ }
321+ }
322+
323+ return sample ;
324+ }
325+
326+ /**
327+ * Validates inline jsonLogic rules defined in the schema's x-jsf-logic-computedAttrs.
328+ *
329+ * For each field with computed attributes, checks that the variables
330+ * referenced in the rules exist in the schema.
331+ *
332+ * Throws if any variable in a computed attribute rule does not exist.
333+ *
334+ * @param {Object } jsonSchema - The JSON schema object
335+ * @param {Object } sampleEmptyObject - Sample empty object based on the schema
336+ */
337+ function validateInlineRules ( jsonSchema , sampleEmptyObject ) {
338+ const properties = ( jsonSchema ?. properties || jsonSchema ?. items ?. properties ) ?? { } ;
339+ Object . entries ( properties )
340+ . filter ( ( [ , property ] ) => property [ 'x-jsf-logic-computedAttrs' ] !== undefined )
341+ . forEach ( ( [ fieldName , property ] ) => {
342+ Object . entries ( property [ 'x-jsf-logic-computedAttrs' ] )
343+ . filter ( ( [ , value ] ) => typeof value === 'object' )
344+ . forEach ( ( [ key , item ] ) => {
345+ Object . values ( item ) . forEach ( ( rule ) => {
346+ checkRuleIntegrity (
347+ rule ,
348+ fieldName ,
349+ sampleEmptyObject ,
350+ ( item ) =>
351+ `[json-schema-form] json-logic error: fieldName "${ item . var } " doesn't exist in field "${ fieldName } .x-jsf-logic-computedAttrs.${ key } ".`
352+ ) ;
353+ } ) ;
354+ } ) ;
355+ } ) ;
356+ }
357+
358+ /**
359+ * Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object.
360+ * Throws an error if any variable in the rule does not exist in the data.
361+ *
362+ * @example
363+ *
364+ * const rule = { "+": [{ "var": "iDontExist"}, 10 ]}
365+ * const badData = { a: 1 }
366+ * checkRuleIntegrity(rule, "add_ten_to_field", badData)
367+ * // throws Error(`"iDontExist" in rule "add_ten_to_field" does not exist as a JSON schema property.`)
368+ *
369+ *
370+ * @param {Object|Array } rule - The jsonLogic rule object or array to validate
371+ * @param {string } id - The ID of the rule (used in error messages)
372+ * @param {Object } data - The data object to check the rule variables against
373+ * @param {Function } errorMessage - Function to generate custom error message.
374+ * Receives the invalid rule part and should throw an error message string.
375+ */
376+ function checkRuleIntegrity (
377+ rule ,
378+ id ,
379+ data ,
380+ errorMessage = ( item ) =>
381+ `[json-schema-form] json-logic error: rule "${ id } " has no variable "${ item . var } ".`
382+ ) {
383+ Object . entries ( rule ?? { } ) . map ( ( [ operator , subRule ] ) => {
384+ if ( ! Array . isArray ( subRule ) && subRule !== null && subRule !== undefined ) return ;
385+ throwIfUnknownOperator ( operator , subRule , id ) ;
386+
387+ subRule . map ( ( item ) => {
388+ const isVar = item !== null && typeof item === 'object' && Object . hasOwn ( item , 'var' ) ;
389+ if ( isVar ) {
390+ const exists = jsonLogic . apply ( { var : removeIndicesFromPath ( item . var ) } , data ) ;
391+ if ( exists === null ) {
392+ throw Error ( errorMessage ( item ) ) ;
393+ }
394+ } else {
395+ checkRuleIntegrity ( item , id , data ) ;
396+ }
397+ } ) ;
398+ } ) ;
399+ }
400+
401+ function throwIfUnknownOperator ( operator , subRule , id ) {
402+ try {
403+ jsonLogic . apply ( { [ operator ] : subRule } ) ;
404+ } catch ( e ) {
405+ if ( e . message === `Unrecognized operation ${ operator } ` ) {
406+ throw Error (
407+ `[json-schema-form] json-logic error: in "${ id } " rule there is an unknown operator "${ operator } ".`
408+ ) ;
409+ }
410+ }
411+ }
412+
413+ const regexToGetIndices = / \. \d + \. / g; // eg. .0., .10.
414+
415+ /**
416+ * Removes array indices from a json schema path string.
417+ * Converts paths like "foo.0.bar" to "foo.bar".
418+ * This allows checking if a variable exists in an array item schema without needing the specific index.
419+ *
420+ * @param {string } path - The json schema path potentially containing array indices
421+ * @returns {string } The path with array indices removed
422+ */
423+ function removeIndicesFromPath ( path ) {
424+ const intermediatePath = path . replace ( regexToGetIndices , '.' ) ;
425+ return intermediatePath . replace ( / \. \d + $ / , '' ) ;
426+ }
0 commit comments