Como validar um intervalo de data utilizando um custom validator do ReactiveForms do Angular 2+

Objetivo e construção do ambiente

O objetivo é construir um formulário com dois campos:

  • data inicial
  • data final

Ao término do desenvolvimento o botão Enviar deverá ser habilitado apenas se a diferença entre a data inicial e a data final for menor que 7 dias.

A título de conhecimento, em JavaScript conseguimos efetuar este cálculo da seguinte forma:

const a = new Date(2021, 0, 1);
const b = new Date(2021, 0, 5);
console.log((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));

// Resultado: 4

O desenvolvimento do formulário ficará no AppComponent que pertence ao módulo AppModule. Como vamos trabalhar com Reactive Forms será necessário importar o módulo ReactiveFormsModule:

// app.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    AppRoutingModule,
    BrowserModule,
    CommonModule,
    ReactiveFormsModule,    // <<< Se vamos trabalhar com
                            //     reactive forms, é melhor
                            //     importar seu módulo :)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

No AppComponent será construído um FormGroup utilizando o FormBuilder. Nosso FormGroup (meuFormulario) terá dois campos obrigatórios:

  • dataInicial cujo o valor inicial será '2021-01-01'
  • dataFinal cujo o valor inicial será '2021-01-15'
// app.component.ts

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  meuFormulario: FormGroup;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit() {
    this.meuFormulario = this.formBuilder.group({
      // Para tornar o campo obrigatório devemos
      // adicionar o Validators.required:
      dataInicial: ['2021-01-01', Validators.required],
      dataFinal: ['2021-01-15', Validators.required],
    });
  }

  salvarFormulario() {
    console.log('Salvo!!!');
  }
}

Para vincular o FormGroup (meuFormulario) ao formulário no template vamos utilizar o [formGroup] e depois utilizar o formControlName para vincular os campos (form controls) do meuFormulario aos <input> do template, veja abaixo:

<!-- app.component.html -->

<!-- ... (código ocultado) ... -->

<form [formGroup]="meuFormulario" (ngSubmit)="salvarFormulario()">
<div>
    <label for="dataInicial">Data Inicial</label>
    <input type="text" id="dataInicial"
    placeholder="yyyy-MM-dd"
    formControlName="dataInicial">
</div>
<div *ngIf="meuFormulario.controls.dataInicial.errors?.required">
    Por favor informe a data inicial
</div>
<div>
    <label for="dataFinal">Data Final</label>
    <input type="text" id="dataFinal"
    placeholder="yyyy-MM-dd"
    formControlName="dataFinal">
</div>
<div *ngIf="meuFormulario.controls.dataFinal.errors?.required">
    Por favor informe a data final
</div>
<div>
    <!--
        Veja o [disabled]: o botão será desabilitado
        caso haja algum erro no meuFormulario:
    -->
    <button [disabled]="meuFormulario.invalid"
    type="submit">Enviar</button>
</div>
</form>

<!-- ... (código ocultado) ... -->
Para quem não conhece o safe navigation operator, é a interrogação (?) utilizada na lógica de exibição de mensagem de erro:

meuFormulario.controls.dataInicial.errors?.required

Neste caso a expresssão retornará null caso errors seja null. Perceba que sem este operador caso errors seja null ocorrerá um erro ao tentar efetuar a leitura do campo required de um null.

Testando o código acima:

Resultado da construção inicial do formulário

Veja na animação acima que estamos validando apenas se os dois campos estão preenchidos para então habilitar o botão Enviar. Quem cria essa obrigatoriedade do preenchimento do campo é o Validators.required que adicionamos na configuração destes campos (form controls) no AppComponent.

A próxima etapa é construir um custom validator para cruzar estas duas informações e verificar se o intervalo entre as datas é maior que 7 dias.

Criando um custom validator para cruzar o valor dos dois inputs

Por definição um validator (ValidatorFn) nada mais é do que uma interface que define uma função que recebe um AbstractControl e retorna um ValidationErrors ou null:

// https://angular.io/api/forms/ValidatorFn

interface ValidatorFn {
  (control: AbstractControl): ValidationErrors | null
}

