Skip to content

Commit

Permalink
feat: make the component OnPush
Browse files Browse the repository at this point in the history
feat: make the component OnPush

I have no idea if this change actually makes a difference in terms of performance, but I think it should,
 since it should recompute the errors to display at each change detection.
And I fear that it's not actually completely safe to do it this way, so maybe we should just wait until forms use signals.
  • Loading branch information
jnizet committed Feb 16, 2024
1 parent c5e96eb commit 630a53c
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 25 deletions.
12 changes: 6 additions & 6 deletions projects/ngx-valdemort/src/lib/validation-errors.component.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<ng-container *ngIf="shouldDisplayErrors">
<ng-container *ngIf="errorsToDisplay as e">
<div [class]="errorClasses" *ngFor="let errorDirective of e.errors">
<ng-container *ngIf="vm() as vm">
<ng-container *ngIf="vm.shouldDisplayErrors">
<div [class]="errorClasses" *ngFor="let errorDirective of vm.errorsToDisplay.errors">
<ng-container
*ngTemplateOutlet="errorDirective!.templateRef; context: {
$implicit: label,
error: actualControl!.errors![errorDirective.type]
error: vm.control.errors![errorDirective.type]
}"
></ng-container>
</div>
<div [class]="errorClasses" *ngFor="let error of e.fallbackErrors">
<div [class]="errorClasses" *ngFor="let error of vm.errorsToDisplay.fallbackErrors">
<ng-container
*ngTemplateOutlet="e.fallback!.templateRef; context: {
*ngTemplateOutlet="vm.errorsToDisplay.fallback!.templateRef; context: {
$implicit: label,
type: error.type,
error: error.value
Expand Down
118 changes: 99 additions & 19 deletions projects/ngx-valdemort/src/lib/validation-errors.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
/* eslint-disable @angular-eslint/no-host-metadata-property */
import { Component, ContentChild, ContentChildren, Input, Optional, QueryList } from '@angular/core';
import { AbstractControl, ControlContainer, FormArray, FormGroupDirective, NgForm } from '@angular/forms';
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ContentChild,
ContentChildren,
DoCheck,
Input,
Optional,
QueryList,
Signal
} from '@angular/core';
import { AbstractControl, ControlContainer, FormArray, FormGroupDirective, NgForm, ValidationErrors } from '@angular/forms';
import { DisplayMode, ValdemortConfig } from './valdemort-config.service';
import { DefaultValidationErrors } from './default-validation-errors.service';
import { ValidationErrorDirective } from './validation-error.directive';
import { ValidationFallbackDirective } from './validation-fallback.directive';
import { NgFor, NgIf, NgTemplateOutlet } from '@angular/common';
import { combineLatest, distinctUntilChanged, map, Subject } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

