@player-ui/player 1.1.0-next.1 → 1.1.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Player.native.js +71 -49
- package/dist/Player.native.js.map +1 -1
- package/dist/cjs/index.cjs +67 -45
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.legacy-esm.js +68 -46
- package/dist/index.mjs +68 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/player.test.ts +173 -0
- package/src/__tests__/transform-content.test.ts +131 -0
- package/src/controllers/data/utils.ts +1 -1
- package/src/controllers/validation/controller.ts +53 -47
- package/src/player.ts +28 -5
- package/src/types.ts +41 -1
- package/src/validator/validation-middleware.ts +5 -0
- package/types/controllers/validation/controller.d.ts +1 -0
- package/types/player.d.ts +2 -3
- package/types/types.d.ts +39 -1
- package/types/validator/validation-middleware.d.ts +2 -0
package/package.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"types"
|
|
7
7
|
],
|
|
8
8
|
"name": "@player-ui/player",
|
|
9
|
-
"version": "1.1.0-next.
|
|
9
|
+
"version": "1.1.0-next.3",
|
|
10
10
|
"main": "dist/cjs/index.cjs",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@player-ui/partial-match-registry": "1.1.0-next.
|
|
13
|
-
"@player-ui/make-flow": "1.1.0-next.
|
|
14
|
-
"@player-ui/types": "1.1.0-next.
|
|
12
|
+
"@player-ui/partial-match-registry": "1.1.0-next.3",
|
|
13
|
+
"@player-ui/make-flow": "1.1.0-next.3",
|
|
14
|
+
"@player-ui/types": "1.1.0-next.3",
|
|
15
15
|
"@types/dlv": "^1.1.4",
|
|
16
16
|
"dequal": "^2.0.2",
|
|
17
17
|
"dlv": "^1.1.3",
|
|
@@ -8,6 +8,7 @@ import type { ViewController } from "..";
|
|
|
8
8
|
import actionsFlow from "./helpers/actions.flow";
|
|
9
9
|
import type { InProgressState } from "../types";
|
|
10
10
|
import { ActionExpPlugin } from "./helpers/action-exp.plugin";
|
|
11
|
+
import TrackBindingPlugin from "./helpers/binding.plugin";
|
|
11
12
|
|
|
12
13
|
const minimal = {
|
|
13
14
|
id: "minimal-player-content-response-format",
|
|
@@ -722,3 +723,175 @@ test("view trigger once when moving between views", async () => {
|
|
|
722
723
|
expect(viewTap).toHaveBeenCalledOnce();
|
|
723
724
|
});
|
|
724
725
|
});
|
|
726
|
+
|
|
727
|
+
test("prevents invalid data from being committed during action-driven backward navigation", async () => {
|
|
728
|
+
const player = new Player({
|
|
729
|
+
plugins: [new TrackBindingPlugin()],
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
player.hooks.validationController.tap("test", (validationProvider) => {
|
|
733
|
+
validationProvider.hooks.createValidatorRegistry.tap("test", (registry) => {
|
|
734
|
+
registry.register("minValue", (_context, value) => {
|
|
735
|
+
if (typeof value === "number" && value < 0) {
|
|
736
|
+
return {
|
|
737
|
+
message: "Amount must be non-negative",
|
|
738
|
+
severity: "error",
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const flow = {
|
|
746
|
+
id: "schema-invalid-force-navigation",
|
|
747
|
+
schema: {
|
|
748
|
+
ROOT: {
|
|
749
|
+
someValue: {
|
|
750
|
+
type: "SomeType",
|
|
751
|
+
validation: [
|
|
752
|
+
{
|
|
753
|
+
type: "minValue",
|
|
754
|
+
severity: "error",
|
|
755
|
+
trigger: "change",
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
views: [
|
|
762
|
+
{
|
|
763
|
+
id: "input-view",
|
|
764
|
+
type: "info",
|
|
765
|
+
fields: {
|
|
766
|
+
asset: {
|
|
767
|
+
id: "input-fields",
|
|
768
|
+
type: "collection",
|
|
769
|
+
values: [
|
|
770
|
+
{
|
|
771
|
+
asset: {
|
|
772
|
+
id: "some-input",
|
|
773
|
+
type: "input",
|
|
774
|
+
binding: "someValue",
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
actions: [
|
|
781
|
+
{
|
|
782
|
+
asset: {
|
|
783
|
+
id: "back-action",
|
|
784
|
+
type: "action",
|
|
785
|
+
value: "previous",
|
|
786
|
+
label: {
|
|
787
|
+
asset: {
|
|
788
|
+
id: "back-label",
|
|
789
|
+
type: "text",
|
|
790
|
+
value: "Back",
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
],
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
id: "result-view",
|
|
799
|
+
type: "info",
|
|
800
|
+
title: {
|
|
801
|
+
asset: {
|
|
802
|
+
id: "result-title",
|
|
803
|
+
type: "text",
|
|
804
|
+
value: "Result",
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
primaryInfo: {
|
|
808
|
+
asset: {
|
|
809
|
+
id: "result-text",
|
|
810
|
+
type: "text",
|
|
811
|
+
value: "Leaked value: {{someValue}}",
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
navigation: {
|
|
817
|
+
BEGIN: "FLOW_1",
|
|
818
|
+
FLOW_1: {
|
|
819
|
+
startState: "VIEW_INPUT",
|
|
820
|
+
VIEW_INPUT: {
|
|
821
|
+
state_type: "VIEW",
|
|
822
|
+
ref: "input-view",
|
|
823
|
+
transitions: {
|
|
824
|
+
previous: "ACTION_BACK",
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
ACTION_BACK: {
|
|
828
|
+
state_type: "ACTION",
|
|
829
|
+
exp: "{{unrelated}} = 'touched'",
|
|
830
|
+
transitions: {
|
|
831
|
+
"*": "VIEW_RESULT",
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
VIEW_RESULT: {
|
|
835
|
+
state_type: "VIEW",
|
|
836
|
+
ref: "result-view",
|
|
837
|
+
transitions: {
|
|
838
|
+
"*": "END_done",
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
END_done: {
|
|
842
|
+
state_type: "END",
|
|
843
|
+
outcome: "done",
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
player.start(flow as any);
|
|
850
|
+
|
|
851
|
+
await vitest.waitFor(() => {
|
|
852
|
+
const state = player.getState() as InProgressState;
|
|
853
|
+
return (
|
|
854
|
+
state?.controllers?.flow?.current?.currentState?.name === "VIEW_INPUT"
|
|
855
|
+
);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
let state = player.getState() as InProgressState;
|
|
859
|
+
expect(state.controllers.flow.current?.currentState?.name).toBe("VIEW_INPUT");
|
|
860
|
+
|
|
861
|
+
// Simulate entering invalid data
|
|
862
|
+
state.controllers.data.set([["someValue", -1]]);
|
|
863
|
+
|
|
864
|
+
// The invalid data should be rejected and only available in the shadow model
|
|
865
|
+
expect(state.controllers.data.get("someValue")).toBeUndefined();
|
|
866
|
+
expect(
|
|
867
|
+
state.controllers.data.get("someValue", { includeInvalid: true }),
|
|
868
|
+
).toBe(-1);
|
|
869
|
+
|
|
870
|
+
// Ensure normal navigation is blocked
|
|
871
|
+
state.controllers.flow.current?.transition("previous");
|
|
872
|
+
|
|
873
|
+
let currentState = player.getState() as InProgressState;
|
|
874
|
+
expect(currentState?.controllers?.flow?.current?.currentState?.name).toBe(
|
|
875
|
+
"VIEW_INPUT",
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// Simulate forcing back navigation (bypassing validation)
|
|
879
|
+
state.controllers.flow.current?.transition("previous", { force: true });
|
|
880
|
+
|
|
881
|
+
currentState = player.getState() as InProgressState;
|
|
882
|
+
expect(currentState?.controllers?.flow?.current?.currentState?.name).toBe(
|
|
883
|
+
"VIEW_RESULT",
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
state = player.getState() as InProgressState;
|
|
887
|
+
|
|
888
|
+
// The invalid data should NOT be committed to the main model because of the transition
|
|
889
|
+
const finalValue = state.controllers.data.get("someValue");
|
|
890
|
+
expect(finalValue).toBeUndefined();
|
|
891
|
+
// The transition should have dropped the invalid data entirely, not just
|
|
892
|
+
// hidden it from the main model
|
|
893
|
+
const shadowValue = state.controllers.data.get("someValue", {
|
|
894
|
+
includeInvalid: true,
|
|
895
|
+
});
|
|
896
|
+
expect(shadowValue).toBeUndefined();
|
|
897
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test, vitest } from "vitest";
|
|
2
|
+
import { Player } from "..";
|
|
3
|
+
import type { Flow, PlayerPlugin, InProgressState } from "..";
|
|
4
|
+
|
|
5
|
+
/** Minimal Flow used to short-circuit setupFlow. */
|
|
6
|
+
const flowFor = (id: string): Flow => ({
|
|
7
|
+
id,
|
|
8
|
+
views: [{ id: "v1", type: "text", value: "hi" }],
|
|
9
|
+
data: {},
|
|
10
|
+
navigation: {
|
|
11
|
+
BEGIN: "F",
|
|
12
|
+
F: {
|
|
13
|
+
startState: "VIEW_1",
|
|
14
|
+
VIEW_1: {
|
|
15
|
+
state_type: "VIEW",
|
|
16
|
+
ref: "v1",
|
|
17
|
+
transitions: { "*": "END" },
|
|
18
|
+
},
|
|
19
|
+
END: { state_type: "END", outcome: "done" },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("transformContent hook", () => {
|
|
25
|
+
test("default format 'player' passes the payload through unchanged", async () => {
|
|
26
|
+
const player = new Player();
|
|
27
|
+
const flow = flowFor("plain");
|
|
28
|
+
player.start(flow);
|
|
29
|
+
const state = player.getState() as InProgressState;
|
|
30
|
+
expect(state.flow).toBe(flow);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a plugin tapping for a custom format transforms the payload", async () => {
|
|
34
|
+
const greetPlugin: PlayerPlugin = {
|
|
35
|
+
name: "greet",
|
|
36
|
+
apply(p) {
|
|
37
|
+
p.hooks.transformContent.tap("greet", (content, meta) => {
|
|
38
|
+
if (meta.format !== "greet") return undefined;
|
|
39
|
+
const { message } = content as { message: string };
|
|
40
|
+
return { ...flowFor("greet"), data: { message } };
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const player = new Player({ plugins: [greetPlugin] });
|
|
46
|
+
player.start({ message: "Hello world" }, { format: "greet" });
|
|
47
|
+
|
|
48
|
+
const state = player.getState() as InProgressState;
|
|
49
|
+
await vitest.waitFor(() =>
|
|
50
|
+
expect(state.controllers.view.currentView?.lastUpdate).toBeDefined(),
|
|
51
|
+
);
|
|
52
|
+
expect(state.controllers.data.get("message")).toBe("Hello world");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("fires before resolveFlowContent", async () => {
|
|
56
|
+
const order: string[] = [];
|
|
57
|
+
|
|
58
|
+
const orderPlugin: PlayerPlugin = {
|
|
59
|
+
name: "order",
|
|
60
|
+
apply(p) {
|
|
61
|
+
p.hooks.transformContent.tap("order", () => {
|
|
62
|
+
order.push("transformContent");
|
|
63
|
+
// Return undefined so Player's default "player" handler claims it.
|
|
64
|
+
return undefined;
|
|
65
|
+
});
|
|
66
|
+
p.hooks.resolveFlowContent.tap("order", (c) => {
|
|
67
|
+
order.push("resolveFlowContent");
|
|
68
|
+
return c;
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const player = new Player({ plugins: [orderPlugin] });
|
|
74
|
+
player.start(flowFor("order"));
|
|
75
|
+
|
|
76
|
+
expect(order).toEqual(["transformContent", "resolveFlowContent"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("multiple format plugins coexist — only matching one transforms", async () => {
|
|
80
|
+
const aPlugin: PlayerPlugin = {
|
|
81
|
+
name: "a",
|
|
82
|
+
apply(p) {
|
|
83
|
+
p.hooks.transformContent.tap("a", (c, meta) =>
|
|
84
|
+
meta.format === "a" ? flowFor("from-a") : undefined,
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const bPlugin: PlayerPlugin = {
|
|
89
|
+
name: "b",
|
|
90
|
+
apply(p) {
|
|
91
|
+
p.hooks.transformContent.tap("b", (c, meta) =>
|
|
92
|
+
meta.format === "b" ? flowFor("from-b") : undefined,
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const player = new Player({ plugins: [aPlugin, bPlugin] });
|
|
98
|
+
player.start({ raw: true }, { format: "b" });
|
|
99
|
+
|
|
100
|
+
const state = player.getState() as InProgressState;
|
|
101
|
+
expect(state.flow.id).toBe("from-b");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("version is plumbed from StartOptions to the hook's meta arg", () => {
|
|
105
|
+
const versions: Array<string | undefined> = [];
|
|
106
|
+
const probe: PlayerPlugin = {
|
|
107
|
+
name: "probe",
|
|
108
|
+
apply(p) {
|
|
109
|
+
p.hooks.transformContent.tap("probe", (c, meta) => {
|
|
110
|
+
versions.push(meta.version);
|
|
111
|
+
// For format !== "demo", return undefined so the default path runs.
|
|
112
|
+
if (meta.format !== "demo") return undefined;
|
|
113
|
+
return flowFor("demo");
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const player = new Player({ plugins: [probe] });
|
|
119
|
+
player.start({ x: 1 }, { format: "demo", version: "2" });
|
|
120
|
+
player.start({ x: 1 }, { format: "demo", version: "1" });
|
|
121
|
+
player.start(flowFor("noversion"));
|
|
122
|
+
|
|
123
|
+
expect(versions).toEqual(["2", "1", undefined]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("errors when no tap transforms an unknown format into a Flow", async () => {
|
|
127
|
+
const player = new Player();
|
|
128
|
+
const result = player.start({ some: "content" }, { format: "unknown" });
|
|
129
|
+
await expect(result).rejects.toThrow(/format "unknown"/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -12,7 +12,7 @@ export class ReadOnlyDataController
|
|
|
12
12
|
this.controller = controller;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
get(binding: BindingLike, options?: DataModelOptions | undefined) {
|
|
15
|
+
get(binding: BindingLike, options?: DataModelOptions | undefined): any {
|
|
16
16
|
return this.controller.get(binding, options);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -420,6 +420,7 @@ export class ValidationController implements BindingTracker {
|
|
|
420
420
|
private viewValidationProvider?: ValidationProvider;
|
|
421
421
|
private options?: SimpleValidatorContext;
|
|
422
422
|
private weakBindingTracker = new Set<BindingInstance>();
|
|
423
|
+
private validationMiddleware?: ValidationMiddleware;
|
|
423
424
|
|
|
424
425
|
constructor(schema: SchemaController, options?: SimpleValidatorContext) {
|
|
425
426
|
this.schema = schema;
|
|
@@ -433,6 +434,55 @@ export class ValidationController implements BindingTracker {
|
|
|
433
434
|
|
|
434
435
|
/** Return the middleware for the data-model to stop propagation of invalid data */
|
|
435
436
|
public getDataMiddleware(): Array<DataModelMiddleware> {
|
|
437
|
+
const validationMiddleware = new ValidationMiddleware(
|
|
438
|
+
(binding) => {
|
|
439
|
+
if (!this.options) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this.updateValidationsForBinding(binding, "change", this.options);
|
|
444
|
+
const strongValidation = this.getValidationForBinding(binding);
|
|
445
|
+
|
|
446
|
+
// return validation issues directly on bindings first
|
|
447
|
+
if (strongValidation?.get()?.severity === "error") {
|
|
448
|
+
return strongValidation.get();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// if none, check to see any validations this binding may be a weak ref of and return
|
|
452
|
+
const newInvalidBindings: Set<StrongOrWeakBinding> = new Set();
|
|
453
|
+
this.validations.forEach((weakValidation, strongBinding) => {
|
|
454
|
+
if (
|
|
455
|
+
caresAboutDataChanges(
|
|
456
|
+
new Set([binding]),
|
|
457
|
+
weakValidation.weakBindings,
|
|
458
|
+
) &&
|
|
459
|
+
weakValidation?.get()?.severity === "error"
|
|
460
|
+
) {
|
|
461
|
+
weakValidation?.weakBindings.forEach((weakBinding) => {
|
|
462
|
+
if (weakBinding === strongBinding) {
|
|
463
|
+
newInvalidBindings.add({
|
|
464
|
+
binding: weakBinding,
|
|
465
|
+
isStrong: true,
|
|
466
|
+
});
|
|
467
|
+
} else {
|
|
468
|
+
newInvalidBindings.add({
|
|
469
|
+
binding: weakBinding,
|
|
470
|
+
isStrong: false,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (newInvalidBindings.size > 0) {
|
|
478
|
+
return newInvalidBindings;
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
{ logger: new ProxyLogger(() => this.options?.logger) },
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
this.validationMiddleware = validationMiddleware;
|
|
485
|
+
|
|
436
486
|
return [
|
|
437
487
|
{
|
|
438
488
|
set: (transaction, options, next) => {
|
|
@@ -450,52 +500,7 @@ export class ValidationController implements BindingTracker {
|
|
|
450
500
|
return next?.delete(binding, options);
|
|
451
501
|
},
|
|
452
502
|
},
|
|
453
|
-
|
|
454
|
-
(binding) => {
|
|
455
|
-
if (!this.options) {
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
this.updateValidationsForBinding(binding, "change", this.options);
|
|
460
|
-
const strongValidation = this.getValidationForBinding(binding);
|
|
461
|
-
|
|
462
|
-
// return validation issues directly on bindings first
|
|
463
|
-
if (strongValidation?.get()?.severity === "error") {
|
|
464
|
-
return strongValidation.get();
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// if none, check to see any validations this binding may be a weak ref of and return
|
|
468
|
-
const newInvalidBindings: Set<StrongOrWeakBinding> = new Set();
|
|
469
|
-
this.validations.forEach((weakValidation, strongBinding) => {
|
|
470
|
-
if (
|
|
471
|
-
caresAboutDataChanges(
|
|
472
|
-
new Set([binding]),
|
|
473
|
-
weakValidation.weakBindings,
|
|
474
|
-
) &&
|
|
475
|
-
weakValidation?.get()?.severity === "error"
|
|
476
|
-
) {
|
|
477
|
-
weakValidation?.weakBindings.forEach((weakBinding) => {
|
|
478
|
-
if (weakBinding === strongBinding) {
|
|
479
|
-
newInvalidBindings.add({
|
|
480
|
-
binding: weakBinding,
|
|
481
|
-
isStrong: true,
|
|
482
|
-
});
|
|
483
|
-
} else {
|
|
484
|
-
newInvalidBindings.add({
|
|
485
|
-
binding: weakBinding,
|
|
486
|
-
isStrong: false,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
if (newInvalidBindings.size > 0) {
|
|
494
|
-
return newInvalidBindings;
|
|
495
|
-
}
|
|
496
|
-
},
|
|
497
|
-
{ logger: new ProxyLogger(() => this.options?.logger) },
|
|
498
|
-
),
|
|
503
|
+
validationMiddleware,
|
|
499
504
|
];
|
|
500
505
|
}
|
|
501
506
|
|
|
@@ -533,10 +538,11 @@ export class ValidationController implements BindingTracker {
|
|
|
533
538
|
public reset() {
|
|
534
539
|
this.validations.clear();
|
|
535
540
|
this.tracker = undefined;
|
|
541
|
+
this.validationMiddleware?.reset();
|
|
536
542
|
}
|
|
537
543
|
|
|
538
544
|
public onView(view: ViewInstance): void {
|
|
539
|
-
this.
|
|
545
|
+
this.reset();
|
|
540
546
|
if (!this.options) {
|
|
541
547
|
return;
|
|
542
548
|
}
|
package/src/player.ts
CHANGED
|
@@ -3,7 +3,7 @@ import deferred from "p-defer";
|
|
|
3
3
|
import type { Flow, FlowResult } from "@player-ui/types";
|
|
4
4
|
import queueMicrotask from "queue-microtask";
|
|
5
5
|
|
|
6
|
-
import { SyncHook, SyncWaterfallHook } from "tapable-ts";
|
|
6
|
+
import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
|
|
7
7
|
import type { Logger } from "./logger";
|
|
8
8
|
import { TapableLogger } from "./logger";
|
|
9
9
|
import type { ExpressionType } from "./expressions";
|
|
@@ -29,6 +29,8 @@ import type {
|
|
|
29
29
|
CompletedState,
|
|
30
30
|
ErrorState,
|
|
31
31
|
PlayerHooks,
|
|
32
|
+
ContentMeta,
|
|
33
|
+
StartOptions,
|
|
32
34
|
} from "./types";
|
|
33
35
|
import { NOT_STARTED_STATE } from "./types";
|
|
34
36
|
|
|
@@ -125,6 +127,7 @@ export class Player {
|
|
|
125
127
|
onStart: new SyncHook<[Flow]>(),
|
|
126
128
|
onEnd: new SyncHook<[]>(),
|
|
127
129
|
resolveFlowContent: new SyncWaterfallHook<[Flow]>(),
|
|
130
|
+
transformContent: new SyncBailHook<[unknown, ContentMeta], Flow>(),
|
|
128
131
|
};
|
|
129
132
|
|
|
130
133
|
constructor(config?: PlayerConfigOptions) {
|
|
@@ -141,6 +144,13 @@ export class Player {
|
|
|
141
144
|
this.config.plugins?.forEach((plugin) => {
|
|
142
145
|
plugin.apply(this);
|
|
143
146
|
});
|
|
147
|
+
|
|
148
|
+
// Default content handler: a `"player"`-format payload is already a `Flow`.
|
|
149
|
+
// Registered after plugins so a format plugin gets first claim on the bail
|
|
150
|
+
// hook; this is the fallback that lets `start()` require a `Flow` out.
|
|
151
|
+
this.hooks.transformContent.tap("player", (payload, meta) =>
|
|
152
|
+
meta.format === "player" ? (payload as Flow) : undefined,
|
|
153
|
+
);
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/** Returns currently registered plugins */
|
|
@@ -513,8 +523,21 @@ export class Player {
|
|
|
513
523
|
};
|
|
514
524
|
}
|
|
515
525
|
|
|
516
|
-
public async start(
|
|
517
|
-
|
|
526
|
+
public async start(
|
|
527
|
+
payload: unknown,
|
|
528
|
+
options?: StartOptions,
|
|
529
|
+
): Promise<CompletedState> {
|
|
530
|
+
const meta: ContentMeta = {
|
|
531
|
+
format: options?.format ?? "player",
|
|
532
|
+
version: options?.version,
|
|
533
|
+
};
|
|
534
|
+
const flow = this.hooks.transformContent.call(payload, meta);
|
|
535
|
+
if (!flow) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Player.start received content with format "${meta.format}" that no plugin transformed into a Flow.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const ref = Symbol(flow.id ?? "payload");
|
|
518
541
|
|
|
519
542
|
/** A check to avoid updating the state for a flow that's not the current one */
|
|
520
543
|
const maybeUpdateState = <T extends PlayerFlowState>(newState: T) => {
|
|
@@ -537,7 +560,7 @@ export class Player {
|
|
|
537
560
|
});
|
|
538
561
|
|
|
539
562
|
try {
|
|
540
|
-
const { state, start } = this.setupFlow(
|
|
563
|
+
const { state, start } = this.setupFlow(flow);
|
|
541
564
|
this.setState({
|
|
542
565
|
ref,
|
|
543
566
|
...state,
|
|
@@ -564,7 +587,7 @@ export class Player {
|
|
|
564
587
|
const errorState: ErrorState = {
|
|
565
588
|
status: "error",
|
|
566
589
|
ref,
|
|
567
|
-
flow
|
|
590
|
+
flow,
|
|
568
591
|
error,
|
|
569
592
|
};
|
|
570
593
|
|
package/src/types.ts
CHANGED
|
@@ -11,9 +11,39 @@ import type {
|
|
|
11
11
|
ErrorController,
|
|
12
12
|
} from "./controllers";
|
|
13
13
|
import type { ReadOnlyDataController } from "./controllers/data/utils";
|
|
14
|
-
import { SyncHook, SyncWaterfallHook } from "tapable-ts";
|
|
14
|
+
import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
|
|
15
15
|
import { ViewInstance } from "./view";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Metadata describing the incoming content to `Player.start()`. Passed
|
|
19
|
+
* alongside the raw payload through the `transformContent` hook so plugins
|
|
20
|
+
* can decide whether to claim/convert it.
|
|
21
|
+
*/
|
|
22
|
+
export interface ContentMeta {
|
|
23
|
+
/**
|
|
24
|
+
* Content format identifier. `"player"` (default) means the input is
|
|
25
|
+
* already a `Flow` and needs no conversion. Anything else is a free-form
|
|
26
|
+
* string a plugin can recognize.
|
|
27
|
+
*/
|
|
28
|
+
format: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional format version (e.g., `"0.9"`). Free-form — plugins decide the
|
|
31
|
+
* convention. Lets a single format plugin dispatch across major/minor
|
|
32
|
+
* versions without the caller picking a different `format` string.
|
|
33
|
+
*/
|
|
34
|
+
version?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options passed to `Player.start()` alongside the payload.
|
|
39
|
+
*/
|
|
40
|
+
export interface StartOptions {
|
|
41
|
+
/** Identifier of the input content's format. Default: `"player"`. */
|
|
42
|
+
format?: string;
|
|
43
|
+
/** Optional content-format version. See `ContentMeta.version`. */
|
|
44
|
+
version?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
17
47
|
/**
|
|
18
48
|
* Public Player Hooks
|
|
19
49
|
*/
|
|
@@ -47,6 +77,16 @@ export interface PlayerHooks {
|
|
|
47
77
|
[Flow<Asset<string>>],
|
|
48
78
|
Record<string, any>
|
|
49
79
|
>;
|
|
80
|
+
/**
|
|
81
|
+
* Transform raw input content into a Player `Flow` before any state is set
|
|
82
|
+
* up. Fires at the top of `Player.start()` — after plugins are applied,
|
|
83
|
+
* before `resolveFlowContent`. Being a bail hook, taps inspect `meta.format`
|
|
84
|
+
* (and optionally `meta.version`) and return a `Flow` to claim the content;
|
|
85
|
+
* returning `undefined` passes to the next tap. Player registers a default
|
|
86
|
+
* tap for the `"player"` format that returns the payload as-is, so the hook
|
|
87
|
+
* is guaranteed to yield a `Flow` and `start()` needs no type-casting.
|
|
88
|
+
*/
|
|
89
|
+
transformContent: SyncBailHook<[unknown, ContentMeta], Flow<Asset<string>>>;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
/** The status for a flow's execution state */
|
|
@@ -163,4 +163,9 @@ export class ValidationMiddleware implements DataModelMiddleware {
|
|
|
163
163
|
|
|
164
164
|
return next?.delete(binding, options);
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
/** Clears any invalid values staged in the shadow model */
|
|
168
|
+
public reset(): void {
|
|
169
|
+
this.shadowModelPaths.clear();
|
|
170
|
+
}
|
|
166
171
|
}
|
|
@@ -120,6 +120,7 @@ export declare class ValidationController implements BindingTracker {
|
|
|
120
120
|
private viewValidationProvider?;
|
|
121
121
|
private options?;
|
|
122
122
|
private weakBindingTracker;
|
|
123
|
+
private validationMiddleware?;
|
|
123
124
|
constructor(schema: SchemaController, options?: SimpleValidatorContext);
|
|
124
125
|
setOptions(options: SimpleValidatorContext): void;
|
|
125
126
|
/** Return the middleware for the data-model to stop propagation of invalid data */
|
package/types/player.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { Flow } from "@player-ui/types";
|
|
2
1
|
import type { Logger } from "./logger";
|
|
3
2
|
import { TapableLogger } from "./logger";
|
|
4
3
|
import { ConstantsController } from "./controllers";
|
|
5
|
-
import type { PlayerFlowState, CompletedState, PlayerHooks } from "./types";
|
|
4
|
+
import type { PlayerFlowState, CompletedState, PlayerHooks, StartOptions } from "./types";
|
|
6
5
|
/**
|
|
7
6
|
Variables injected at build time
|
|
8
7
|
*/
|
|
@@ -75,7 +74,7 @@ export declare class Player {
|
|
|
75
74
|
private setState;
|
|
76
75
|
/** Start Player with the given flow */
|
|
77
76
|
private setupFlow;
|
|
78
|
-
start(payload:
|
|
77
|
+
start(payload: unknown, options?: StartOptions): Promise<CompletedState>;
|
|
79
78
|
}
|
|
80
79
|
export {};
|
|
81
80
|
//# sourceMappingURL=player.d.ts.map
|