From 8f1222672d9775228ac26064675e8c14ff80e2ab Mon Sep 17 00:00:00 2001 From: Andrea Maria Piana Date: Mon, 18 Aug 2025 13:43:44 +0100 Subject: [PATCH] feat: allow accumulator/current in reduce/map operations --- v0/src/jsonLogic.js | 20 ++++++++++-- v0/src/tests/jsonLogic.fixtures.js | 52 ++++++++++++++++++++++++++++-- v0/src/tests/jsonLogic.test.js | 16 +++++++-- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/v0/src/jsonLogic.js b/v0/src/jsonLogic.js index e5490d159..47e45fc0d 100644 --- a/v0/src/jsonLogic.js +++ b/v0/src/jsonLogic.js @@ -404,26 +404,40 @@ function validateInlineRules(jsonSchema, sampleEmptyObject) { * @param {Function} errorMessage - Function to generate custom error message. * Receives the invalid rule part and should throw an error message string. */ + function checkRuleIntegrity( rule, id, data, errorMessage = (item) => - `[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".` + `[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".`, + inReduceOrMap = false ) { Object.entries(rule ?? {}).map(([operator, subRule]) => { if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return; throwIfUnknownOperator(operator, subRule, id); + // If we are within a reduce or map, we allow references to `accumulator` and/or `current`. + // We augment the data so that we still validate any other field. + const isReduceOrMap = inReduceOrMap || operator === 'reduce' || operator === 'map'; + + const validationData = isReduceOrMap + ? { + ...data, + accumulator: 0, + current: null, + } + : data; + subRule.map((item) => { const isVar = item !== null && typeof item === 'object' && Object.hasOwn(item, 'var'); if (isVar) { - const exists = jsonLogic.apply({ var: removeIndicesFromPath(item.var) }, data); + const exists = jsonLogic.apply({ var: removeIndicesFromPath(item.var) }, validationData); if (exists === null) { throw Error(errorMessage(item)); } } else { - checkRuleIntegrity(item, id, data); + checkRuleIntegrity(item, id, data, errorMessage, isReduceOrMap); } }); }); diff --git a/v0/src/tests/jsonLogic.fixtures.js b/v0/src/tests/jsonLogic.fixtures.js index c21d70cf4..2845e38ee 100644 --- a/v0/src/tests/jsonLogic.fixtures.js +++ b/v0/src/tests/jsonLogic.fixtures.js @@ -1061,7 +1061,7 @@ export const schemaWithReduceAccumulator = { type: 'number', 'x-jsf-logic-computedAttrs': { const: 'computed_work_hours_per_week', - defaultValue: 'computed_work_hours_per_week', + default: 'computed_work_hours_per_week', title: '{{computed_work_hours_per_week}} hours per week', }, }, @@ -1073,7 +1073,7 @@ export const schemaWithReduceAccumulator = { '*': [ { var: 'working_hours_per_day' }, { - reduce: [{ var: 'work_days' }, { '+': [{ var: ['accumulator', 0] }, 1] }, 0], + reduce: [{ var: 'work_days' }, { '+': [{ var: 'accumulator' }, 1] }, 0], }, ], }, @@ -1081,3 +1081,51 @@ export const schemaWithReduceAccumulator = { }, }, }; + +export const schemaWithReduceAccumulatorAndMerge = { + properties: { + work_days: { + items: { + anyOf: [ + { const: 'monday', title: 'Monday' }, + { const: 'tuesday', title: 'Tuesday' }, + { const: 'wednesday', title: 'Wednesday' }, + { const: 'thursday', title: 'Thursday' }, + { const: 'friday', title: 'Friday' }, + { const: 'saturday', title: 'Saturday' }, + { const: 'sunday', title: 'Sunday' }, + ], + }, + type: 'array', + uniqueItems: true, + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + working_hours_per_day: { + type: 'number', + }, + working_hours_per_week: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + const: 'computed_work_hours_per_week', + default: 'computed_work_hours_per_week', + title: '{{computed_work_hours_per_week}} hours per week', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + computed_work_hours_per_week: { + rule: { + reduce: [ + { var: 'work_days' }, + { merge: [{ var: 'accumulator' }, [{ var: 'current' }]] }, + // note we use the wrong accumulator type + 0, + ], + }, + }, + }, + }, +}; diff --git a/v0/src/tests/jsonLogic.test.js b/v0/src/tests/jsonLogic.test.js index 989b1e312..d49071e73 100644 --- a/v0/src/tests/jsonLogic.test.js +++ b/v0/src/tests/jsonLogic.test.js @@ -35,6 +35,7 @@ import { schemaWithValidationThatDoesNotExistOnProperty, badSchemaThatWillNotSetAForcedValue, schemaWithReduceAccumulator, + schemaWithReduceAccumulatorAndMerge, schemaWithComputedPresentationAttributes, } from './jsonLogic.fixtures'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; @@ -245,8 +246,7 @@ describe('jsonLogic: cross-values validations', () => { }); }); - // TODO: Implement this test. - describe.skip('Reduce', () => { + describe('Reduce', () => { it('reduce: working_hours_per_day * work_days', () => { const { fields, handleValidation } = createHeadlessForm(schemaWithReduceAccumulator, { strictInputType: false, @@ -260,6 +260,18 @@ describe('jsonLogic: cross-values validations', () => { expect(field.default).toEqual(16); expect(field.label).toEqual('16 hours per week'); }); + + it('reduce: handles when operator is non numerical', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithReduceAccumulatorAndMerge, { + strictInputType: false, + }); + handleValidation({ + work_days: ['monday', 'tuesday'], + working_hours_per_day: 8, + }); + const field = fields.find((i) => i.name === 'working_hours_per_week'); + expect(field.const).toEqual([0, 'monday', 'tuesday']); + }); }); describe('Logical: ||, &&', () => {