diff --git a/src/app/shared/components/template/components/data-items/data-items.component.ts b/src/app/shared/components/template/components/data-items/data-items.component.ts index b32c9275ee..55b5c82f7e 100644 --- a/src/app/shared/components/template/components/data-items/data-items.component.ts +++ b/src/app/shared/components/template/components/data-items/data-items.component.ts @@ -185,9 +185,8 @@ export class TmplDataItemsComponent extends TemplateBaseComponent implements OnD parsed[listKey] = listValue; for (const [itemKey, itemValue] of Object.entries(listValue)) { if (typeof itemValue === "string") { - parsed[listKey][itemKey] = await this.templateVariablesService.evaluateConditionString( - itemValue - ); + parsed[listKey][itemKey] = + await this.templateVariablesService.evaluateConditionString(itemValue); } } } diff --git a/src/app/shared/components/template/services/template-calc.service.spec.ts b/src/app/shared/components/template/services/template-calc.service.spec.ts index 2752062482..9b689552c7 100644 --- a/src/app/shared/components/template/services/template-calc.service.spec.ts +++ b/src/app/shared/components/template/services/template-calc.service.spec.ts @@ -1,16 +1,23 @@ import { ICalcContext, TemplateCalcService } from "./template-calc.service"; +const CALC_CONTEXT_BASE: ICalcContext = { + thisCtxt: {}, + globalConstants: {}, + globalFunctions: {}, +}; + export class MockTemplateCalcService implements Partial { + private calcContext: ICalcContext; + constructor(mockCalcContext?: Partial) { + this.calcContext = { ...CALC_CONTEXT_BASE, ...mockCalcContext }; + } + public async ready(): Promise { return true; } public getCalcContext(): ICalcContext { - return { - thisCtxt: {}, - globalConstants: {}, - globalFunctions: {}, - }; + return this.calcContext; } } diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts index 259296d750..3341064070 100644 --- a/src/app/shared/components/template/services/template-variables.service.spec.ts +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -6,8 +6,9 @@ import { MockTemplateFieldService } from "./template-field.service.spec"; import { AppDataService } from "src/app/shared/services/data/app-data.service"; import { CampaignService } from "src/app/feature/campaign/campaign.service"; import { MockAppDataService } from "src/app/shared/services/data/app-data.service.spec"; -import { TemplateCalcService } from "./template-calc.service"; +import { ICalcContext, TemplateCalcService } from "./template-calc.service"; import { MockTemplateCalcService } from "./template-calc.service.spec"; +import clone from "clone"; const MOCK_APP_DATA = {}; @@ -32,10 +33,7 @@ const MOCK_CONTEXT_BASE: IVariableContext = { calcContext: { globalConstants: {}, globalFunctions: {}, - thisCtxt: { - fields: MOCK_FIELDS, - local: {}, - }, + thisCtxt: {}, }, }; @@ -57,6 +55,17 @@ const TEST_FIELD_CONTEXT: IVariableContext = { }, }; +const MOCK_ITEM_CONTEXT: IVariableContext["itemContext"] = { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, +}; + // Context adapted from this debug template: // https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 const TEST_ITEM_CONTEXT: IVariableContext = { @@ -76,18 +85,44 @@ const TEST_ITEM_CONTEXT: IVariableContext = { ], }, }, - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, + itemContext: MOCK_ITEM_CONTEXT, +}; + +const TEST_LOCAL_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, + templateRowMap: { + string_local: { + name: "string_local", + value: "Jasper", + type: "set_variable", + _nested_name: "string_local", + }, + }, + row: { + ...MOCK_CONTEXT_BASE.row, + value: "Hello @local.string_local", + _dynamicFields: { + value: [ + { + fullExpression: "Hello @local.string_local", + matchedExpression: "@local.string_local", + type: "local", + fieldName: "string_local", + }, + ], + }, }, }; +const TEST_LOCAL_CONTEXT_WITH_ITEM_CONTEXT = { + ...TEST_LOCAL_CONTEXT, + itemContext: MOCK_ITEM_CONTEXT, +}; + +const MOCK_CALC_CONTEXT: Partial = { + thisCtxt: { local: { string_local: "Jasper2" } }, +}; + /** * Call standalone tests via: * yarn ng test --include src/app/shared/components/template/services/template-variables.service.spec.ts @@ -111,7 +146,9 @@ describe("TemplateVariablesService", () => { }, { provide: TemplateCalcService, - useValue: new MockTemplateCalcService(), + // HACK: hardcoded calcContext from mock context is overridden by calcContext returned from MockTemplateCalcService, + // so insert values here for testing evaluation of local variable inside item loop + useValue: new MockTemplateCalcService(clone(MOCK_CALC_CONTEXT)), }, // Mock single method from campaign service called { @@ -178,4 +215,23 @@ describe("TemplateVariablesService", () => { ); expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); }); + + it("Evaluates string containing local variable", async () => { + const MOCK_LOCAL_STRING = "Hello @local.string_local"; + const resWithLocalContext = await service.evaluatePLHData( + MOCK_LOCAL_STRING, + TEST_LOCAL_CONTEXT + ); + expect(resWithLocalContext).toEqual("Hello Jasper"); + }); + + it("Evaluates string containing local variable, inside item loop", async () => { + const MOCK_LOCAL_STRING = "Hello @local.string_local"; + // When itemContext is included (i.e. in an item loop), look to thisCtxt for the parsed local var + const resWithLocalContext = await service.evaluatePLHData( + MOCK_LOCAL_STRING, + TEST_LOCAL_CONTEXT_WITH_ITEM_CONTEXT + ); + expect(resWithLocalContext).toEqual("Hello Jasper2"); + }); }); diff --git a/src/app/shared/components/template/services/template-variables.service.ts b/src/app/shared/components/template/services/template-variables.service.ts index cfd818a7ec..c6f729b5b1 100644 --- a/src/app/shared/components/template/services/template-variables.service.ts +++ b/src/app/shared/components/template/services/template-variables.service.ts @@ -332,36 +332,48 @@ export class TemplateVariablesService extends AsyncServiceBase { // TODO - assumed 'value' field will be returned but this could be provided instead as an arg const returnField: keyof FlowTypes.TemplateRow = "value"; - // find any rows where nested path corresponds to match path - let matchedRows: { row: FlowTypes.TemplateRow; nestedName: string }[] = []; - Object.entries(templateRowMap).forEach(([nestedName, row]) => { - if (nestedName === fieldName || nestedName.endsWith(`.${fieldName}`)) { - matchedRows.push({ row, nestedName }); - } - }); - // no match found. If condition assume this is fine, otherwise authoring error - if (matchedRows.length === 0) { - if (field === "condition") { - parsedValue = false; - } else { + // In a data-items loop, the templateRowMap is only of the items rows. + // In this case, we can look at the calcContext to see if the local variable value has already been parsed, and return this value + if (context.itemContext && field !== "condition") { + parsedValue = context.calcContext.thisCtxt?.local?.[fieldName]; + if (!parsedValue && parsedValue !== 0) { parseSuccess = false; console.error(`@local.${fieldName} not found`, { evaluator, rowMap: templateRowMap, }); } - } - // match found - return least nested (in case of duplicates) - else { - matchedRows = matchedRows.sort( - (a, b) => a.nestedName.split(".").length - b.nestedName.split(".").length - ); - if (matchedRows.length > 1) { - console.warn(`@local.${fieldName} found multiple`, { matchedRows }); + } else { + // find any rows where nested path corresponds to match path + let matchedRows: { row: FlowTypes.TemplateRow; nestedName: string }[] = []; + Object.entries(templateRowMap).forEach(([nestedName, row]) => { + if (nestedName === fieldName || nestedName.endsWith(`.${fieldName}`)) { + matchedRows.push({ row, nestedName }); + } + }); + // no match found. If condition assume this is fine, otherwise authoring error + if (matchedRows.length === 0) { + if (field === "condition") { + parsedValue = false; + } else { + parseSuccess = false; + console.error(`@local.${fieldName} not found`, { + evaluator, + rowMap: templateRowMap, + }); + } + } + // match found - return least nested (in case of duplicates) + else { + matchedRows = matchedRows.sort( + (a, b) => a.nestedName.split(".").length - b.nestedName.split(".").length + ); + if (matchedRows.length > 1) { + console.warn(`@local.${fieldName} found multiple`, { matchedRows }); + } + parsedValue = matchedRows[0].row[returnField]; } - parsedValue = matchedRows[0].row[returnField]; } - break; case "field": // console.warn("To keep consistency with rapidpro, @fields should be used instead of @field");