Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: data items local #2772

Merged
merged 23 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5151d20
feat: wip data items local
chrismclarke Feb 4, 2025
11c2957
feat: updateRow recursive util and tests
chrismclarke Feb 4, 2025
ec2de3a
chore: fix broken test
chrismclarke Feb 4, 2025
1511719
chore: code tidying
chrismclarke Feb 4, 2025
5a6128d
refactor: data item row parsing
chrismclarke Feb 4, 2025
bc4d58c
chore: code tidying
chrismclarke Feb 4, 2025
f447c02
Merge branch 'master' into feat/data-items-local
chrismclarke Feb 4, 2025
1beaa85
chore: test mock improvements
chrismclarke Feb 4, 2025
9d1b950
Merge branch 'chore/ng-test-ci' of https://github.com/idemsinternatio…
chrismclarke Feb 4, 2025
83f7786
test: data-items eval context specs
chrismclarke Feb 4, 2025
adf8ed0
Merge branch 'fix/data-items-outer-local' of https://github.com/idems…
chrismclarke Feb 5, 2025
078d07b
chore: code tidying
chrismclarke Feb 5, 2025
3f72885
Merge branch 'fix/data-items-outer-local' of https://github.com/idems…
chrismclarke Feb 5, 2025
c9846c4
chore: fix tests
chrismclarke Feb 5, 2025
1f673e6
Merge branch 'fix/data-items-outer-local' of https://github.com/idems…
chrismclarke Feb 5, 2025
6ccf2c8
Merge branch 'master' of https://github.com/idemsinternational/open-a…
chrismclarke Feb 11, 2025
2871250
feat: template calc service specs
chrismclarke Feb 11, 2025
8ca2771
Merge branch 'master' into feat/data-items-local
chrismclarke Feb 17, 2025
66ae936
Merge branch 'master' into feat/data-items-local
chrismclarke Feb 17, 2025
c007c2d
Merge branch 'master' into feat/data-items-local
esmeetewinkel Feb 19, 2025
e0e25f7
fix: data actions infinite loop
chrismclarke Feb 19, 2025
15601b1
Merge branch 'master' into feat/data-items-local
esmeetewinkel Feb 20, 2025
ec05628
Merge branch 'master' into feat/data-items-local
esmeetewinkel Feb 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(() => {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): FlowTypes.TemplateRow => ({
_nested_name: "",
name: "",
type: "button",
Expand All @@ -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: "",
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -115,18 +123,20 @@ 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);
// ordinarily data from items row will be processed
// 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,
Expand All @@ -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(
Expand All @@ -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(
[
Expand All @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ 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 {
constructor(
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 */
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more efficient (although more verbose) to handle these with a single loop, e.g.

const templatedRows = [];
const variableRows = [];

for (const r of rows) {
  if (r.type === "set_variable") {
    variableRows.push(r);
  } else {
    templatedRows.push(r);
  }
}

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
Expand Down Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
@@ -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" }];

Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading