@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
@@ -1,5 +1,6 @@
1
1
  import type { Validation } from '@player-ui/types';
2
2
  import { SyncHook, SyncWaterfallHook } from 'tapable-ts';
3
+ import { setIn } from 'timm';
3
4
 
4
5
  import type { BindingInstance, BindingFactory } from '../../binding';
5
6
  import { isBinding } from '../../binding';
@@ -8,13 +9,18 @@ import type { SchemaController } from '../../schema';
8
9
  import type {
9
10
  ErrorValidationResponse,
10
11
  ValidationObject,
12
+ ValidationObjectWithHandler,
11
13
  ValidatorContext,
12
14
  ValidationProvider,
13
15
  ValidationResponse,
14
16
  WarningValidationResponse,
15
17
  StrongOrWeakBinding,
16
18
  } from '../../validator';
17
- import { ValidationMiddleware, ValidatorRegistry } from '../../validator';
19
+ import {
20
+ ValidationMiddleware,
21
+ ValidatorRegistry,
22
+ removeBindingAndChildrenFromMap,
23
+ } from '../../validator';
18
24
  import type { Logger } from '../../logger';
19
25
  import { ProxyLogger } from '../../logger';
20
26
  import type { Resolve, ViewInstance } from '../../view';
@@ -28,7 +34,22 @@ import type {
28
34
  import type { BindingTracker } from './binding-tracker';
29
35
  import { ValidationBindingTrackerViewPlugin } from './binding-tracker';
30
36
 
31
- type SimpleValidatorContext = Omit<ValidatorContext, 'validation'>;
37
+ export const SCHEMA_VALIDATION_PROVIDER_NAME = 'schema';
38
+ export const VIEW_VALIDATION_PROVIDER_NAME = 'view';
39
+
40
+ export const VALIDATION_PROVIDER_NAME_SYMBOL: unique symbol = Symbol.for(
41
+ 'validation-provider-name'
42
+ );
43
+
44
+ export type ValidationObjectWithSource = ValidationObjectWithHandler & {
45
+ /** The name of the validation */
46
+ [VALIDATION_PROVIDER_NAME_SYMBOL]: string;
47
+ };
48
+
49
+ type SimpleValidatorContext = Omit<
50
+ ValidatorContext,
51
+ 'validation' | 'schemaType'
52
+ >;
32
53
 
33
54
  interface BaseActiveValidation<T> {
34
55
  /** The validation is being actively shown */
@@ -52,7 +73,10 @@ type StatefulWarning = {
52
73
  type: 'warning';
53
74
 
54
75
  /** The underlying validation this tracks */
55
- value: ValidationObject;
76
+ value: ValidationObjectWithSource;
77
+
78
+ /** If this is currently preventing navigation from continuing */
79
+ isBlockingNavigation: boolean;
56
80
  } & (
57
81
  | {
58
82
  /** warnings start with no state, but can active or dismissed */
@@ -67,7 +91,10 @@ type StatefulError = {
67
91
  type: 'error';
68
92
 
69
93
  /** The underlying validation this tracks */
70
- value: ValidationObject;
94
+ value: ValidationObjectWithSource;
95
+
96
+ /** If this is currently preventing navigation from continuing */
97
+ isBlockingNavigation: boolean;
71
98
  } & (
72
99
  | {
73
100
  /** Errors start with no state an can be activated */
@@ -78,18 +105,26 @@ type StatefulError = {
78
105
 
79
106
  export type StatefulValidationObject = StatefulWarning | StatefulError;
80
107
 
108
+ /** Helper function to determin if the subset is within the containingSet */
109
+ function isSubset<T>(subset: Set<T>, containingSet: Set<T>): boolean {
110
+ if (subset.size > containingSet.size) return false;
111
+ for (const entry of subset) if (!containingSet.has(entry)) return false;
112
+ return true;
113
+ }
114
+
81
115
  /** Helper for initializing a validation object that tracks state */
82
116
  function createStatefulValidationObject(
83
- obj: ValidationObject
117
+ obj: ValidationObjectWithSource
84
118
  ): StatefulValidationObject {
85
119
  return {
86
120
  value: obj,
87
121
  type: obj.severity,
88
122
  state: 'none',
123
+ isBlockingNavigation: false,
89
124
  };
90
125
  }
91
126
 
92
- type ValidationRunner = (obj: ValidationObject) =>
127
+ type ValidationRunner = (obj: ValidationObjectWithHandler) =>
93
128
  | {
94
129
  /** A validation message */
95
130
  message: string;
@@ -98,7 +133,7 @@ type ValidationRunner = (obj: ValidationObject) =>
98
133
 
99
134
  /** A class that manages validating bindings across phases */
100
135
  class ValidatedBinding {
101
- private currentPhase?: Validation.Trigger;
136
+ public currentPhase?: Validation.Trigger;
102
137
  private applicableValidations: Array<StatefulValidationObject> = [];
103
138
  private validationsByState: Record<
104
139
  Validation.Trigger,
@@ -109,11 +144,16 @@ class ValidatedBinding {
109
144
  navigation: [],
110
145
  };
111
146
 
147
+ public get allValidations(): Array<StatefulValidationObject> {
148
+ return Object.values(this.validationsByState).flat();
149
+ }
150
+
112
151
  public weakBindings: Set<BindingInstance>;
152
+
113
153
  private onDismiss?: () => void;
114
154
 
115
155
  constructor(
116
- possibleValidations: Array<ValidationObject>,
156
+ possibleValidations: Array<ValidationObjectWithSource>,
117
157
  onDismiss?: () => void,
118
158
  log?: Logger,
119
159
  weakBindings?: Set<BindingInstance>
@@ -123,9 +163,8 @@ class ValidatedBinding {
123
163
  const { trigger } = vObj;
124
164
 
125
165
  if (this.validationsByState[trigger]) {
126
- this.validationsByState[trigger].push(
127
- createStatefulValidationObject(vObj)
128
- );
166
+ const statefulValidationObject = createStatefulValidationObject(vObj);
167
+ this.validationsByState[trigger].push(statefulValidationObject);
129
168
  } else {
130
169
  log?.warn(`Unknown validation trigger: ${trigger}`);
131
170
  }
@@ -133,87 +172,125 @@ class ValidatedBinding {
133
172
  this.weakBindings = weakBindings ?? new Set();
134
173
  }
135
174
 
175
+ private checkIfBlocking(statefulObj: StatefulValidationObject) {
176
+ if (statefulObj.state === 'active') {
177
+ const { isBlockingNavigation } = statefulObj;
178
+ return isBlockingNavigation;
179
+ }
180
+
181
+ return false;
182
+ }
183
+
184
+ public getAll(): Array<ValidationResponse> {
185
+ return this.applicableValidations.reduce((all, statefulObj) => {
186
+ if (statefulObj.state === 'active' && statefulObj.response) {
187
+ return [
188
+ ...all,
189
+ {
190
+ ...statefulObj.response,
191
+ blocking: this.checkIfBlocking(statefulObj),
192
+ },
193
+ ];
194
+ }
195
+
196
+ return all;
197
+ }, [] as Array<ValidationResponse>);
198
+ }
199
+
136
200
  public get(): ValidationResponse | undefined {
137
- const firstError = this.applicableValidations.find((statefulObj) => {
138
- const blocking =
139
- this.currentPhase === 'navigation' ? statefulObj.value.blocking : true;
140
- return statefulObj.state === 'active' && blocking !== false;
201
+ const firstInvalid = this.applicableValidations.find((statefulObj) => {
202
+ return statefulObj.state === 'active' && statefulObj.response;
141
203
  });
142
204
 
143
- if (firstError?.state === 'active') {
144
- return firstError.response;
205
+ if (firstInvalid?.state === 'active') {
206
+ return {
207
+ ...firstInvalid.response,
208
+ blocking: this.checkIfBlocking(firstInvalid),
209
+ };
145
210
  }
146
211
  }
147
212
 
148
213
  private runApplicableValidations(
149
214
  runner: ValidationRunner,
150
- canDismiss: boolean
215
+ canDismiss: boolean,
216
+ phase: Validation.Trigger
151
217
  ) {
152
218
  // If the currentState is not load, skip those
153
- this.applicableValidations = this.applicableValidations.map((obj) => {
154
- if (obj.state === 'dismissed') {
155
- // Don't rerun any dismissed warnings
156
- return obj;
157
- }
219
+ this.applicableValidations = this.applicableValidations.map(
220
+ (originalValue) => {
221
+ if (originalValue.state === 'dismissed') {
222
+ // Don't rerun any dismissed warnings
223
+ return originalValue;
224
+ }
158
225
 
159
- const blocking =
160
- obj.value.blocking ??
161
- ((obj.value.severity === 'warning' && 'once') ||
162
- (obj.value.severity === 'error' && true));
163
-
164
- const dismissable = canDismiss && blocking === 'once';
165
-
166
- if (
167
- this.currentPhase === 'navigation' &&
168
- obj.state === 'active' &&
169
- dismissable
170
- ) {
171
- if (obj.value.severity === 'warning') {
172
- const warn = obj as ActiveWarning;
173
- if (warn.dismissable && warn.response.dismiss) {
174
- warn.response.dismiss();
175
- } else {
176
- warn.dismissable = true;
177
- }
226
+ // treat all warnings the same and block it once (unless blocking is true)
227
+ const blocking =
228
+ originalValue.value.blocking ??
229
+ ((originalValue.value.severity === 'warning' && 'once') || true);
230
+
231
+ const obj = setIn(
232
+ originalValue,
233
+ ['value', 'blocking'],
234
+ blocking
235
+ ) as StatefulValidationObject;
178
236
 
179
- return obj;
237
+ const isBlockingNavigation =
238
+ blocking === true || (blocking === 'once' && !canDismiss);
239
+
240
+ if (
241
+ phase === 'navigation' &&
242
+ obj.state === 'active' &&
243
+ obj.value.blocking !== true
244
+ ) {
245
+ if (obj.value.severity === 'warning') {
246
+ const warn = obj as ActiveWarning;
247
+ if (
248
+ warn.dismissable &&
249
+ warn.response.dismiss &&
250
+ (warn.response.blocking !== 'once' || !warn.response.blocking)
251
+ ) {
252
+ warn.response.dismiss();
253
+ } else {
254
+ if (warn?.response.blocking === 'once') {
255
+ warn.response.blocking = false;
256
+ }
257
+
258
+ warn.dismissable = true;
259
+ }
260
+
261
+ return warn as StatefulValidationObject;
262
+ }
180
263
  }
181
264
 
182
- if (obj.value.severity === 'error') {
183
- const err = obj as StatefulError;
184
- err.state = 'none';
185
- return obj;
265
+ const response = runner(obj.value);
266
+
267
+ const newState = {
268
+ type: obj.type,
269
+ value: obj.value,
270
+ state: response ? 'active' : 'none',
271
+ isBlockingNavigation,
272
+ dismissable:
273
+ obj.value.severity === 'warning' && phase === 'navigation',
274
+ response: response
275
+ ? {
276
+ ...obj.value,
277
+ message: response.message ?? 'Something is broken',
278
+ severity: obj.value.severity,
279
+ displayTarget: obj.value.displayTarget ?? 'field',
280
+ }
281
+ : undefined,
282
+ } as StatefulValidationObject;
283
+
284
+ if (newState.state === 'active' && obj.value.severity === 'warning') {
285
+ (newState.response as WarningValidationResponse).dismiss = () => {
286
+ (newState as StatefulWarning).state = 'dismissed';
287
+ this.onDismiss?.();
288
+ };
186
289
  }
187
- }
188
290
 
189
- const response = runner(obj.value);
190
-
191
- const newState = {
192
- type: obj.type,
193
- value: obj.value,
194
- state: response ? 'active' : 'none',
195
- dismissable:
196
- obj.value.severity === 'warning' &&
197
- this.currentPhase === 'navigation',
198
- response: response
199
- ? {
200
- ...obj.value,
201
- message: response.message ?? 'Something is broken',
202
- severity: obj.value.severity,
203
- displayTarget: obj.value.displayTarget ?? 'field',
204
- }
205
- : undefined,
206
- } as StatefulValidationObject;
207
-
208
- if (newState.state === 'active' && obj.value.severity === 'warning') {
209
- (newState.response as WarningValidationResponse).dismiss = () => {
210
- (newState as StatefulWarning).state = 'dismissed';
211
- this.onDismiss?.();
212
- };
291
+ return newState;
213
292
  }
214
-
215
- return newState;
216
- });
293
+ );
217
294
  }
218
295
 
219
296
  public update(
@@ -221,6 +298,8 @@ class ValidatedBinding {
221
298
  canDismiss: boolean,
222
299
  runner: ValidationRunner
223
300
  ) {
301
+ const newApplicableValidations: StatefulValidationObject[] = [];
302
+
224
303
  if (phase === 'load' && this.currentPhase !== undefined) {
225
304
  // Tried to run the 'load' phase twice. Aborting
226
305
  return;
@@ -228,7 +307,7 @@ class ValidatedBinding {
228
307
 
229
308
  if (this.currentPhase === 'navigation' || phase === this.currentPhase) {
230
309
  // Already added all the types. No need to continue adding new validations
231
- this.runApplicableValidations(runner, canDismiss);
310
+ this.runApplicableValidations(runner, canDismiss, phase);
232
311
  return;
233
312
  }
234
313
 
@@ -247,15 +326,30 @@ class ValidatedBinding {
247
326
  (this.currentPhase === 'load' || this.currentPhase === 'change')
248
327
  ) {
249
328
  // Can transition to a nav state from a change or load
329
+
330
+ // if there is an non-blocking error that is active then remove the error from applicable validations so it can no longer be shown
331
+ // which is needed if there are additional warnings to become active for that binding after the error is shown
332
+ this.applicableValidations.forEach((element) => {
333
+ if (
334
+ !(
335
+ element.type === 'error' &&
336
+ element.state === 'active' &&
337
+ element.isBlockingNavigation === false
338
+ )
339
+ ) {
340
+ newApplicableValidations.push(element);
341
+ }
342
+ });
343
+
250
344
  this.applicableValidations = [
251
- ...this.applicableValidations,
252
- ...(this.currentPhase === 'load' ? this.validationsByState.change : []),
345
+ ...newApplicableValidations,
253
346
  ...this.validationsByState.navigation,
347
+ ...(this.currentPhase === 'load' ? this.validationsByState.change : []),
254
348
  ];
255
349
  this.currentPhase = 'navigation';
256
350
  }
257
351
 
258
- this.runApplicableValidations(runner, canDismiss);
352
+ this.runApplicableValidations(runner, canDismiss, phase);
259
353
  }
260
354
  }
261
355
 
@@ -292,21 +386,48 @@ export class ValidationController implements BindingTracker {
292
386
  onRemoveValidation: new SyncWaterfallHook<
293
387
  [ValidationResponse, BindingInstance]
294
388
  >(),
389
+
390
+ resolveValidationProviders: new SyncWaterfallHook<
391
+ [
392
+ Array<{
393
+ /** The name of the provider */
394
+ source: string;
395
+ /** The provider itself */
396
+ provider: ValidationProvider;
397
+ }>
398
+ ],
399
+ {
400
+ /** The view this is triggered for */
401
+ view?: ViewInstance;
402
+ }
403
+ >(),
404
+
405
+ /** A hook called when a binding is added to the tracker */
406
+ onTrackBinding: new SyncHook<[BindingInstance]>(),
295
407
  };
296
408
 
297
409
  private tracker: BindingTracker | undefined;
298
410
  private validations = new Map<BindingInstance, ValidatedBinding>();
299
411
  private validatorRegistry?: ValidatorRegistry;
300
412
  private schema: SchemaController;
301
- private providers: Array<ValidationProvider>;
413
+
414
+ private providers:
415
+ | Array<{
416
+ /** The name of the provider */
417
+ source: string;
418
+ /** The provider itself */
419
+ provider: ValidationProvider;
420
+ }>
421
+ | undefined;
422
+
423
+ private viewValidationProvider?: ValidationProvider;
302
424
  private options?: SimpleValidatorContext;
303
425
  private weakBindingTracker = new Set<BindingInstance>();
304
- private lastActiveBindings = new Set<BindingInstance>();
305
426
 
306
427
  constructor(schema: SchemaController, options?: SimpleValidatorContext) {
307
428
  this.schema = schema;
308
429
  this.options = options;
309
- this.providers = [schema];
430
+ this.reset();
310
431
  }
311
432
 
312
433
  setOptions(options: SimpleValidatorContext) {
@@ -316,6 +437,22 @@ export class ValidationController implements BindingTracker {
316
437
  /** Return the middleware for the data-model to stop propagation of invalid data */
317
438
  public getDataMiddleware(): Array<DataModelMiddleware> {
318
439
  return [
440
+ {
441
+ set: (transaction, options, next) => {
442
+ return next?.set(transaction, options) ?? [];
443
+ },
444
+ get: (binding, options, next) => {
445
+ return next?.get(binding, options);
446
+ },
447
+ delete: (binding, options, next) => {
448
+ this.validations = removeBindingAndChildrenFromMap(
449
+ this.validations,
450
+ binding
451
+ );
452
+
453
+ return next?.delete(binding, options);
454
+ },
455
+ },
319
456
  new ValidationMiddleware(
320
457
  (binding) => {
321
458
  if (!this.options) {
@@ -323,7 +460,6 @@ export class ValidationController implements BindingTracker {
323
460
  }
324
461
 
325
462
  this.updateValidationsForBinding(binding, 'change', this.options);
326
-
327
463
  const strongValidation = this.getValidationForBinding(binding);
328
464
 
329
465
  // return validation issues directly on bindings first
@@ -342,15 +478,17 @@ export class ValidationController implements BindingTracker {
342
478
  weakValidation?.get()?.severity === 'error'
343
479
  ) {
344
480
  weakValidation?.weakBindings.forEach((weakBinding) => {
345
- weakBinding === strongBinding
346
- ? newInvalidBindings.add({
347
- binding: weakBinding,
348
- isStrong: true,
349
- })
350
- : newInvalidBindings.add({
351
- binding: weakBinding,
352
- isStrong: false,
353
- });
481
+ if (weakBinding === strongBinding) {
482
+ newInvalidBindings.add({
483
+ binding: weakBinding,
484
+ isStrong: true,
485
+ });
486
+ } else {
487
+ newInvalidBindings.add({
488
+ binding: weakBinding,
489
+ isStrong: false,
490
+ });
491
+ }
354
492
  });
355
493
  }
356
494
  });
@@ -364,9 +502,44 @@ export class ValidationController implements BindingTracker {
364
502
  ];
365
503
  }
366
504
 
367
- public onView(view: ViewInstance): void {
505
+ private getValidationProviders() {
506
+ if (this.providers) {
507
+ return this.providers;
508
+ }
509
+
510
+ this.providers = this.hooks.resolveValidationProviders.call([
511
+ {
512
+ source: SCHEMA_VALIDATION_PROVIDER_NAME,
513
+ provider: this.schema,
514
+ },
515
+ {
516
+ source: VIEW_VALIDATION_PROVIDER_NAME,
517
+ provider: {
518
+ getValidationsForBinding: (
519
+ binding: BindingInstance
520
+ ): Array<ValidationObject> | undefined => {
521
+ return this.viewValidationProvider?.getValidationsForBinding?.(
522
+ binding
523
+ );
524
+ },
525
+
526
+ getValidationsForView: (): Array<ValidationObject> | undefined => {
527
+ return this.viewValidationProvider?.getValidationsForView?.();
528
+ },
529
+ },
530
+ },
531
+ ]);
532
+
533
+ return this.providers;
534
+ }
535
+
536
+ public reset() {
368
537
  this.validations.clear();
538
+ this.tracker = undefined;
539
+ }
369
540
 
541
+ public onView(view: ViewInstance): void {
542
+ this.validations.clear();
370
543
  if (!this.options) {
371
544
  return;
372
545
  }
@@ -375,7 +548,10 @@ export class ValidationController implements BindingTracker {
375
548
  ...this.options,
376
549
  callbacks: {
377
550
  onAdd: (binding) => {
378
- if (!this.options) {
551
+ if (
552
+ !this.options ||
553
+ this.getValidationForBinding(binding) !== undefined
554
+ ) {
379
555
  return;
380
556
  }
381
557
 
@@ -400,30 +576,43 @@ export class ValidationController implements BindingTracker {
400
576
  view.update(new Set([binding]));
401
577
  }
402
578
  );
579
+
580
+ this.hooks.onTrackBinding.call(binding);
403
581
  },
404
582
  },
405
583
  });
406
584
 
407
585
  this.tracker = bindingTrackerPlugin;
408
- this.providers = [this.schema, view];
586
+ this.viewValidationProvider = view;
409
587
 
410
588
  bindingTrackerPlugin.apply(view);
411
589
  }
412
590
 
413
- private updateValidationsForBinding(
591
+ updateValidationsForBinding(
414
592
  binding: BindingInstance,
415
593
  trigger: Validation.Trigger,
416
- context: SimpleValidatorContext,
594
+ validationContext?: SimpleValidatorContext,
417
595
  onDismiss?: () => void
418
596
  ): void {
597
+ const context = validationContext ?? this.options;
598
+
599
+ if (!context) {
600
+ throw new Error(`Context is required for executing validations`);
601
+ }
602
+
419
603
  if (trigger === 'load') {
420
604
  // Get all of the validations from each provider
421
- const possibleValidations = this.providers.reduce<
422
- Array<ValidationObject>
605
+ const possibleValidations = this.getValidationProviders().reduce<
606
+ Array<ValidationObjectWithSource>
423
607
  >(
424
608
  (vals, provider) => [
425
609
  ...vals,
426
- ...(provider.getValidationsForBinding?.(binding) ?? []),
610
+ ...(provider.provider
611
+ .getValidationsForBinding?.(binding)
612
+ ?.map((valObj) => ({
613
+ ...valObj,
614
+ [VALIDATION_PROVIDER_NAME_SYMBOL]: provider.source,
615
+ })) ?? []),
427
616
  ],
428
617
  []
429
618
  );
@@ -444,7 +633,7 @@ export class ValidationController implements BindingTracker {
444
633
 
445
634
  const trackedValidations = this.validations.get(binding);
446
635
  trackedValidations?.update(trigger, true, (validationObj) => {
447
- const response = this.validationRunner(validationObj, context, binding);
636
+ const response = this.validationRunner(validationObj, binding, context);
448
637
 
449
638
  if (this.weakBindingTracker.size > 0) {
450
639
  const t = this.validations.get(binding) as ValidatedBinding;
@@ -464,8 +653,8 @@ export class ValidationController implements BindingTracker {
464
653
  validation.update(trigger, true, (validationObj) => {
465
654
  const response = this.validationRunner(
466
655
  validationObj,
467
- context,
468
- vBinding
656
+ vBinding,
657
+ context
469
658
  );
470
659
  return response ? { message: response.message } : undefined;
471
660
  });
@@ -474,21 +663,28 @@ export class ValidationController implements BindingTracker {
474
663
  }
475
664
  }
476
665
 
477
- private validationRunner(
478
- validationObj: ValidationObject,
479
- context: SimpleValidatorContext,
480
- binding: BindingInstance
666
+ validationRunner(
667
+ validationObj: ValidationObjectWithHandler,
668
+ binding: BindingInstance,
669
+ context: SimpleValidatorContext | undefined = this.options
481
670
  ) {
482
- const handler = this.getValidator(validationObj.type);
671
+ if (!context) {
672
+ throw new Error('No context provided to validation runner');
673
+ }
674
+
675
+ const handler =
676
+ validationObj.handler ?? this.getValidator(validationObj.type);
677
+
483
678
  const weakBindings = new Set<BindingInstance>();
484
679
 
485
680
  // For any data-gets in the validation runner, default to using the _invalid_ value (since that's what we're testing against)
486
681
  const model: DataModelWithParser = {
487
- get(b, options = { includeInvalid: true }) {
682
+ get(b, options) {
488
683
  weakBindings.add(isBinding(b) ? binding : context.parseBinding(b));
489
- return context.model.get(b, options);
684
+ return context.model.get(b, { ...options, includeInvalid: true });
490
685
  },
491
686
  set: context.model.set,
687
+ delete: context.model.delete,
492
688
  };
493
689
 
494
690
  const result = handler?.(
@@ -500,6 +696,7 @@ export class ValidationController implements BindingTracker {
500
696
  ) => context.evaluate(exp, options),
501
697
  model,
502
698
  validation: validationObj,
699
+ schemaType: this.schema.getType(binding),
503
700
  },
504
701
  context.model.get(binding, {
505
702
  includeInvalid: true,
@@ -519,7 +716,6 @@ export class ValidationController implements BindingTracker {
519
716
  model,
520
717
  evaluate: context.evaluate,
521
718
  });
522
-
523
719
  if (parameters) {
524
720
  message = replaceParams(message, parameters);
525
721
  }
@@ -532,31 +728,34 @@ export class ValidationController implements BindingTracker {
532
728
  }
533
729
 
534
730
  private updateValidationsForView(trigger: Validation.Trigger): void {
535
- const { activeBindings } = this;
536
-
537
- const canDismiss =
538
- trigger !== 'navigation' ||
539
- this.setCompare(this.lastActiveBindings, activeBindings);
540
-
541
- this.getBindings().forEach((binding) => {
542
- this.validations.get(binding)?.update(trigger, canDismiss, (obj) => {
543
- if (!this.options) {
544
- return;
545
- }
731
+ const isNavigationTrigger = trigger === 'navigation';
732
+ const lastActiveBindings = this.activeBindings;
733
+
734
+ /** Run validations for all bindings in view */
735
+ const updateValidations = (dismissValidations: boolean) => {
736
+ this.getBindings().forEach((binding) => {
737
+ this.validations
738
+ .get(binding)
739
+ ?.update(trigger, dismissValidations, (obj) => {
740
+ if (!this.options) {
741
+ return;
742
+ }
546
743
 
547
- return this.validationRunner(obj, this.options, binding);
744
+ return this.validationRunner(obj, binding, this.options);
745
+ });
548
746
  });
549
- });
747
+ };
550
748
 
551
- if (trigger === 'navigation') {
552
- this.lastActiveBindings = activeBindings;
553
- }
554
- }
749
+ // Should dismiss for non-navigation triggers.
750
+ updateValidations(!isNavigationTrigger);
555
751
 
556
- private setCompare<T>(set1: Set<T>, set2: Set<T>): boolean {
557
- if (set1.size !== set2.size) return false;
558
- for (const entry of set1) if (!set2.has(entry)) return false;
559
- return true;
752
+ if (isNavigationTrigger) {
753
+ // If validations didn't change since last update, dismiss all dismissible validations.
754
+ const { activeBindings } = this;
755
+ if (isSubset(activeBindings, lastActiveBindings)) {
756
+ updateValidations(true);
757
+ }
758
+ }
560
759
  }
561
760
 
562
761
  private get activeBindings(): Set<BindingInstance> {
@@ -583,6 +782,10 @@ export class ValidationController implements BindingTracker {
583
782
  return this.tracker?.getBindings() ?? new Set();
584
783
  }
585
784
 
785
+ trackBinding(binding: BindingInstance): void {
786
+ this.tracker?.trackBinding(binding);
787
+ }
788
+
586
789
  /** Executes all known validations for the tracked bindings using the given model */
587
790
  validateView(trigger: Validation.Trigger = 'navigation'): {
588
791
  /** Indicating if the view can proceed without error */
@@ -595,26 +798,35 @@ export class ValidationController implements BindingTracker {
595
798
 
596
799
  const validations = new Map<BindingInstance, ValidationResponse>();
597
800
 
801
+ let canTransition = true;
802
+
598
803
  this.getBindings().forEach((b) => {
599
- const invalid = this.getValidationForBinding(b)?.get();
804
+ const allValidations = this.getValidationForBinding(b)?.getAll();
805
+
806
+ allValidations?.forEach((v) => {
807
+ if (trigger === 'navigation' && v.blocking) {
808
+ this.options?.logger.debug(
809
+ `Validation on binding: ${b.asString()} is preventing navigation. ${JSON.stringify(
810
+ v
811
+ )}`
812
+ );
600
813
 
601
- if (invalid) {
602
- this.options?.logger.debug(
603
- `Validation on binding: ${b.asString()} is preventing navigation. ${JSON.stringify(
604
- invalid
605
- )}`
606
- );
814
+ canTransition = false;
815
+ }
607
816
 
608
- validations.set(b, invalid);
609
- }
817
+ if (!validations.has(b)) {
818
+ validations.set(b, v);
819
+ }
820
+ });
610
821
  });
611
822
 
612
823
  return {
613
- canTransition: validations.size === 0,
824
+ canTransition,
614
825
  validations: validations.size ? validations : undefined,
615
826
  };
616
827
  }
617
828
 
829
+ /** Get the current tracked validation for the given binding */
618
830
  public getValidationForBinding(
619
831
  binding: BindingInstance
620
832
  ): ValidatedBinding | undefined {
@@ -626,11 +838,10 @@ export class ValidationController implements BindingTracker {
626
838
  _getValidationForBinding: (binding) => {
627
839
  return this.getValidationForBinding(
628
840
  isBinding(binding) ? binding : parser(binding)
629
- )?.get();
841
+ );
630
842
  },
631
843
  getAll: () => {
632
844
  const bindings = this.getBindings();
633
-
634
845
  if (bindings.size === 0) {
635
846
  return undefined;
636
847
  }
@@ -653,6 +864,9 @@ export class ValidationController implements BindingTracker {
653
864
  get() {
654
865
  throw new Error('Error Access be provided by the view plugin');
655
866
  },
867
+ getValidationsForBinding() {
868
+ throw new Error('Error rollup should be provided by the view plugin');
869
+ },
656
870
  getChildren() {
657
871
  throw new Error('Error rollup should be provided by the view plugin');
658
872
  },
@@ -664,7 +878,7 @@ export class ValidationController implements BindingTracker {
664
878
  },
665
879
  register: () => {
666
880
  throw new Error(
667
- 'Section funcationality hould be provided by the view plugin'
881
+ 'Section functionality should be provided by the view plugin'
668
882
  );
669
883
  },
670
884
  type: (binding) =>