@player-ui/player 0.0.1-next.1

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.
@@ -0,0 +1,661 @@
1
+ import type { BindingInstance, BindingFactory } from '@player-ui/binding';
2
+ import { isBinding } from '@player-ui/binding';
3
+ import type { DataModelWithParser, DataModelMiddleware } from '@player-ui/data';
4
+ import type { SchemaController } from '@player-ui/schema';
5
+ import type { Validation } from '@player-ui/types';
6
+ import type {
7
+ ErrorValidationResponse,
8
+ ValidationObject,
9
+ ValidatorContext,
10
+ ValidationProvider,
11
+ ValidationResponse,
12
+ WarningValidationResponse,
13
+ } from '@player-ui/validator';
14
+ import { ValidationMiddleware, ValidatorRegistry } from '@player-ui/validator';
15
+ import type { Logger } from '@player-ui/logger';
16
+ import { ProxyLogger } from '@player-ui/logger';
17
+ import type { Resolve, ViewInstance } from '@player-ui/view';
18
+ import { caresAboutDataChanges } from '@player-ui/view';
19
+ import { replaceParams } from '@player-ui/utils';
20
+ import { resolveDataRefs } from '@player-ui/string-resolver';
21
+ import type {
22
+ ExpressionEvaluatorOptions,
23
+ ExpressionType,
24
+ } from '@player-ui/expressions';
25
+ import { SyncHook, SyncWaterfallHook } from 'tapable';
26
+ import type { BindingTracker } from './binding-tracker';
27
+ import { ValidationBindingTrackerViewPlugin } from './binding-tracker';
28
+
29
+ type SimpleValidatorContext = Omit<ValidatorContext, 'validation'>;
30
+
31
+ interface BaseActiveValidation<T> {
32
+ /** The validation is being actively shown */
33
+ state: 'active';
34
+
35
+ /** The validation response */
36
+ response: T;
37
+ }
38
+
39
+ type ActiveWarning = BaseActiveValidation<WarningValidationResponse> & {
40
+ /** Warnings track if they can be dismissed automatically (by navigating) */
41
+ dismissable: boolean;
42
+ };
43
+ type ActiveError = BaseActiveValidation<ErrorValidationResponse>;
44
+
45
+ /**
46
+ * warnings that keep track of their active state
47
+ */
48
+ type StatefulWarning = {
49
+ /** A common key to differentiate between errors and warnings */
50
+ type: 'warning';
51
+
52
+ /** The underlying validation this tracks */
53
+ value: ValidationObject;
54
+ } & (
55
+ | {
56
+ /** warnings start with no state, but can active or dismissed */
57
+ state: 'none' | 'dismissed';
58
+ }
59
+ | ActiveWarning
60
+ );
61
+
62
+ /** Errors that keep track of their state */
63
+ type StatefulError = {
64
+ /** A common key to differentiate between errors and warnings */
65
+ type: 'error';
66
+
67
+ /** The underlying validation this tracks */
68
+ value: ValidationObject;
69
+ } & (
70
+ | {
71
+ /** Errors start with no state an can be activated */
72
+ state: 'none';
73
+ }
74
+ | ActiveError
75
+ );
76
+
77
+ export type StatefulValidationObject = StatefulWarning | StatefulError;
78
+
79
+ /** Helper for initializing a validation object that tracks state */
80
+ function createStatefulValidationObject(
81
+ obj: ValidationObject
82
+ ): StatefulValidationObject {
83
+ return {
84
+ value: obj,
85
+ type: obj.severity,
86
+ state: 'none',
87
+ };
88
+ }
89
+
90
+ type ValidationRunner = (obj: ValidationObject) =>
91
+ | {
92
+ /** A validation message */
93
+ message: string;
94
+ }
95
+ | undefined;
96
+
97
+ /** A class that manages validating bindings across phases */
98
+ class ValidatedBinding {
99
+ private currentPhase?: Validation.Trigger;
100
+ private applicableValidations: Array<StatefulValidationObject> = [];
101
+ private validationsByState: Record<
102
+ Validation.Trigger,
103
+ Array<StatefulValidationObject>
104
+ > = {
105
+ load: [],
106
+ change: [],
107
+ navigation: [],
108
+ };
109
+
110
+ public weakBindings: Set<BindingInstance>;
111
+ private onDismiss?: () => void;
112
+
113
+ constructor(
114
+ possibleValidations: Array<ValidationObject>,
115
+ onDismiss?: () => void,
116
+ log?: Logger,
117
+ weakBindings?: Set<BindingInstance>
118
+ ) {
119
+ this.onDismiss = onDismiss;
120
+ possibleValidations.forEach((vObj) => {
121
+ const { trigger } = vObj;
122
+
123
+ if (this.validationsByState[trigger]) {
124
+ this.validationsByState[trigger].push(
125
+ createStatefulValidationObject(vObj)
126
+ );
127
+ } else {
128
+ log?.warn(`Unknown validation trigger: ${trigger}`);
129
+ }
130
+ });
131
+ this.weakBindings = weakBindings ?? new Set();
132
+ }
133
+
134
+ public get(): ValidationResponse | undefined {
135
+ const firstError = this.applicableValidations.find((statefulObj) => {
136
+ const blocking =
137
+ this.currentPhase === 'navigation' ? statefulObj.value.blocking : true;
138
+ return statefulObj.state === 'active' && blocking !== false;
139
+ });
140
+
141
+ if (firstError?.state === 'active') {
142
+ return firstError.response;
143
+ }
144
+ }
145
+
146
+ private runApplicableValidations(
147
+ runner: ValidationRunner,
148
+ canDismiss: boolean
149
+ ) {
150
+ // If the currentState is not load, skip those
151
+ this.applicableValidations = this.applicableValidations.map((obj) => {
152
+ if (obj.state === 'dismissed') {
153
+ // Don't rerun any dismissed warnings
154
+ return obj;
155
+ }
156
+
157
+ const blocking =
158
+ obj.value.blocking ??
159
+ ((obj.value.severity === 'warning' && 'once') ||
160
+ (obj.value.severity === 'error' && true));
161
+
162
+ const dismissable = canDismiss && blocking === 'once';
163
+
164
+ if (
165
+ this.currentPhase === 'navigation' &&
166
+ obj.state === 'active' &&
167
+ dismissable
168
+ ) {
169
+ if (obj.value.severity === 'warning') {
170
+ const warn = obj as ActiveWarning;
171
+ if (warn.dismissable && warn.response.dismiss) {
172
+ warn.response.dismiss();
173
+ } else {
174
+ warn.dismissable = true;
175
+ }
176
+
177
+ return obj;
178
+ }
179
+
180
+ if (obj.value.severity === 'error') {
181
+ const err = obj as StatefulError;
182
+ err.state = 'none';
183
+ return obj;
184
+ }
185
+ }
186
+
187
+ const response = runner(obj.value);
188
+
189
+ const newState = {
190
+ type: obj.type,
191
+ value: obj.value,
192
+ state: response ? 'active' : 'none',
193
+ dismissable:
194
+ obj.value.severity === 'warning' &&
195
+ this.currentPhase === 'navigation',
196
+ response: response
197
+ ? {
198
+ ...obj.value,
199
+ message: response.message ?? 'Something is broken',
200
+ severity: obj.value.severity,
201
+ displayTarget: obj.value.displayTarget ?? 'field',
202
+ }
203
+ : undefined,
204
+ } as StatefulValidationObject;
205
+
206
+ if (newState.state === 'active' && obj.value.severity === 'warning') {
207
+ (newState.response as WarningValidationResponse).dismiss = () => {
208
+ (newState as StatefulWarning).state = 'dismissed';
209
+ this.onDismiss?.();
210
+ };
211
+ }
212
+
213
+ return newState;
214
+ });
215
+ }
216
+
217
+ public update(
218
+ phase: Validation.Trigger,
219
+ canDismiss: boolean,
220
+ runner: ValidationRunner
221
+ ) {
222
+ if (phase === 'load' && this.currentPhase !== undefined) {
223
+ // Tried to run the 'load' phase twice. Aborting
224
+ return;
225
+ }
226
+
227
+ if (this.currentPhase === 'navigation' || phase === this.currentPhase) {
228
+ // Already added all the types. No need to continue adding new validations
229
+ this.runApplicableValidations(runner, canDismiss);
230
+ return;
231
+ }
232
+
233
+ if (phase === 'load') {
234
+ this.currentPhase = 'load';
235
+ this.applicableValidations = [...this.validationsByState.load];
236
+ } else if (phase === 'change' && this.currentPhase === 'load') {
237
+ this.currentPhase = 'change';
238
+ // The transition to the 'change' type can only come from a 'load' type
239
+ this.applicableValidations = [
240
+ ...this.applicableValidations,
241
+ ...this.validationsByState.change,
242
+ ];
243
+ } else if (
244
+ phase === 'navigation' &&
245
+ (this.currentPhase === 'load' || this.currentPhase === 'change')
246
+ ) {
247
+ // Can transition to a nav state from a change or load
248
+ this.applicableValidations = [
249
+ ...this.applicableValidations,
250
+ ...(this.currentPhase === 'load' ? this.validationsByState.change : []),
251
+ ...this.validationsByState.navigation,
252
+ ];
253
+ this.currentPhase = 'navigation';
254
+ }
255
+
256
+ this.runApplicableValidations(runner, canDismiss);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * A controller for orchestrating validation within a running player
262
+ *
263
+ * The current validation flow is as follows:
264
+ *
265
+ * - When a binding is first seen, gather all of the possible validations for it from the providers
266
+ * - Schema and Crossfield (view) are both providers of possible validations
267
+ * - Run all of the applicable validations for that binding for the `load` trigger
268
+ *
269
+ * - When a change occurs, set the phase of the binding to `change`.
270
+ * - Run all of the `change` triggered validations for that binding.
271
+ *
272
+ * - When a navigation event occurs, set the phase of the binding to `navigate`.
273
+ * - Run all `change` and `navigate` validations for each tracked binding.
274
+ * - For any warnings, also keep a state of `shown` or `dismissed`.
275
+ * - Set all non-dismissed warnings to `shown`.
276
+ * - Set all `shown` warnings to `dismissed`.
277
+ * - Allow navigation forward if there are no non-dismissed warnings and no valid errors.
278
+ */
279
+ export class ValidationController implements BindingTracker {
280
+ public readonly hooks = {
281
+ /** A hook called to tap into the validator registry for adding more validators */
282
+ createValidatorRegistry: new SyncHook<ValidatorRegistry>(['registry']),
283
+
284
+ /** A callback/event when a new validation is added to the view */
285
+ onAddValidation: new SyncWaterfallHook<ValidationResponse, BindingInstance>(
286
+ ['validation', 'binding']
287
+ ),
288
+
289
+ /** The inverse of onAddValidation, this is called when a validation is removed from the list */
290
+ onRemoveValidation: new SyncWaterfallHook<
291
+ ValidationResponse,
292
+ BindingInstance
293
+ >(['validation', 'binding']),
294
+ };
295
+
296
+ private tracker: BindingTracker | undefined;
297
+ private validations = new Map<BindingInstance, ValidatedBinding>();
298
+ private validatorRegistry?: ValidatorRegistry;
299
+ private schema: SchemaController;
300
+ private providers: Array<ValidationProvider>;
301
+ private options?: SimpleValidatorContext;
302
+ private weakBindingTracker = new Set<BindingInstance>();
303
+ private lastActiveBindings = new Set<BindingInstance>();
304
+
305
+ constructor(schema: SchemaController, options?: SimpleValidatorContext) {
306
+ this.schema = schema;
307
+ this.options = options;
308
+ this.providers = [schema];
309
+ }
310
+
311
+ setOptions(options: SimpleValidatorContext) {
312
+ this.options = options;
313
+ }
314
+
315
+ /** Return the middleware for the data-model to stop propagation of invalid data */
316
+ public getDataMiddleware(): Array<DataModelMiddleware> {
317
+ return [
318
+ new ValidationMiddleware(
319
+ (binding) => {
320
+ if (!this.options) {
321
+ return;
322
+ }
323
+
324
+ this.updateValidationsForBinding(binding, 'change', this.options);
325
+
326
+ const strongValidation = this.getValidationForBinding(binding);
327
+
328
+ // return validation issues directly on bindings first
329
+ if (strongValidation?.get()) return strongValidation.get();
330
+
331
+ // if none, check to see any validations this binding may be a weak ref of and return
332
+ const newInvalidBindings: Set<BindingInstance> = new Set();
333
+ for (const [, weakValidation] of Array.from(this.validations)) {
334
+ if (
335
+ caresAboutDataChanges(
336
+ new Set([binding]),
337
+ weakValidation.weakBindings
338
+ ) &&
339
+ weakValidation?.get()
340
+ ) {
341
+ weakValidation?.weakBindings.forEach(
342
+ newInvalidBindings.add,
343
+ newInvalidBindings
344
+ );
345
+ }
346
+ }
347
+
348
+ if (newInvalidBindings.size > 0) {
349
+ return newInvalidBindings;
350
+ }
351
+ },
352
+ { logger: new ProxyLogger(() => this.options?.logger) }
353
+ ),
354
+ ];
355
+ }
356
+
357
+ public onView(view: ViewInstance): void {
358
+ this.validations.clear();
359
+
360
+ if (!this.options) {
361
+ return;
362
+ }
363
+
364
+ const bindingTrackerPlugin = new ValidationBindingTrackerViewPlugin({
365
+ ...this.options,
366
+ callbacks: {
367
+ onAdd: (binding) => {
368
+ if (!this.options) {
369
+ return;
370
+ }
371
+
372
+ // Set the default value for the binding if we need to
373
+ const originalValue = this.options.model.get(binding);
374
+ const withoutDefault = this.options.model.get(binding, {
375
+ ignoreDefaultValue: true,
376
+ });
377
+
378
+ if (originalValue !== withoutDefault) {
379
+ this.options.model.set([[binding, originalValue]]);
380
+ }
381
+
382
+ this.updateValidationsForBinding(
383
+ binding,
384
+ 'load',
385
+ this.options,
386
+ () => {
387
+ view.update(new Set([binding]));
388
+ }
389
+ );
390
+ },
391
+ },
392
+ });
393
+
394
+ this.tracker = bindingTrackerPlugin;
395
+ this.providers = [this.schema, view];
396
+
397
+ bindingTrackerPlugin.apply(view);
398
+ }
399
+
400
+ private updateValidationsForBinding(
401
+ binding: BindingInstance,
402
+ trigger: Validation.Trigger,
403
+ context: SimpleValidatorContext,
404
+ onDismiss?: () => void
405
+ ): void {
406
+ if (trigger === 'load') {
407
+ // Get all of the validations from each provider
408
+ const possibleValidations = this.providers.reduce<
409
+ Array<ValidationObject>
410
+ >(
411
+ (vals, provider) => [
412
+ ...vals,
413
+ ...(provider.getValidationsForBinding?.(binding) ?? []),
414
+ ],
415
+ []
416
+ );
417
+
418
+ if (possibleValidations.length === 0) {
419
+ return;
420
+ }
421
+
422
+ this.validations.set(
423
+ binding,
424
+ new ValidatedBinding(
425
+ possibleValidations,
426
+ onDismiss,
427
+ this.options?.logger
428
+ )
429
+ );
430
+ }
431
+
432
+ const trackedValidations = this.validations.get(binding);
433
+ trackedValidations?.update(trigger, true, (validationObj) => {
434
+ const response = this.validationRunner(validationObj, context, binding);
435
+
436
+ if (this.weakBindingTracker.size > 0) {
437
+ const t = this.validations.get(binding) as ValidatedBinding;
438
+ this.weakBindingTracker.forEach((b) => t.weakBindings.add(b));
439
+ }
440
+
441
+ return response ? { message: response.message } : undefined;
442
+ });
443
+
444
+ // Also run any validations that binding or sub-binding is a weak binding of
445
+ if (trigger !== 'load') {
446
+ this.validations.forEach((validation, vBinding) => {
447
+ if (
448
+ vBinding !== binding &&
449
+ caresAboutDataChanges(new Set([binding]), validation.weakBindings)
450
+ ) {
451
+ validation.update(trigger, true, (validationObj) => {
452
+ const response = this.validationRunner(
453
+ validationObj,
454
+ context,
455
+ binding
456
+ );
457
+ return response ? { message: response.message } : undefined;
458
+ });
459
+ }
460
+ });
461
+ }
462
+ }
463
+
464
+ private validationRunner(
465
+ validationObj: ValidationObject,
466
+ context: SimpleValidatorContext,
467
+ binding: BindingInstance
468
+ ) {
469
+ const handler = this.getValidator(validationObj.type);
470
+ const weakBindings = new Set<BindingInstance>();
471
+
472
+ // For any data-gets in the validation runner, default to using the _invalid_ value (since that's what we're testing against)
473
+ const model: DataModelWithParser = {
474
+ get(b, options = { includeInvalid: true }) {
475
+ weakBindings.add(isBinding(b) ? binding : context.parseBinding(b));
476
+ return context.model.get(b, options);
477
+ },
478
+ set: context.model.set,
479
+ };
480
+
481
+ const result = handler?.(
482
+ {
483
+ ...context,
484
+ evaluate: (
485
+ exp: ExpressionType,
486
+ options: ExpressionEvaluatorOptions = { model }
487
+ ) => context.evaluate(exp, options),
488
+ model,
489
+ validation: validationObj,
490
+ },
491
+ context.model.get(binding, {
492
+ includeInvalid: true,
493
+ formatted: validationObj.dataTarget === 'formatted',
494
+ }),
495
+ validationObj
496
+ );
497
+
498
+ this.weakBindingTracker = weakBindings;
499
+
500
+ if (result) {
501
+ let { message } = result;
502
+ const { parameters } = result;
503
+
504
+ if (validationObj.message) {
505
+ message = resolveDataRefs(validationObj.message, {
506
+ model,
507
+ evaluate: context.evaluate,
508
+ });
509
+
510
+ if (parameters) {
511
+ message = replaceParams(message, parameters);
512
+ }
513
+ }
514
+
515
+ return {
516
+ message,
517
+ };
518
+ }
519
+ }
520
+
521
+ private updateValidationsForView(trigger: Validation.Trigger): void {
522
+ const { activeBindings } = this;
523
+
524
+ const canDismiss =
525
+ trigger !== 'navigation' ||
526
+ this.setCompare(this.lastActiveBindings, activeBindings);
527
+
528
+ this.getBindings().forEach((binding) => {
529
+ this.validations.get(binding)?.update(trigger, canDismiss, (obj) => {
530
+ if (!this.options) {
531
+ return;
532
+ }
533
+
534
+ return this.validationRunner(obj, this.options, binding);
535
+ });
536
+ });
537
+
538
+ if (trigger === 'navigation') {
539
+ this.lastActiveBindings = activeBindings;
540
+ }
541
+ }
542
+
543
+ private setCompare<T>(set1: Set<T>, set2: Set<T>): boolean {
544
+ if (set1.size !== set2.size) return false;
545
+ for (const entry of set1) if (!set2.has(entry)) return false;
546
+ return true;
547
+ }
548
+
549
+ private get activeBindings(): Set<BindingInstance> {
550
+ return new Set(
551
+ Array.from(this.getBindings()).filter(
552
+ (b) => this.validations.get(b)?.get() !== undefined
553
+ )
554
+ );
555
+ }
556
+
557
+ private getValidator(type: string) {
558
+ if (this.validatorRegistry) {
559
+ return this.validatorRegistry.get(type);
560
+ }
561
+
562
+ const registry = new ValidatorRegistry();
563
+ this.hooks.createValidatorRegistry.call(registry);
564
+ this.validatorRegistry = registry;
565
+
566
+ return registry.get(type);
567
+ }
568
+
569
+ getBindings(): Set<BindingInstance> {
570
+ return this.tracker?.getBindings() ?? new Set();
571
+ }
572
+
573
+ /** Executes all known validations for the tracked bindings using the given model */
574
+ validateView(trigger: Validation.Trigger = 'navigation'): {
575
+ /** Indicating if the view can proceed without error */
576
+ canTransition: boolean;
577
+
578
+ /** the validations that are preventing the view from continuing */
579
+ validations?: Map<BindingInstance, ValidationResponse>;
580
+ } {
581
+ this.updateValidationsForView(trigger);
582
+
583
+ const validations = new Map<BindingInstance, ValidationResponse>();
584
+
585
+ for (const b of this.getBindings()) {
586
+ const invalid = this.getValidationForBinding(b)?.get();
587
+
588
+ if (invalid) {
589
+ this.options?.logger.debug(
590
+ `Validation on binding: ${b.asString()} is preventing navigation. ${JSON.stringify(
591
+ invalid
592
+ )}`
593
+ );
594
+
595
+ validations.set(b, invalid);
596
+ }
597
+ }
598
+
599
+ return {
600
+ canTransition: validations.size === 0,
601
+ validations: validations.size ? validations : undefined,
602
+ };
603
+ }
604
+
605
+ public getValidationForBinding(
606
+ binding: BindingInstance
607
+ ): ValidatedBinding | undefined {
608
+ return this.validations.get(binding);
609
+ }
610
+
611
+ forView(parser: BindingFactory): Resolve.Validation {
612
+ return {
613
+ _getValidationForBinding: (binding) => {
614
+ return this.getValidationForBinding(
615
+ isBinding(binding) ? binding : parser(binding)
616
+ )?.get();
617
+ },
618
+ getAll: () => {
619
+ const bindings = this.getBindings();
620
+
621
+ if (bindings.size === 0) {
622
+ return undefined;
623
+ }
624
+
625
+ const validationMapping = new Map<
626
+ BindingInstance,
627
+ ValidationResponse
628
+ >();
629
+
630
+ bindings.forEach((b) => {
631
+ const validation = this.getValidationForBinding(b)?.get();
632
+
633
+ if (validation) {
634
+ validationMapping.set(b, validation);
635
+ }
636
+ });
637
+
638
+ return validationMapping.size === 0 ? undefined : validationMapping;
639
+ },
640
+ get() {
641
+ throw new Error('Error Access be provided by the view plugin');
642
+ },
643
+ getChildren() {
644
+ throw new Error('Error rollup should be provided by the view plugin');
645
+ },
646
+ getValidationsForSection() {
647
+ throw new Error('Error rollup should be provided by the view plugin');
648
+ },
649
+ track: () => {
650
+ throw new Error('Tracking should be provided by the view plugin');
651
+ },
652
+ register: () => {
653
+ throw new Error(
654
+ 'Section funcationality hould be provided by the view plugin'
655
+ );
656
+ },
657
+ type: (binding) =>
658
+ this.schema.getType(isBinding(binding) ? binding : parser(binding)),
659
+ };
660
+ }
661
+ }
@@ -0,0 +1,2 @@
1
+ export * from './controller';
2
+ export * from './binding-tracker';