Skip to content

Commit

Permalink
Merge pull request #2625 from IDEMSInternational/feat/set-data-action…
Browse files Browse the repository at this point in the history
…-operators

Feat: add_data action
  • Loading branch information
esmeetewinkel authored Dec 18, 2024
2 parents 40946f7 + 2c7f0cc commit 8dcbf4d
Show file tree
Hide file tree
Showing 16 changed files with 599 additions and 271 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"stacktrace-js": "^2.0.2",
"swiper": "^8.4.7",
"trusted-types": "^2.0.0",
"uuid": "^11.0.3",
"zone.js": "~0.14.2"
},
"devDependencies": {
Expand Down Expand Up @@ -141,6 +142,7 @@
"@types/nouislider": "^15.0.0",
"@types/qrcode": "^1.5.5",
"@types/swiper": "~4.2.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"codelyzer": "^6.0.2",
Expand Down
23 changes: 18 additions & 5 deletions packages/data-models/flowTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,19 @@ export namespace FlowTypes {

export type IDynamicPrefix = (typeof DYNAMIC_PREFIXES)[number];

/**
* Namespaced hashmap of dynamic variables. Values may include evaluated value,
* or may include variable metadata depending on use context
* @example
* ```
* {
* local: {my_local_var: {}
* field: {some_field_var: {}
* }
* ```
* */
export type IDynamicContext = { [key in IDynamicPrefix]?: { [fieldName: string]: any } };

/** Data passed back from regex match, e.g. expression @local.someField => type:local, fieldName: someField */
export interface TemplateRowDynamicEvaluator {
fullExpression: string;
Expand All @@ -383,6 +396,9 @@ export namespace FlowTypes {
| "sent" // notification sent
| "uncompleted";

const DATA_ACTIONS_LIST = ["add_data", "remove_data", "set_data"] as const;
const ITEMS_ACTIONS_LIST = ["remove_item", "set_item", "set_items"] as const;

// TODO document '' action for stop propagation
// note - to keep target nav within component stack go_to is actually just a special case of pop_up
// TODO - 2021-03-11 - most of list needs reconsideration/implementation
Expand Down Expand Up @@ -413,11 +429,6 @@ export namespace FlowTypes {
"save_to_device",
"screen_orientation",
"set_field",
/** NOTE - only available from with data_items loop */
"set_item",
/** NOTE - only available from with data_items loop */
"set_items",
"set_data",
"set_local",
"share",
"style",
Expand All @@ -428,6 +439,8 @@ export namespace FlowTypes {
"track_event",
"trigger_actions",
"user",
...DATA_ACTIONS_LIST,
...ITEMS_ACTIONS_LIST,
] as const;

export interface TemplateRowAction<ParamsType = any> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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";

/**
Expand Down Expand Up @@ -44,6 +45,16 @@ export function updateItemMeta(
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",
Expand Down
9 changes: 7 additions & 2 deletions src/app/shared/components/template/processors/itemPipe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FlowTypes } from "packages/data-models/flowTypes";
import { JSEvaluator } from "packages/shared/src/models/jsEvaluator/jsEvaluator";
import { shuffleArray } from "src/app/shared/utils";

Expand All @@ -22,13 +23,17 @@ export class ItemDataPipe {
if (!sortField) return items;
return items.sort((a, b) => (a[sortField] > b[sortField] ? 1 : -1));
},
filter: (items: any[] = [], expression: string) => {
filter: (
items: any[] = [],
expression: string,
contextVariables: FlowTypes.IDynamicContext = {}
) => {
if (!expression) return;
return items.filter((item) => {
// NOTE - expects all non-item condition to be evaluated
// e.g. `@item.field > @local.some_value` already be evaluated to `this.item.field > "local value"`
const evaluator = new JSEvaluator();
const evaluated = evaluator.evaluate(expression, { item });
const evaluated = evaluator.evaluate(expression, { ...contextVariables, item });
return evaluated;
});
},
Expand Down
138 changes: 138 additions & 0 deletions src/app/shared/services/dynamic-data/actions/add_data.action.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { TestBed } from "@angular/core/testing";

import { HttpClientTestingModule } from "@angular/common/http/testing";
import { MockAppDataService } from "../../data/app-data.service.mock.spec";
import { AppDataService } from "../../data/app-data.service";

import addDataAction, { IActionAddDataParams } from "./add_data.action";
import { DynamicDataService } from "../dynamic-data.service";
import { firstValueFrom } from "rxjs";
import { FlowTypes } from "packages/data-models";
import { DeploymentService } from "../../deployment/deployment.service";
import { MockDeploymentService } from "../../deployment/deployment.service.spec";
import { TemplateActionRegistry } from "../../../components/template/services/instance/template-action.registry";
import { DynamicDataActionFactory, IActionRemoveDataParams } from "./index";

type ITestRow = { id: string; number: number; string: string; _meta_field?: any };

const TEST_DATA_ROWS = (): FlowTypes.Data_listRow<ITestRow>[] => [
{ id: "id_0", number: 0, string: "hello", _meta_field: "original" },
{ id: "id_1", number: 1, string: "hello", _meta_field: "original" },
];

const TEST_DATA_LIST = (): FlowTypes.Data_list => ({
flow_name: "test_flow",
flow_type: "data_list",
// Make deep clone of data to avoid data overwrite issues
rows: TEST_DATA_ROWS(),
// Metadata would be extracted from parser based on data or defined schema
_metadata: {
boolean: { type: "boolean" },
number: { type: "number" },
_meta_field: { type: "object" },
},
});

/********************************************************************************
* Test Utilities
*******************************************************************************/

/**
* Trigger the set_data action with included params and return first update
* to corresponding data_list
* * */
async function triggerTestAddDataAction(
service: DynamicDataService,
params: Partial<IActionAddDataParams>
) {
await addDataAction(service, { ...params, _list_id: "test_flow" });
const obs = await service.query$<any>("data_list", "test_flow");
const data = await firstValueFrom(obs);
return data;
}

async function triggerRemoveDataAction(
service: DynamicDataService,
params: Partial<IActionRemoveDataParams>
) {
const { remove_data } = new DynamicDataActionFactory(service);
await remove_data({
action_id: "remove_data",
args: [],
trigger: "click",
params: { ...params, _list_id: "test_flow" },
});
const obs = await service.query$<any>("data_list", "test_flow");
const data = await firstValueFrom(obs);
return data;
}

/********************************************************************************
* Tests
* yarn ng test --include src/app/shared/services/dynamic-data/actions/add_data.action.spec.ts
*******************************************************************************/
describe("set_data Action", () => {
let service: DynamicDataService;

beforeEach(async () => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DynamicDataService,
{
provide: AppDataService,
useValue: new MockAppDataService({
data_list: {
test_flow: TEST_DATA_LIST(),
},
}),
},
{
provide: DeploymentService,
useValue: new MockDeploymentService({ name: "test" }),
},
{
provide: TemplateActionRegistry,
useValue: { register: () => null },
},
],
});

// HACK - polyfill not loaded for rxdb dev plugin so manually fill global before running tests
window.global = window;
service = TestBed.inject(DynamicDataService);
await service.ready();
// ensure data cleared between flows
await service.resetFlow("data_list", "test_flow");
});

/*************************************************************
* Main Tests
************************************************************/
it("add_data", async () => {
const data = await triggerTestAddDataAction(service, { number: 2 });
expect(data.length).toEqual(3);
const addedData = data[2];
expect(addedData.number).toEqual(2);
// populated metadata
expect(addedData._user_created).toEqual(true);
expect(addedData.row_index).toEqual(2);
expect(addedData.id).toBeTruthy();
});

it("add_data type coercion", async () => {
const data = await triggerTestAddDataAction(service, { number: "2" });
const addedData = data[2];
expect(addedData.number).toEqual(2);
});

it("remove_data", async () => {
const data = await triggerTestAddDataAction(service, { number: 2 });
const addedData = data[2];
expect(addedData.number).toEqual(2);
const updatedData = await triggerRemoveDataAction(service, { _id: addedData.id });
expect(updatedData.length).toEqual(2);
});

// TODO - @list evaluation (when supported)
});
31 changes: 31 additions & 0 deletions src/app/shared/services/dynamic-data/actions/add_data.action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { generateUUID } from "src/app/shared/utils";
import { coerceDataUpdateTypes, evaluateDynamicDataUpdate } from "../dynamic-data.utils";
import { DynamicDataService } from "../dynamic-data.service";

export type IActionAddDataParams = {
_list_id: string;
} & Record<string, any>;

export default async (service: DynamicDataService, params: IActionAddDataParams) => {
const { _list_id, ...data } = await parseParams(params);

const schema = await service.getSchema("data_list", _list_id);

// assign a row_index to push user_generated docs to bottom of list
data.row_index = await service.getCount("data_list", _list_id);
// generate a unique id for the entry
data.id = generateUUID();
// add metadata to track user created
data._user_created = true;
// HACK - use the same dynamic data evaluator as set_data action
// This requires passing an item list, so just create an ad-hoc list with a single item
// TODO - add support for evaluating @list reference to parent list
const [evaluated] = evaluateDynamicDataUpdate([{ id: data.id }], data);

const [coerced] = coerceDataUpdateTypes(schema?.jsonSchema?.properties, [evaluated]);
return service.insert("data_list", _list_id, coerced);
};

async function parseParams(params) {
return params;
}
86 changes: 86 additions & 0 deletions src/app/shared/services/dynamic-data/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { isObjectLiteral } from "packages/shared/src/utils/object-utils";
import { IActionHandler } from "../../../components/template/services/instance/template-action.registry";
import type { DynamicDataService } from "../dynamic-data.service";

import addDataAction, { IActionAddDataParams } from "./add_data.action";
import setDataAction, { IActionSetDataParams } from "./set_data.action";

interface IActionBaseParams {
_list_id?: string;
_index?: string | number;
}

export type IActionRemoveDataParams = {
_list_id: string;
_id: string;
};

type IActionResetDataParams = {
_list_id: string;
};

// Use action factory to make it easier to apply common logic to action params and pass service
export class DynamicDataActionFactory {
constructor(private service: DynamicDataService) {}

public set_data: IActionHandler = async ({ params }: { params?: IActionSetDataParams }) => {
const parsedParams = this.parseActionParams(params);
return setDataAction(this.service, parsedParams);
};

public reset_data: IActionHandler = async ({ params }: { params?: IActionResetDataParams }) => {
const { _list_id } = this.parseActionParams(params);
return this.service.resetFlow("data_list", _list_id);
};

public remove_data: IActionHandler = async ({ params }: { params?: IActionRemoveDataParams }) => {
const { _id, _list_id } = params;
return this.service.remove("data_list", _list_id, [_id]);
};

public add_data: IActionHandler = async ({ params }: { params?: IActionAddDataParams }) => {
const parsedParams = this.parseActionParams(params);
return addDataAction(this.service, parsedParams);
};

/** Parse action parameters to ensure list id provided and */
private parseActionParams<T extends IActionBaseParams>(params: T) {
if (isObjectLiteral(params)) {
const parsed = hackParseTemplatedParams(params);
if (!parsed._list_id) {
console.error(params);
throw new Error("[Data Actions] could not parse list id");
}
return parsed;
}

// throw error if args not parsed correctly
console.error(params);
throw new Error("[set_data] could not parse params");
}
}

/**
* Any params provided by templating system will already be partially parsed to replace dynamic references
* such as @local or @field. This also converts @item references to `this.item`
* Revert the item changes and also convert string parameter values to number where required
*
* TODO - this method should be removed and `items` made to update their own `this` context
**/
function hackParseTemplatedParams<T extends IActionBaseParams>(params: T) {
const parsed = {} as T;
// HACK - un-parse @item references that the templating system converts to `this.item`
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
parsed[key] = value.replace(/this\.item/g, "@item");
} else {
parsed[key] = value;
}
}
// convert _index param which may be passed as string from template if defined inline
// NOTE - RXDB will automatically cast all other string values to correct type due to inferred schema
if (typeof params._index === "string") {
parsed._index = Number(params._index);
}
return parsed;
}
Loading

0 comments on commit 8dcbf4d

Please sign in to comment.