@player-ui/player 0.15.5-next.0 → 0.15.6

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/package.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "types"
7
7
  ],
8
8
  "name": "@player-ui/player",
9
- "version": "0.15.5-next.0",
9
+ "version": "0.15.6",
10
10
  "main": "dist/cjs/index.cjs",
11
11
  "dependencies": {
12
- "@player-ui/partial-match-registry": "0.15.5-next.0",
13
- "@player-ui/make-flow": "0.15.5-next.0",
14
- "@player-ui/types": "0.15.5-next.0",
12
+ "@player-ui/partial-match-registry": "0.15.6",
13
+ "@player-ui/make-flow": "0.15.6",
14
+ "@player-ui/types": "0.15.6",
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
+ });
@@ -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
- new ValidationMiddleware(
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.validations.clear();
545
+ this.reset();
540
546
  if (!this.options) {
541
547
  return;
542
548
  }
@@ -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
  }
@@ -845,4 +845,94 @@ describe("templates", () => {
845
845
  // Make sure the ordering is preserved in the snapshot
846
846
  expect(resolved.asset.values).toMatchSnapshot();
847
847
  });
848
+
849
+ it("Should respect placement for a template nested under a non-asset/view value node (e.g. topicGroup.values[x].topics)", () => {
850
+ const model = withParser(new LocalModel({}), parseBinding);
851
+ const evaluator = new ExpressionEvaluator({ model });
852
+ const schema = new SchemaController();
853
+
854
+ // "values" here is generated by an outer template, so each item (e.g. the object
855
+ // at topicGroup.values[x]) is parsed as a plain NodeType.Value node rather than an
856
+ // Asset/View node. That item has its own static "topics" array plus a nested
857
+ // template targeting "topics" with a placement - which needs to be sorted just like
858
+ // it would be on an Asset/View node.
859
+ const view = new ViewInstance(
860
+ {
861
+ id: "my-view",
862
+ asset: {
863
+ id: "topicGroup",
864
+ type: "collection",
865
+ template: [
866
+ {
867
+ dynamic: false,
868
+ data: "groups",
869
+ output: "values",
870
+ value: {
871
+ id: "group-_index_",
872
+ topics: [
873
+ {
874
+ asset: {
875
+ id: "static-topic-1-_index_",
876
+ type: "text",
877
+ value: "static topic 1",
878
+ },
879
+ },
880
+ {
881
+ asset: {
882
+ id: "static-topic-2-_index_",
883
+ type: "text",
884
+ value: "static topic 2",
885
+ },
886
+ },
887
+ ],
888
+ template: [
889
+ {
890
+ dynamic: false,
891
+ data: "foo.topics",
892
+ output: "topics",
893
+ placement: "prepend",
894
+ value: {
895
+ asset: {
896
+ id: "topic-_index1_",
897
+ type: "text",
898
+ value: "topic {{foo.topics._index1_}}",
899
+ },
900
+ },
901
+ },
902
+ ],
903
+ },
904
+ },
905
+ ],
906
+ },
907
+ } as any,
908
+ {
909
+ model,
910
+ parseBinding,
911
+ evaluator,
912
+ schema,
913
+ },
914
+ );
915
+
916
+ const pluginOptions = toNodeResolveOptions(view.resolverOptions);
917
+ new TemplatePlugin(pluginOptions).apply(view);
918
+ new StringResolverPlugin().apply(view);
919
+ new AssetPlugin().apply(view);
920
+ new MultiNodePlugin().apply(view);
921
+
922
+ model.set([
923
+ ["groups", [{}]],
924
+ ["foo.topics", ["A", "B"]],
925
+ ]);
926
+
927
+ const resolved: any = view.update();
928
+ const topics = resolved.asset.values[0].topics;
929
+
930
+ expect(topics).toHaveLength(4);
931
+ // Prepend placement means the templated topics should come before the static ones,
932
+ // even though "topics" is declared before "template" in the source.
933
+ expect(topics[0].asset.value).toBe("topic A");
934
+ expect(topics[1].asset.value).toBe("topic B");
935
+ expect(topics[2].asset.value).toBe("static topic 1");
936
+ expect(topics[3].asset.value).toBe("static topic 2");
937
+ });
848
938
  });
@@ -146,7 +146,9 @@ export class TemplatePlugin implements ViewPlugin {
146
146
 
147
147
  if (
148
148
  node &&
149
- (node.type === NodeType.View || node.type === NodeType.Asset) &&
149
+ (node.type === NodeType.View ||
150
+ node.type === NodeType.Asset ||
151
+ node.type === NodeType.Value) &&
150
152
  Array.isArray(node.children)
151
153
  ) {
152
154
  node.children = node.children.sort((a, b) => {
@@ -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 */
@@ -32,5 +32,7 @@ export declare class ValidationMiddleware implements DataModelMiddleware {
32
32
  set(transaction: BatchSetTransaction, options?: DataModelOptions, next?: DataModelImpl): Updates;
33
33
  get(binding: BindingInstance, options?: DataModelOptions, next?: DataModelImpl): any;
34
34
  delete(binding: BindingInstance, options?: DataModelOptions, next?: DataModelImpl): void | undefined;
35
+ /** Clears any invalid values staged in the shadow model */
36
+ reset(): void;
35
37
  }
36
38
  //# sourceMappingURL=validation-middleware.d.ts.map