Se ficou um pouco confuso a explicação acima, tente pensar que para criar um custom validator precisamos criar uma função que receberá como parâmetro um AbstractControl e devolverá null caso a validação esteja de acordo com suas políticas ou um objeto indicando os detalhes do erro da validação. Se baseando nisso vamos criar uma variável que receba uma função respeitando esta assinatura:

// intervalo-data.validator.ts

export const intervaloDataValidator =
    (formGroup: AbstractControl): ValidationErrors | null  => {
        return null;
    };

Vejam que intervalorDataValidator é uma função que recebe como parâmetro um AbstractControl. Até aqui temos uma função, mas quem irá chamar essa função? quem irá injetar esse AbstractControl?

Se repararmos bem, o FormGroup extende a classe AbstractControl. Então quem irá injetar este FormGroup é o próprio Angular e quem irá chamar essa validação é o seu FormGroup. Confuso? veja abaixo na prática como isso é simples:

Como nossa função de validação irá receber o FormGroup (meuFormulario), nos dará acesso ao dataInicial e dataFinal. Desta forma conseguimos criar a lógica para validar o intervalo de data conforme as etapas abaixo:

  1. Pegar os valores dos inputs: dataInicial e dataFinal
  2. Converter para um objeto do tipo Date para facilitar o cálculo
  3. Calcular a diferença de dias
  4. Se o intervalo for menor que 7 será retornado um erro

Traduzindo estas 4 etapas em código:

// intervalo-data.validator.ts

export const intervaloDataValidator =
    (formGroup: AbstractControl): ValidationErrors | null  => {
        // Etapa 1
        const valorDataInicial = formGroup.get('dataInicial').value;
        const valorDataFinal = formGroup.get('dataFinal').value;

        // Etapa 2
        const dataInicial = new Date(valorDataInicial);
        const dataFinal = new Date(valorDataFinal);

        // Etapa 3
        const diferencaEmDias =
            (dataFinal.getTime() - dataInicial.getTime())
            / (1000 * 60 * 60 * 24);

        // Etapa 4
        return diferencaEmDias < 7
            ? {
                    intervaloData: {
                        atual: diferencaEmDias,
                        min: 7
                    }
            }
            : null;
    };

Quando nossa função retornar null, significa que a informação está válida, no nosso caso significa que o intervalo é maior que 7, caso contrário será retornado um objeto com os detalhes do erro no seguinte formato:

{
  intervaloData: {
    atual: 999,
    min: 7
  }
}

Vinculando o custom validator ao formGroup

Para vincular o intervaloDataValidator ao meuFormulario basta adicionarmos um segundo argumento em FormBuilder.group:

// app.component.ts

import { intervaloDataValidator } from './validators/intervalo-data.validator';

// ... (código ocultado) ...

ngOnInit() {
  this.meuFormulario = this.formBuilder.group({
    dataInicial: ['2021-01-01', Validators.required],
    dataFinal: ['2021-01-15', Validators.required],
  }, {
    // *******************************
    // Registrando o custom validator:
    // *******************************
    validators: [intervaloDataValidator]
  });
}

// ... (código ocultado) ...

Agora que o custom validator está vinculado, vamos editar o template de modo a exibir a mensagem de validação de intervalo:

<!-- app.component.html -->

<!-- ... (código ocultado) ... -->

<form [formGroup]="meuFormulario" (ngSubmit)="salvarFormulario()">
<div>
    <label for="dataInicial">Data Inicial</label>
    <input type="text" id="dataInicial"
    placeholder="yyyy-MM-dd"
    formControlName="dataInicial">
</div>
<div *ngIf="meuFormulario.controls.dataInicial.errors?.required">
    Por favor informe a data inicial
</div>
<div>
    <label for="dataFinal">Data Final</label>
    <input type="text" id="dataFinal"
    placeholder="yyyy-MM-dd"
    formControlName="dataFinal">
</div>
<div *ngIf="meuFormulario.controls.dataFinal.errors?.required">
    Por favor informe a data final
