UNPKG

@player-ui/player

Version:

1,950 lines (1,760 loc) 89.9 kB
import { test, expect, describe, it, beforeEach } from "vitest"; import { omit } from "timm"; import { makeFlow } from "@player-ui/make-flow"; import { vitest } from "vitest"; import type { Flow } from "@player-ui/types"; import type { SchemaController } from "../schema"; import type { BindingParser } from "../binding"; import TrackBindingPlugin, { addValidator } from "./helpers/binding.plugin"; import { Player } from ".."; import { VALIDATION_PROVIDER_NAME_SYMBOL } from "../controllers/validation"; import type { ValidationController } from "../controllers/validation"; import type { InProgressState } from "../types"; import TestExpressionPlugin, { RequiredIfValidationProviderPlugin, } from "./helpers/expression.plugin"; const simpleFlow: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", thing1: { asset: { type: "whatevs", id: "thing1", binding: "data.thing1", }, }, thing2: { asset: { type: "whatevs", id: "thing2", binding: "data.thing2", }, }, }, ], data: {}, schema: { ROOT: { data: { type: "DataType", }, }, DataType: { thing1: { type: "CatType", validation: [ { type: "names", names: ["frodo", "sam"], trigger: "navigation", severity: "warning", }, ], }, thing2: { type: "CatType", validation: [ { type: "names", trigger: "navigation", names: ["frodo", "sam"], severity: "warning", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const simpleExpressionFlow: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", foo: { asset: { type: "whatevs", id: "foo", binding: "data.foo", }, }, foo2: { asset: { type: "whatevs", id: "foo2", binding: "data.foo2", }, }, bar: { asset: { type: "whatevs", id: "bar", binding: "data.bar", }, }, bar2: { asset: { type: "whatevs", id: "bar2", binding: "data.bar2", }, }, }, ], data: {}, schema: { ROOT: { data: { type: "DataType", }, }, DataType: { foo: { type: "CatType", validation: [ { type: "expression", exp: "!(isEmpty({{data.foo}}) && !isEmpty({{data.foo2}}))", severity: "warning", }, ], }, bar: { type: "CatType", validation: [ { type: "expression", exp: "!(isEmpty({{data.bar}}) && !isEmpty({{data.bar2}}))", severity: "warning", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const flowWithMultiNode: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", multiNode: [ { nestedMultiNode: [ { asset: { type: "asset-type", id: "nested-asset", binding: "data.foo", }, }, ], }, ], }, ], data: {}, schema: { ROOT: { data: { type: "DataType", }, }, DataType: { foo: { type: "CatType", validation: [ { type: "names", names: ["frodo", "sam"], trigger: "navigation", severity: "warning", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const flowWithThings: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", thing1: { asset: { type: "whatevs", id: "thing1", binding: "data.thing1", applicability: "{{applicability.thing1}}", }, }, thing2: { asset: { type: "whatevs", id: "thing2", binding: "data.thing2", applicability: "{{applicability.thing2}}", }, }, thing3: { asset: { type: "whatevs", id: "thing3", applicability: "{{applicability.thing3}}", binding: "data.thing3", other: { asset: { type: "whatevs", id: "thing3a", binding: "data.thing3a", applicability: "{{applicability.thing3a}}", }, }, }, }, thing5: { asset: { type: "section", id: "thing5", binding: "data.thing5", applicability: "{{applicability.thing5}}", thing6: { asset: { type: "section", id: "thing6", binding: "data.thing6", applicability: "{{applicability.thing6}}", thing7: { asset: { type: "whatevs", id: "thing7", binding: "data.thing7", applicability: "{{applicability.thing7}}", }, }, }, }, }, }, alreadyInvalidData: { asset: { type: "invalid", id: "thing4", binding: "data.thing4", }, }, }, ], data: { applicability: { thing1: true, thing2: true, thing3: true, thing3a: true, thing5: true, thing6: true, thing7: true, }, data: { thing2: "frodo", thing4: "frodo", }, }, schema: { ROOT: { data: { type: "DataType", }, }, DataType: { thing2: { type: "CatType", validation: [ { type: "names", names: ["frodo", "sam"], }, ], }, thing4: { type: "CatType", validation: [ { type: "names", names: ["sam"], }, ], }, thing5: { type: "CatType", validation: [ { type: "names", names: ["frodo"], displayTarget: "page", }, ], }, thing6: { type: "CatType", validation: [ { type: "names", names: ["sam"], displayTarget: "section", }, ], }, thing7: { type: "CatType", validation: [ { type: "names", names: ["bilbo"], displayTarget: "section", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const flowWithApplicability: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", thing1: { asset: { type: "whatevs", id: "thing1", binding: "dependentBinding", }, }, thing2: { asset: { type: "whatevs", id: "thing2", binding: "independentBinding", }, }, thing3: { asset: { type: "whatevs", id: "thing3", applicability: "{{independentBinding}} == true", }, }, validation: [ { type: "requiredIf", ref: "dependentBinding", trigger: "load", param: "{{independentBinding}}", message: "required based on independent value", }, ], }, ], data: {}, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const flowWithItemsInArray: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", pets: [ { asset: { type: "whatevs", id: "thing1", binding: "pets.0.name", }, }, { asset: { type: "whatevs", id: "thing2", binding: "pets.1.name", }, }, { asset: { type: "whatevs", id: "thing2", binding: "pets.2.name", }, }, ], }, ], data: { pets: [], }, schema: { ROOT: { pets: { type: "PetType", isArray: true, }, }, PetType: { name: { type: "string", validation: [ { type: "required", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; const multipleWarningsFlow: Flow = { id: "input-validation-flow", views: [ { type: "view", id: "view", loadWarning: { asset: { id: "load-warning", type: "warning-asset", binding: "foo.load", }, }, navigationWarning: { asset: { id: "required-warning", type: "warning-asset", binding: "foo.navigation", }, }, }, ], schema: { ROOT: { foo: { type: "FooType", }, }, FooType: { navigation: { type: "String", validation: [ { type: "required", severity: "warning", blocking: "once", trigger: "navigation", }, ], }, load: { type: "String", validation: [ { type: "required", severity: "warning", blocking: "once", trigger: "load", }, ], }, }, }, data: {}, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view", transitions: { "*": "END_Done", }, }, END_Done: { state_type: "END", outcome: "done", }, }, }, }; const simpleFlowWithViewValidation: Flow = { id: "test-flow", views: [ { id: "view-1", type: "view", thing1: { asset: { type: "whatevs", id: "thing1", binding: "data.thing1", }, }, validation: [ { ref: "data.thing1", type: "expression", exp: "{{data.thing1}} > 50", trigger: "navigation", message: "Must be greater than 50", }, ], }, ], data: {}, schema: { ROOT: { data: { type: "DataType", }, }, DataType: { thing1: { type: "IntegerType", validation: [ { type: "required", }, ], }, }, }, navigation: { BEGIN: "FLOW_1", FLOW_1: { startState: "VIEW_1", VIEW_1: { state_type: "VIEW", ref: "view-1", transitions: { "*": "END_1", }, }, END_1: { state_type: "END", outcome: "test", }, }, }, }; test("alt APIs", async () => { const player = new Player(); player.hooks.validationController.tap("test", (validationProvider) => { addValidator(validationProvider); }); player.hooks.viewController.tap("test", (vc) => { vc.hooks.view.tap("test", (view) => { view.hooks.resolver.tap("test", (resolver) => { resolver.hooks.resolve.tap("test", (val, node, options) => { if (val.type === "section") { options.validation?.register({ type: "section" }); } if (val?.binding) { return { ...val, validation: options.validation?.get(val.binding, { track: true }), childValidations: options.validation?.getChildren, sectionValidations: options.validation?.getValidationsForSection, allValidations: options.validation?.getAll(), }; } return { ...val, childValidations: options.validation?.getChildren, groupValidations: options.validation?.getValidationsForSection, allValidations: options.validation?.getAll(), }; }); }); }); }); player.start(flowWithThings); const state = player.getState() as InProgressState; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toBe(undefined); // Updates when data is updated to throw an error state.controllers.data.set([["data.thing2", "ginger"]]); await vitest.waitFor(() => expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toMatchObject({ severity: "error", message: `Names just be in: frodo,sam`, displayTarget: "field", }), ); expect( Array.from( state.controllers.view.currentView?.lastUpdate?.thing2.asset.allValidations.values(), ), ).toMatchObject([ { severity: "error", message: `Names just be in: frodo,sam`, displayTarget: "field", }, ]); // check that the childValidations and sectionValidation computation works and state.controllers.data.set([["data.thing5", "sam"]]); state.controllers.data.set([["data.thing6", "frodo"]]); state.controllers.data.set([["data.thing7", "golumn"]]); // Gets all page errors for all children await vitest.waitFor(() => expect( Array.from( state.controllers.view.currentView?.lastUpdate ?.childValidations("page") .values(), ), ).toMatchObject([ { severity: "error", message: `Names just be in: frodo`, displayTarget: "page", }, ]), ); // Gets all section errors for all children expect( Array.from( state.controllers.view.currentView?.lastUpdate ?.childValidations("section") .values(), ), ).toMatchObject([ { severity: "error", message: `Names just be in: sam`, displayTarget: "section", }, { severity: "error", message: `Names just be in: bilbo`, displayTarget: "section", }, ]); // Gets section error for child that is not wrapped in nested section expect( Array.from( state.controllers.view.currentView?.lastUpdate?.thing5.asset ?.sectionValidations() .values(), ), ).toMatchObject([ { severity: "error", message: `Names just be in: sam`, displayTarget: "section", }, ]); // Ensure that nested section still produces an error expect( Array.from( state.controllers.view.currentView?.lastUpdate?.thing5.asset.thing6.asset ?.sectionValidations() .values(), ), ).toMatchObject([ { severity: "error", message: `Names just be in: bilbo`, displayTarget: "section", }, ]); }); describe("validation", () => { let player: Player; let validationController: ValidationController; let schema: SchemaController; let parser: BindingParser; beforeEach(() => { player = new Player({ plugins: [new TrackBindingPlugin()], }); player.hooks.validationController.tap("test", (vc) => { validationController = vc; }); player.hooks.schema.tap("test", (s) => { schema = s; }); player.hooks.bindingParser.tap("test", (p) => { parser = p; }); player.start(flowWithThings); }); describe("binding tracker", () => { it("tracks bindings in the view", () => { expect(validationController?.getBindings().size).toStrictEqual(8); }); it("preserves tracked bindings for non-updated things", () => { expect(validationController?.getBindings().size).toStrictEqual(8); (player.getState() as InProgressState).controllers.data.set([ ["not.there", false], ]); expect(validationController?.getBindings().size).toStrictEqual(8); }); it("drops bindings for non-applicable things", async () => { expect(validationController?.getBindings().size).toStrictEqual(8); (player.getState() as InProgressState).controllers.data.set([ ["applicability.thing3", false], ]); await vitest.waitFor(() => expect(validationController?.getBindings().size).toStrictEqual(6), ); }); it("track bindings in nested multi nodes", async () => { player.start(flowWithMultiNode); await vitest.waitFor(() => expect(validationController?.getBindings().size).toStrictEqual(1), ); }); }); describe("schema", () => { it("tests the types right", () => { expect(schema.getType(parser.parse("data.thing2"))?.type).toBe("CatType"); }); }); describe("data model delete", () => { it("deletes the validation when the data is deleted", async () => { const state = player.getState() as InProgressState; const { validation, data, binding, view } = state.controllers; const thing2Binding = binding.parse("data.thing2"); expect(validation.getBindings().has(thing2Binding)).toBe(true); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.thing2.asset.validation, ).toBeUndefined(); }); data.set([["data.thing2", "gandalf"]]); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.thing2.asset.validation?.message, ).toBe("Names just be in: frodo,sam"); }); data.delete("data.thing2"); expect(data.get("data.thing2", { includeInvalid: true })).toBe(undefined); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.thing2.asset.validation, ).toBeUndefined(); }); data.set([["data.thing2", "gandalf"]]); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.thing2.asset.validation?.message, ).toBe("Names just be in: frodo,sam"); }); }); it("handles arrays", async () => { player.start(flowWithItemsInArray); const state = player.getState() as InProgressState; const { data, binding, view } = state.controllers; await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.pets[1].asset.validation, ).toBeUndefined(); }); // Trigger validation for the second item data.set([["pets.1.name", ""]]); expect( schema.getType(binding.parse("pets.1.name"))?.validation, ).toHaveLength(1); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.pets[1].asset.validation?.message, ).toBe("A value is required"); }); // Delete the first item, the items should shift up and validation moves to the first item data.delete("pets.0"); await vitest.waitFor(() => { expect( view.currentView?.lastUpdate?.pets[1].asset.validation, ).toBeUndefined(); expect( view.currentView?.lastUpdate?.pets[0].asset.validation?.message, ).toBe("A value is required"); }); }); }); describe("state", () => { it("updates when setting data", async () => { const state = player.getState() as InProgressState; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toBe(undefined); // Updates when data is updated to throw an error state.controllers.data.set([["data.thing2", "ginger"]]); await vitest.waitFor(() => expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset .validation, ).toMatchObject({ severity: "error", message: `Names just be in: frodo,sam`, displayTarget: "field", }), ); // Back to nothing when the error is fixed state.controllers.data.set([["data.thing2", "frodo"]]); await vitest.waitFor(() => expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset .validation, ).toBe(undefined), ); }); }); describe("validation object", () => { it("returns the whole validation object", async () => { const state = player.getState() as InProgressState; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toBe(undefined); // Updates when data is updated to throw an error state.controllers.data.set([["data.thing2", "ginger"]]); await vitest.waitFor(() => expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset .validation, ).toStrictEqual({ severity: "error", message: `Names just be in: frodo,sam`, names: ["frodo", "sam"], displayTarget: "field", trigger: "change", type: "names", blocking: true, [VALIDATION_PROVIDER_NAME_SYMBOL]: "schema", }), ); // Back to nothing when the error is fixed state.controllers.data.set([["data.thing2", "frodo"]]); await vitest.waitFor(() => expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset .validation, ).toBe(undefined), ); }); }); describe("navigation", () => { it("prevents navigation for pre-existing invalid data", async () => { const state = player.getState() as InProgressState; const { flowResult } = state; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.alreadyInvalidData.asset .validation, ).toBe(undefined); // Try to transition state.controllers.flow.transition("foo"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); // Fix the error. state.controllers.data.set([["data.thing4", "sam"]]); state.controllers.data.set([["data.thing5", "frodo"]]); state.controllers.data.set([["data.thing6", "sam"]]); state.controllers.data.set([["data.thing7", "bilbo"]]); // Try to transition again state.controllers.flow.transition("foo"); // Should work now that there's no error const result = await flowResult; expect(result.endState.outcome).toBe("test"); }); it("block navigation after data changes on first input, show warning on second input, then navigation succeeds", async () => { player.start(simpleFlow); const state = player.getState() as InProgressState; const { flowResult } = state; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toBe(undefined); state.controllers.data.set([["data.thing1", "sam"]]); // Try to transition state.controllers.flow.transition("foo"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).not.toBe(undefined); state.controllers.data.set([["data.thing1", "bilbo"]]); // Try to transition state.controllers.flow.transition("foo"); // Should transition to end since data changes already occured on first input expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("END"); // Should work now that there's no error const result = await flowResult; expect(result.endState.outcome).toBe("test"); }); it("doesnt remove existing expression warnings if a new warning is triggered", async () => { player.hooks.expressionEvaluator.tap("test", (evaluator) => { evaluator.addExpressionFunction("isEmpty", (ctx: any, val: any) => { if (val === undefined || val === null) { return true; } if (typeof val === "string") { return val.length === 0; } return false; }); }); player.start(simpleExpressionFlow); const state = player.getState() as InProgressState; const { flowResult } = state; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.foo.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.bar.asset.validation, ).toBe(undefined); state.controllers.data.set([["data.foo2", "someData"]]); // Try to transition state.controllers.flow.transition("foo"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); expect( state.controllers.view.currentView?.lastUpdate?.foo.asset.validation, ).not.toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.foo2.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.bar.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.bar2.asset.validation, ).toBe(undefined); state.controllers.data.set([["data.bar2", "someData"]]); // Try to transition state.controllers.flow.transition("foo"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); // existing validation // FAILS HERE expect( state.controllers.view.currentView?.lastUpdate?.foo.asset.validation, ).not.toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.foo2.asset.validation, ).toBe(undefined); // new validation expect( state.controllers.view.currentView?.lastUpdate?.bar.asset.validation, ).not.toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.bar2.asset.validation, ).toBe(undefined); state.controllers.data.set([["data.foo", "frodo"]]); state.controllers.data.set([["data.bar", "sam"]]); // Try to transition again state.controllers.flow.transition("foo"); // Should work now that there's no error const result = await flowResult; expect(result.endState.outcome).toBe("test"); }); it("autodismiss if data change already took place on input with warning, manually dismiss second warning", async () => { player.start(simpleFlow); const state = player.getState() as InProgressState; const { flowResult } = state; // Starts out with nothing expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).toBe(undefined); state.controllers.data.set([["data.thing1", "sam"]]); // Try to transition state.controllers.flow.transition("foo"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); expect( state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation, ).not.toBe(undefined); state.controllers.data.set([["data.thing1", "bilbo"]]); state.controllers.view.currentView?.lastUpdate?.thing2.asset.validation.dismiss(); // Try to transition state.controllers.flow.transition("foo"); // Since data change (setting "sam") already triggered validation next step is auto dismiss expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("END"); // Should work now that there's no error const result = await flowResult; expect(result.endState.outcome).toBe("test"); }); it("should auto-dismiss when dismissal is triggered", async () => { player.start(multipleWarningsFlow); const state = player.getState() as InProgressState; const { flowResult } = state; // Starts with one warning expect( state.controllers.view.currentView?.lastUpdate?.loadWarning.asset .validation, ).toBeDefined(); expect( state.controllers.view.currentView?.lastUpdate?.navigationWarning.asset .validation, ).toBeUndefined(); // Try to transition state.controllers.flow.transition("next"); // Stays on the same view expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); // new warning appears expect( state.controllers.view.currentView?.lastUpdate?.loadWarning.asset .validation, ).toBeDefined(); expect( state.controllers.view.currentView?.lastUpdate?.navigationWarning.asset .validation, ).toBeDefined(); // Try to transition state.controllers.flow.transition("next"); // Since data change (setting "sam") already triggered validation next step is auto dismiss expect( state.controllers.flow.current?.currentState?.value.state_type, ).toBe("END"); // Should work now that there's no error const result = await flowResult; expect(result.endState.outcome).toBe("done"); }); }); describe("introspection and filtering", () => { /** * */ const getAllKnownValidations = () => { const allBindings = validationController.getBindings(); const allValidations = Array.from(allBindings).flatMap((b) => { const validatedBinding = validationController.getValidationForBinding(b); if (!validatedBinding) { return []; } return validatedBinding.allValidations.map((v) => { return { binding: b, validation: v, response: validationController.validationRunner(v.value, b), }; }); }); return allValidations; }; it("can query all triggered validations", async () => { const state = player.getState() as InProgressState; state.controllers.data.set([["data.thing4", "not-sam"]]); await vitest.waitFor(() => { expect( state.controllers.view.currentView?.lastUpdate?.alreadyInvalidData .asset.validation.message, ).toBe("Names just be in: sam"); }); const currentValidations = getAllKnownValidations(); expect(currentValidations).toHaveLength(5); expect( currentValidations[0]?.validation.value[ VALIDATION_PROVIDER_NAME_SYMBOL ], ).toBe("schema"); }); it("can compute new validations without dismissing existing ones", async () => { const updatedFlow = { ...flowWithThings, views: [ { ...flowWithThings.views?.[0], validation: [ { type: "expression", ref: "data.thing2", message: "Both need to equal 100", exp: "{{data.thing1}} + {{data.thing2}} == 100", }, ], }, ], }; player.start(updatedFlow as any); const currentValidations = getAllKnownValidations(); expect(currentValidations).toHaveLength(6); }); }); }); describe("cross-field validation", () => { const crossFieldFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, thing2: { asset: { id: "thing-2", binding: "foo.data.thing2", type: "input", }, }, validation: [ { type: "expression", ref: "foo.data.thing1", message: "Both need to equal 100", exp: "{{foo.data.thing1}} + {{foo.data.thing2}} == 100", }, ], }); it("works for navigate triggers", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()], }); player.start(crossFieldFlow); const state = player.getState() as InProgressState; // Validation starts as nothing expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); // Updating a thing is still nothing (haven't navigated yet) state.controllers.data.set([["foo.data.thing1", 20]]); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); // Try to navigate, should show the validation now state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ severity: "error", message: "Both need to equal 100", displayTarget: "field", }); // Updating a thing is still nothing (haven't navigated yet) state.controllers.data.set([["foo.data.thing2", 85]]); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ severity: "error", message: "Both need to equal 100", displayTarget: "field", }); // Set it equal to 100 and continue on state.controllers.data.set([["foo.data.thing2", 80]]); state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("takes precedence over schema validation for the same binding", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()], }); player.start(simpleFlowWithViewValidation); const state = player.getState() as InProgressState; // Validation starts as nothing expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toBe(undefined); // Try to navigate, should show the validation now state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ severity: "error", message: "Must be greater than 50", displayTarget: "field", }); // Updating a thing is still nothing (haven't navigated yet) state.controllers.data.set([["data.thing1", 51]]); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ severity: "error", message: "Must be greater than 50", displayTarget: "field", }); // Set it equal to 100 and continue on state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); }); test("shows errors on load", () => { const errFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", message: "Stuffs broken", trigger: "load", severity: "error", }, ], }); const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(errFlow); const state = player.getState() as InProgressState; // Validation starts with a warning on load expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "Stuffs broken", severity: "error", displayTarget: "field", }); }); describe("errors", () => { const errorFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", trigger: "load", severity: "error", }, ], }); const nonBlockingErrorFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", trigger: "load", severity: "error", blocking: false, }, ], }); const onceBlockingErrorFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", trigger: "navigation", severity: "error", blocking: "once", }, ], }); const oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", severity: "error", trigger: "load", blocking: "false", }, { type: "required", ref: "foo.data.thing1", trigger: "navigation", severity: "warning", }, ], }); const oneInputWithErrorOnLoadBlockingFalseAndWarningChangeTriggerFlow = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "required", ref: "foo.data.thing1", severity: "error", trigger: "load", blocking: "false", }, { type: "required", ref: "foo.data.thing1", trigger: "change", severity: "warning", }, ], }); it("blocks navigation by default", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(errorFlow); const state = player.getState() as InProgressState; // Try to navigate, should prevent the navigation and display the error state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Try to navigate, should prevent the navigation and keep displaying the error state.controllers.flow.transition("next"); // We make it to the next state expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); }); it("blocking once allows navigation on second attempt", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(onceBlockingErrorFlow); const state = player.getState() as InProgressState; // Try to navigate, should prevent the navigation and display the error state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Navigate _again_ this should dismiss it state.controllers.flow.transition("next"); // We make it to the next state expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("error on load blocking false then warning with change trigger on navigation attempt", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start( oneInputWithErrorOnLoadBlockingFalseAndWarningChangeTriggerFlow, ); const state = player.getState() as InProgressState; expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Try to navigate, should prevent the navigation and display the warning state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "warning", displayTarget: "field", }); // Navigate _again_ this should dismiss it state.controllers.flow.transition("next"); // We make it to the next state expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("error on load blocking false then warning on navigation attempt", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start( oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow, ); const state = player.getState() as InProgressState; expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Try to navigate, should prevent the navigation and display the warning state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "warning", displayTarget: "field", }); // Navigate _again_ this should dismiss it state.controllers.flow.transition("next"); // We make it to the next state expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("error on load blocking false then input active then warning on navigation attempt", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start( oneInputWithErrorOnLoadBlockingFalseAndWarningNavigationTriggerFlow, ); const state = player.getState() as InProgressState; expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Type something to dismiss the error, should be empty to see the warning state.controllers.data.set([["foo.data.thing1", ""]]); // Try to navigate, should prevent the navigation and display the warning state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "VIEW", ); expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "warning", displayTarget: "field", }); // Navigate _again_ this should dismiss it state.controllers.flow.transition("next"); // We make it to the next state expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("blocking false allows navigation", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(nonBlockingErrorFlow); const state = player.getState() as InProgressState; // Try to navigate, should allow navigation because blocking is false state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); it("blocking false still shows validation", async () => { const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(nonBlockingErrorFlow); const state = player.getState() as InProgressState; expect( state.controllers.view.currentView?.lastUpdate?.thing1.asset.validation, ).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); // Try to navigate, should allow navigation because blocking is false state.controllers.flow.transition("next"); expect(state.controllers.flow.current?.currentState?.value.state_type).toBe( "END", ); }); }); test("validations return non-blocking errors", async () => { const flow = makeFlow({ id: "view-1", type: "view", blocking: { asset: { id: "thing-1", binding: "foo.blocking", type: "input", }, }, nonblocking: { asset: { id: "thing-2", binding: "foo.nonblocking", type: "input", }, }, }); flow.schema = { ROOT: { foo: { type: "FooType", }, }, FooType: { blocking: { type: "TestType", validation: [ { type: "required", }, ], }, nonblocking: { type: "TestType", validation: [ { type: "required", blocking: false, }, ], }, }, }; const player = new Player({ plugins: [new TrackBindingPlugin()] }); player.start(flow); /** * */ const getState = () => player.getState() as InProgressState; /** * */ const getCurrentView = () => getState().controllers.view.currentView?.lastUpdate; // No errors show up initially await vitest.waitFor(() => { expect(getState().controllers.view.currentView?.lastUpdate?.id).toBe( "view-1", ); }); expect(getCurrentView()?.blocking.asset.validation).toBeUndefined(); expect(getCurrentView()?.nonblocking.asset.validation).toBeUndefined(); getState().controllers.flow.transition("next"); expect( getState().controllers.flow.current?.currentState?.value.state_type, ).toBe("VIEW"); expect(player.getState().status).toBe("in-progress"); await vitest.waitFor(() => { expect(getCurrentView()?.blocking.asset.validation).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); expect(getCurrentView()?.nonblocking.asset.validation).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); }); getState().controllers.data.set([["foo.blocking", "foo"]]); await vitest.waitFor(() => { expect(getCurrentView()?.blocking.asset.validation).toBeUndefined(); expect(getCurrentView()?.nonblocking.asset.validation).toMatchObject({ message: "A value is required", severity: "error", displayTarget: "field", }); }); getState().controllers.flow.transition("next"); await vitest.waitFor(() => { expect(player.getState().status).toBe("completed"); }); }); describe("warnings", () => { const warningFlowOnNavigation = makeFlow({ id: "view-1", type: "view", thing1: { asset: { id: "thing-1", binding: "foo.data.thing1", type: "input", }, }, validation: [ { type: "