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 968992d62..da80e9983 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 @@ -3,7 +3,8 @@ import { FlowTypes } from "../../models"; import { TemplateBaseComponent } from "../base"; import { DataItemsService } from "./data-items.service"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; -import { switchMap, filter } from "rxjs"; +import { switchMap, filter, distinctUntilChanged } from "rxjs"; +import { isEqual } from "packages/shared/src/utils/object-utils"; @Component({ selector: "plh-data-items", @@ -23,25 +24,20 @@ export class TmplDataItemsComponent extends TemplateBaseComponent { public itemRows = toSignal( toObservable(this.rowSignal).pipe( filter((row) => row !== undefined), - switchMap((row) => this.subscribeToDynamicData(row)) + switchMap((row) => this.subscribeToDynamicData(row)), + distinctUntilChanged(isEqual) ) ); - /** List of actions to trigger on data_change */ - private dataActions = computed(() => - this.actionList().filter((a) => a.trigger === "data_changed") - ); - constructor(private dataItemsService: DataItemsService) { super(); effect(async () => { - const actions = this.dataActions(); const itemRows = this.itemRows(); - if (actions.length > 0 && itemRows !== undefined) { + if (itemRows !== undefined) { // TODO - if data_items have child rows the generated itemRows will be looped items // Need different method to always pass data_items list and not generated loop // (will only work if no child rows defined as fallback returns list rows and not generated) - await this.hackTriggerDataChangedActions(actions, itemRows); + await this.hackTriggerDataChangedActions(itemRows); } }); effect(() => { @@ -51,12 +47,12 @@ export class TmplDataItemsComponent extends TemplateBaseComponent { } /** Trigger a `data_changed` action and evaluate with items list context */ - private async hackTriggerDataChangedActions( - actions: FlowTypes.TemplateRowAction[] = [], - itemRows: any[] = [] - ) { - const evaluatedActions = this.dataItemsService.evaluateDataActions(actions, itemRows); - await this.parent.handleActions(evaluatedActions, this._row); + private async hackTriggerDataChangedActions(itemRows: any[] = []) { + const actions = this.actionList().filter((a) => a.trigger === "data_changed"); + if (actions.length > 0) { + const evaluatedActions = this.dataItemsService.evaluateDataActions(actions, itemRows); + await this.parent.handleActions(evaluatedActions, this._row); + } } private subscribeToDynamicData(row: FlowTypes.TemplateRow) { diff --git a/src/app/shared/components/template/components/data-items/data-items.service.spec.ts b/src/app/shared/components/template/components/data-items/data-items.service.spec.ts index c9ce85892..c6d3409f0 100644 --- a/src/app/shared/components/template/components/data-items/data-items.service.spec.ts +++ b/src/app/shared/components/template/components/data-items/data-items.service.spec.ts @@ -19,18 +19,21 @@ const MOCK_DATA_ITEMS_LIST: FlowTypes.Data_listRow[] = [ { id: "id_1", completed: true, + number: 1, }, { id: "id_2", completed: true, + number: 2, }, { id: "id_3", completed: false, + number: 3, }, ]; -const MOCK_BUTTON = (): FlowTypes.TemplateRow => ({ +const MOCK_BUTTON = (overrides: Partial = {}): FlowTypes.TemplateRow => ({ _nested_name: "", name: "", type: "button", @@ -47,9 +50,10 @@ const MOCK_BUTTON = (): FlowTypes.TemplateRow => ({ }, }, ], + ...overrides, }); -const MOCK_TEMPLATE_ROWS_WITH_NESTED: FlowTypes.TemplateRow[] = [ +const MOCK_TEMPLATE_ROWS_WITH_NESTED = (): FlowTypes.TemplateRow[] => [ MOCK_BUTTON(), { _nested_name: "", @@ -59,13 +63,13 @@ const MOCK_TEMPLATE_ROWS_WITH_NESTED: FlowTypes.TemplateRow[] = [ }, ]; -const MOCK_DATA_ITEMS_ROW: FlowTypes.TemplateRow = { +const MOCK_DATA_ITEMS_ROW = (): FlowTypes.TemplateRow => ({ _nested_name: "", name: "", type: "data_items", value: "mock_data_items_list", - rows: MOCK_TEMPLATE_ROWS_WITH_NESTED, -}; + rows: MOCK_TEMPLATE_ROWS_WITH_NESTED(), +}); /*************************************************************************************** * Test Methods @@ -97,7 +101,11 @@ describe("DataItemsService", () => { { provide: Injector, useValue: {} }, { provide: TemplateVariablesService, useValue: { evaluateConditionString: (v) => v } }, { provide: TemplateTranslateService, useValue: {} }, - { provide: TemplateCalcService, useValue: new MockTemplateCalcService() }, + { + provide: TemplateCalcService, + // use custom calc service methods to test item local context evaluation + useValue: new MockTemplateCalcService({ globalFunctions: { double: (v) => v * 2 } }), + }, ], }); service = TestBed.inject(DataItemsService); @@ -115,7 +123,7 @@ describe("DataItemsService", () => { }); it("retrieves data_list data and provides observable list of processed data", async () => { - const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW, {}); + const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW(), {}); const data = await firstValueFrom(obs); // should generate looped item rows (2 template rows x 3 item rows) expect(data.length).toEqual(6); @@ -123,10 +131,12 @@ describe("DataItemsService", () => { // check that it calls processor with item context expect(rowProcessorSpy).toHaveBeenCalledTimes(1); const [rowProcessorItemRowsArg] = rowProcessorSpy.calls.first().args; + console.log({ rowProcessorItemRowsArg }); expect(rowProcessorItemRowsArg[0]._evalContext).toEqual({ item: { id: "id_1", completed: true, + number: 1, _index: 0, _id: "id_1", _first: true, @@ -135,9 +145,43 @@ describe("DataItemsService", () => { }); }); + it("includes local variable context within item loops", async () => { + // child rows include local variable setter and display component + const itemsRow = { + ...MOCK_DATA_ITEMS_ROW(), + rows: [ + { + type: "set_variable", + name: "itemDouble", + _nested_name: "", + // use custom calc context variable to test calc evaluation + value: "@calc(double(@item.number))", + }, + { + type: "set_variable", + name: "mockString", + _nested_name: "", + // test previous variables can be processed within templated data + value: "test @local.itemDouble", + }, + MOCK_BUTTON({ value: "@local.mockString" }), + ], + }; + + const obs = service.getItemsObservable(itemsRow, {}); + const data: FlowTypes.TemplateRow[] = await firstValueFrom(obs); + expect(data.length).toEqual(3); + // local context + expect(data[0]._evalContext.local).toEqual({ itemDouble: 2, mockString: "test 2" }); + expect(data[1]._evalContext.local).toEqual({ itemDouble: 4, mockString: "test 4" }); + expect(data[2]._evalContext.local).toEqual({ itemDouble: 6, mockString: "test 6" }); + // templated row values will not be evaluated by service, but instead by template processor + expect(data[0].value).toEqual("@local.mockString"); + }); + it("evaluates data actions rows with items context (if empty)", async () => { // HACK - Actions only trigger correctly if data_items do not contain looped child rows - const emptyItemsRow = { ...MOCK_DATA_ITEMS_ROW, rows: undefined }; + const emptyItemsRow = { ...MOCK_DATA_ITEMS_ROW(), rows: undefined }; const obs = service.getItemsObservable(emptyItemsRow, {}); const data = await firstValueFrom(obs); const [evaluated] = service.evaluateDataActions( @@ -156,7 +200,7 @@ describe("DataItemsService", () => { // TODO - fix case where items context refers to generated loop items and not list items xit("evaluates data actions rows with items context", async () => { - const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW, {}); + const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW(), {}); const data = await firstValueFrom(obs); const [evaluated] = service.evaluateDataActions( [ @@ -173,7 +217,7 @@ describe("DataItemsService", () => { }); it("translated data_item rows", async () => { - const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW, {}); + const obs = service.getItemsObservable(MOCK_DATA_ITEMS_ROW(), {}); await firstValueFrom(obs); expect(translateDataListRowsSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/app/shared/components/template/components/data-items/data-items.service.ts b/src/app/shared/components/template/components/data-items/data-items.service.ts index abf86f8c3..e77324f0f 100644 --- a/src/app/shared/components/template/components/data-items/data-items.service.ts +++ b/src/app/shared/components/template/components/data-items/data-items.service.ts @@ -6,11 +6,13 @@ import { ITemplateRowMap, TemplateRowService } from "../../services/instance/tem import { defer } from "rxjs/internal/observable/defer"; import { TemplateVariablesService } from "../../services/template-variables.service"; import { ItemProcessor } from "../../processors/item"; -import { updateItemMeta } from "./data-items.utils"; +import { generateItemMeta, updateItemActionLists } from "./data-items.utils"; import { isEqual } from "packages/shared/src/utils/object-utils"; import { AppDataEvaluator } from "packages/shared/src/models/appDataEvaluator/appDataEvaluator"; import { JSEvaluator } from "packages/shared/src/models/jsEvaluator/jsEvaluator"; import { TemplateTranslateService } from "../../services/template-translate.service"; +import { updateRowPropertyRecursively } from "../../utils"; +import { TemplateCalcService } from "../../services/template-calc.service"; @Injectable({ providedIn: "root" }) export class DataItemsService { @@ -18,7 +20,8 @@ export class DataItemsService { private dynamicDataService: DynamicDataService, private injector: Injector, private templateVariablesService: TemplateVariablesService, - private templateTranslateService: TemplateTranslateService + private templateTranslateService: TemplateTranslateService, + private templateCalcService: TemplateCalcService ) {} /** Process an template data_items row and generate an observable of generated child item rows */ @@ -49,15 +52,23 @@ export class DataItemsService { // and templated rows to generate item rows const parsedItemList = await this.hackParseDataList(data); const itemProcessor = new ItemProcessor(parsedItemList, parameter_list); - const { itemTemplateRows, itemData } = itemProcessor.process(rows); + + // apply pipe operations to items to handle sort, filter, limit etc. + const itemData = itemProcessor.pipeData(parsedItemList, parameter_list); + // if no child rows for data_items loop assume want back raw items if (rows.length === 0) { return itemData; } - // otherwise process generated template rows - const itemRowsWithMeta = updateItemMeta(itemTemplateRows, itemData, dataListName); + + const itemTemplateRows = this.prepareItemRows({ + items: itemData, + rows, + dataListName, + }); + const parsedItemRows = await this.hackProcessRows( - itemRowsWithMeta, + itemTemplateRows, templateRowMap ); return parsedItemRows; @@ -69,6 +80,67 @@ export class DataItemsService { ); } + /** + * Iterate over item list and child rows, and generate corresponding templated item rows. + * Includes evaluating local variables generated within the item loop and adding to evalContext + * used when rendering the templated rows + */ + private prepareItemRows(config: { + items: FlowTypes.Data_listRow[]; + rows: FlowTypes.TemplateRow[]; + dataListName: string; + }) { + const { items, rows, dataListName } = config; + const evaluator = new AppDataEvaluator(); + + // any set_variable statements within data_items loop will be managed internally as + // local context variables. This applies by default to any templated rows without a row type + const templatedRows = rows.filter((r) => r.type !== "set_variable"); + const variableRows = rows.filter((r) => r.type === "set_variable"); + const itemRows: FlowTypes.TemplateRow[] = []; + const lastItemIndex = items.length - 1; + const itemDataIds: string[] = items.map((i) => i.id); + + for (const [index, item] of items.entries()) { + // assign item metadata and store to evalContext + const _evalContext: FlowTypes.TemplateRowEvalContext = { + item: { ...item, ...generateItemMeta(item, index, lastItemIndex) }, + }; + // generate a list of local variables within item loop and also store within evalContext + if (variableRows.length > 0) { + _evalContext.local = {}; + for (const { name, value } of variableRows) { + if (typeof value === "string") { + let evaluated: any; + // HACK - AppDataEvaluator can't detect or extract `@calc(...)` statements so process manually + // TODO - add support to extract @calc statements to AppDataEvaluator and provide callable function + if (value.startsWith("@calc")) { + evaluated = this.templateCalcService.evaluate(value, _evalContext); + } else { + evaluator.setExecutionContext(_evalContext as any); + evaluated = evaluator.evaluate(value); + } + // use base name instead of nested as still unique within item loop context + _evalContext.local[name] = evaluated; + } + } + } + + // add eval context to all templated rows and recursive child rows + for (const row of templatedRows) { + const rowWithRecursiveEvalContext = updateRowPropertyRecursively(row, { _evalContext }); + const rowWithUpdatedActionList = updateItemActionLists( + rowWithRecursiveEvalContext, + dataListName, + itemDataIds + ); + itemRows.push(rowWithUpdatedActionList); + } + } + + return itemRows; + } + /** * If triggering data_change action args will be evaluated current item data available within `@items` context * @param actions List of actions to process @@ -164,6 +236,7 @@ export class DataItemsService { }, } as any); // HACK - still want to be able to use localContext from parent rows so copy to child processor + // TODO - review how best to manage this... is parent data_items row missing out on local context? processor.templateRowMap = JSON.parse(JSON.stringify(templateRowMap)); const templateRowMapValues = Object.fromEntries( Object.entries(templateRowMap).map(([key, { value }]) => [key, value]) diff --git a/src/app/shared/components/template/components/data-items/data-items.utils.spec.ts b/src/app/shared/components/template/components/data-items/data-items.utils.spec.ts index 1e32464a0..17c46e488 100644 --- a/src/app/shared/components/template/components/data-items/data-items.utils.spec.ts +++ b/src/app/shared/components/template/components/data-items/data-items.utils.spec.ts @@ -1,5 +1,5 @@ import { FlowTypes } from "../../models"; -import { updateItemMeta } from "./data-items.utils"; +import { updateItemActionLists, generateItemMeta } from "./data-items.utils"; const MOCK_ITEM_ROWS = [{ id: "id_0" }, { id: "id_1" }]; @@ -34,19 +34,14 @@ const MOCK_TEMPLATE_ITEM_ROW: FlowTypes.TemplateRow = { * yarn ng test --include src/app/shared/components/template/components/data-items/data-items.utils.spec.ts */ describe("Data Items Utils", () => { - it("updateItemMeta updates item metadata", () => { - const res = updateItemMeta([MOCK_TEMPLATE_ITEM_ROW], MOCK_ITEM_ROWS, "mock_data_list"); - const [updatedRow] = res; - // should automatically assign index, first and last meta from item list id lookup - const expectedItemContext = { _id: "id_1", _index: 1, _first: false, _last: true }; - expect(updatedRow._evalContext.item).toEqual(expectedItemContext); - // also check recursive child rows updated - expect(updatedRow.rows[0]._evalContext.item).toEqual(expectedItemContext); + it("generateItemMeta", () => { + const res = generateItemMeta(MOCK_ITEM_ROWS[0], 0, 1); + expect(res).toEqual({ _first: true, _last: false, _id: "id_0", _index: 0 }); }); - it("updateItemMeta assigns set_item action context", () => { - const res = updateItemMeta([MOCK_TEMPLATE_ITEM_ROW], MOCK_ITEM_ROWS, "mock_data_list"); - const updatedButtonActionListArgs = res[0].rows[0].action_list[0].args; + it("updateItemActionLists assigns set_item action context", () => { + const res = updateItemActionLists(MOCK_TEMPLATE_ITEM_ROW, "mock_data_list", ["id_0", "id_1"]); + const updatedButtonActionListArgs = res.rows[0].action_list[0].args; expect(updatedButtonActionListArgs).toEqual([ { flow_name: "mock_data_list", diff --git a/src/app/shared/components/template/components/data-items/data-items.utils.ts b/src/app/shared/components/template/components/data-items/data-items.utils.ts index a55cfee93..e3516801e 100644 --- a/src/app/shared/components/template/components/data-items/data-items.utils.ts +++ b/src/app/shared/components/template/components/data-items/data-items.utils.ts @@ -2,6 +2,18 @@ import { FlowTypes } from "packages/data-models"; import { IActionRemoveDataParams } from "src/app/shared/services/dynamic-data/actions"; import { ISetItemContext } from "src/app/shared/services/dynamic-data/dynamic-data.service"; +/** Generate metadata stored with items */ +export const generateItemMeta = ( + item: FlowTypes.Data_listRow, + index: number, + lastItemIndex: number +): FlowTypes.TemplateRowItemEvalContextMetadata => ({ + _id: item.id, + _index: index, + _first: index === 0, + _last: index === lastItemIndex, +}); + /** * Update item dynamic evaluation context and action lists to include relevant item data. * Additionally handle assigning `set_item` context args @@ -14,66 +26,48 @@ import { ISetItemContext } from "src/app/shared/services/dynamic-data/dynamic-da * @param itemData List of original item data used to create item rows (post operations such as filter/sort) * @param dataListName The name of the source data list (i.e. this.dataListName, extracted for ease of testing) * */ -export function updateItemMeta( - templateRows: FlowTypes.TemplateRow[], - itemData: FlowTypes.Data_listRow[], - dataListName: string +export function updateItemActionLists( + r: FlowTypes.TemplateRow, + dataListName: string, + itemDataIDs: string[] ) { - const lastItemIndex = itemData.length - 1; - 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.item._id; - // Map the row item context to the original list of items rendered to know position in item list. - const itemIndex = itemDataIDs.indexOf(itemId); - // Update metadata fields as _first, _last and index may have changed based on dynamic updates - r._evalContext.item = { - ...r._evalContext.item, - _index: itemIndex, - _first: itemIndex === 0, - _last: itemIndex === lastItemIndex, - }; - // 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: dataListName, - itemDataIDs, - currentItemId: itemId, - }; - r.action_list = r.action_list.map((a) => { - if (a.action_id === "set_item") { - a.args = [setItemContext]; - } - // re-map remove_item to remove_data action - // TODO - set_item and set_items should also be remapped - if (a.action_id === "remove_item") { - a.action_id = "remove_data"; - const removeDataParams: IActionRemoveDataParams = { - _id: itemId, - _list_id: dataListName, - }; - a.params = removeDataParams; - } - if (a.action_id === "set_items") { - console.warn( - "[Deprecated] set_items should not be used from within an items loop", - "Use a `set_data` action instead outside of loop" - ); - // 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 = [setItemContext]; - } - return a; - }); - } - // Apply recursively to ensure item children with nested rows (e.g. display groups) also inherit item context - // Do not override the item context for rows that create their own item lists (i.e. data_items and items) - if (r.rows && r.type !== "data_items" && r.type !== "items") { - r.rows = updateItemMeta(r.rows, itemData, dataListName); - } + const itemId = r._evalContext.item._id; + // Map the row item context to the original list of items rendered to know position in item list. + + // 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) { + r.action_list = r.action_list.map((a) => { + if (a.action_id === "set_item") { + const context: ISetItemContext = { + flow_name: dataListName, + currentItemId: itemId, + itemDataIDs, + }; + a.args = [context]; + } + // re-map remove_item to remove_data action + // TODO - set_item and set_items should also be remapped + if (a.action_id === "remove_item") { + a.action_id = "remove_data"; + const removeDataParams: IActionRemoveDataParams = { + _id: itemId, + _list_id: dataListName, + }; + a.params = removeDataParams; + } + // HACK - avoid issues with nested object references + return JSON.parse(JSON.stringify(a)); + }); + } + + // Apply recursively to ensure item children with nested rows (e.g. display groups) also inherit item context + // Do not override the item context for rows that create their own item lists (i.e. data_items and items) + if (r.rows && r.type !== "data_items" && r.type !== "items") { + r.rows = r.rows.map((childRow) => updateItemActionLists(childRow, dataListName, itemDataIDs)); + } - return r; - }); + return r; } diff --git a/src/app/shared/components/template/services/instance/template-row.service.ts b/src/app/shared/components/template/services/instance/template-row.service.ts index 5e0e66bf0..8e22982a7 100644 --- a/src/app/shared/components/template/services/instance/template-row.service.ts +++ b/src/app/shared/components/template/services/instance/template-row.service.ts @@ -288,7 +288,7 @@ export class TemplateRowService extends SyncServiceBase { if (type === "template") isNestedTemplate = true; // data_items still need to process on render so avoid populating child rows to templateRowMap - if (type === "data_items") isNestedTemplate = true; + if (type === "data_items") return row; // Instead of returning themselves items looped child rows if (type === "items") { diff --git a/src/app/shared/components/template/utils/template-utils.spec.ts b/src/app/shared/components/template/utils/template-utils.spec.ts new file mode 100644 index 000000000..2b6227aa9 --- /dev/null +++ b/src/app/shared/components/template/utils/template-utils.spec.ts @@ -0,0 +1,37 @@ +import type { FlowTypes } from "packages/data-models"; +import { mergeTemplateRows, objectToArray, updateRowPropertyRecursively } from "./template-utils"; + +const MOCK_ROW = (): FlowTypes.TemplateRow => ({ + _nested_name: "", + name: "", + type: "button", +}); + +/** + * Call standalone tests via: + * yarn ng test --include src/app/shared/components/template/utils/template-utils.spec.ts + */ +describe("Template Utils", () => { + it("updateRowPropertyRecursively", () => { + const r: FlowTypes.TemplateRow = { + ...MOCK_ROW(), + _evalContext: { local: { string: "hello", number: 1 } }, + rows: [ + { + ...MOCK_ROW(), + _evalContext: { local: { string: "hello", number: 1 } }, + }, + ], + }; + const res = updateRowPropertyRecursively(r, { + _evalContext: { local: { number: 2 } }, + }); + // both top-level and nested row eval context should be updated + expect(res._evalContext).toEqual({ local: { string: "hello", number: 2 } }); + expect(res.rows[0]._evalContext).toEqual({ local: { string: "hello", number: 2 } }); + }); + // TODO + xit("mergeTemplateRows", () => {}); + // TODO + xit("objectToArray", () => {}); +}); diff --git a/src/app/shared/components/template/utils/template-utils.ts b/src/app/shared/components/template/utils/template-utils.ts index af9b87079..af3b7e119 100644 --- a/src/app/shared/components/template/utils/template-utils.ts +++ b/src/app/shared/components/template/utils/template-utils.ts @@ -1,5 +1,5 @@ import { FlowTypes } from "src/app/shared/model"; -import { arrayToHashmap } from "src/app/shared/utils"; +import { arrayToHashmap, deepMergeObjects } from "src/app/shared/utils"; /** * Take 2 template rows and perform a deep merge, including deep merge of nested row.rows @@ -89,6 +89,18 @@ function flattenJson(json: any, tree = {}, nestedPath?: string): { [key: stri return tree; } +/** Update a property on a row and all child rows */ +export function updateRowPropertyRecursively( + row: FlowTypes.TemplateRow, + update: Partial +) { + const updated = deepMergeObjects({} as FlowTypes.TemplateRow, row, update); + if (updated.rows) { + updated.rows = updated.rows.map((r) => updateRowPropertyRecursively(r, update)); + } + return updated; +} + /** * Take an object and return an array via the object.values method. * Provide additional check in case already is array (return array), or is not an object