</div>
<!-- ****************************************************** -->
<!-- Inclusão da mensagem de validação do intervalo de data -->
<!-- ****************************************************** -->
<div *ngIf="meuFormulario.errors?.intervaloData">
    O intervalo deve ser
    maior que {{meuFormulario.errors.intervaloData.min}} dias.
    Valor atual: {{meuFormulario.errors.intervaloData.atual}}
</div>
<div>
    <!--
        Veja o [disabled]: o botão será desabilitado
        caso haja algum erro no meuFormulario:
    -->
    <button [disabled]="meuFormulario.invalid"
    type="submir">Enviar</button>
</div>
</form>

<!-- ... (código ocultado) ... -->

Resultado da validação do intervalo de data:

Validação do intervalo de data utilizando o custom validator

Melhorando o código

Com o objetivo de reaproveitar o intervaloDataValidator vamos parametrizar o nome dos campos (dataInicial e dataFinal) utilizados dentro do custom validator.

Para isto, ao invés de utilizarmos uma constante que representava uma função, agora criamos uma função que recebe como parâmetro o nome dos campos (data inicial e data final) e retorna uma outra função:

// intervalo-data.validator.ts

// Troca do const por function:
export function intervaloDataValidator(nomeCampo1: string, nomeCampo2: string) {
    // Agora retornamos uma função como resultado:
    return (formGroup: AbstractControl): ValidationErrors | null  => {
        // Etapa 1
        const valorDataInicial = formGroup.get(nomeCampo1).value;
        const valorDataFinal = formGroup.get(nomeCampo2).value;

        // ... (código ocultado) ...
    };
}


No AppComponent a alteração é bem simples:

// app.component.ts

// ... (código ocultado) ...

ngOnInit() {
  this.meuFormulario = this.formBuilder.group({
    dataInicial: ['2021-01-01', Validators.required],
    dataFinal: ['2021-01-15', Validators.required],
  }, {
    validators: [intervaloDataValidator('dataInicial', 'dataFinal')]
  });
}
  
// ... (código ocultado) ...

Com esta pequena alteração conseguimos reaproveitar este custom validator em outros lugares. Para melhorar ainda mais, poderíamos parametrizar o intervalo mínimo que atualmente é de 7 dias:

// intervalo-data.validator.ts

export function intervaloDataValidator(nomeCampo1: string,
                                       nomeCampo2: string,
                                       intervalorMinimo: number = 7) {
    return (formGroup: AbstractControl): ValidationErrors | null  => {
        // Etapa 1
        const valorDataInicial = formGroup.get(nomeCampo1).value;
        const valorDataFinal = formGroup.get(nomeCampo2).value;

        // Etapa 2
        const dataInicial = new Date(valorDataInicial);
        const dataFinal = new Date(valorDataFinal);

        // Etapa 3
        const diferencaEmDias =
            (dataFinal.getTime() - dataInicial.getTime())
            / (1000 * 60 * 60 * 24);

        // Etapa 4
        return diferencaEmDias < intervalorMinimo
            ? {
                    intervaloData: {
                        atual: diferencaEmDias,
                        min: intervalorMinimo
                    }
            }
            : null;
    };
}
// app.component.ts

// ... (código ocultado) ...

ngOnInit() {
  this.meuFormulario = this.formBuilder.group({
    dataInicial: ['2021-01-01', Validators.required],
    dataFinal: ['2021-01-15', Validators.required],
  }, {
    validators: [intervaloDataValidator('dataInicial', 'dataFinal', 7)]
  });
}
  
// ... (código ocultado) ...

Considerações

No processo de validação nós ignoramos o formato da data que o usuário digita para focarmos apenas em como construir um custom validator para validar dois campos relacionados.

De um modo geral essa lógica poderá ser empregada em outras situações como por exemplo, a confirmação de senha ou e-mail onde o usuário digita duas vezes as mesma informação para garantir que não há erros durante a digitação.

Exemplo da implementação deste artigo: https://stackblitz.com/edit/validacao-de-intervalo-de-data-utilizando-custom-validator-do-r?file=src/app/app.component.html

Links interessantes para leitura: