@player-ui/player 0.4.0 → 0.4.1-next.0

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.
Files changed (47) hide show
  1. package/dist/index.cjs.js +795 -390
  2. package/dist/index.d.ts +238 -81
  3. package/dist/index.esm.js +787 -388
  4. package/dist/player.dev.js +4768 -5282
  5. package/dist/player.prod.js +1 -1
  6. package/package.json +12 -3
  7. package/src/binding/binding.ts +8 -0
  8. package/src/binding/index.ts +14 -4
  9. package/src/binding/resolver.ts +1 -1
  10. package/src/binding-grammar/custom/index.ts +17 -9
  11. package/src/controllers/constants/index.ts +9 -5
  12. package/src/controllers/{data.ts → data/controller.ts} +60 -61
  13. package/src/controllers/data/index.ts +1 -0
  14. package/src/controllers/data/utils.ts +42 -0
  15. package/src/controllers/flow/controller.ts +16 -12
  16. package/src/controllers/flow/flow.ts +6 -1
  17. package/src/controllers/index.ts +1 -1
  18. package/src/controllers/validation/binding-tracker.ts +42 -19
  19. package/src/controllers/validation/controller.ts +359 -145
  20. package/src/controllers/view/asset-transform.ts +4 -1
  21. package/src/controllers/view/controller.ts +20 -3
  22. package/src/data/dependency-tracker.ts +14 -0
  23. package/src/data/local-model.ts +25 -1
  24. package/src/data/model.ts +55 -8
  25. package/src/data/noop-model.ts +2 -0
  26. package/src/expressions/evaluator-functions.ts +24 -2
  27. package/src/expressions/evaluator.ts +37 -33
  28. package/src/expressions/index.ts +1 -0
  29. package/src/expressions/parser.ts +53 -27
  30. package/src/expressions/types.ts +23 -5
  31. package/src/expressions/utils.ts +19 -0
  32. package/src/player.ts +47 -48
  33. package/src/plugins/default-exp-plugin.ts +57 -0
  34. package/src/plugins/flow-exp-plugin.ts +2 -2
  35. package/src/schema/schema.ts +28 -9
  36. package/src/string-resolver/index.ts +25 -9
  37. package/src/types.ts +6 -3
  38. package/src/validator/binding-map-splice.ts +59 -0
  39. package/src/validator/index.ts +1 -0
  40. package/src/validator/types.ts +11 -3
  41. package/src/validator/validation-middleware.ts +38 -4
  42. package/src/view/parser/index.ts +51 -3
  43. package/src/view/plugins/applicability.ts +1 -1
  44. package/src/view/plugins/string-resolver.ts +8 -4
  45. package/src/view/plugins/template-plugin.ts +1 -6
  46. package/src/view/resolver/index.ts +119 -54
  47. package/src/view/resolver/types.ts +48 -7
package/src/player.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { setIn } from 'timm';
2
2
  import deferred from 'p-defer';
3
- import queueMicrotask from 'queue-microtask';
4
3
  import type { Flow as FlowType, FlowResult } from '@player-ui/types';
5
4
 
6
5
  import { SyncHook, SyncWaterfallHook } from 'tapable-ts';
7
6
  import type { Logger } from './logger';
8
7
  import { TapableLogger } from './logger';
9
- import type { ExpressionHandler } from './expressions';
8
+ import type { ExpressionType } from './expressions';
10
9
  import { ExpressionEvaluator } from './expressions';
11
10
  import { SchemaController } from './schema';
12
11
  import { BindingParser } from './binding';
@@ -21,6 +20,7 @@ import {
21
20
  FlowController,
22
21
  } from './controllers';
23
22
  import { FlowExpPlugin } from './plugins/flow-exp-plugin';
