diff --git a/packages/data-models/flowTypes.ts b/packages/data-models/flowTypes.ts index 215cf2906e..9b568bfe78 100644 --- a/packages/data-models/flowTypes.ts +++ b/packages/data-models/flowTypes.ts @@ -340,17 +340,24 @@ export namespace FlowTypes { _evalContext?: { itemContext: TemplateRowItemEvalContext }; // force specific context variables when calculating eval statements (such as loop items) __EMPTY?: any; // empty cells (can be removed after pr 679 merged) } + export type IDynamicField = { [key: string]: IDynamicField | TemplateRowDynamicEvaluator[] }; - export interface TemplateRowItemEvalContext { + export interface TemplateRowItemEvalContextMetadata { // item metadata _id: string; _index: number; _first: boolean; _last: boolean; - // item data - [key: string]: any; } + // Enumerable list of metadata columns for use by processing functions + export const TEMPLATE_ROW_ITEM_METADATA_FIELDS: Array = + ["_id", "_index", "_first", "_last"]; + + // General interface for row items which can contain any key-value pairs with metadata + export type TemplateRowItemEvalContext = TemplateRowItemEvalContextMetadata & { + [key: string]: any; + }; type IDynamicPrefix = IAppConfig["DYNAMIC_PREFIXES"][number]; 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 7f94e2756f..b32c9275ee 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 @@ -7,7 +7,10 @@ import { OnDestroy, } from "@angular/core"; import { debounceTime, Subscription } from "rxjs"; -import { DynamicDataService } from "src/app/shared/services/dynamic-data/dynamic-data.service"; +import { + DynamicDataService, + ISetItemContext, +} from "src/app/shared/services/dynamic-data/dynamic-data.service"; import { FlowTypes } from "../../models"; import { ItemProcessor } from "../../processors/item"; import { TemplateRowService } from "../../services/instance/template-row.service"; @@ -96,8 +99,9 @@ export class TmplDataItemsComponent extends TemplateBaseComponent implements OnD const itemDataIDs = itemData.map((item) => item.id); // Reassign metadata fields previously assigned by item as rendered row count may have changed return templateRows.map((r) => { + const itemId = r._evalContext.itemContext._id; // Map the row item context to the original list of items rendered to know position in item list. - const itemIndex = itemDataIDs.indexOf(r._evalContext.itemContext._id); + const itemIndex = itemDataIDs.indexOf(itemId); // Update metadata fields as _first, _last and index may have changed based on dynamic updates r._evalContext.itemContext = { ...r._evalContext.itemContext, @@ -108,14 +112,19 @@ export class TmplDataItemsComponent extends TemplateBaseComponent implements OnD // Update any action list set_item args to contain name of current data list and item id // and set_items action to include all currently displayed rows if (r.action_list) { + const setItemContext: ISetItemContext = { + flow_name: this.dataListName, + itemDataIDs, + currentItemId: itemId, + }; r.action_list = r.action_list.map((a) => { if (a.action_id === "set_item") { - a.args = [this.dataListName, r._evalContext.itemContext._id]; + a.args = [setItemContext]; } if (a.action_id === "set_items") { // TODO - add a check for @item refs and replace parameter list with correct values // for each individual item (default will be just to pick the first) - a.args = [this.dataListName, itemDataIDs]; + a.args = [setItemContext]; } return a; }); diff --git a/src/app/shared/components/template/services/template-calc.spec.ts b/src/app/shared/components/template/services/template-calc.service.spec.ts similarity index 88% rename from src/app/shared/components/template/services/template-calc.spec.ts rename to src/app/shared/components/template/services/template-calc.service.spec.ts index 344a5affad..2752062482 100644 --- a/src/app/shared/components/template/services/template-calc.spec.ts +++ b/src/app/shared/components/template/services/template-calc.service.spec.ts @@ -1,3 +1,19 @@ +import { ICalcContext, TemplateCalcService } from "./template-calc.service"; + +export class MockTemplateCalcService implements Partial { + public async ready(): Promise { + return true; + } + + public getCalcContext(): ICalcContext { + return { + thisCtxt: {}, + globalConstants: {}, + globalFunctions: {}, + }; + } +} + /** * TODO - Add testing data and methods * diff --git a/src/app/shared/components/template/services/template-field.service.spec.ts b/src/app/shared/components/template/services/template-field.service.spec.ts index f2ae3e1291..dcfd73f763 100644 --- a/src/app/shared/components/template/services/template-field.service.spec.ts +++ b/src/app/shared/components/template/services/template-field.service.spec.ts @@ -3,6 +3,8 @@ import { TestBed } from "@angular/core/testing"; import { TemplateFieldService } from "./template-field.service"; import type { PromiseExtended } from "dexie"; import { booleanStringToBoolean } from "src/app/shared/utils"; +import { ErrorHandlerService } from "src/app/shared/services/error-handler/error-handler.service"; +import { MockErrorHandlerService } from "src/app/shared/services/error-handler/error-handler.service.spec"; /** Mock calls for field values from the template field service to return test data */ export class MockTemplateFieldService implements Partial { @@ -27,11 +29,13 @@ export class MockTemplateFieldService implements Partial { describe("TemplateFieldService", () => { let service: TemplateFieldService; - beforeEach(() => { + beforeEach(async () => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], + providers: [{ provide: ErrorHandlerService, useValue: new MockErrorHandlerService() }], }); service = TestBed.inject(TemplateFieldService); + await service.ready(); }); it("should be created", () => { diff --git a/src/app/shared/components/template/services/template-translate.service.spec.ts b/src/app/shared/components/template/services/template-translate.service.spec.ts index e293081bfa..249e87c97f 100644 --- a/src/app/shared/components/template/services/template-translate.service.spec.ts +++ b/src/app/shared/components/template/services/template-translate.service.spec.ts @@ -12,7 +12,7 @@ export class MockTemplateTranslateService implements Partial { +describe("TemplateTranslateService", () => { let service: TemplateTranslateService; beforeEach(() => { 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 new file mode 100644 index 0000000000..259296d750 --- /dev/null +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -0,0 +1,181 @@ +import { TestBed } from "@angular/core/testing"; +import { IVariableContext, TemplateVariablesService } from "./template-variables.service"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { TemplateFieldService } from "./template-field.service"; +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 { MockTemplateCalcService } from "./template-calc.service.spec"; + +const MOCK_APP_DATA = {}; + +// Fields populated to mock field service +const MOCK_FIELDS = { + _app_language: "gb_en", + _app_skin: "default", + string_field: "test_string_value", + number_field: 2, +}; + +const MOCK_CONTEXT_BASE: IVariableContext = { + // Assume the row will have a dynamic 'field' entry + field: "value", + row: { + type: "text", + value: "", + name: "test_row", + _nested_name: "test_row", + }, + templateRowMap: {}, + calcContext: { + globalConstants: {}, + globalFunctions: {}, + thisCtxt: { + fields: MOCK_FIELDS, + local: {}, + }, + }, +}; + +const TEST_FIELD_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, + row: { + ...MOCK_CONTEXT_BASE.row, + value: "Hello @fields.string_field", + _dynamicFields: { + value: [ + { + fullExpression: "Hello @fields.string_field", + matchedExpression: "@fields.string_field", + type: "fields", + fieldName: "string_field", + }, + ], + }, + }, +}; + +// Context adapted from this debug template: +// https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 +const TEST_ITEM_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, + row: { + ...MOCK_CONTEXT_BASE.row, + value: "@item._index + 1", + // NOTE - any evaluated fields should appea + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + }, + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, +}; + +/** + * Call standalone tests via: + * yarn ng test --include src/app/shared/components/template/services/template-variables.service.spec.ts + */ +describe("TemplateVariablesService", () => { + let service: TemplateVariablesService; + let getNextCampaignRowsSpy: jasmine.Spy; + + beforeEach(async () => { + getNextCampaignRowsSpy = jasmine.createSpy(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: TemplateFieldService, + useValue: new MockTemplateFieldService(MOCK_FIELDS), + }, + { + provide: AppDataService, + useValue: new MockAppDataService(MOCK_APP_DATA), + }, + { + provide: TemplateCalcService, + useValue: new MockTemplateCalcService(), + }, + // Mock single method from campaign service called + { + provide: CampaignService, + useValue: { + ready: async () => { + return true; + }, + getNextCampaignRows: getNextCampaignRowsSpy, + }, + }, + ], + }); + service = TestBed.inject(TemplateVariablesService); + await service.ready(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("Evaluates PLH Data String", async () => { + console.log({ TEST_FIELD_CONTEXT }); + const res = await service.evaluatePLHData("Hello @fields.string_field", TEST_FIELD_CONTEXT); + expect(res).toEqual("Hello test_string_value"); + // Data will only be evaluated if it has been pre-parsed, extracting dynamic references + // If not returns raw value + delete TEST_FIELD_CONTEXT.row._dynamicFields; + const resWithoutDynamicContext = await service.evaluatePLHData( + "@fields.string_field", + TEST_FIELD_CONTEXT + ); + expect(resWithoutDynamicContext).toEqual("@fields.string_field"); + /** + * TODO - include all edge cases, e.g. raw, item, calc, deep-nested, object, array etc. + const res = await service.evaluatePLHData(["@fields.string_field"], MOCK_CONTEXT); + expect(res).toEqual({ 1: "test_string_value" }); + const res = await service.evaluatePLHData( + { + nested: "@fields.string_field", + }, + MOCK_CONTEXT + ); + expect(res).toEqual({ nested: "test_string_value" }); + */ + }); + it("Evaluates condition strings", async () => { + // Condition strings are evaluated without any previous pre-parsed dynamic fields + const res = await service.evaluateConditionString("@fields.number_field > 3"); + expect(res).toEqual(false); + }); + + it("evaluates string containing item variable", async () => { + const MOCK_ITEM_STRING = "@item._index + 1"; + // Parse expression when item context included + const resWithItemContext = await service.evaluatePLHData(MOCK_ITEM_STRING, TEST_ITEM_CONTEXT); + expect(resWithItemContext).toEqual(1); + // Retain raw expression if evaluating outside of item context + // https://github.com/IDEMSInternational/parenting-app-ui/pull/2215#discussion_r1514757364 + delete TEST_ITEM_CONTEXT.itemContext; + const resWithoutItemContext = await service.evaluatePLHData( + MOCK_ITEM_STRING, + TEST_ITEM_CONTEXT + ); + expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); + }); +}); 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 75984760e8..cfd818a7ec 100644 --- a/src/app/shared/components/template/services/template-variables.service.ts +++ b/src/app/shared/components/template/services/template-variables.service.ts @@ -16,6 +16,8 @@ const log = SHOW_DEBUG_LOGS ? console.log : () => null; const log_group = SHOW_DEBUG_LOGS ? console.group : () => null; const log_groupEnd = SHOW_DEBUG_LOGS ? console.groupEnd : () => null; +const { TEMPLATE_ROW_ITEM_METADATA_FIELDS } = FlowTypes; + /** * Most methods in this class depend on factors relating to the execution context * (e.g.row, variables etc.). Store as a single object to make it easier to pass between methods @@ -120,12 +122,19 @@ export class TemplateVariablesService extends AsyncServiceBase { } /** - * Inore evaluation of meta, comment, and specifiedfields. + * Ignore evaluation of meta, comment, and specifiedfields. * Could provide single list of approved fields, but as dynamic fields also can be found in parameter lists * would likely prove too restrictive **/ - private shouldEvaluateField(fieldName: keyof FlowTypes.TemplateRow, omitFields: string[] = []) { + private shouldEvaluateField( + fieldName: keyof FlowTypes.TemplateRow | keyof FlowTypes.TemplateRowItemEvalContextMetadata, + omitFields: string[] = [] + ) { if (omitFields.includes(fieldName)) return false; + + // Evaluate fields that are names of item metadata fields, e.g. "_index", "_id", + // E.g. for use in actions such as `click | set_item | _index: @item._index + 1, completed:false` + if (TEMPLATE_ROW_ITEM_METADATA_FIELDS.includes(fieldName as any)) return true; if (fieldName.startsWith("_")) return false; return true; } @@ -183,6 +192,11 @@ export class TemplateVariablesService extends AsyncServiceBase { return evaluator.fullExpression.replace(/`/gi, ""); } + // Do not evaluate if the appropriate context is not available + if (type === "item" && !context.itemContext) { + return evaluator.fullExpression; + } + // process the main lookup, e.g. @local.some_val, @campaign.some_val // NOTE - if parse fail an empty string will be returned let { parsedValue, parseSuccess } = await this.processDynamicEvaluator(evaluator, context); diff --git a/src/app/shared/services/dynamic-data/dynamic-data.service.spec.ts b/src/app/shared/services/dynamic-data/dynamic-data.service.spec.ts index b9e66c86b0..50ec015cc2 100644 --- a/src/app/shared/services/dynamic-data/dynamic-data.service.spec.ts +++ b/src/app/shared/services/dynamic-data/dynamic-data.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { firstValueFrom } from "rxjs"; -import { DynamicDataService } from "./dynamic-data.service"; +import { DynamicDataService, ISetItemContext } from "./dynamic-data.service"; import { AppDataService } from "../data/app-data.service"; import { MockAppDataService } from "../data/app-data.service.spec"; @@ -12,6 +12,12 @@ const TEST_DATA_ROWS = [ ]; type ITestRow = (typeof TEST_DATA_ROWS)[number]; +const SET_ITEM_CONTEXT: ISetItemContext = { + flow_name: "test_flow", + itemDataIDs: ["id1", "id2"], + currentItemId: "id1", +}; + /** * Call standalone tests via: * yarn ng test --include src/app/shared/services/dynamic-data/dynamic-data.service.spec.ts @@ -102,6 +108,41 @@ describe("DynamicDataService", () => { expect(res.length).toEqual(20); }); + it("sets an item correctly for current item", async () => { + await service.setItem({ + context: SET_ITEM_CONTEXT, + writeableProps: { string: "sets an item correctly for current item" }, + }); + const obs = await service.query$("data_list", "test_flow"); + const data = await firstValueFrom(obs); + expect(data[0].string).toEqual("sets an item correctly for current item"); + expect(data[1].string).toEqual("goodbye"); + }); + + it("sets an item correctly for a given _id", async () => { + await service.setItem({ + context: SET_ITEM_CONTEXT, + _id: "id2", + writeableProps: { string: "sets an item correctly for a given _id" }, + }); + const obs = await service.query$("data_list", "test_flow"); + const data = await firstValueFrom(obs); + expect(data[0].string).toEqual("hello"); + expect(data[1].string).toEqual("sets an item correctly for a given _id"); + }); + + it("sets an item correctly for a given _index", async () => { + await service.setItem({ + context: SET_ITEM_CONTEXT, + _index: 1, + writeableProps: { string: "sets an item correctly for a given _index" }, + }); + const obs = await service.query$("data_list", "test_flow"); + const data = await firstValueFrom(obs); + expect(data[0].string).toEqual("hello"); + expect(data[1].string).toEqual("sets an item correctly for a given _index"); + }); + // QA it("prevents query of non-existent data lists", async () => { let errMsg: string; diff --git a/src/app/shared/services/dynamic-data/dynamic-data.service.ts b/src/app/shared/services/dynamic-data/dynamic-data.service.ts index 1f47c86473..d54b2b5c48 100644 --- a/src/app/shared/services/dynamic-data/dynamic-data.service.ts +++ b/src/app/shared/services/dynamic-data/dynamic-data.service.ts @@ -66,14 +66,24 @@ export class DynamicDataService extends AsyncServiceBase { } private registerTemplateActionHandlers() { this.templateActionRegistry.register({ + /** + * Write properties on the current item (default), or on an explicitly targeted item, + * e.g. + * click | set_item | completed:true; + * click | set_item | _id: @item.id, completed:true; + * click | set_item | _index: @item._index + 1, completed:true; + */ set_item: async ({ args, params }) => { - const [flow_name, row_id] = args; - await this.update("data_list", flow_name, row_id, params); + // The data-items component populates the context for evaluating the target item to be updated + const context = args[0] as ISetItemContext; + // The params come directly from the authored action + const { _index, _id, ...writeableProps } = params; + await this.setItem({ context, _index, _id, writeableProps }); }, set_items: async ({ args, params }) => { - const [flow_name, row_ids] = args; + const { flow_name, itemDataIDs } = args[0] as ISetItemContext; // Hack, no current method for bulk update so make successive (changes debounced in component) - for (const row_id of row_ids) { + for (const row_id of itemDataIDs) { await this.update("data_list", flow_name, row_id, params); } }, @@ -223,4 +233,47 @@ export class DynamicDataService extends AsyncServiceBase { } return schema; } + + /** + * Update an "item", a row within a data-items loop, e.g. as triggered by the set_item action. + * If an _id is specified, the row with that ID is updated, + * else if an _index is specified, the row with corresponding to the item at that index is updated, + * if neither is specified, then the current item (as defined in context) is updated + */ + public async setItem(params: { + context: ISetItemContext; + /** the index of a specific item to update */ + _index?: number; + /** the id of a specific item to update */ + _id?: string; + /** the property key/values to update on the targeted item */ + writeableProps: any; + }) { + const { flow_name, itemDataIDs, currentItemId } = params.context; + const { _index, _id, writeableProps } = params; + + let targetRowId = currentItemId; + if (_id) { + targetRowId = _id; + } + if (_index !== undefined) { + targetRowId = itemDataIDs[_index]; + } + + if (itemDataIDs.includes(targetRowId)) { + await this.update("data_list", flow_name, targetRowId, writeableProps); + } else { + console.warn(`[SET ITEM] - No item ${_id ? "with ID " + _id : "at index " + _index}`); + } + } +} + +/** the context for evaluating the target item to be updated, provided by the data-items component */ +export interface ISetItemContext { + /** the name of the data_list containing the item to update */ + flow_name: string; + /** an array of the IDs of all the item rows in the loop */ + itemDataIDs: string[]; + /** the ID of the current item */ + currentItemId: string; } diff --git a/src/app/shared/services/error-handler/error-handler.service.spec.ts b/src/app/shared/services/error-handler/error-handler.service.spec.ts index 943381c9f6..96afdbf7f1 100644 --- a/src/app/shared/services/error-handler/error-handler.service.spec.ts +++ b/src/app/shared/services/error-handler/error-handler.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { ErrorHandlerService } from "./error-handler.service"; +import { FirebaseService } from "../firebase/firebase.service"; /** Mock calls for sheets from the appData service to return test data */ export class MockErrorHandlerService implements Partial { @@ -16,7 +17,14 @@ describe("ErrorHandlerService", () => { let service: ErrorHandlerService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: FirebaseService, + useValue: {}, + }, + ], + }); service = TestBed.inject(ErrorHandlerService); });