interface FallbackError {
type: string;
Expand All @@ -24,6 +37,36 @@ interface ErrorsToDisplay {
fallbackErrors: Array<FallbackError>;
}

type ViewModel =
| {
shouldDisplayErrors: false;
}
| {
shouldDisplayErrors: true;
errorsToDisplay: ErrorsToDisplay;
control: AbstractControl;
};

const NO_ERRORS: ViewModel = {
shouldDisplayErrors: false
};

interface ValidationState {
control: AbstractControl | null;
errorsDisplayed: boolean | null;
errors: ValidationErrors | null;
}

const NO_VALIDATION_STATE: ValidationState = {
control: null,
errorsDisplayed: null,
errors: null
};

function areValidationStatesEqual(previous: ValidationState, current: ValidationState): boolean {
return previous.control === current.control && previous.errorsDisplayed === current.errorsDisplayed && previous.errors === current.errors;
}

/**
* Component allowing to display validation error messages associated to a given form control, form group or form array.
* The control is provided using the `control` input of the component. If it's used inside an enclosing form group or
Expand Down Expand Up @@ -105,12 +148,13 @@ interface ErrorsToDisplay {
templateUrl: './validation-errors.component.html',
host: {
'[class]': 'errorsClasses',
'[style.display]': `shouldDisplayErrors ? '' : 'none'`
'[style.display]': `vm().shouldDisplayErrors ? '' : 'none'`
},
standalone: true,
imports: [NgIf, NgFor, NgTemplateOutlet]
imports: [NgIf, NgFor, NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ValidationErrorsComponent {
export class ValidationErrorsComponent implements AfterContentInit, DoCheck {
/**
* The FormControl, FormGroup or FormArray containing the validation errors.
* If set, the controlName input is ignored
Expand Down Expand Up @@ -144,6 +188,14 @@ export class ValidationErrorsComponent {
@ContentChild(ValidationFallbackDirective)
fallbackDirective: ValidationFallbackDirective | undefined;

readonly vm: Signal<ViewModel>;

readonly errorsClasses = this.config.errorsClasses || '';
readonly errorClasses = this.config.errorClasses || '';

private validationStateChanges = new Subject<ValidationState>();
private contentInit = new Subject<void>();

/**
* @param config the Config service instance, defining the behavior of this component
* @param defaultValidationErrors the service holding the default error templates, optionally
Expand All @@ -156,32 +208,60 @@ export class ValidationErrorsComponent {
private config: ValdemortConfig,
private defaultValidationErrors: DefaultValidationErrors,
@Optional() private controlContainer: ControlContainer
) {}
) {
this.vm = toSignal(
combineLatest([this.validationStateChanges.pipe(distinctUntilChanged(areValidationStatesEqual)), this.contentInit]).pipe(
map(([validationState]) => {
const ctrl = validationState.control;
if (this.shouldDisplayErrors(ctrl)) {
const errorsToDisplay = this.findErrorsToDisplay(ctrl);
return {
shouldDisplayErrors: true,
control: ctrl,
errorsToDisplay
};
} else {
return NO_ERRORS;
}
})
),
{ initialValue: NO_ERRORS }
);
}

ngAfterContentInit(): void {
this.contentInit.next();
}

get shouldDisplayErrors(): boolean {
const ctrl = this.actualControl;
ngDoCheck(): void {
const ctrl = this.findActualControl();
if (ctrl) {
const formDirective = this.controlContainer?.formDirective as NgForm | FormGroupDirective | undefined;
const errorsDisplayed = this.config.shouldDisplayErrors(ctrl, formDirective);
this.validationStateChanges.next({
control: ctrl,
errorsDisplayed,
errors: ctrl.errors
});
} else {
this.validationStateChanges.next(NO_VALIDATION_STATE);
}
}

private shouldDisplayErrors(ctrl: AbstractControl | null): ctrl is AbstractControl {
if (!ctrl || !ctrl.invalid || !this.hasDisplayableError(ctrl)) {
return false;
}
const form = this.controlContainer && (this.controlContainer.formDirective as NgForm | FormGroupDirective);
return this.config.shouldDisplayErrors(ctrl, form);
}

get errorsClasses(): string {
return this.config.errorsClasses || '';
}

get errorClasses(): string {
return this.config.errorClasses || '';
}

get errorsToDisplay(): ErrorsToDisplay {
private findErrorsToDisplay(ctrl: AbstractControl): ErrorsToDisplay {
const mergedDirectives: Array<ValidationErrorDirective> = [];
const fallbackErrors: Array<FallbackError> = [];
const alreadyMetTypes = new Set<string>();
const shouldContinue = () =>
this.config.displayMode === DisplayMode.ALL || (mergedDirectives.length === 0 && fallbackErrors.length === 0);
const ctrl = this.actualControl!;
for (let i = 0; i < this.defaultValidationErrors.directives.length && shouldContinue(); i++) {
const defaultDirective = this.defaultValidationErrors.directives[i];
if (ctrl.hasError(defaultDirective.type)) {
Expand Down Expand Up @@ -219,7 +299,7 @@ export class ValidationErrorsComponent {
};
}

get actualControl(): AbstractControl | null {
private findActualControl(): AbstractControl | null {
if (this.control) {
return this.control;
} else if ((this.controlName || (this.controlName as number) === 0) && (this.controlContainer.control as FormArray)?.controls) {
Expand Down

0 comments on commit 630a53c

Please sign in to comment.