23
+ import { DefaultExpPlugin } from './plugins/default-exp-plugin';
24
24
  import type {
25
25
  PlayerFlowState,
26
26
  InProgressState,
@@ -30,8 +30,8 @@ import type {
30
30
  import { NOT_STARTED_STATE } from './types';
31
31
 
32
32
  // Variables injected at build time
33
- const PLAYER_VERSION = '0.4.0';
34
- const COMMIT = '39b851fc45e4903eae2f5b0697dea142c890443c';
33
+ const PLAYER_VERSION = '0.4.1-next.0';
34
+ const COMMIT = '3fca14a3bf47195b400f5989a2f3ecbc5f73ac4b';
35
35
 
36
36
  export interface PlayerPlugin {
37
37
  /**
@@ -49,6 +49,18 @@ export interface PlayerPlugin {
49
49
  apply: (player: Player) => void;
50
50
  }
51
51
 
52
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
53
+ export interface ExtendedPlayerPlugin<
54
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
55
+ Assets = void,
56
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
57
+ Views = void,
58
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
59
+ Expressions = void,
60
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
61
+ DataTypes = void
62
+ > {}
63
+
52
64
  export interface PlayerConfigOptions {
53
65
  /** A set of plugins to load */
54
66
  plugins?: PlayerPlugin[];
@@ -117,17 +129,16 @@ export class Player {
117
129
  };
118
130
 
119
131
  constructor(config?: PlayerConfigOptions) {
120
- const initialPlugins: PlayerPlugin[] = [];
121
- const flowExpPlugin = new FlowExpPlugin();
122
-
123
- initialPlugins.push(flowExpPlugin);
124
-
125
132
  if (config?.logger) {
126
133
  this.logger.addHandler(config.logger);
127
134
  }
128
135
 
129
136
  this.config = config || {};
130
- this.config.plugins = [...(this.config.plugins || []), ...initialPlugins];
137
+ this.config.plugins = [
138
+ new DefaultExpPlugin(),
139
+ ...(this.config.plugins || []),
140
+ new FlowExpPlugin(),
141
+ ];
131
142
  this.config.plugins?.forEach((plugin) => {
132
143
  plugin.apply(this);
133
144
  });
@@ -277,10 +288,11 @@ export class Player {
277
288
  });
278
289
 
279
290
  /** Resolve any data references in a string */
280
- function resolveStrings<T>(val: T) {
291
+ function resolveStrings<T>(val: T, formatted?: boolean) {
281
292
  return resolveDataRefs(val, {
282
293
  model: dataController,
283
294
  evaluate: expressionEvaluator.evaluate,
295
+ formatted,
284
296
  });
285
297
  }
286
298
 
@@ -294,7 +306,7 @@ export class Player {
294
306
  if (typeof state.onEnd === 'object' && 'exp' in state.onEnd) {
295
307
  expressionEvaluator?.evaluate(state.onEnd.exp);
296
308
  } else {
297
- expressionEvaluator?.evaluate(state.onEnd);
309
+ expressionEvaluator?.evaluate(state.onEnd as ExpressionType);
298
310
  }
299
311
  }
300
312
 
@@ -341,7 +353,7 @@ export class Player {
341
353
  newState = setIn(
342
354
  state,
343
355
  ['param'],
344
- resolveStrings(state.param)
356
+ resolveStrings(state.param, false)
345
357
  ) as any;
346
358
  }
347
359
 
@@ -349,26 +361,18 @@ export class Player {
349
361
  });
350
362
 
351
363
  flow.hooks.transition.tap('player', (_oldState, newState) => {
352
- if (newState.value.state_type === 'ACTION') {
353
- const { exp } = newState.value;
354
-
355
- // The nested transition call would trigger another round of the flow transition hooks to be called.
356
- // This created a weird timing where this nested transition would happen before the view had a chance to respond to the first one
357
-
358
- // Additionally, because we are using queueMicrotask, errors could get swallowed in the detached queue
359
- // Use a try catch and fail player explicitly if any errors are caught in the nested transition/state
360
- queueMicrotask(() => {
361
- try {
362
- flowController?.transition(
363
- String(expressionEvaluator?.evaluate(exp))
364
- );
365
- } catch (error) {
366
- const state = this.getState();
367
- if (error instanceof Error && state.status === 'in-progress') {
368
- state.fail(error);
369
- }
370
- }
371
- });
364
+ if (newState.value.state_type !== 'VIEW') {
365
+ validationController.reset();
366
+ }
367
+ });
368
+
369
+ flow.hooks.afterTransition.tap('player', (flowInstance) => {
370
+ const value = flowInstance.currentState?.value;
371
+ if (value && value.state_type === 'ACTION') {
372
+ const { exp } = value;
373
+ flowController?.transition(
374
+ String(expressionEvaluator?.evaluate(exp))
375
+ );
372
376
  }
373
377
 
374
378
  expressionEvaluator.reset();
@@ -390,6 +394,11 @@ export class Player {
390
394
  parseBinding,
391
395
  transition: flowController.transition,
392
396
  model: dataController,
397
+ utils: {
398
+ findPlugin: <Plugin = unknown>(pluginSymbol: symbol) => {
399
+ return this.findPlugin(pluginSymbol) as unknown as Plugin;
400
+ },
401
+ },
393
402
  logger: this.logger,
394
403
  flowController,
395
404
  schema,
@@ -407,6 +416,7 @@ export class Player {
407
416
  ...validationController.forView(parseBinding),
408
417
  type: (b) => schema.getType(parseBinding(b)),
409
418
  },
419
+ constants: this.constantsController,
410
420
  });
411
421
  viewController.hooks.view.tap('player', (view) => {
412
422
  validationController.onView(view);
@@ -414,26 +424,13 @@ export class Player {
414
424
  });
415
425
  this.hooks.viewController.call(viewController);
416
426
 
417
- /** Gets formatter for given formatName and formats value if found, returns value otherwise */
418
- const formatFunction: ExpressionHandler<[unknown, string], any> = (
419
- ctx,
420
- value,
421
- formatName
422
- ) => {
423
- return (
424
- schema.getFormatterForType({ type: formatName })?.format(value) ?? value
425
- );
426
- };
427
-
428
- expressionEvaluator.addExpressionFunction('format', formatFunction);
429
-
430
427
  return {
431
428
  start: () => {
432
429
  flowController
433
430
  .start()
434
431
  .then((endState) => {
435
432
  const flowResult: FlowResult = {
436
- endState: resolveStrings(endState),
433
+ endState: resolveStrings(endState, false),
437
434
  data: dataController.serialize(),
438
435
  };
439
436
 
@@ -504,7 +501,9 @@ export class Player {
504
501
  ref,
505
502
  status: 'completed',
506
503
  flow: state.flow,
507
- dataModel: state.controllers.data.getModel(),
504
+ controllers: {
505
+ data: state.controllers.data.makeReadOnly(),
506
+ },
508
507
  } as const;
509
508
 
510
509
  return maybeUpdateState({
@@ -0,0 +1,57 @@
1
+ import type { ExpressionHandler, ExpressionType } from '../expressions';
2
+ import type { SchemaController } from '../schema';
3
+ import type { Player, PlayerPlugin } from '../player';
4
+
5
+ /** Gets formatter for given formatName and formats value if found, returns value otherwise */
6
+ const createFormatFunction = (schema: SchemaController) => {
7
+ /**
8
+ * The generated handler for the given schema
9
+ */
10
+ const handler: ExpressionHandler<[unknown, string], any> = (
11
+ ctx,
12
+ value,
13
+ formatName
14
+ ) => {
15
+ return (
16
+ schema.getFormatterForType({ type: formatName })?.format(value) ?? value
17
+ );
18
+ };
19
+
20
+ return handler;
21
+ };
22
+
23
+ /**
24
+ * A plugin that provides the out-of-the-box expressions for player
25
+ */
26
+ export class DefaultExpPlugin implements PlayerPlugin {
27
+ name = 'flow-exp-plugin';
28
+
29
+ apply(player: Player) {
30
+ let formatFunction: ExpressionHandler<[unknown, string]> | undefined;
31
+
32
+ player.hooks.schema.tap(this.name, (schemaController) => {
33
+ formatFunction = createFormatFunction(schemaController);
34
+ });
35
+
36
+ player.hooks.expressionEvaluator.tap(this.name, (expEvaluator) => {
37
+ if (formatFunction) {
38
+ expEvaluator.addExpressionFunction('format', formatFunction);
39
+ }
40
+
41
+ expEvaluator.addExpressionFunction('log', (ctx, ...args) => {
42
+ player.logger.info(...args);
43
+ });
44
+
45
+ expEvaluator.addExpressionFunction('debug', (ctx, ...args) => {
46
+ player.logger.debug(...args);
47
+ });
48
+
49
+ expEvaluator.addExpressionFunction(
50
+ 'eval',
51
+ (ctx, ...args: [ExpressionType]) => {
52
+ return ctx.evaluate(...args);
53
+ }
54
+ );
55
+ });
56
+ }
57
+ }
@@ -3,7 +3,7 @@ import type {
3
3
  ExpressionObject,
4
4
  NavigationFlowState,
5
5
  } from '@player-ui/types';
6
- import type { ExpressionEvaluator } from '../expressions';
6
+ import type { ExpressionEvaluator, ExpressionType } from '../expressions';
7
7
  import type { FlowInstance } from '../controllers';
8
8
  import type { Player, PlayerPlugin } from '../player';
9
9
 
@@ -28,7 +28,7 @@ export class FlowExpPlugin implements PlayerPlugin {
28
28
  if (typeof exp === 'object' && 'exp' in exp) {
29
29
  expressionEvaluator?.evaluate(exp.exp);
30
30
  } else {
31
- expressionEvaluator?.evaluate(exp);
31
+ expressionEvaluator?.evaluate(exp as ExpressionType);
32
32
  }
33
33
  }
34
34
  };
@@ -11,8 +11,8 @@ const identify = (val: any) => val;
11
11
  /** Expand the authored schema into a set of paths -> DataTypes */
12
12
  export function parse(
13
13
  schema: SchemaType.Schema
14
- ): Map<string, SchemaType.DataType> {
15
- const expandedPaths = new Map<string, SchemaType.DataType>();
14
+ ): Map<string, SchemaType.DataTypes> {
15
+ const expandedPaths = new Map<string, SchemaType.DataTypes>();
16
16
 
17
17
  if (!schema.ROOT) {
18
18
  return expandedPaths;
@@ -62,6 +62,10 @@ export function parse(
62
62
  nestedPath.push('[]');
63
63
  }
64
64
 
65
+ if (type.isRecord) {
66
+ nestedPath.push('{}');
67
+ }
68
+
65
69
  if (type.type && schema[type.type]) {
66
70
  parseQueue.push({
67
71
  path: nestedPath,
@@ -85,14 +89,14 @@ export class SchemaController implements ValidationProvider {
85
89
  new Map();
86
90
 
87
91
  private types: Map<string, SchemaType.DataType<any>> = new Map();
88
- public readonly schema: Map<string, SchemaType.DataType> = new Map();
92
+ public readonly schema: Map<string, SchemaType.DataTypes> = new Map();
89
93
 
90
94
  private bindingSchemaNormalizedCache: Map<BindingInstance, string> =
91
95
  new Map();
92
96
 
93
97
  public readonly hooks = {
94
98
  resolveTypeForBinding: new SyncWaterfallHook<
95
- [SchemaType.DataType | undefined, BindingInstance]
99
+ [SchemaType.DataTypes | undefined, BindingInstance]
96
100
  >(),
97
101
  };
98
102
 
@@ -135,17 +139,32 @@ export class SchemaController implements ValidationProvider {
135
139
  return cached;
136
140
  }
137
141
 
138
- const normalized = binding
139
- .asArray()
142
+ let bindingArray = binding.asArray();
143
+ let normalized = bindingArray
140
144
  .map((p) => (typeof p === 'number' ? '[]' : p))
141
145
  .join('.');
142
146
 
143
- this.bindingSchemaNormalizedCache.set(binding, normalized);
147
+ if (normalized) {
148
+ this.bindingSchemaNormalizedCache.set(binding, normalized);
149
+ bindingArray = normalized.split('.');
150
+ }
151
+
152
+ bindingArray.forEach((item) => {
153
+ const recordBinding = bindingArray
154
+ .map((p) => (p === item ? '{}' : p))
155
+ .join('.');
156
+
157
+ if (this.schema.get(recordBinding)) {
158
+ this.bindingSchemaNormalizedCache.set(binding, recordBinding);
159
+ bindingArray = recordBinding.split('.');
160
+ normalized = recordBinding;
161
+ }
162
+ });
144
163
 
145
164
  return normalized;
146
165
  }
147
166
 
148
- public getType(binding: BindingInstance): SchemaType.DataType | undefined {
167
+ public getType(binding: BindingInstance): SchemaType.DataTypes | undefined {
149
168
  return this.hooks.resolveTypeForBinding.call(
150
169
  this.schema.get(this.normalizeBinding(binding)),
151
170
  binding
@@ -154,7 +173,7 @@ export class SchemaController implements ValidationProvider {
154
173
 
155
174
  public getApparentType(
156
175
  binding: BindingInstance
157
- ): SchemaType.DataType | undefined {
176
+ ): SchemaType.DataTypes | undefined {
158
177
  const schemaType = this.getType(binding);
159
178
 
160
179
  if (schemaType === undefined) {
@@ -6,11 +6,22 @@ const DOUBLE_OPEN_CURLY = '{{';
6
6
  const DOUBLE_CLOSE_CURLY = '}}';
7
7
 
8
8
  export interface Options {
9
- /** The model to use when resolving refs */
10
- model: DataModelWithParser;
11
-
12
- /** A function to evaluate an expression */
13
- evaluate: (exp: Expression) => any;
9
+ /**
10
+ * The model to use when resolving refs
11
+ * Passing `false` will skip trying to resolve any direct model refs ({{foo}})
12
+ */
13
+ model: false | DataModelWithParser;
14
+
15
+ /**
16
+ * A function to evaluate an expression
17
+ * Passing `false` will skip trying to evaluate any expressions (@[ foo() ]@)
18
+ */
19
+ evaluate: false | ((exp: Expression) => any);
20
+
21
+ /**
22
+ * Optionaly resolve binding without formatting in case Type format applies
23
+ */
24
+ formatted?: boolean;
14
25
  }
15
26
 
16
27
  /** Search the given string for the coordinates of the next expression to resolve */
@@ -70,6 +81,10 @@ export function resolveExpressionsInString(
70
81
  val: string,
71
82
  { evaluate }: Options
72
83
  ): string {
84
+ if (!evaluate) {
85
+ return val;
86
+ }
87
+
73
88
  const expMatch = /@\[.*?\]@/;
74
89
  let newVal = val;
75
90
  let match = newVal.match(expMatch);
@@ -106,10 +121,11 @@ export function resolveExpressionsInString(
106
121
 
107
122
  /** Return a string with all data model references resolved */
108
123
  export function resolveDataRefsInString(val: string, options: Options): string {
109
- const { model } = options;
124
+ const { model, formatted = true } = options;
110
125
  let workingString = resolveExpressionsInString(val, options);
111
126
 
112
127
  if (
128
+ !model ||
113
129
  typeof workingString !== 'string' ||
114
130
  workingString.indexOf(DOUBLE_OPEN_CURLY) === -1
115
131
  ) {
@@ -133,7 +149,7 @@ export function resolveDataRefsInString(val: string, options: Options): string {
133
149
  )
134
150
  .trim();
135
151
 
136
- const evaledVal = model.get(binding, { formatted: true });
152
+ const evaledVal = model.get(binding, { formatted });
137
153
 
138
154
  // Exit early if the string is _just_ a model lookup
139
155
  // If the result is a string, we may need further processing for nested bindings
@@ -166,13 +182,13 @@ function traverseObject<T>(val: T, options: Options): T {
166
182
  let newVal = val;
167
183
 
168
184
  if (keys.length > 0) {
169
- for (const key of keys) {
185
+ keys.forEach((key) => {
170
186
  newVal = setIn(
171
187
  newVal as any,
172
188
  [key],
173
189
  traverseObject((val as any)[key], options)
174
190
  ) as any;
175
- }
191
+ });
176
192
  }
177
193
 
178
194
  return newVal;
package/src/types.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { Flow, FlowResult } from '@player-ui/types';
2
- import type { DataModelWithParser } from './data';
3
2
  import type { BindingParser, BindingLike } from './binding';
4
3
  import type { SchemaController } from './schema';
5
4
  import type { ExpressionEvaluator } from './expressions';
@@ -10,6 +9,7 @@ import type {
10
9
  ValidationController,
11
10
  FlowController,
12
11
  } from './controllers';
12
+ import type { ReadOnlyDataController } from './controllers/data/utils';
13
13
 
14
14
  /** The status for a flow's execution state */
15
15
  export type PlayerFlowStatus =
@@ -86,8 +86,11 @@ export type InProgressState = BaseFlowState<'in-progress'> &
86
86
  export type CompletedState = BaseFlowState<'completed'> &
87
87
  PlayerFlowExecutionData &
88
88
  FlowResult & {
89
- /** The top-level data-model for the flow */
90
- dataModel: DataModelWithParser;
89
+ /** Readonly Player controllers to provide Player functionality after the flow has ended */
90
+ controllers: {
91
+ /** A read only instance of the Data Controller */
92
+ data: ReadOnlyDataController;
93
+ };
91
94
  };
92
95
 
93
96
  /** The flow finished but not successfully */
@@ -0,0 +1,59 @@
1
+ import type { BindingInstance } from '../binding';
2
+
3
+ /**
4
+ * Remove a binding, and any children from from the map
5
+ * If the binding is an array-item, then it will be spliced from the array and the others will be shifted down
6
+ *
7
+ * @param sourceMap - A map of bindings to values
8
+ * @param binding - The binding to remove from the map
9
+ */
10
+ export function removeBindingAndChildrenFromMap<T>(
11
+ sourceMap: Map<BindingInstance, T>,
12
+ binding: BindingInstance
13
+ ): Map<BindingInstance, T> {
14
+ const targetMap = new Map(sourceMap);
15
+
16
+ const parentBinding = binding.parent();
17
+ const property = binding.key();
18
+
19
+ // Clear out any that are sub-bindings of this binding
20
+
21
+ targetMap.forEach((_value, trackedBinding) => {
22
+ if (binding === trackedBinding || binding.contains(trackedBinding)) {
23
+ targetMap.delete(trackedBinding);
24
+ }
25
+ });
26
+
27
+ if (typeof property === 'number') {
28
+ // Splice out this index from the rest
29
+
30
+ // Order matters here b/c we are shifting items in the array
31
+ // Start with the smallest index and work our way down
32
+ const bindingsToRewrite = Array.from(sourceMap.keys())
33
+ .filter((b) => {
34
+ if (parentBinding.contains(b)) {
35
+ const [childIndex] = b.relative(parentBinding);
36
+ return typeof childIndex === 'number' && childIndex > property;
37
+ }
38
+
39
+ return false;
40
+ })
41
+ .sort();
42
+
43
+ bindingsToRewrite.forEach((trackedBinding) => {
44
+ // If the tracked binding is a sub-binding of the parent binding, then we need to
45
+ // update the path to reflect the new index
46
+
47
+ const [childIndex, ...childPath] = trackedBinding.relative(parentBinding);
48
+
49
+ if (typeof childIndex === 'number') {
50
+ const newSegments = [childIndex - 1, ...childPath];
51
+ const newChildBinding = parentBinding.descendent(newSegments);
52
+ targetMap.set(newChildBinding, targetMap.get(trackedBinding) as T);
53
+ targetMap.delete(trackedBinding);
54
+ }
55
+ });
56
+ }
57
+
58
+ return targetMap;
59
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './validation-middleware';
2
2
  export * from './types';
3
3
  export * from './registry';
4
+ export * from './binding-map-splice';
@@ -1,4 +1,4 @@
1
- import type { Validation } from '@player-ui/types';
1
+ import type { Schema, Validation } from '@player-ui/types';
2
2
 
3
3
  import type { BindingInstance, BindingFactory } from '../binding';
4
4
  import type { DataModelWithParser } from '../data';
@@ -40,12 +40,17 @@ type RequiredValidationKeys = 'severity' | 'trigger';
40
40
  export type ValidationObject = Validation.Reference &
41
41
  Required<Pick<Validation.Reference, RequiredValidationKeys>>;
42
42
 
43
+ export type ValidationObjectWithHandler = ValidationObject & {
44
+ /** A predefined handler for this validation object */
45
+ handler?: ValidatorFunction;
46
+ };
47
+
43
48
  export interface ValidationProvider {
44
49
  getValidationsForBinding?(
45
50
  binding: BindingInstance
46
- ): Array<ValidationObject> | undefined;
51
+ ): Array<ValidationObjectWithHandler> | undefined;
47
52
 
48
- getValidationsForView?(): Array<ValidationObject> | undefined;
53
+ getValidationsForView?(): Array<ValidationObjectWithHandler> | undefined;
49
54
  }
50
55
 
51
56
  export interface ValidatorContext {
@@ -66,6 +71,9 @@ export interface ValidatorContext {
66
71
 
67
72
  /** The constants for messages */
68
73
  constants: ConstantsProvider;
74
+
75
+ /** The type in the schema that triggered the validation if there is one */
76
+ schemaType: Schema.DataType | undefined;
69
77
  }
70
78
 
71
79
  export type ValidatorFunction<Options = unknown> = (
@@ -11,6 +11,7 @@ import { toModel } from '../data';
11
11
  import type { Logger } from '../logger';
12
12
 
13
13
  import type { ValidationResponse } from './types';
14
+ import { removeBindingAndChildrenFromMap } from './binding-map-splice';
14
15
 
15
16
  /**
16
17
  * A BindingInstance with an indicator of whether or not it's a strong binding
@@ -37,17 +38,21 @@ export class ValidationMiddleware implements DataModelMiddleware {
37
38
  public validator: MiddlewareChecker;
38
39
  public shadowModelPaths: Map<BindingInstance, any>;
39
40
  private logger?: Logger;
41
+ private shouldIncludeInvalid?: (options?: DataModelOptions) => boolean;
40
42
 
41
43
  constructor(
42
44
  validator: MiddlewareChecker,
43
45
  options?: {
44
46
  /** A logger instance */
45
47
  logger?: Logger;
48
+ /** Optional function to include data staged in shadowModel */
49
+ shouldIncludeInvalid?: (options?: DataModelOptions) => boolean;
46
50
  }
47
51
  ) {
48
52
  this.validator = validator;
49
53
  this.shadowModelPaths = new Map();
50
54
  this.logger = options?.logger;
55
+ this.shouldIncludeInvalid = options?.shouldIncludeInvalid;
51
56
  }
52
57
 
53
58
  public set(
@@ -58,8 +63,11 @@ export class ValidationMiddleware implements DataModelMiddleware {
58
63
  const asModel = toModel(this, { ...options, includeInvalid: true }, next);
59
64
  const nextTransaction: BatchSetTransaction = [];
60
65
 
66
+ const includedBindings = new Set<BindingInstance>();
67
+
61
68
  transaction.forEach(([binding, value]) => {
62
69
  this.shadowModelPaths.set(binding, value);
70
+ includedBindings.add(binding);
63
71
  });
64
72
 
65
73
  const invalidBindings: Array<BindingInstance> = [];
@@ -72,11 +80,15 @@ export class ValidationMiddleware implements DataModelMiddleware {
72
80
  } else if (validations instanceof Set) {
73
81
  validations.forEach((validation) => {
74
82
  invalidBindings.push(validation.binding);
75
- if (!validation.isStrong) {
83
+ if (
84
+ !validation.isStrong &&
85
+ validation.binding.asString() === binding.asString()
86
+ ) {
76
87
  nextTransaction.push([validation.binding, value]);
77
88
  }
78
89
  });
79
- } else {
90
+ } else if (includedBindings.has(binding)) {
91
+ invalidBindings.push(binding);
80
92
  this.logger?.debug(
81
93
  `Invalid value for path: ${binding.asString()} - ${
82
94
  validations.severity
@@ -85,6 +97,8 @@ export class ValidationMiddleware implements DataModelMiddleware {
85
97
  }
86
98
  });
87
99
 
100
+ let validResults: Updates = [];
101
+
88
102
  if (next && nextTransaction.length > 0) {
89
103
  // defer clearing the shadow model to prevent validations that are run twice due to weak binding refs still needing the data
90
104
  nextTransaction.forEach(([binding]) =>
@@ -94,9 +108,11 @@ export class ValidationMiddleware implements DataModelMiddleware {
94
108
  if (invalidBindings.length === 0) {
95
109
  return result;
96
110
  }
111
+
112
+ validResults = result;
97
113
  }
98
114
 
99
- return invalidBindings.map((binding) => {
115
+ const invalidResults = invalidBindings.map((binding) => {
100
116
  return {
101
117
  binding,
102
118
  oldValue: asModel.get(binding),
@@ -104,6 +120,8 @@ export class ValidationMiddleware implements DataModelMiddleware {
104
120
  force: true,
105
121
  };
106
122
  });
123
+
124
+ return [...validResults, ...invalidResults];
107
125
  }
108
126
 
109
127
  public get(
@@ -113,7 +131,10 @@ export class ValidationMiddleware implements DataModelMiddleware {
113
131
  ) {
114
132
  let val = next?.get(binding, options);
115
133
 
116
- if (options?.includeInvalid === true) {
134
+ if (
135
+ this.shouldIncludeInvalid?.(options) ??
136
+ options?.includeInvalid === true
137
+ ) {
117
138
  this.shadowModelPaths.forEach((shadowValue, shadowBinding) => {
118
139
  if (shadowBinding === binding) {
119
140
  val = shadowValue;
@@ -129,4 +150,17 @@ export class ValidationMiddleware implements DataModelMiddleware {
129
150
 
130
151
  return val;
131
152
  }
153
+
154
+ public delete(
155
+ binding: BindingInstance,
156
+ options?: DataModelOptions,
157
+ next?: DataModelImpl
158
+ ) {
159
+ this.shadowModelPaths = removeBindingAndChildrenFromMap(
160
+ this.shadowModelPaths,
161
+ binding
162
+ );
163
+
164
+ return next?.delete(binding, options);
165
+ }
132
166
  }