diff --git a/src/app/shared/components/template/components/base.ts b/src/app/shared/components/template/components/base.ts index 937045b9db..57a2e649d5 100644 --- a/src/app/shared/components/template/components/base.ts +++ b/src/app/shared/components/template/components/base.ts @@ -1,4 +1,4 @@ -import { Component, Input, signal } from "@angular/core"; +import { Component, computed, Input, signal } from "@angular/core"; import { isEqual } from "packages/shared/src/utils/object-utils"; import { FlowTypes, ITemplateRowProps } from "../models"; import { TemplateContainerComponent } from "../template-container.component"; @@ -20,9 +20,13 @@ export class TemplateBaseComponent implements ITemplateRowProps { /** @ignore */ _row: FlowTypes.TemplateRow; - value = signal(undefined, { equal: isEqual }); - parameterList = signal({}, { equal: isEqual }); - actionList = signal([], { equal: isEqual }); + // TODO - main row should just be an input.required and child code refactored to avoid set override + // TODO - could also consider whether setting parent required (is it template row map or services?), possibly merge with row + rowSignal = signal(undefined, { equal: isEqual }); + value = computed(() => this.rowSignal().value, { equal: isEqual }); + parameterList = computed(() => this.rowSignal().parameter_list || {}, { equal: isEqual }); + actionList = computed(() => this.rowSignal().action_list || [], { equal: isEqual }); + rows = computed(() => this.rowSignal().rows || [], { equal: isEqual }); /** * @ignore @@ -30,9 +34,7 @@ export class TemplateBaseComponent implements ITemplateRowProps { **/ @Input() set row(row: FlowTypes.TemplateRow) { this._row = row; - this.value.set(row.value); - this.parameterList.set(row.parameter_list); - this.actionList.set(row.action_list); + this.rowSignal.set(row); } /** @@ -40,7 +42,6 @@ export class TemplateBaseComponent implements ITemplateRowProps { * reference to parent template container - does not have setter as should remain static **/ @Input() parent: TemplateContainerComponent; - constructor() {} /** * Whenever actions are triggered handle in the parent template component diff --git a/src/app/shared/components/template/components/data-items/data-items.component.html b/src/app/shared/components/template/components/data-items/data-items.component.html index d17627aa75..22c83255c5 100644 --- a/src/app/shared/components/template/components/data-items/data-items.component.html +++ b/src/app/shared/components/template/components/data-items/data-items.component.html @@ -1,7 +1,7 @@ - +@for (row of itemRows(); track row._nested_name) { + +} 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 e7bfe8dddf..cea8758ec0 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 @@ -1,21 +1,9 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - Injector, - Input, - OnDestroy, -} from "@angular/core"; -import { debounceTime, Subscription } from "rxjs"; -import { - DynamicDataService, - ISetItemContext, -} from "src/app/shared/services/dynamic-data/dynamic-data.service"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { FlowTypes } from "../../models"; -import { ItemProcessor } from "../../processors/item"; -import { TemplateRowService } from "../../services/instance/template-row.service"; -import { TemplateVariablesService } from "../../services/template-variables.service"; import { TemplateBaseComponent } from "../base"; +import { DataItemsService } from "./data-items.service"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { switchMap, filter } from "rxjs"; @Component({ selector: "plh-data-items", @@ -32,176 +20,22 @@ import { TemplateBaseComponent } from "../base"; * - Should be refactored into structural container instead of display component * - Could possibly refactor to feature module including services */ -export class TmplDataItemsComponent extends TemplateBaseComponent implements OnDestroy { - public itemRows: FlowTypes.TemplateRow[] = []; - - private dataListName: string; - private _parameterList: Record; - - private dataQuery$: Subscription; - - @Input() set row(row: FlowTypes.TemplateRow) { - this._row = row; - this.dataListName = this.hackGetRawDataListName(row); - this._parameterList = row.parameter_list; - this.subscribeToData(); - } - - constructor( - private dynamicDataService: DynamicDataService, - private templateVariablesService: TemplateVariablesService, - private injector: Injector, - private cdr: ChangeDetectorRef - ) { +export class TmplDataItemsComponent extends TemplateBaseComponent { + // HACK - create signal from combination of signals and observables + // https://github.com/angular/angular/issues/53519 + public itemRows = toSignal( + toObservable(this.rowSignal).pipe( + filter((row) => row !== undefined), + switchMap((row) => this.subscribeToDynamicData(row)) + ) + ); + + constructor(private dataItemsService: DataItemsService) { super(); } - private async subscribeToData() { - if (this.dataQuery$) { - this.dataQuery$.unsubscribe(); - } - if (this.dataListName) { - await this.dynamicDataService.ready(); - const query = await this.dynamicDataService.query$("data_list", this.dataListName); - this.dataQuery$ = query.pipe(debounceTime(50)).subscribe(async (data) => { - await this.renderItems(data, this._row.rows, this._parameterList); - }); - } else { - await this.renderItems([], [], {}); - } - } - - private async renderItems( - itemDataList: any[], - rows: FlowTypes.TemplateRow[], - parameterList: any - ) { - const parsedItemDataList = await this.parseDataList(itemDataList); - const { itemTemplateRows, itemData } = new ItemProcessor( - Object.values(parsedItemDataList), - parameterList - ).process(rows); - const itemRowsWithMeta = this.setItemMeta(itemTemplateRows, itemData, this.dataListName); - - const parsedItemRows = await this.hackProcessRows(itemRowsWithMeta); - // TODO - deep diff and only update changed - this.itemRows = parsedItemRows; - this.cdr.markForCheck(); - } - - /** - * Update item dynamic evaluation context and action lists to include relevant item data. - * @param templateRows List of template rows generated from itemData by item processor - * @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) - * */ - private setItemMeta( - templateRows: FlowTypes.TemplateRow[], - itemData: FlowTypes.Data_listRow[], - dataListName: 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.itemContext._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.itemContext = { - ...r._evalContext.itemContext, - _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: this.dataListName, - itemDataIDs, - currentItemId: itemId, - }; - r.action_list = r.action_list.map((a) => { - if (a.action_id === "set_item") { - 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 = [setItemContext]; - } - return a; - }); - } - - // Apply recursively to ensure item children with nested rows (e.g. display groups) also inherit item context - if (r.rows) { - r.rows = this.setItemMeta(r.rows, itemData, dataListName); - } - - return r; - }); - } - - /** - * Ordinarily rows would be processed as part of the regular template processing, - * however this must be bypassed to allow multiple reprocessing on item updates - */ - private async hackProcessRows(rows: FlowTypes.TemplateRow[]) { - const processor = new TemplateRowService(this.injector, { - name: "", - template: { - rows, - }, - row: { - rows: [], - }, - } as any); - // HACK - still want to be able to use localContext from parent rows so copy to child processor - processor.templateRowMap = JSON.parse( - JSON.stringify(this.parent.templateRowService.templateRowMap) - ); - await processor.processContainerTemplateRows(); - return processor.renderedRows; - } - - /** - * If datalist referenced as @data.some_list it will already be parsed, so extract - * name from raw values. - * Alternatively any list provided as a string value can be returned directly - * */ - private hackGetRawDataListName(row: FlowTypes.TemplateRow) { - if (!row.value) return; - if (typeof row.value === "string") { - return row.value; - } - // HACK - if list name contains '_list' template.parser will parse as an array instead of string - if (Array.isArray(row.value)) { - return row.value[0]; - } - // Extract raw name in case full datalist object supplied in place of name - return row._dynamicFields?.value?.[0]?.fieldName; - } - - /** Copied from template-row service */ - private async parseDataList(dataList: { [id: string]: any }) { - const parsed: { [id: string]: any } = {}; - for (const [listKey, listValue] of Object.entries(dataList)) { - parsed[listKey] = listValue; - for (const [itemKey, itemValue] of Object.entries(listValue)) { - if (typeof itemValue === "string") { - parsed[listKey][itemKey] = - await this.templateVariablesService.evaluateConditionString(itemValue); - } - } - } - return parsed; - } - - ngOnDestroy() { - if (this.dataQuery$) { - this.dataQuery$.unsubscribe(); - } + private subscribeToDynamicData(row: FlowTypes.TemplateRow) { + const { templateRowMap } = this.parent; + return this.dataItemsService.getItemsObservable(row, templateRowMap); } } 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 new file mode 100644 index 0000000000..9662b28051 --- /dev/null +++ b/src/app/shared/components/template/components/data-items/data-items.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Injector } from "@angular/core"; +import { FlowTypes } from "packages/data-models"; +import { debounceTime, of, switchMap } from "rxjs"; +import { + DynamicDataService, + ISetItemContext, +} from "src/app/shared/services/dynamic-data/dynamic-data.service"; +import { ItemProcessor } from "../../processors/item"; +import { ITemplateRowMap, TemplateRowService } from "../../services/instance/template-row.service"; +import { TemplateVariablesService } from "../../services/template-variables.service"; +import { defer } from "rxjs/internal/observable/defer"; + +@Injectable({ providedIn: "root" }) +export class DataItemsService { + constructor( + private dynamicDataService: DynamicDataService, + private injector: Injector + ) {} + + /** Process an input data_items row and generate an observable of generated child item rows */ + public getItemsObservable(dataItemsRow: FlowTypes.TemplateRow, templateRowMap: ITemplateRowMap) { + const dataListName = this.hackGetRawDataListName(dataItemsRow); + if (!dataListName) { + console.warn("[Data Items] no list provided", dataItemsRow); + return of([]); + } + const itemProcessor = new DataItemProcessor(dataListName, this.injector, templateRowMap); + const { rows = [], parameter_list = {} } = dataItemsRow; + + // Create an observable that subscribes to data changes, debounced to avoid immediate re-processing + // Use defer to allow async code within observable + return ( + defer(async () => { + await this.dynamicDataService.ready(); + const query = await this.dynamicDataService.query$("data_list", dataListName); + return query.pipe(debounceTime(50)); + }) + // Map the output from the query to a new defer block that handles item processing and + // Uses inner defer block to allow async processing when outer query emits data + .pipe( + switchMap((query) => + query.pipe( + switchMap((data) => + defer(async () => { + const processed = await itemProcessor.renderItems(data, rows, parameter_list); + return processed; + }) + ) + ) + ) + ) + ); + } + + /** + * If datalist referenced as @data.some_list it will already be parsed, so extract + * name from raw values. + * Alternatively any list provided as a string value can be returned directly + * */ + private hackGetRawDataListName(row: FlowTypes.TemplateRow) { + if (!row.value) return; + if (typeof row.value === "string") { + return row.value; + } + // HACK - if list name contains '_list' template.parser will parse as an array instead of string + if (Array.isArray(row.value)) { + return row.value[0]; + } + // Extract raw name in case full datalist object supplied in place of name + return row._dynamicFields?.value?.[0]?.fieldName; + } +} + +class DataItemProcessor { + private templateVariablesService = this.injector.get(TemplateVariablesService); + + constructor( + private dataListName: string, + private injector: Injector, + private templateRowMap + ) {} + + public async renderItems(itemDataList: any[], rows: FlowTypes.TemplateRow[], parameterList: any) { + const parsedItemDataList = await this.parseDataList(itemDataList); + const { itemTemplateRows, itemData } = new ItemProcessor( + Object.values(parsedItemDataList), + parameterList + ).process(rows); + const itemRowsWithMeta = this.setItemMeta(itemTemplateRows, itemData, this.dataListName); + + const parsedItemRows = await this.hackProcessRows(itemRowsWithMeta); + return parsedItemRows; + } + + /** + * Update item dynamic evaluation context and action lists to include relevant item data. + * @param templateRows List of template rows generated from itemData by item processor + * @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) + * */ + private setItemMeta( + templateRows: FlowTypes.TemplateRow[], + itemData: FlowTypes.Data_listRow[], + dataListName: 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.itemContext._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.itemContext = { + ...r._evalContext.itemContext, + _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]; + } + 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 = [setItemContext]; + } + return a; + }); + } + + // Apply recursively to ensure item children with nested rows (e.g. display groups) also inherit item context + if (r.rows) { + r.rows = this.setItemMeta(r.rows, itemData, dataListName); + } + + return r; + }); + } + + /** + * Ordinarily rows would be processed as part of the regular template processing, + * however this must be bypassed to allow multiple reprocessing on item updates + */ + private async hackProcessRows(rows: FlowTypes.TemplateRow[]) { + const processor = new TemplateRowService(this.injector, { + name: "", + template: { + rows, + }, + row: { + rows: [], + }, + } as any); + // HACK - still want to be able to use localContext from parent rows so copy to child processor + processor.templateRowMap = JSON.parse(JSON.stringify(this.templateRowMap)); + await processor.processContainerTemplateRows(); + return processor.renderedRows; + } + + /** Copied from template-row service */ + private async parseDataList(dataList: { [id: string]: any }) { + const parsed: { [id: string]: any } = {}; + for (const [listKey, listValue] of Object.entries(dataList)) { + parsed[listKey] = listValue; + for (const [itemKey, itemValue] of Object.entries(listValue)) { + if (typeof itemValue === "string") { + parsed[listKey][itemKey] = + await this.templateVariablesService.evaluateConditionString(itemValue); + } + } + } + return parsed; + } +} diff --git a/src/app/shared/components/template/components/layout/workshops_accordion.ts b/src/app/shared/components/template/components/layout/workshops_accordion.ts index 9ff82a03f3..d0e0815793 100644 --- a/src/app/shared/components/template/components/layout/workshops_accordion.ts +++ b/src/app/shared/components/template/components/layout/workshops_accordion.ts @@ -1,13 +1,13 @@ import { Component, OnInit } from "@angular/core"; -import { FlowTypes } from "src/app/shared/model"; import { TemplateBaseComponent } from "../base"; +import { FlowTypes } from "packages/data-models"; @Component({ selector: "plh-tmpl-workshops-accordion", template: `
{ + const updatedRows = this.accordionRows.map((row, idx) => { if (idx === +rowId) { if (row.parameter_list.state === "open") { row.parameter_list.state = "closed"; @@ -38,6 +38,6 @@ export class WorkshopsComponent extends TemplateBaseComponent implements OnInit } return row; }); - this.rows = updatedRows; + this.accordionRows = updatedRows; } } diff --git a/src/app/shared/components/template/template-component.ts b/src/app/shared/components/template/template-component.ts index dbd9609990..a6ff8c09fa 100644 --- a/src/app/shared/components/template/template-component.ts +++ b/src/app/shared/components/template/template-component.ts @@ -152,8 +152,8 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow const viewContainerRef = this.tmplComponentHost.viewContainerRef; const componentRef = viewContainerRef.createComponent(component); // assign input variables (note template name taken from the row's value column) - componentRef.instance.row = row; componentRef.instance.parent = this.parent; + componentRef.instance.row = row; componentRef.instance.name = row.name; componentRef.instance.templatename = row.value; this.componentRef = componentRef;