UNPKG

@player-ui/player

Version:

849 lines (773 loc) 24.8 kB
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 { ViewInstance } from "../../view"; import type { Options } from "../options"; import { TemplatePlugin, MultiNodePlugin, AssetPlugin } from "../"; import { StringResolverPlugin, toNodeResolveOptions } from "../.."; import type { View } from "@player-ui/types"; const templateJoinValues = { id: "generated-flow", views: [ { id: "collection", type: "collection", template: [ { data: "foo", output: "values", value: { asset: { id: "value-_index_", type: "text", value: "item {{foo._index_}}", }, }, }, ], label: { asset: { id: "label-test", value: "A text label", type: "text", }, }, values: [ { asset: { id: "value-2", type: "text", value: "First value in the collection", }, }, { asset: { id: "value-3", type: "text", value: "Second value in the collection", }, }, ], }, { id: "collection", type: "collection", values: [ { asset: { id: "value-2", type: "text", value: "First value in the collection", }, }, { asset: { id: "value-3", type: "text", value: "Second value in the collection", }, }, ], label: { asset: { id: "label-test", value: "A text label", type: "text", }, }, template: [ { data: "foo", output: "values", value: { asset: { id: "value-_index_", type: "text", value: "item {{foo._index_}}", }, }, }, ], }, ], data: { foo: [1, 2], }, navigation: { 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", }, }, }, }; const parseBinding = new BindingParser().parse; describe("templates", () => { let model: DataModelWithParser; let expressionEvaluator: ExpressionEvaluator; let options: Options; let parser: Parser; beforeEach(() => { model = withParser(new LocalModel(), parseBinding); expressionEvaluator = new ExpressionEvaluator({ model, }); parser = new Parser(); options = { evaluate: expressionEvaluator.evaluate, schema: new SchemaController(), data: { format: (binding, val) => val, formatValue: (val) => val, model, }, }; new TemplatePlugin(options).applyParser(parser); new AssetPlugin().applyParser(parser); }); it("works with simple ones", () => { const petNames = ["Ginger", "Daisy", "Afra"]; model.set([["foo.bar", petNames]]); expect( parser.parseObject({ id: "foo", type: "collection", template: [ { data: "foo.bar", output: "values", value: { value: "{{foo.bar._index_}}", }, }, ], }), ).toMatchSnapshot(); }); it("works with static nested templates", () => { const petNames = ["Ginger", "Daisy", "Afra"]; model.set([["foo.pets", petNames]]); const peopleNames = ["Adam", "Jenny"]; model.set([["foo.people", peopleNames]]); expect( parser.parseObject({ id: "foo", type: "collection", template: [ { data: "foo.pets", output: "values", value: { asset: { type: "collection", id: "outer-collection-_index_", template: [ { data: "foo.people", output: "values", value: { text: "{{foo.pets._index_}} + {{foo.people._index1_}}", }, }, ], }, }, }, ], }), ).toMatchSnapshot(); }); describe("works with data changes as expected", () => { it("static - nodes are not updated", () => { const petNames = ["Ginger", "Vokey"]; const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { id: "foo", type: "collection", template: [ { dynamic: false, data: "foo.bar", output: "values", value: { value: "{{foo.bar._index_}}", }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); model.set([["foo.bar", petNames]]); const resolved = view.update(); expect(resolved).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Ginger", "Vokey"].map((value) => ({ value })), }, }); model.set([["foo.bar", ["Ginger", "Vokey", "Harry"]]]); let updated = view.update(); expect(updated).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Ginger", "Vokey"].map((value) => ({ value })), }, }); model.set([["foo.bar", ["Ginger"]]]); updated = view.update(); expect(updated).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Ginger", undefined].map((value) => ({ value })), }, }); }); it("dynamic - nodes are updated", () => { const petNames = ["Ginger", "Vokey"]; const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { id: "foo", type: "collection", template: [ { dynamic: true, data: "foo.bar", output: "values", value: { value: "{{foo.bar._index_}}", }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); model.set([["foo.bar", petNames]]); const resolved = view.update(); expect(resolved).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Ginger", "Vokey"].map((value) => ({ value })), }, }); const barBinding = parseBinding("foo.bar"); model.set([[barBinding, ["Vokey", "Louis", "Bob"]]]); let updated = view.update(); expect(updated).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Vokey", "Louis", "Bob"].map((value) => ({ value })), }, }); model.set([[barBinding, ["Nuri"]]]); updated = view.update(); expect(updated).toStrictEqual({ id: "my-view", asset: { id: "foo", type: "collection", values: ["Nuri"].map((value) => ({ value })), }, }); }); }); describe("Works with template items plus value items", () => { const model = withParser( new LocalModel(templateJoinValues.data), parseBinding, ); const evaluator = new ExpressionEvaluator({ model }); it("Should show static template item first when coming before values on lexical order", () => { const view = new ViewInstance(templateJoinValues.views[0] as View, { model, parseBinding, evaluator, schema: new SchemaController(), }); 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).toMatchSnapshot(); }); it("Should show static template item last when coming after values on lexical order", () => { const view = new ViewInstance(templateJoinValues.views[1] as View, { model, parseBinding, evaluator, schema: new SchemaController(), }); 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).toMatchSnapshot(); }); it("Should show static template items last when using placement append", () => { const viewWithAppend = { ...templateJoinValues.views[0], template: [ { ...templateJoinValues.views[0]?.template[0], placement: "append", }, ], }; const view = new ViewInstance(viewWithAppend as View, { model, parseBinding, evaluator, schema: new SchemaController(), }); 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(); // Verify the order: first the non-template values, then the template values expect(resolved.values[0].asset.id).toBe("value-2"); expect(resolved.values[0].asset.value).toBe( "First value in the collection", ); expect(resolved.values[1].asset.id).toBe("value-3"); expect(resolved.values[1].asset.value).toBe( "Second value in the collection", ); expect(resolved.values).toHaveLength(4); expect(resolved.values).toMatchSnapshot(); }); it("Should show static template items first when using placement prepend", () => { const viewWithPrepend = { ...templateJoinValues.views[1], template: [ { ...templateJoinValues.views[1]?.template[0], placement: "prepend", }, ], }; const view = new ViewInstance(viewWithPrepend as View, { model, parseBinding, evaluator, schema: new SchemaController(), }); 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(); // Verify the order: first the template values, then the non-template values expect(resolved.values[0].asset.id).toBe("value-0"); expect(resolved.values[0].asset.value).toBe("item 1"); expect(resolved.values[1].asset.id).toBe("value-1"); expect(resolved.values[1].asset.value).toBe("item 2"); expect(resolved.values).toHaveLength(4); expect(resolved.values).toMatchSnapshot(); }); it("Should show dynamic template items last when using placement append", () => { const petNames = ["Ginger", "Vokey"]; const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { id: "foo", type: "collection", template: [ { dynamic: true, data: "foo.bar", output: "values", placement: "append", value: { asset: { id: "dynamic-_index_", type: "text", value: "item {{foo.bar._index_}}", }, }, }, ], label: { asset: { id: "label-test", value: "A text label", type: "text", }, }, 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", }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); new AssetPlugin().apply(view); new MultiNodePlugin().apply(view); model.set([["foo.bar", petNames]]); const resolved = view.update(); expect(resolved.asset.values).toHaveLength(4); expect(resolved.asset.values[2].asset.value).toBe("item Ginger"); expect(resolved.asset.values[3].asset.value).toBe("item Vokey"); const barBinding = parseBinding("foo.bar"); model.set([[barBinding, ["Louis", "Bob", "Nuri"]]]); const updated = view.update(); expect(updated.asset.values).toHaveLength(5); expect(updated.asset.values[2].asset.value).toBe("item Louis"); expect(updated.asset.values[3].asset.value).toBe("item Bob"); expect(updated.asset.values[4].asset.value).toBe("item Nuri"); }); it("Should show dynamic template items first when using placement prepend", () => { const petNames = ["Ginger", "Vokey"]; const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { 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", }, }, ], label: { asset: { id: "label-test", value: "A text label", type: "text", }, }, template: [ { dynamic: true, data: "foo.bar", output: "values", placement: "prepend", value: { asset: { id: "dynamic-_index_", type: "text", value: "item {{foo.bar._index_}}", }, }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); new AssetPlugin().apply(view); new MultiNodePlugin().apply(view); model.set([["foo.bar", petNames]]); const resolved = view.update(); expect(resolved.asset.values).toHaveLength(4); expect(resolved.asset.values).toMatchSnapshot(); expect(resolved.asset.values[0].asset.value).toBe("item Ginger"); expect(resolved.asset.values[1].asset.value).toBe("item Vokey"); // Test that dynamic updates maintain the correct order const barBinding = parseBinding("foo.bar"); model.set([[barBinding, ["Louis", "Bob", "Nuri"]]]); const updated = view.update(); expect(updated.asset.values).toMatchSnapshot(); expect(updated.asset.values).toHaveLength(5); expect(updated.asset.values[0].asset.value).toBe("item Louis"); expect(updated.asset.values[1].asset.value).toBe("item Bob"); expect(updated.asset.values[2].asset.value).toBe("item Nuri"); }); it("Should support placement for both static and dynamic templates", () => { const petNames = ["Ginger", "Vokey"]; const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { id: "foo", type: "collection", template: [ { dynamic: true, data: "foo.bar", output: "values", placement: "append", value: { asset: { id: "dynamic-_index_", type: "text", value: "dynamic {{foo.bar._index_}}", }, }, }, { dynamic: false, data: "foo.bar", output: "values", placement: "prepend", value: { asset: { id: "static-_index_", type: "text", value: "static {{foo.bar._index_}}", }, }, }, ], label: { asset: { id: "label-test", value: "A text label", type: "text", }, }, 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", }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); new AssetPlugin().apply(view); new MultiNodePlugin().apply(view); model.set([["foo.bar", petNames]]); const resolved = view.update(); expect(resolved.asset.values).toMatchSnapshot(); expect(resolved.asset.values).toHaveLength(6); // Prepend first - static template expect(resolved.asset.values[0].asset.value).toBe("static Ginger"); expect(resolved.asset.values[1].asset.value).toBe("static Vokey"); // Non-template data expect(resolved.asset.values[2].asset.value).toBe( "First value in the collection", ); expect(resolved.asset.values[3].asset.value).toBe( "Second value in the collection", ); // Append last - dynamic template expect(resolved.asset.values[4].asset.value).toBe("dynamic Ginger"); expect(resolved.asset.values[5].asset.value).toBe("dynamic Vokey"); const barBinding = parseBinding("foo.bar"); model.set([[barBinding, ["Louis", "Bob", "Nuri"]]]); const updated = view.update(); expect(updated.asset.values).toMatchSnapshot(); expect(updated.asset.values).toHaveLength(7); expect(updated.asset.values[4].asset.value).toBe("dynamic Louis"); expect(updated.asset.values[5].asset.value).toBe("dynamic Bob"); expect(updated.asset.values[6].asset.value).toBe("dynamic Nuri"); }); }); it("Should preserve order when multiple templates have the same placement", () => { const model = withParser(new LocalModel({}), parseBinding); const evaluator = new ExpressionEvaluator({ model }); const schema = new SchemaController(); const view = new ViewInstance( { id: "my-view", asset: { id: "foo", type: "collection", template: [ { dynamic: true, data: "first.data", output: "values", placement: "append", // Both templates have "append" placement value: { asset: { id: "first-_index_", type: "text", value: "first {{first.data._index_}}", }, }, }, { dynamic: true, data: "second.data", output: "values", placement: "append", // Both templates have "append" placement value: { asset: { id: "second-_index_", type: "text", value: "second {{second.data._index_}}", }, }, }, ], values: [ { asset: { id: "static-value", type: "text", value: "Static value in the collection", }, }, ], }, } as any, { model, parseBinding, evaluator, schema, }, ); const pluginOptions = toNodeResolveOptions(view.resolverOptions); new TemplatePlugin(pluginOptions).apply(view); new StringResolverPlugin().apply(view); new AssetPlugin().apply(view); new MultiNodePlugin().apply(view); // Set data for both template data sources model.set([ ["first.data", ["A", "B"]], ["second.data", ["C", "D"]], ]); const resolved = view.update(); // We should have 5 values total: // 1 static value + 2 values from first.data + 2 values from second.data expect(resolved.asset.values).toHaveLength(5); // Static value should be first expect(resolved.asset.values[0].asset.id).toBe("static-value"); expect(resolved.asset.values[0].asset.value).toBe( "Static value in the collection", ); // Then first.data template items (in their original order) expect(resolved.asset.values[1].asset.id).toBe("first-0"); expect(resolved.asset.values[1].asset.value).toBe("first A"); expect(resolved.asset.values[2].asset.id).toBe("first-1"); expect(resolved.asset.values[2].asset.value).toBe("first B"); // Then second.data template items expect(resolved.asset.values[3].asset.id).toBe("second-0"); expect(resolved.asset.values[3].asset.value).toBe("second C"); expect(resolved.asset.values[4].asset.id).toBe("second-1"); expect(resolved.asset.values[4].asset.value).toBe("second D"); // Make sure the ordering is preserved in the snapshot expect(resolved.asset.values).toMatchSnapshot(); }); });