@player-ui/player
Version:
1,950 lines (1,760 loc) • 89.9 kB
text/typescript
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: "