diff --git a/source/nodejs/adaptivecards-templating/src/template-engine.ts b/source/nodejs/adaptivecards-templating/src/template-engine.ts index fc80d0c834..4aa44f8882 100644 --- a/source/nodejs/adaptivecards-templating/src/template-engine.ts +++ b/source/nodejs/adaptivecards-templating/src/template-engine.ts @@ -3,17 +3,19 @@ import * as AEL from "adaptive-expressions"; class EvaluationContext { - private static readonly _reservedFields = ["$data", "$when", "$root", "$index"]; + private static readonly _reservedFields = ["$data", "$when", "$root", "$index", "$host"]; private _stateStack: Array<{ $data: any, $index: any }> = []; private _$data: any; $root: any; + $host: any; $index: number; constructor(context?: IEvaluationContext) { if (context !== undefined) { this.$root = context.$root; + this.$host = context.$host; } } @@ -55,6 +57,7 @@ class TemplateObjectMemory implements AEL.MemoryInterface { $root: any; $data: any; $index: any; + $host: any; constructor() { this._memory = new AEL.SimpleObjectMemory(this); @@ -84,24 +87,24 @@ export class GlobalSettings { * and that field is undefined or null. By default, expression evaluation will substitute an undefined * field with its binding expression (e.g. `${field}`). This callback makes it possible to customize that * behavior. - * + * * **Example** * Given this data object: - * + * * ```json * { * firstName: "David" * } * ``` - * + * * The expression `${firstName} ${lastName}` will evaluate to "David ${lastName}" because the `lastName` * field is undefined. - * + * * Now let's set the callback: * ```typescript * GlobalSettings.getUndefinedFieldValueSubstitutionString = (path: string) => { return ""; } * ``` - * + * * With that, the above expression will evaluate to "David <undefined value>" */ static getUndefinedFieldValueSubstitutionString?: (path: string) => string | undefined = undefined; @@ -115,7 +118,12 @@ export interface IEvaluationContext { * The root data object the template will bind to. Expressions that refer to $root in the template payload * map to this field. Initially, $data also maps to $root. */ - $root: any + $root?: any; + /** + * The host data object the template will bind to. Expressions that refer to $host in the template payload + * map to this field. This allows a host process to supply additional context to the template. + */ + $host?: any; } /** @@ -157,6 +165,7 @@ export class Template { memory.$root = context.$root; memory.$data = context.$data; memory.$index = context.$index; + memory.$host = context.$host; let options: AEL.Options | undefined = undefined; @@ -166,9 +175,9 @@ export class Template { let substitutionValue: string | undefined = undefined; if (GlobalSettings.getUndefinedFieldValueSubstitutionString) { - substitutionValue = GlobalSettings.getUndefinedFieldValueSubstitutionString(path); + substitutionValue = GlobalSettings.getUndefinedFieldValueSubstitutionString(path); } - + return substitutionValue ? substitutionValue : "${" + path + "}"; } } @@ -182,7 +191,7 @@ export class Template { for (let childExpression of expression.children) { let evaluationResult: { value: any; error: string }; - + try { evaluationResult = childExpression.tryEvaluate(memory, options); } @@ -203,13 +212,13 @@ export class Template { return { value: result, error: undefined }; } - + return expression.tryEvaluate(memory, options); } /** * Parses an interpolated string into an Expression object ready to evaluate. - * + * * @param interpolatedString The interpolated string to parse. Example: "Hello ${name}" * @returns An Expression object if the provided interpolated string contained at least one expression (e.g. "${expression}"); the original string otherwise. */ @@ -262,7 +271,7 @@ export class Template { /** * Tries to evaluate the provided expression using the provided context. - * + * * @param expression The expression to evaluate. * @param context The context (data) used to evaluate the expression. * @param allowSubstitutions Indicates if the expression evaluator should substitute undefined value with a default @@ -374,7 +383,7 @@ export class Template { if (when instanceof AEL.Expression) { let evaluationResult = Template.internalTryEvaluateExpression(when, this._context, false); let whenValue: boolean = false; - + // If $when fails to evaluate or evaluates to anything but a boolean, consider it is false if (!evaluationResult.error) { whenValue = typeof evaluationResult.value === "boolean" && evaluationResult.value; @@ -412,8 +421,8 @@ export class Template { * Initializes a new Template instance based on the provided payload. * Once created, the instance can be bound to different data objects * in a loop. - * - * @param payload The template payload. + * + * @param payload The template payload. */ constructor(payload: any) { this._preparedPayload = Template.prepare(payload); @@ -423,9 +432,9 @@ export class Template { * Expands the template using the provided context. Template expansion involves * evaluating the expressions used in the original template payload, as well as * repeating (expanding) parts of that payload that are bound to arrays. - * + * * Example: - * + * * ```typescript * let context = { * $root: { @@ -437,7 +446,7 @@ export class Template { * ] * } * } - * + * * let templatePayload = { * type: "AdaptiveCard", * version: "1.2", @@ -453,14 +462,14 @@ export class Template { * } * ] * } - * + * * let template = new Template(templatePayload); - * + * * let expandedTemplate = template.expand(context); * ``` - * + * * With the above code, the value of `expandedTemplate` will be - * + * * ```json * { * type: "AdaptiveCard", @@ -481,7 +490,7 @@ export class Template { * ] * } * ``` - * + * * @param context The context to bind the template to. * @returns A value representing the expanded template. The type of that value * is dependent on the type of the original template payload passed to the constructor. diff --git a/source/nodejs/lerna.json b/source/nodejs/lerna.json index 7c640a62b0..c0aa60bb9b 100644 --- a/source/nodejs/lerna.json +++ b/source/nodejs/lerna.json @@ -4,7 +4,8 @@ "adaptivecards*/**", "marked-schema/**", "spec-generator/**", - "ac-typed-schema/**" + "ac-typed-schema/**", + "tests/unit-tests/**" ], "command": { "publish": { diff --git a/source/nodejs/tests/unit-tests/package.json b/source/nodejs/tests/unit-tests/package.json index 576f6adc27..8cd6d96c1d 100644 --- a/source/nodejs/tests/unit-tests/package.json +++ b/source/nodejs/tests/unit-tests/package.json @@ -19,6 +19,7 @@ "devDependencies": { "adaptivecards": "^2.10.0", "monaco-editor": "^0.29.1", - "vkbeautify": "^0.99.3" + "vkbeautify": "^0.99.3", + "adaptivecards-templating": "^2.3.0-alpha.0" } } diff --git a/source/nodejs/tests/unit-tests/src/unit-tests.test.ts b/source/nodejs/tests/unit-tests/src/unit-tests.test.ts index 6840360f57..7e18b13fb0 100644 --- a/source/nodejs/tests/unit-tests/src/unit-tests.test.ts +++ b/source/nodejs/tests/unit-tests/src/unit-tests.test.ts @@ -1,4 +1,6 @@ import * as AdaptiveCards from "adaptivecards"; +import * as ACData from "adaptivecards-templating"; +import { readFileSync } from "fs"; describe("test Text property on TextBlock", () => { it("UnitTest1", () => { @@ -14,3 +16,153 @@ describe("test Text property on TextBlock", () => { expect('{"type":"TextBlock","text":"Some text"}').toBe(JSON.stringify(textBlockJson)); }); }); + +const helloOutput = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello Adaptive Cards!" + } + ] +}; + +const helloThemeOutput = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello Adaptive Cards! You're using the Light theme!" + } + ] +}; + +describe("Test Templating Library", () => { + it("BasicTemplateOnlyRoot", () => { + const templatePayload = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello ${name}!" + } + ] + }; + + const root = { + "name": "Adaptive Cards" + }; + + runTest(templatePayload, helloOutput, root); + }) + + it("BasicTemplateOnlyHost", () => { + const templatePayload = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello Adaptive Cards! You're using the ${$host.WindowsTheme} theme!" + } + ] + }; + + const host = { + "WindowsTheme": "Light" + } + + runTest(templatePayload, helloThemeOutput, undefined, host); + }); + + it("BasicTemplateBoth", () => { + const templatePayload = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello ${name}! You're using the ${$host.WindowsTheme} theme!" + } + ] + }; + + const root = { + "name": "Adaptive Cards" + }; + const host = { + "WindowsTheme": "Light" + } + + runTest(templatePayload, helloThemeOutput, root, host); + }); + + it("HostAsString", () => { + const templatePayload = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello ${$host}!" + } + ] + }; + + const host = "Adaptive Cards"; + + runTest(templatePayload, helloOutput, undefined, host); + }); + + it("HostAsArray", () => { + const templatePayload = { + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Hello ${$host[0]}! You're using the ${$host[1].WindowsTheme} theme!" + } + ] + }; + + const host = [ "Adaptive Cards", {"WindowsTheme": "Light"}] + + runTest(templatePayload, helloThemeOutput, undefined, host); + + }); + + it("ComplexTemplate", () => { + runTest(loadFile("template-test-resources/complex-template.json"), + loadFile("template-test-resources/complex-template.output.json"), + loadFile("template-test-resources/complex-template.data.json")); + }); + + it("ComplexTemplateWithHost", () => { + runTest(loadFile("template-test-resources/complex-template-host.json"), + loadFile("template-test-resources/complex-template-host.output.json"), + loadFile("template-test-resources/complex-template-host.data.json"), + loadFile("template-test-resources/complex-template-host.host.json")); + }); +}); + +function runTest(templatePayload: any, expectedOutput: any, data?: any, host?: any) { + let template = new ACData.Template(templatePayload); + + let context = { + $root: data, + $host: host + }; + + let card = template.expand(context); + + expect(card).toStrictEqual(expectedOutput); +} + +function loadFile(filePath: string) { + const dataObject = readFileSync(filePath, "utf8"); + return JSON.parse(dataObject); +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.data.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.data.json new file mode 100644 index 0000000000..66dbeb92e2 --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.data.json @@ -0,0 +1,22 @@ +{ + "createdUtc": "2017-02-14T06:08:39Z", + "viewUrl": "https://adaptivecards.io", + "properties": [ + { + "key": "Board", + "value": "Adaptive Cards" + }, + { + "key": "List", + "value": "Backlog" + }, + { + "key": "Assigned to", + "value": "Matt Hidinger" + }, + { + "key": "Due date", + "value": "Not set" + } + ] +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.host.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.host.json new file mode 100644 index 0000000000..5cbed14db4 --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.host.json @@ -0,0 +1,8 @@ +{ + "title": "Publish Adaptive Card Schema", + "description": "Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation.", + "creator": { + "name": "Matt Hidinger", + "profileImage": "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" + } +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.json new file mode 100644 index 0000000000..59c2b83fa4 --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.json @@ -0,0 +1,97 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "${$host.title}" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "Image", + "style": "Person", + "url": "${$host.creator.profileImage}", + "size": "Small" + } + ], + "width": "auto" + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": "${$host.creator.name}", + "wrap": true + }, + { + "type": "TextBlock", + "spacing": "None", + "text": "Created {{DATE(${createdUtc},SHORT)}}", + "isSubtle": true, + "wrap": true + } + ], + "width": "stretch" + } + ] + }, + { + "type": "TextBlock", + "text": "${$host.description}", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + { + "$data": "${properties}", + "title": "${key}:", + "value": "${value}" + } + ] + } + ], + "actions": [ + { + "type": "Action.ShowCard", + "title": "Set due date", + "card": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Input.Date", + "id": "dueDate" + }, + { + "type": "Input.Text", + "id": "comment", + "placeholder": "Add a comment", + "isMultiline": true + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "OK" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json" + } + }, + { + "type": "Action.OpenUrl", + "title": "View", + "url": "${viewUrl}" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.output.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.output.json new file mode 100644 index 0000000000..b9b138fc1f --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template-host.output.json @@ -0,0 +1,108 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "Publish Adaptive Card Schema" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "Image", + "style": "Person", + "url": "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg", + "size": "Small" + } + ], + "width": "auto" + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": "Matt Hidinger", + "wrap": true + }, + { + "type": "TextBlock", + "spacing": "None", + "text": "Created {{DATE(2017-02-14T06:08:39Z,SHORT)}}", + "isSubtle": true, + "wrap": true + } + ], + "width": "stretch" + } + ] + }, + { + "type": "TextBlock", + "text": "Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation.", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Board:", + "value": "Adaptive Cards" + }, + { + "title": "List:", + "value": "Backlog" + }, + { + "title": "Assigned to:", + "value": "Matt Hidinger" + }, + { + "title": "Due date:", + "value": "Not set" + } + ] + } + ], + "actions": [ + { + "type": "Action.ShowCard", + "title": "Set due date", + "card": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Input.Date", + "id": "dueDate" + }, + { + "type": "Input.Text", + "id": "comment", + "placeholder": "Add a comment", + "isMultiline": true + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "OK" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json" + } + }, + { + "type": "Action.OpenUrl", + "title": "View", + "url": "https://adaptivecards.io" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template.data.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.data.json new file mode 100644 index 0000000000..91bda6c373 --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.data.json @@ -0,0 +1,28 @@ +{ + "title": "Publish Adaptive Card Schema", + "description": "Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation.", + "creator": { + "name": "Matt Hidinger", + "profileImage": "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" + }, + "createdUtc": "2017-02-14T06:08:39Z", + "viewUrl": "https://adaptivecards.io", + "properties": [ + { + "key": "Board", + "value": "Adaptive Cards" + }, + { + "key": "List", + "value": "Backlog" + }, + { + "key": "Assigned to", + "value": "Matt Hidinger" + }, + { + "key": "Due date", + "value": "Not set" + } + ] +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.json new file mode 100644 index 0000000000..a525b228ad --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.json @@ -0,0 +1,97 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "${title}" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "Image", + "style": "Person", + "url": "${creator.profileImage}", + "size": "Small" + } + ], + "width": "auto" + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": "${creator.name}", + "wrap": true + }, + { + "type": "TextBlock", + "spacing": "None", + "text": "Created {{DATE(${createdUtc},SHORT)}}", + "isSubtle": true, + "wrap": true + } + ], + "width": "stretch" + } + ] + }, + { + "type": "TextBlock", + "text": "${description}", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + { + "$data": "${properties}", + "title": "${key}:", + "value": "${value}" + } + ] + } + ], + "actions": [ + { + "type": "Action.ShowCard", + "title": "Set due date", + "card": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Input.Date", + "id": "dueDate" + }, + { + "type": "Input.Text", + "id": "comment", + "placeholder": "Add a comment", + "isMultiline": true + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "OK" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json" + } + }, + { + "type": "Action.OpenUrl", + "title": "View", + "url": "${viewUrl}" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/source/nodejs/tests/unit-tests/template-test-resources/complex-template.output.json b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.output.json new file mode 100644 index 0000000000..b9b138fc1f --- /dev/null +++ b/source/nodejs/tests/unit-tests/template-test-resources/complex-template.output.json @@ -0,0 +1,108 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "Publish Adaptive Card Schema" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "Image", + "style": "Person", + "url": "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg", + "size": "Small" + } + ], + "width": "auto" + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": "Matt Hidinger", + "wrap": true + }, + { + "type": "TextBlock", + "spacing": "None", + "text": "Created {{DATE(2017-02-14T06:08:39Z,SHORT)}}", + "isSubtle": true, + "wrap": true + } + ], + "width": "stretch" + } + ] + }, + { + "type": "TextBlock", + "text": "Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation.", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Board:", + "value": "Adaptive Cards" + }, + { + "title": "List:", + "value": "Backlog" + }, + { + "title": "Assigned to:", + "value": "Matt Hidinger" + }, + { + "title": "Due date:", + "value": "Not set" + } + ] + } + ], + "actions": [ + { + "type": "Action.ShowCard", + "title": "Set due date", + "card": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Input.Date", + "id": "dueDate" + }, + { + "type": "Input.Text", + "id": "comment", + "placeholder": "Add a comment", + "isMultiline": true + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "OK" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json" + } + }, + { + "type": "Action.OpenUrl", + "title": "View", + "url": "https://adaptivecards.io" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +}