From 0ed9b29eb0c0d2dd2f9f35dae7e2716d34d3ea7b Mon Sep 17 00:00:00 2001 From: Mrigank Mehta Date: Tue, 23 Jul 2024 15:01:25 -0400 Subject: [PATCH] Refactored view parser into plugin decomposition --- .circleci/config.yml | 2 +- .../player/src/plugins/default-view-plugin.ts | 4 + .../src/view/__tests__/view.immutable.test.ts | 9 +- core/player/src/view/__tests__/view.test.ts | 4 + .../src/view/parser/__tests__/parser.test.ts | 17 +- core/player/src/view/parser/index.ts | 305 ++---------- core/player/src/view/parser/utils.ts | 26 +- .../__snapshots__/asset.test.ts.snap | 215 +++++++++ .../__snapshots__/multi-node.test.ts.snap | 67 +++ .../__snapshots__/template.test.ts.snap | 110 ++--- .../plugins/__tests__/applicability.test.ts | 40 +- .../src/view/plugins/__tests__/asset.test.ts | 141 ++++++ .../view/plugins/__tests__/multi-node.test.ts | 38 ++ .../view/plugins/__tests__/template.test.ts | 436 ++---------------- core/player/src/view/plugins/applicability.ts | 62 ++- core/player/src/view/plugins/asset.ts | 42 ++ core/player/src/view/plugins/index.ts | 4 +- core/player/src/view/plugins/multi-node.ts | 73 +++ core/player/src/view/plugins/switch.ts | 131 ++++-- .../{template-plugin.ts => template.ts} | 62 ++- .../view/resolver/__tests__/edgecases.test.ts | 15 +- core/player/src/view/view.ts | 9 +- package.json | 3 +- .../src/__tests__/propertiesToSkip.test.ts | 7 +- plugins/async-node/core/src/index.ts | 48 +- 25 files changed, 1022 insertions(+), 848 deletions(-) create mode 100644 core/player/src/view/plugins/__tests__/__snapshots__/asset.test.ts.snap create mode 100644 core/player/src/view/plugins/__tests__/__snapshots__/multi-node.test.ts.snap create mode 100644 core/player/src/view/plugins/__tests__/asset.test.ts create mode 100644 core/player/src/view/plugins/__tests__/multi-node.test.ts create mode 100644 core/player/src/view/plugins/asset.ts create mode 100644 core/player/src/view/plugins/multi-node.ts rename core/player/src/view/plugins/{template-plugin.ts => template.ts} (76%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 628ed2a51..9bfa960cc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -535,4 +535,4 @@ workflows: requires: - android_test - coverage - - build_ios + - build_ios \ No newline at end of file diff --git a/core/player/src/plugins/default-view-plugin.ts b/core/player/src/plugins/default-view-plugin.ts index a4ffbb28e..a96bf2f8e 100644 --- a/core/player/src/plugins/default-view-plugin.ts +++ b/core/player/src/plugins/default-view-plugin.ts @@ -1,6 +1,8 @@ import type { Player, PlayerPlugin } from "../player"; import { ApplicabilityPlugin, + AssetPlugin, + MultiNodePlugin, StringResolverPlugin, SwitchPlugin, TemplatePlugin, @@ -17,12 +19,14 @@ export class DefaultViewPlugin implements PlayerPlugin { player.hooks.viewController.tap(this.name, (viewController) => { viewController.hooks.view.tap(this.name, (view) => { const pluginOptions = toNodeResolveOptions(view.resolverOptions); + new AssetPlugin().apply(view); new SwitchPlugin(pluginOptions).apply(view); new ApplicabilityPlugin().apply(view); new StringResolverPlugin().apply(view); const templatePlugin = new TemplatePlugin(pluginOptions); templatePlugin.apply(view); view.hooks.onTemplatePluginCreated.call(templatePlugin); + new MultiNodePlugin().apply(view); }); }); } diff --git a/core/player/src/view/__tests__/view.immutable.test.ts b/core/player/src/view/__tests__/view.immutable.test.ts index 39e433884..3e36ea26d 100644 --- a/core/player/src/view/__tests__/view.immutable.test.ts +++ b/core/player/src/view/__tests__/view.immutable.test.ts @@ -3,7 +3,12 @@ import { LocalModel, withParser, PipelinedDataModel } from "../../data"; import { ExpressionEvaluator } from "../../expressions"; import { BindingParser } from "../../binding"; import { SchemaController } from "../../schema"; -import { ApplicabilityPlugin, StringResolverPlugin, ViewInstance } from ".."; +import { + ApplicabilityPlugin, + AssetPlugin, + StringResolverPlugin, + ViewInstance, +} from ".."; import { NodeType } from "../parser"; const parseBinding = new BindingParser().parse; @@ -34,6 +39,7 @@ test("uses the exact same object if nothing changes", () => { ); new StringResolverPlugin().apply(view); + new AssetPlugin().apply(view); view.hooks.resolver.tap("input", (resolver) => { resolver.hooks.resolve.tap("input", (value, astNode, options) => { @@ -205,6 +211,7 @@ test("hardcore immutability", () => { ); new StringResolverPlugin().apply(view); + new AssetPlugin().apply(view); const resolved = view.update(); diff --git a/core/player/src/view/__tests__/view.test.ts b/core/player/src/view/__tests__/view.test.ts index d122c1efc..521d33368 100644 --- a/core/player/src/view/__tests__/view.test.ts +++ b/core/player/src/view/__tests__/view.test.ts @@ -4,6 +4,7 @@ import { ExpressionEvaluator } from "../../expressions"; import { BindingParser } from "../../binding"; import { SchemaController } from "../../schema"; import { + MultiNodePlugin, StringResolverPlugin, SwitchPlugin, ViewInstance, @@ -122,6 +123,7 @@ describe("view", () => { const pluginOptions = toNodeResolveOptions(view.resolverOptions); new SwitchPlugin(pluginOptions).apply(view); + new MultiNodePlugin().apply(view); new StringResolverPlugin().apply(view); const resolved = view.update(); @@ -381,6 +383,7 @@ describe("view", () => { const pluginOptions = toNodeResolveOptions(view.resolverOptions); new SwitchPlugin(pluginOptions).apply(view); + new MultiNodePlugin().apply(view); new StringResolverPlugin().apply(view); const resolved = view.update(); @@ -735,6 +738,7 @@ describe("view", () => { const pluginOptions = toNodeResolveOptions(view.resolverOptions); new SwitchPlugin(pluginOptions).apply(view); + new MultiNodePlugin().apply(view); new StringResolverPlugin().apply(view); const resolved = view.update(); diff --git a/core/player/src/view/parser/__tests__/parser.test.ts b/core/player/src/view/parser/__tests__/parser.test.ts index 1d131b69e..533148126 100644 --- a/core/player/src/view/parser/__tests__/parser.test.ts +++ b/core/player/src/view/parser/__tests__/parser.test.ts @@ -3,7 +3,13 @@ import { BindingParser } from "../../../binding"; import { LocalModel, withParser } from "../../../data"; import { SchemaController } from "../../../schema"; import { NodeType, Parser } from "../index"; -import { SwitchPlugin, ApplicabilityPlugin, TemplatePlugin } from "../.."; +import { + SwitchPlugin, + ApplicabilityPlugin, + TemplatePlugin, + MultiNodePlugin, + AssetPlugin, +} from "../.."; import type { Options } from "../../plugins/options"; import { ExpressionEvaluator } from "../../../expressions"; import type { DataModelWithParser } from "../../../data"; @@ -30,9 +36,11 @@ describe("generates the correct AST", () => { model, }, }; + new AssetPlugin().applyParser(parser); new TemplatePlugin(options).applyParser(parser); new ApplicabilityPlugin().applyParser(parser); new SwitchPlugin(options).applyParser(parser); + new MultiNodePlugin().applyParser(parser); }); test("works with basic objects", () => { @@ -154,8 +162,10 @@ describe("parseView", () => { model, }, }; + new AssetPlugin().applyParser(parser); new TemplatePlugin(options).applyParser(parser); new ApplicabilityPlugin().applyParser(parser); + new MultiNodePlugin().applyParser(parser); new SwitchPlugin(options).applyParser(parser); }); @@ -249,8 +259,13 @@ describe("generates the correct AST when using switch plugin", () => { return true; }, } as any); + const multiNodePlugin = new MultiNodePlugin(); + const assetPlugin = new AssetPlugin(); + const parser = new Parser(); switchPlugin.applyParser(parser); + multiNodePlugin.applyParser(parser); + assetPlugin.applyParser(parser); test("works with asset wrapped objects", () => { expect(parser.parseObject(toughStaticSwitchView)).toMatchSnapshot(); diff --git a/core/player/src/view/parser/index.ts b/core/player/src/view/parser/index.ts index 0add5bec0..a47bb4e1b 100644 --- a/core/player/src/view/parser/index.ts +++ b/core/player/src/view/parser/index.ts @@ -1,9 +1,7 @@ -import { omit, setIn } from "timm"; +import { setIn } from "timm"; import { SyncBailHook, SyncWaterfallHook } from "tapable-ts"; -import type { Template } from "@player-ui/types"; import type { AnyAssetType, Node } from "./types"; import { NodeType } from "./types"; -import { getNodeID, hasAsync } from "./utils"; export * from "./types"; export * from "./utils"; @@ -17,6 +15,12 @@ export interface ParseObjectOptions { templateDepth?: number; } +export interface ParseObjectChildOptions { + key: string; + path: Node.PathSegment[]; + parentObj: object; +} + interface NestedObj { /** The values of a nested local object */ children: Node.Child[]; @@ -52,16 +56,14 @@ export class Parser { [Node.Node | undefined | null, object] >(), - determineNodeType: new SyncBailHook<[object | string], NodeType>(), - parseNode: new SyncBailHook< [ obj: object, nodeType: Node.ChildrenTypes, parseOptions: ParseObjectOptions, - determinedNodeType: NodeType | null, + childOptions?: ParseObjectChildOptions, ], - Node.Node + Node.Node | Node.Child[] >(), }; @@ -75,27 +77,6 @@ export class Parser { return viewNode as Node.View; } - private parseAsync( - obj: object, - type: Node.ChildrenTypes, - options: ParseObjectOptions, - ): Node.Node | null { - const parsedAsync = this.parseObject(omit(obj, "async"), type, options); - const parsedNodeId = getNodeID(parsedAsync); - if (parsedAsync !== null && parsedNodeId) { - return this.createASTNode( - { - id: parsedNodeId, - type: NodeType.Async, - value: parsedAsync, - }, - obj, - ); - } - - return null; - } - public createASTNode(node: Node.Node | null, value: any): Node.Node | null { const tapped = this.hooks.onCreateASTNode.call(node, value); @@ -106,42 +87,19 @@ export class Parser { return tapped; } - /** - * Checks if there are templated values in the object - * - * @param obj - The Parsed Object to check to see if we have a template array type for - * @param localKey - The key being checked - */ - private hasTemplateValues(obj: any, localKey: string) { - return ( - Object.hasOwnProperty.call(obj, "template") && - Array.isArray(obj?.template) && - obj.template.length && - obj.template.find((tmpl: any) => tmpl.output === localKey) - ); - } - - private hasSwitchKey(localKey: string) { - return localKey === ("staticSwitch" || "dynamicSwitch"); - } - public parseObject( obj: object, type: Node.ChildrenTypes = NodeType.Value, options: ParseObjectOptions = { templateDepth: 0 }, ): Node.Node | null { - const nodeType = this.hooks.determineNodeType.call(obj); - - if (nodeType !== undefined) { - const parsedNode = this.hooks.parseNode.call( - obj, - type, - options, - nodeType, - ); - if (parsedNode) { - return parsedNode; - } + const parsedNode = this.hooks.parseNode.call( + obj, + type, + options, + ) as Node.Node; + + if (parsedNode) { + return parsedNode; } /** @@ -178,209 +136,39 @@ export class Parser { }; const newValue = objEntries.reduce((accumulation, current): NestedObj => { - const { children, ...rest } = accumulation; + let { value } = accumulation; + const { children } = accumulation; const [localKey, localValue] = current; - if (localKey === "asset" && typeof localValue === "object") { - const assetAST = this.parseObject( - localValue, - NodeType.Asset, - options, - ); - - if (assetAST) { - return { - ...rest, - children: [ - ...children, - { - path: [...path, "asset"], - value: assetAST, - }, - ], - }; - } - } else if ( - this.hooks.determineNodeType.call(localKey) === NodeType.Template && - Array.isArray(localValue) - ) { - const templateChildren = localValue - .map((template: Template) => { - const templateAST = this.hooks.onCreateASTNode.call( - { - type: NodeType.Template, - depth: options.templateDepth ?? 0, - data: template.data, - template: template.value, - dynamic: template.dynamic ?? false, - }, - template, - ); - - if (templateAST?.type === NodeType.MultiNode) { - templateAST.values.forEach((v) => { - // eslint-disable-next-line no-param-reassign - v.parent = templateAST; - }); - } - - if (templateAST) { - return { - path: [...path, template.output], - value: templateAST, - }; - } - - // eslint-disable-next-line no-useless-return - return; - }) - .filter((element) => !!element); - - return { - ...rest, - children: [...children, ...templateChildren], - } as NestedObj; - } else if ( - (localValue && - this.hooks.determineNodeType.call(localValue) === - NodeType.Switch) || - this.hasSwitchKey(localKey) - ) { - const localSwitch = this.hooks.parseNode.call( - this.hasSwitchKey(localKey) - ? { [localKey]: localValue } - : localValue, - NodeType.Value, - options, - NodeType.Switch, - ); - - if ( - localSwitch && - localSwitch.type === NodeType.Value && - localSwitch.children?.length === 1 && - localSwitch.value === undefined - ) { - const firstChild = localSwitch.children[0]; - return { - ...rest, - children: [ - ...children, - { - path: [...path, localKey, ...firstChild.path], - value: firstChild.value, - }, - ], - }; - } - - if (localSwitch) { - return { - ...rest, - children: [ - ...children, - { - path: [...path, localKey], - value: localSwitch, - }, - ], - }; - } - } else if (localValue && hasAsync(localValue)) { - const localAsync = this.parseAsync( - localValue, - NodeType.Value, - options, - ); - if (localAsync) { - children.push({ - path: [...path, localKey], - value: localAsync, - }); - } - } else if (localValue && Array.isArray(localValue)) { - const childValues = localValue - .map((childVal) => - this.parseObject(childVal, NodeType.Value, options), - ) - .filter((child): child is Node.Node => !!child); - - if (childValues.length > 0) { - const multiNode = this.hooks.onCreateASTNode.call( - { - type: NodeType.MultiNode, - override: !this.hasTemplateValues(localObj, localKey), - values: childValues, - }, - localValue, - ); - - if (multiNode?.type === NodeType.MultiNode) { - multiNode.values.forEach((v) => { - // eslint-disable-next-line no-param-reassign - v.parent = multiNode; - }); - } - if (multiNode) { - return { - ...rest, - children: [ - ...children, - { - path: [...path, localKey], - value: multiNode, - }, - ], - }; - } - } + const newChildren = this.hooks.parseNode.call( + localValue, + NodeType.Value, + options, + { + path, + key: localKey, + parentObj: localObj, + }, + ) as Node.Child[]; + + if (newChildren) { + children.push(...newChildren); } else if (localValue && typeof localValue === "object") { - const determineNodeType = - this.hooks.determineNodeType.call(localValue); + const result = parseLocalObject(accumulation.value, localValue, [ + ...path, + localKey, + ]); - if (determineNodeType === NodeType.Applicability) { - const parsedNode = this.hooks.parseNode.call( - localValue, - NodeType.Value, - options, - determineNodeType, - ); - if (parsedNode) { - return { - ...rest, - children: [ - ...children, - { - path: [...path, localKey], - value: parsedNode, - }, - ], - }; - } - } else { - const result = parseLocalObject(accumulation.value, localValue, [ - ...path, - localKey, - ]); - return { - value: result.value, - children: [...children, ...result.children], - }; - } + value = result.value; + children.push(...result.children); } else { - const value = setIn( - accumulation.value, - [...path, localKey], - localValue, - ); - - return { - children, - value, - }; + value = setIn(accumulation.value, [...path, localKey], localValue); } - return accumulation; + return { + value, + children, + }; }, defaultValue); return newValue; @@ -389,18 +177,17 @@ export class Parser { const { value, children } = parseLocalObject(undefined, obj); const baseAst = - value === undefined && children.length === 0 + value === undefined && !children.length ? undefined : { type, value, }; - if (baseAst !== undefined && children.length > 0) { - const parent = baseAst as Node.BaseWithChildren; + if (baseAst && children.length) { + const parent: Node.BaseWithChildren = baseAst; parent.children = children; children.forEach((child) => { - // eslint-disable-next-line no-param-reassign child.value.parent = parent; }); } diff --git a/core/player/src/view/parser/utils.ts b/core/player/src/view/parser/utils.ts index 93d32ba02..d7fd336b2 100644 --- a/core/player/src/view/parser/utils.ts +++ b/core/player/src/view/parser/utils.ts @@ -1,8 +1,28 @@ import type { Node } from "./types"; -/** Check to see if the object contains async */ -export function hasAsync(obj: object): boolean { - return Object.prototype.hasOwnProperty.call(obj, "async"); +/** + * Checks if there are templated values in the object + * + * @param obj - The Parsed Object to check to see if we have a template array type for + * @param localKey - The key being checked + */ +export function hasTemplateValues(obj: any, localKey: string) { + return ( + Object.hasOwnProperty.call(obj, "template") && + Array.isArray(obj?.template) && + obj.template.length && + obj.template.find((tmpl: any) => tmpl.output === localKey) + ); +} + +/** Check to see if the string is a valid switch key */ +export function hasSwitchKey(localKey: string) { + return localKey === ("staticSwitch" || "dynamicSwitch"); +} + +/** Check to see if the string is a valid template key */ +export function hasTemplateKey(localKey: string) { + return localKey === "template"; } /** Get the ID of the Node if there is one */ diff --git a/core/player/src/view/plugins/__tests__/__snapshots__/asset.test.ts.snap b/core/player/src/view/plugins/__tests__/__snapshots__/asset.test.ts.snap new file mode 100644 index 000000000..b2ebf73d1 --- /dev/null +++ b/core/player/src/view/plugins/__tests__/__snapshots__/asset.test.ts.snap @@ -0,0 +1,215 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`asset > applicability 1`] = ` +{ + "children": [ + { + "path": [ + "asset", + ], + "value": { + "children": [ + { + "path": [ + "values", + ], + "value": { + "override": true, + "parent": [Circular], + "type": "multi-node", + "values": [ + { + "expression": "{{foo}}", + "parent": [Circular], + "type": "applicability", + "value": { + "parent": [Circular], + "type": "value", + "value": { + "value": "foo", + }, + }, + }, + { + "parent": [Circular], + "type": "value", + "value": { + "value": "bar", + }, + }, + ], + }, + }, + ], + "parent": [Circular], + "type": "asset", + "value": undefined, + }, + }, + ], + "type": "value", + "value": undefined, +} +`; + +exports[`asset > multi-node 1`] = ` +{ + "children": [ + { + "path": [ + "asset", + ], + "value": { + "children": [ + { + "path": [ + "values", + ], + "value": { + "override": true, + "parent": [Circular], + "type": "multi-node", + "values": [ + { + "children": [ + { + "path": [ + "asset", + ], + "value": { + "parent": [Circular], + "type": "asset", + "value": { + "id": "value-1", + "type": "text", + "value": "First value in the collection", + }, + }, + }, + ], + "parent": [Circular], + "type": "value", + "value": undefined, + }, + ], + }, + }, + ], + "parent": [Circular], + "type": "asset", + "value": { + "id": "foo", + "type": "collection", + }, + }, + }, + ], + "type": "value", + "value": undefined, +} +`; + +exports[`asset > object 1`] = ` +{ + "children": [ + { + "path": [ + "asset", + ], + "value": { + "parent": [Circular], + "type": "asset", + "value": { + "type": "bar", + }, + }, + }, + ], + "type": "value", + "value": undefined, +} +`; + +exports[`asset > switch 1`] = ` +{ + "children": [ + { + "path": [ + "title", + "asset", + ], + "value": { + "parent": [Circular], + "type": "asset", + "value": { + "id": "test", + "type": "text", + "value": "test-text.", + }, + }, + }, + ], + "type": "value", + "value": { + "id": "toughView", + "type": "view", + }, +} +`; + +exports[`asset > template 1`] = ` +{ + "children": [ + { + "path": [ + "asset", + ], + "value": { + "children": [ + { + "path": [ + "values", + ], + "value": { + "override": false, + "parent": [Circular], + "type": "multi-node", + "values": [ + { + "parent": [Circular], + "type": "value", + "value": { + "value": "{{foo.bar.0}}", + }, + }, + { + "parent": [Circular], + "type": "value", + "value": { + "value": "{{foo.bar.1}}", + }, + }, + { + "parent": [Circular], + "type": "value", + "value": { + "value": "{{foo.bar.2}}", + }, + }, + ], + }, + }, + ], + "parent": [Circular], + "type": "asset", + "value": { + "id": "foo", + "type": "collection", + }, + }, + }, + ], + "type": "value", + "value": undefined, +} +`; diff --git a/core/player/src/view/plugins/__tests__/__snapshots__/multi-node.test.ts.snap b/core/player/src/view/plugins/__tests__/__snapshots__/multi-node.test.ts.snap new file mode 100644 index 000000000..f2c31d12d --- /dev/null +++ b/core/player/src/view/plugins/__tests__/__snapshots__/multi-node.test.ts.snap @@ -0,0 +1,67 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`multi-node > multi-node collection 1`] = ` +{ + "children": [ + { + "path": [ + "values", + ], + "value": { + "override": true, + "parent": [Circular], + "type": "multi-node", + "values": [ + { + "children": [ + { + "path": [ + "asset", + ], + "value": { + "parent": [Circular], + "type": "asset", + "value": { + "id": "value-1", + "type": "text", + "value": "First value in the collection", + }, + }, + }, + ], + "parent": [Circular], + "type": "value", + "value": undefined, + }, + { + "children": [ + { + "path": [ + "asset", + ], + "value": { + "parent": [Circular], + "type": "asset", + "value": { + "id": "value-2", + "type": "text", + "value": "Second value in the collection", + }, + }, + }, + ], + "parent": [Circular], + "type": "value", + "value": undefined, + }, + ], + }, + }, + ], + "type": "value", + "value": { + "id": "foo", + "type": "collection", + }, +} +`; diff --git a/core/player/src/view/plugins/__tests__/__snapshots__/template.test.ts.snap b/core/player/src/view/plugins/__tests__/__snapshots__/template.test.ts.snap index 449730f65..ad905e76e 100644 --- a/core/player/src/view/plugins/__tests__/__snapshots__/template.test.ts.snap +++ b/core/player/src/view/plugins/__tests__/__snapshots__/template.test.ts.snap @@ -1,67 +1,69 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`dynamic templates > Works with template items plus value items > Should show template item first when coming before values on lexical order 1`] = ` -{ - "asset": { - "id": "overviewItem3", - "label": { - "asset": { - "id": "overviewItem3-label", - "type": "text", - "value": "1099-A", - }, +[ + { + "asset": { + "id": "value-0", + "type": "text", + "value": "item 1", }, - "type": "overviewItem", - "values": [ - { - "asset": { - "id": "overviewItem3-year", - "type": "text", - "value": "Desciption of concept 1099 1", - }, - }, - { - "asset": { - "id": "loverviewItem3-cy", - "type": "text", - "value": "4000", - }, - }, - ], }, -} + { + "asset": { + "id": "value-1", + "type": "text", + "value": "item 2", + }, + }, + { + "asset": { + "id": "value-2", + "type": "text", + "value": "First value in the collection", + }, + }, + { + "asset": { + "id": "value-3", + "type": "text", + "value": "Second value in the collection", + }, + }, +] `; exports[`dynamic templates > Works with template items plus value items > Should show template item last when coming after values on lexical order 1`] = ` -{ - "asset": { - "id": "overviewItem1", - "label": { - "asset": { - "id": "overviewItem1-label", - "type": "text", - "value": "First Summary", - }, +[ + { + "asset": { + "id": "value-2", + "type": "text", + "value": "First value in the collection", }, - "type": "overviewItem", - "values": [ - { - "asset": { - "id": "overviewItem1-year", - "type": "text", - "value": "Desciption of year summary 1", - }, - }, - { - "asset": { - "id": "loverviewItem1-cy", - "type": "text", - "value": "14000", - }, - }, - ], }, -} + { + "asset": { + "id": "value-3", + "type": "text", + "value": "Second value in the collection", + }, + }, + { + "asset": { + "id": "value-0", + "type": "text", + "value": "item 1", + }, + }, + { + "asset": { + "id": "value-1", + "type": "text", + "value": "item 2", + }, + }, +] `; exports[`templates > works with nested templates 1`] = ` diff --git a/core/player/src/view/plugins/__tests__/applicability.test.ts b/core/player/src/view/plugins/__tests__/applicability.test.ts index ada230dd1..d02af1939 100644 --- a/core/player/src/view/plugins/__tests__/applicability.test.ts +++ b/core/player/src/view/plugins/__tests__/applicability.test.ts @@ -8,7 +8,7 @@ import type { Resolve } from "../../resolver"; import { Resolver } from "../../resolver"; import type { Node } from "../../parser"; import { Parser } from "../../parser"; -import { ApplicabilityPlugin, StringResolverPlugin } from ".."; +import { ApplicabilityPlugin, MultiNodePlugin, StringResolverPlugin } from ".."; const parseBinding = new BindingParser().parse; @@ -37,8 +37,10 @@ describe("applicability", () => { it("undefined does not remove asset", () => { const aP = new ApplicabilityPlugin(); const sP = new StringResolverPlugin(); + const mnP = new MultiNodePlugin(); aP.applyParser(parser); + mnP.applyParser(parser); const root = parser.parseObject({ asset: { @@ -70,6 +72,7 @@ describe("applicability", () => { it("removes empty objects", () => { new ApplicabilityPlugin().applyParser(parser); + new MultiNodePlugin().applyParser(parser); const root = parser.parseObject({ asset: { values: [ @@ -245,21 +248,26 @@ describe("applicability", () => { }); }); - it("determines if nodeType is applicability", () => { - new ApplicabilityPlugin().applyParser(parser); - const nodeTest = { - applicability: "{{bar}} == true", - }; - const nodeType = parser.hooks.determineNodeType.call(nodeTest); - expect(nodeType).toStrictEqual("applicability"); - }); + it("does not return field object if applicability node does not resolve", () => { + const applicabilityPlugin = new ApplicabilityPlugin(); + const stringResolverPlugin = new StringResolverPlugin(); - it("Does not return a nodeType", () => { - new ApplicabilityPlugin().applyParser(parser); - const nodeTest = { - value: "foo", - }; - const nodeType = parser.hooks.determineNodeType.call(nodeTest); - expect(nodeType).toBe(undefined); + applicabilityPlugin.applyParser(parser); + const root = parser.parseObject({ + id: "foo", + fields: { + applicability: "{{foo.bar}}", + }, + } as any); + const resolver = new Resolver(root as Node.Node, resolverOptions); + + applicabilityPlugin.applyResolver(resolver); + stringResolverPlugin.applyResolver(resolver); + + const resolved = resolver.update(); + + expect(resolved).toStrictEqual({ + id: "foo", + }); }); }); diff --git a/core/player/src/view/plugins/__tests__/asset.test.ts b/core/player/src/view/plugins/__tests__/asset.test.ts new file mode 100644 index 000000000..e438b1229 --- /dev/null +++ b/core/player/src/view/plugins/__tests__/asset.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { BindingParser } from "../../../binding"; +import type { DataModelWithParser } from "../../../data"; +import { LocalModel, withParser } from "../../../data"; +import { ExpressionEvaluator } from "../../../expressions"; +import { SchemaController } from "../../../schema"; +import { Parser } from "../../parser"; +import type { Options } from "../options"; +import { + MultiNodePlugin, + AssetPlugin, + ApplicabilityPlugin, + TemplatePlugin, + SwitchPlugin, +} from ".."; + +const parseBinding = new BindingParser().parse; + +describe("asset", () => { + let parser: Parser; + + beforeEach(() => { + parser = new Parser(); + new AssetPlugin().applyParser(parser); + }); + + it("object", () => { + expect(parser.parseObject({ asset: { type: "bar" } })).toMatchSnapshot(); + }); + + it("applicability", () => { + new ApplicabilityPlugin().applyParser(parser); + new MultiNodePlugin().applyParser(parser); + + expect( + parser.parseObject({ + asset: { + values: [ + { + applicability: "{{foo}}", + value: "foo", + }, + { + value: "bar", + }, + ], + }, + }), + ).toMatchSnapshot(); + }); + + it("multi-node", () => { + new MultiNodePlugin().applyParser(parser); + + expect( + parser.parseObject({ + asset: { + id: "foo", + type: "collection", + values: [ + { + asset: { + id: "value-1", + type: "text", + value: "First value in the collection", + }, + }, + ], + }, + }), + ).toMatchSnapshot(); + }); + + it("template", () => { + const model: DataModelWithParser = withParser( + new LocalModel(), + parseBinding, + ); + const expressionEvaluator: ExpressionEvaluator = new ExpressionEvaluator({ + model, + }); + const options: Options = { + evaluate: expressionEvaluator.evaluate, + schema: new SchemaController(), + data: { + format: (binding, val) => val, + formatValue: (val) => val, + model, + }, + }; + new TemplatePlugin(options).applyParser(parser); + + const petNames = ["Ginger", "Daisy", "Afra"]; + model.set([["foo.bar", petNames]]); + + expect( + parser.parseObject({ + asset: { + id: "foo", + type: "collection", + template: [ + { + data: "foo.bar", + output: "values", + value: { + value: "{{foo.bar._index_}}", + }, + }, + ], + }, + }), + ).toMatchSnapshot(); + }); + + it("switch", () => { + new SwitchPlugin({ + evaluate: () => { + return true; + }, + } as any).applyParser(parser); + + expect( + parser.parseObject({ + id: "toughView", + type: "view", + title: { + staticSwitch: [ + { + case: "'true'", + asset: { + id: "test", + type: "text", + value: "test-text.", + }, + }, + ], + }, + }), + ).toMatchSnapshot(); + }); +}); diff --git a/core/player/src/view/plugins/__tests__/multi-node.test.ts b/core/player/src/view/plugins/__tests__/multi-node.test.ts new file mode 100644 index 000000000..151ea27e4 --- /dev/null +++ b/core/player/src/view/plugins/__tests__/multi-node.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Parser } from "../../parser"; +import { MultiNodePlugin, AssetPlugin } from ".."; + +describe("multi-node", () => { + let parser: Parser; + + beforeEach(() => { + parser = new Parser(); + new AssetPlugin().applyParser(parser); + new MultiNodePlugin().applyParser(parser); + }); + + it("multi-node collection", () => { + expect( + parser.parseObject({ + id: "foo", + type: "collection", + values: [ + { + asset: { + id: "value-1", + type: "text", + value: "First value in the collection", + }, + }, + { + asset: { + id: "value-2", + type: "text", + value: "Second value in the collection", + }, + }, + ], + }), + ).toMatchSnapshot(); + }); +}); diff --git a/core/player/src/view/plugins/__tests__/template.test.ts b/core/player/src/view/plugins/__tests__/template.test.ts index 8c79efa5e..e3eaf8b0d 100644 --- a/core/player/src/view/plugins/__tests__/template.test.ts +++ b/core/player/src/view/plugins/__tests__/template.test.ts @@ -4,135 +4,27 @@ import type { DataModelWithParser } from "../../../data"; import { LocalModel, withParser } from "../../../data"; import { ExpressionEvaluator } from "../../../expressions"; import { SchemaController } from "../../../schema"; -import { NodeType } from "../../parser"; import { Parser } from "../../parser"; import { ViewInstance } from "../../view"; import type { Options } from "../options"; -import TemplatePlugin from "../template-plugin"; +import { TemplatePlugin, MultiNodePlugin, AssetPlugin } from "../"; import { StringResolverPlugin, toNodeResolveOptions } from "../.."; const templateJoinValues = { - id: "snippet-of-json", - topic: "Snippet", - schema: {}, - data: { - forms: { - "1099-A": [ - { - description: "Desciption of concept 1099 1", - amount: "Help", - }, - ], - "1099-B": [ - { - description: "Desciption of concept 1099 2", - amount: "Help", - }, - ], - }, - }, + id: "generated-flow", views: [ { - id: "overviewGroup", - type: "overviewGroup", - metaData: { - role: "stateful", - }, - modifiers: [ - { - type: "tag", - value: "fancy-header", - }, - ], - headers: { - label: { - asset: { - id: "line-of-work-summary-gh-header-label", - type: "text", - value: "Header", - }, - }, - values: [ - { - asset: { - id: "line-of-work-summary-gh-expenses-simple-header-previous-year", - type: "text", - value: "Type", - }, - }, - { - asset: { - id: "line-of-work-summary-gh-expenses-simple-header-cy", - type: "text", - value: "2022", - }, - }, - ], - }, + id: "collection", + type: "collection", template: [ { - data: "forms.1099-A", + data: "foo", output: "values", value: { asset: { - id: "overviewItem3", - type: "overviewItem", - label: { - asset: { - id: "overviewItem3-label", - type: "text", - value: "1099-A", - }, - }, - values: [ - { - asset: { - id: "overviewItem3-year", - type: "text", - value: "Desciption of concept 1099 1", - }, - }, - { - asset: { - id: "loverviewItem3-cy", - type: "text", - value: "4000", - }, - }, - ], - }, - }, - }, - { - data: "forms.1099-B", - output: "values", - value: { - asset: { - id: "overviewItem4", - type: "overviewItem", - label: { - asset: { - id: "overviewItem4-label", - type: "text", - value: "1099-B", - }, - }, - values: [ - { - asset: { - id: "overviewItem4-year", - type: "text", - value: "Desciption of concept 1099 2", - }, - }, - { - asset: { - id: "loverviewItem3-cy", - type: "text", - value: "6000", - }, - }, - ], + id: "value-_index_", + type: "text", + value: "item {{foo._index_}}", }, }, }, @@ -140,238 +32,71 @@ const templateJoinValues = { values: [ { asset: { - id: "overviewItem1", - type: "overviewItem", - label: { - asset: { - id: "overviewItem1-label", - type: "text", - value: "First Summary", - }, - }, - values: [ - { - asset: { - id: "overviewItem1-year", - type: "text", - value: "Desciption of year summary 1", - }, - }, - { - asset: { - id: "loverviewItem1-cy", - type: "text", - value: "14000", - }, - }, - ], + id: "value-2", + type: "text", + value: "First value in the collection", }, }, { asset: { - id: "overviewItem2", - type: "overviewItem", - label: { - asset: { - id: "overviewItem2-label", - type: "text", - value: "Second year Summary", - }, - }, - values: [ - { - asset: { - id: "overviewItem2-year", - type: "text", - value: "Desciption of year summary item 2", - }, - }, - { - asset: { - id: "loverviewItem1-cy", - type: "text", - value: "19000", - }, - }, - ], + id: "value-3", + type: "text", + value: "Second value in the collection", }, }, ], }, { - id: "overviewGroup", - type: "overviewGroup", - metaData: { - role: "stateful", - }, - modifiers: [ - { - type: "tag", - value: "fancy-header", - }, - ], - headers: { - label: { - asset: { - id: "line-of-work-summary-gh-header-label", - type: "text", - value: "Header", - }, - }, - values: [ - { - asset: { - id: "line-of-work-summary-gh-expenses-simple-header-previous-year", - type: "text", - value: "Type", - }, - }, - { - asset: { - id: "line-of-work-summary-gh-expenses-simple-header-cy", - type: "text", - value: "2022", - }, - }, - ], - }, + id: "collection", + type: "collection", values: [ { asset: { - id: "overviewItem1", - type: "overviewItem", - label: { - asset: { - id: "overviewItem1-label", - type: "text", - value: "First Summary", - }, - }, - values: [ - { - asset: { - id: "overviewItem1-year", - type: "text", - value: "Desciption of year summary 1", - }, - }, - { - asset: { - id: "loverviewItem1-cy", - type: "text", - value: "14000", - }, - }, - ], + id: "value-2", + type: "text", + value: "First value in the collection", }, }, { asset: { - id: "overviewItem2", - type: "overviewItem", - label: { - asset: { - id: "overviewItem2-label", - type: "text", - value: "Second year Summary", - }, - }, - values: [ - { - asset: { - id: "overviewItem2-year", - type: "text", - value: "Desciption of year summary item 2", - }, - }, - { - asset: { - id: "loverviewItem1-cy", - type: "text", - value: "19000", - }, - }, - ], + id: "value-3", + type: "text", + value: "Second value in the collection", }, }, ], template: [ { - data: "forms.1099-A", - output: "values", - value: { - asset: { - id: "overviewItem3", - type: "overviewItem", - label: { - asset: { - id: "overviewItem3-label", - type: "text", - value: "1099-A", - }, - }, - values: [ - { - asset: { - id: "overviewItem3-year", - type: "text", - value: "Desciption of concept 1099 1", - }, - }, - { - asset: { - id: "loverviewItem3-cy", - type: "text", - value: "4000", - }, - }, - ], - }, - }, - }, - { - data: "forms.1099-B", + data: "foo", output: "values", value: { asset: { - id: "overviewItem4", - type: "overviewItem", - label: { - asset: { - id: "overviewItem4-label", - type: "text", - value: "1099-B", - }, - }, - values: [ - { - asset: { - id: "overviewItem4-year", - type: "text", - value: "Desciption of concept 1099 2", - }, - }, - { - asset: { - id: "loverviewItem3-cy", - type: "text", - value: "6000", - }, - }, - ], + id: "value-_index_", + type: "text", + value: "item {{foo._index_}}", }, }, }, ], }, ], + data: { + foo: [1, 2], + }, navigation: { - BEGIN: "SnippetFlow", - SnippetFlow: { - startState: "VIEW_Snippet-View1", - "VIEW_Snippet-View1": { - ref: "overviewGroup", + BEGIN: "FLOW_1", + FLOW_1: { + startState: "VIEW_1", + VIEW_1: { state_type: "VIEW", + ref: "collection", + transitions: { + "*": "END_Done", + }, + }, + END_Done: { + state_type: "END", + outcome: "done", }, }, }, @@ -401,6 +126,7 @@ describe("templates", () => { }, }; new TemplatePlugin(options).applyParser(parser); + new AssetPlugin().applyParser(parser); }); it("works with simple ones", () => { @@ -459,76 +185,6 @@ describe("templates", () => { }), ).toMatchSnapshot(); }); - - it("determines if nodeType is template", () => { - const nodeTest = "template"; - const nodeType = parser.hooks.determineNodeType.call(nodeTest); - expect(nodeType).toStrictEqual("template"); - }); - - it("Does not return a nodeType", () => { - const nodeTest = { - value: "foo", - }; - const nodeType = parser.hooks.determineNodeType.call(nodeTest); - expect(nodeType).toBe(undefined); - }); - - it("returns templateNode if template exists", () => { - const obj = { - dynamic: true, - data: "foo.bar", - output: "values", - value: { - value: "{{foo.bar._index_}}", - }, - }; - const nodeOptions = { - templateDepth: 1, - }; - const parsedNode = parser.hooks.parseNode.call( - obj, - NodeType.Value, - nodeOptions, - NodeType.Template, - ); - expect(parsedNode).toStrictEqual({ - data: "foo.bar", - depth: 1, - dynamic: true, - template: { - value: "{{foo.bar._index_}}", - }, - type: "template", - }); - }); - - it("returns templateNode if template exists, and templateDepth is not set", () => { - const obj = { - data: "foo.bar2", - output: "values", - dynamic: true, - value: { - value: "{{foo.bar2._index_}}", - }, - }; - const nodeOptions = {}; - const parsedNode = parser.hooks.parseNode.call( - obj, - NodeType.Value, - nodeOptions, - NodeType.Template, - ); - expect(parsedNode).toStrictEqual({ - data: "foo.bar2", - depth: 0, - dynamic: true, - template: { - value: "{{foo.bar2._index_}}", - }, - type: "template", - }); - }); }); describe("dynamic templates", () => { @@ -695,13 +351,15 @@ describe("dynamic templates", () => { }); const pluginOptions = toNodeResolveOptions(view.resolverOptions); + new AssetPlugin().apply(view); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); + new MultiNodePlugin().apply(view); const resolved = view.update(); expect(resolved.values).toHaveLength(4); - expect(resolved.values[0]).toMatchSnapshot(); + expect(resolved.values).toMatchSnapshot(); }); it("Should show template item last when coming after values on lexical order", () => { const view = new ViewInstance(templateJoinValues.views[1], { @@ -712,13 +370,15 @@ describe("dynamic templates", () => { }); const pluginOptions = toNodeResolveOptions(view.resolverOptions); + new AssetPlugin().apply(view); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); + new MultiNodePlugin().apply(view); const resolved = view.update(); expect(resolved.values).toHaveLength(4); - expect(resolved.values[0]).toMatchSnapshot(); + expect(resolved.values).toMatchSnapshot(); }); }); }); diff --git a/core/player/src/view/plugins/applicability.ts b/core/player/src/view/plugins/applicability.ts index cdf6abec8..bb36b4396 100644 --- a/core/player/src/view/plugins/applicability.ts +++ b/core/player/src/view/plugins/applicability.ts @@ -1,12 +1,21 @@ import { omit } from "timm"; import type { Options } from "./options"; import type { Resolver } from "../resolver"; -import type { Node, ParseObjectOptions, Parser } from "../parser"; +import type { + Node, + ParseObjectOptions, + ParseObjectChildOptions, + Parser, +} from "../parser"; import { NodeType } from "../parser"; import { ViewInstance, ViewPlugin } from "../view"; /** A view plugin to remove inapplicable assets from the tree */ export default class ApplicabilityPlugin implements ViewPlugin { + private isApplicability(obj: any) { + return obj && Object.prototype.hasOwnProperty.call(obj, "applicability"); + } + applyResolver(resolver: Resolver) { resolver.hooks.beforeResolve.tap( "applicability", @@ -29,43 +38,50 @@ export default class ApplicabilityPlugin implements ViewPlugin { } applyParser(parser: Parser) { - /** Switches resolved during the parsing phase are static */ - parser.hooks.determineNodeType.tap("applicability", (obj: any) => { - if (Object.prototype.hasOwnProperty.call(obj, "applicability")) { - return NodeType.Applicability; - } - }); - parser.hooks.parseNode.tap( "applicability", ( obj: any, nodeType: Node.ChildrenTypes, options: ParseObjectOptions, - determinedNodeType: null | NodeType, + childOptions?: ParseObjectChildOptions, ) => { - if (determinedNodeType === NodeType.Applicability) { + if (this.isApplicability(obj)) { const parsedApplicability = parser.parseObject( omit(obj, "applicability"), nodeType, options, ); - if (parsedApplicability !== null) { - const applicabilityNode = parser.createASTNode( - { - type: NodeType.Applicability, - expression: (obj as any).applicability, - value: parsedApplicability, - }, - obj, - ); - if (applicabilityNode?.type === NodeType.Applicability) { - applicabilityNode.value.parent = applicabilityNode; - } + if (!parsedApplicability) { + return childOptions ? [] : undefined; + } - return applicabilityNode; + const applicabilityNode = parser.createASTNode( + { + type: NodeType.Applicability, + expression: (obj as any).applicability, + value: parsedApplicability, + }, + obj, + ); + + if (!applicabilityNode) { + return childOptions ? [] : undefined; + } + + if (applicabilityNode.type === NodeType.Applicability) { + applicabilityNode.value.parent = applicabilityNode; } + + return childOptions + ? [ + { + path: [...childOptions.path, childOptions.key], + value: applicabilityNode, + }, + ] + : applicabilityNode; } }, ); diff --git a/core/player/src/view/plugins/asset.ts b/core/player/src/view/plugins/asset.ts new file mode 100644 index 000000000..31e24c2ef --- /dev/null +++ b/core/player/src/view/plugins/asset.ts @@ -0,0 +1,42 @@ +import { ViewInstance, ViewPlugin } from "../view"; +import type { + Parser, + Node, + ParseObjectOptions, + ParseObjectChildOptions, +} from "../parser"; +import { NodeType } from "../parser"; + +/** A view plugin to resolve assets */ +export default class AssetPlugin implements ViewPlugin { + applyParser(parser: Parser) { + parser.hooks.parseNode.tap( + "asset", + ( + obj: any, + nodeType: Node.ChildrenTypes, + options: ParseObjectOptions, + childOptions?: ParseObjectChildOptions, + ) => { + if (childOptions?.key === "asset" && typeof obj === "object") { + const assetAST = parser.parseObject(obj, NodeType.Asset, options); + + if (!assetAST) { + return []; + } + + return [ + { + path: [...childOptions.path, childOptions.key], + value: assetAST, + }, + ]; + } + }, + ); + } + + apply(view: ViewInstance) { + view.hooks.parser.tap("asset", this.applyParser.bind(this)); + } +} diff --git a/core/player/src/view/plugins/index.ts b/core/player/src/view/plugins/index.ts index a59f342ba..7abfeb2c8 100644 --- a/core/player/src/view/plugins/index.ts +++ b/core/player/src/view/plugins/index.ts @@ -1,4 +1,6 @@ -export { default as TemplatePlugin } from "./template-plugin"; +export { default as TemplatePlugin } from "./template"; export { default as StringResolverPlugin } from "./string-resolver"; export { default as ApplicabilityPlugin } from "./applicability"; export { default as SwitchPlugin } from "./switch"; +export { default as MultiNodePlugin } from "./multi-node"; +export { default as AssetPlugin } from "./asset"; diff --git a/core/player/src/view/plugins/multi-node.ts b/core/player/src/view/plugins/multi-node.ts new file mode 100644 index 000000000..6e43736b4 --- /dev/null +++ b/core/player/src/view/plugins/multi-node.ts @@ -0,0 +1,73 @@ +import { ViewInstance, ViewPlugin } from "../view"; +import type { + Parser, + Node, + ParseObjectOptions, + ParseObjectChildOptions, +} from "../parser"; +import { NodeType } from "../parser"; +import { hasTemplateValues, hasTemplateKey } from "../parser/utils"; + +/** A view plugin to resolve multi nodes */ +export default class MultiNodePlugin implements ViewPlugin { + applyParser(parser: Parser) { + parser.hooks.parseNode.tap( + "multi-node", + ( + obj: any, + nodeType: Node.ChildrenTypes, + options: ParseObjectOptions, + childOptions?: ParseObjectChildOptions, + ) => { + if ( + childOptions && + !hasTemplateKey(childOptions.key) && + Array.isArray(obj) + ) { + const values = obj + .map((childVal) => + parser.parseObject(childVal, NodeType.Value, options), + ) + .filter((child): child is Node.Node => !!child); + + if (!values.length) { + return []; + } + + const multiNode = parser.createASTNode( + { + type: NodeType.MultiNode, + override: !hasTemplateValues( + childOptions.parentObj, + childOptions.key, + ), + values, + }, + obj, + ); + + if (!multiNode) { + return []; + } + + if (multiNode.type === NodeType.MultiNode) { + multiNode.values.forEach((v) => { + v.parent = multiNode; + }); + } + + return [ + { + path: [...childOptions.path, childOptions.key], + value: multiNode, + }, + ]; + } + }, + ); + } + + apply(view: ViewInstance) { + view.hooks.parser.tap("multi-node", this.applyParser.bind(this)); + } +} diff --git a/core/player/src/view/plugins/switch.ts b/core/player/src/view/plugins/switch.ts index 3e7fbc920..502992280 100644 --- a/core/player/src/view/plugins/switch.ts +++ b/core/player/src/view/plugins/switch.ts @@ -1,8 +1,14 @@ +import { ViewInstance, ViewPlugin } from "../view"; import type { Options } from "./options"; -import type { Parser, Node, ParseObjectOptions } from "../parser"; +import type { + Parser, + Node, + ParseObjectOptions, + ParseObjectChildOptions, +} from "../parser"; import { EMPTY_NODE, NodeType } from "../parser"; import type { Resolver } from "../resolver"; -import { ViewInstance, ViewPlugin } from "../view"; +import { hasSwitchKey } from "../parser/utils"; /** A view plugin to resolve switches */ export default class SwitchPlugin implements ViewPlugin { @@ -23,6 +29,14 @@ export default class SwitchPlugin implements ViewPlugin { return EMPTY_NODE; } + private isSwitch(obj: any) { + return ( + obj && + (Object.prototype.hasOwnProperty.call(obj, "dynamicSwitch") || + Object.prototype.hasOwnProperty.call(obj, "staticSwitch")) + ); + } + applyParser(parser: Parser) { /** Switches resolved during the parsing phase are static */ parser.hooks.onCreateASTNode.tap("switch", (node) => { @@ -33,75 +47,92 @@ export default class SwitchPlugin implements ViewPlugin { return node; }); - parser.hooks.determineNodeType.tap("switch", (obj) => { - if ( - Object.prototype.hasOwnProperty.call(obj, "dynamicSwitch") || - Object.prototype.hasOwnProperty.call(obj, "staticSwitch") - ) { - return NodeType.Switch; - } - }); - parser.hooks.parseNode.tap( "switch", ( obj: any, _nodeType: Node.ChildrenTypes, options: ParseObjectOptions, - determinedNodeType: null | NodeType, + childOptions?: ParseObjectChildOptions, ) => { - if (determinedNodeType === NodeType.Switch) { - const dynamic = "dynamicSwitch" in obj; - const switchContent = - "dynamicSwitch" in obj ? obj.dynamicSwitch : obj.staticSwitch; - - const cases: Node.SwitchCase[] = []; - - switchContent.forEach( - (switchCase: { - [x: string]: any; - /** - * - */ - case: any; - }) => { - const { case: switchCaseExpr, ...switchBody } = switchCase; - const value = parser.parseObject( - switchBody, - NodeType.Value, - options, - ); - - if (value) { - cases.push({ - case: switchCaseExpr, - value: value as Node.Value, - }); - } - }, - ); - - const switchAST = parser.hooks.onCreateASTNode.call( + if ( + this.isSwitch(obj) || + (childOptions && hasSwitchKey(childOptions.key)) + ) { + const objToParse = + childOptions && hasSwitchKey(childOptions.key) + ? { [childOptions.key]: obj } + : obj; + const dynamic = "dynamicSwitch" in objToParse; + const switchContent = dynamic + ? objToParse.dynamicSwitch + : objToParse.staticSwitch; + + const cases: Node.SwitchCase[] = switchContent + .map( + (switchCase: { + [x: string]: any; + /** + * + */ + case: any; + }) => { + const { case: switchCaseExpr, ...switchBody } = switchCase; + const value = parser.parseObject( + switchBody, + NodeType.Value, + options, + ); + + if (value) { + return { + case: switchCaseExpr, + value: value as Node.Value, + }; + } + + return; + }, + ) + .filter(Boolean); + + const switchAST = parser.createASTNode( { type: NodeType.Switch, dynamic, cases, }, - obj, + objToParse, ); - if (switchAST?.type === NodeType.Switch) { + if (!switchAST || switchAST.type === NodeType.Empty) { + return childOptions ? [] : null; + } + + if (switchAST.type === NodeType.Switch) { switchAST.cases.forEach((sCase) => { - // eslint-disable-next-line no-param-reassign sCase.value.parent = switchAST; }); } - if (switchAST?.type === NodeType.Empty) { - return null; + if (childOptions) { + let path = [...childOptions.path, childOptions.key]; + let value: any = switchAST; + + if ( + switchAST.type === NodeType.Value && + switchAST.children?.length === 1 && + switchAST.value === undefined + ) { + const firstChild = switchAST.children[0]; + path = [...path, ...firstChild.path]; + value = firstChild.value; + } + + return [{ path, value }]; } - return switchAST ?? null; + return switchAST; } }, ); diff --git a/core/player/src/view/plugins/template-plugin.ts b/core/player/src/view/plugins/template.ts similarity index 76% rename from core/player/src/view/plugins/template-plugin.ts rename to core/player/src/view/plugins/template.ts index 6f1a3b6ed..48e5e5ef4 100644 --- a/core/player/src/view/plugins/template-plugin.ts +++ b/core/player/src/view/plugins/template.ts @@ -1,9 +1,16 @@ import { SyncWaterfallHook } from "tapable-ts"; -import type { Node, ParseObjectOptions, Parser } from "../parser"; +import type { Template } from "@player-ui/types"; +import type { + Node, + ParseObjectOptions, + ParseObjectChildOptions, + Parser, +} from "../parser"; import { NodeType } from "../parser"; +import { ViewInstance, ViewPlugin } from "../view"; import type { Options } from "./options"; import type { Resolver } from "../resolver"; -import { ViewInstance, ViewPlugin } from "../view"; +import { hasTemplateKey } from "../parser/utils"; export interface TemplateItemInfo { /** The index of the data for the current iteration of the template */ @@ -115,35 +122,42 @@ export default class TemplatePlugin implements ViewPlugin { return node; }); - parser.hooks.determineNodeType.tap("template", (obj: any) => { - if (obj === "template") { - return NodeType.Template; - } - }); - parser.hooks.parseNode.tap( "template", ( obj: any, _nodeType: Node.ChildrenTypes, options: ParseObjectOptions, - determinedNodeType: null | NodeType, + childOptions?: ParseObjectChildOptions, ) => { - if (determinedNodeType === NodeType.Template) { - const templateNode = parser.createASTNode( - { - type: NodeType.Template, - depth: options.templateDepth ?? 0, - data: obj.data, - template: obj.value, - dynamic: obj.dynamic ?? false, - }, - obj, - ); - - if (templateNode) { - return templateNode; - } + if (childOptions && hasTemplateKey(childOptions.key)) { + return obj + .map((template: Template) => { + const templateAST = parser.createASTNode( + { + type: NodeType.Template, + depth: options.templateDepth ?? 0, + data: template.data, + template: template.value, + dynamic: template.dynamic ?? false, + }, + template, + ); + + if (!templateAST) return; + + if (templateAST.type === NodeType.MultiNode) { + templateAST.values.forEach((v) => { + v.parent = templateAST; + }); + } + + return { + path: [...childOptions.path, template.output], + value: templateAST, + }; + }) + .filter(Boolean); } }, ); diff --git a/core/player/src/view/resolver/__tests__/edgecases.test.ts b/core/player/src/view/resolver/__tests__/edgecases.test.ts index 77889c734..5b8e5f3a9 100644 --- a/core/player/src/view/resolver/__tests__/edgecases.test.ts +++ b/core/player/src/view/resolver/__tests__/edgecases.test.ts @@ -9,7 +9,11 @@ import { TapableLogger } from "../../../logger"; import { Resolver } from ".."; import type { Node } from "../../parser"; import { NodeType, Parser } from "../../parser"; -import { StringResolverPlugin } from "../../plugins"; +import { + StringResolverPlugin, + MultiNodePlugin, + AssetPlugin, +} from "../../plugins"; describe("Dynamic AST Transforms", () => { const content = { @@ -40,6 +44,8 @@ describe("Dynamic AST Transforms", () => { year: "2021", }); const parser = new Parser(); + new MultiNodePlugin().applyParser(parser); + const bindingParser = new BindingParser(); const inputBinding = bindingParser.parse("year"); const rootNode = parser.parseObject(content); @@ -218,6 +224,7 @@ describe("Dynamic AST Transforms", () => { year: "2021", }); const parser = new Parser(); + new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const inputBinding = bindingParser.parse("year"); const rootNode = parser.parseObject(view); @@ -271,6 +278,7 @@ describe("Dynamic AST Transforms", () => { year: "2021", }); const parser = new Parser(); + new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content); @@ -354,6 +362,7 @@ describe("Duplicate IDs", () => { count2: 0, }); const parser = new Parser(); + new AssetPlugin().applyParser(parser); const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); @@ -449,6 +458,8 @@ describe("Duplicate IDs", () => { count2: 0, }); const parser = new Parser(); + new MultiNodePlugin().applyParser(parser); + const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); @@ -512,6 +523,8 @@ describe("AST caching", () => { const model = new LocalModel(); const parser = new Parser(); + new MultiNodePlugin().applyParser(parser); + const bindingParser = new BindingParser(); const rootNode = parser.parseObject(content, NodeType.View); const resolver = new Resolver(rootNode!, { diff --git a/core/player/src/view/view.ts b/core/player/src/view/view.ts index 6abdff16e..8479e568f 100644 --- a/core/player/src/view/view.ts +++ b/core/player/src/view/view.ts @@ -4,15 +4,10 @@ import type { BindingInstance, BindingFactory } from "../binding"; import type { ValidationProvider, ValidationObject } from "../validator"; import type { Logger } from "../logger"; import type { Resolve } from "./resolver"; -import { Resolver, toNodeResolveOptions } from "./resolver"; +import { Resolver } from "./resolver"; import type { Node } from "./parser"; import { Parser } from "./parser"; -import { - TemplatePlugin, - StringResolverPlugin, - ApplicabilityPlugin, - SwitchPlugin, -} from "./plugins"; +import { TemplatePlugin } from "./plugins"; /** * Manages the view level validations diff --git a/package.json b/package.json index 1c0e6543d..54f7035d2 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,8 @@ }, "volta": { "node": "18.18.0", - "yarn": "1.22.19" + "yarn": "1.22.19", + "pnpm": "8.9.2" }, "resolutions": { "esbuild": "0.19.8", diff --git a/plugins/asset-transform/core/src/__tests__/propertiesToSkip.test.ts b/plugins/asset-transform/core/src/__tests__/propertiesToSkip.test.ts index eae785fb7..5203ea7e6 100644 --- a/plugins/asset-transform/core/src/__tests__/propertiesToSkip.test.ts +++ b/plugins/asset-transform/core/src/__tests__/propertiesToSkip.test.ts @@ -1,9 +1,14 @@ import { test, expect, describe } from "vitest"; import type { Node } from "@player-ui/player"; -import { Parser } from "@player-ui/player"; +import { Parser, AssetPlugin, MultiNodePlugin } from "@player-ui/player"; import { composeBefore, propertiesToSkipTransform } from ".."; const parser = new Parser(); +const assetPlugin = new AssetPlugin(); +const multiNodePlugin = new MultiNodePlugin(); + +assetPlugin.applyParser(parser); +multiNodePlugin.applyParser(parser); const actionAsset = { id: "action", diff --git a/plugins/async-node/core/src/index.ts b/plugins/async-node/core/src/index.ts index c87fb2817..698c10f48 100644 --- a/plugins/async-node/core/src/index.ts +++ b/plugins/async-node/core/src/index.ts @@ -4,6 +4,7 @@ import type { PlayerPlugin, Node, ParseObjectOptions, + ParseObjectChildOptions, ViewInstance, Parser, ViewPlugin, @@ -142,39 +143,52 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin { return node?.type === NodeType.Async; } + private isDeterminedAsync(obj: any) { + return obj && Object.prototype.hasOwnProperty.call(obj, "async"); + } + applyParser(parser: Parser) { - parser.hooks.determineNodeType.tap(this.name, (obj) => { - if (Object.prototype.hasOwnProperty.call(obj, "async")) { - return NodeType.Async; - } - }); parser.hooks.parseNode.tap( this.name, ( obj: any, nodeType: Node.ChildrenTypes, options: ParseObjectOptions, - determinedNodeType: null | NodeType, + childOptions?: ParseObjectChildOptions, ) => { - if (determinedNodeType === NodeType.Async) { + if (this.isDeterminedAsync(obj)) { const parsedAsync = parser.parseObject( omit(obj, "async"), nodeType, options, ); const parsedNodeId = getNodeID(parsedAsync); - if (parsedAsync !== null && parsedNodeId) { - return parser.createASTNode( - { - id: parsedNodeId, - type: NodeType.Async, - value: parsedAsync, - }, - obj, - ); + + if (parsedAsync === null || !parsedNodeId) { + return childOptions ? [] : null; + } + + const asyncAST = parser.createASTNode( + { + id: parsedNodeId, + type: NodeType.Async, + value: parsedAsync, + }, + obj, + ); + + if (childOptions) { + return asyncAST + ? [ + { + path: [...childOptions.path, childOptions.key], + value: asyncAST, + }, + ] + : []; } - return null; + return asyncAST; } }, );