Angular ReactiveForms: ControlValueAccessor validate initial value without notifying change

168 views Asked by At

I tried to find some workaround or solution but without result. I'm trying to implement CustomValueAccessor that can validate the initial value of the given control and, if not, change it without notify the change to the parent control, but changing its value.

The problem is here in the ngOnInit cycle. If I call this code:

if (!this._initialIsValid) {
      this.onChange(this.period);
      this.ngControl.control?.markAsPristine();
}

the value of the control is changed both internal and in the parent formGroup but I get notification on the parent. If I don't call onChange, the value change only inside the control, so I don't get the notification but the parent formGroup has still the initial value. I need to not notify the change because in the parent component I subscribe the formGroup.valueChanges() and trigger some http requests.

I also tried with:

if (!this._initialIsValid) {
      this.ngControl.control?.setValue(this.period, {emitEvent: false});
      this.ngControl.control?.markAsPristine();
}

But it works like onChange and still have notification.

Basically, I'm implementing month-year picker (using Angular Material) to be able to select month-year and transform it in the format yyyyMM before sending changes to parent control. I have dateFilter function that allows datepicker to disable not available dates. The validating function basically check if array of number includes the yyyyMM given period. If the array is empty every date is considered valid.

basic-period-picker-html

<mat-form-field [ngClass]="fullwidth ? 'w100' : ''">
    <mat-label>{{ label }}</mat-label>
    <input
      [placeholder]="label"
      matInput
      [matDatepicker]="dp"
      readonly
      [required]="required"
      [value]="inputDate"
      [disabled]="disabled"
      [matDatepickerFilter]="dateFilter"
    />
    <!-- <mat-hint>MM/YYYY</mat-hint> -->
    <button
      mat-icon-button
      matIconPrefix
      *ngIf="allowClear && period > 0"
      (click)="clearPeriod(dp)"
    >
      <mat-icon>cancel</mat-icon>
    </button>
    <mat-datepicker-toggle matIconSuffix [for]="dp"></mat-datepicker-toggle>
    <mat-datepicker
      #dp
      disabled="false"
      startView="multi-year"
      (monthSelected)="setMonthAndYear($event, dp)"
      panelClass="month-picker"
    >
    </mat-datepicker>
    <mat-error *ngIf="required">Campo obbligatorio</mat-error>
  </mat-form-field>
  

basic-period-picker.component.ts

import { Component, Input, Optional, Self, ViewEncapsulation } from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { MAT_DATE_FORMATS } from '@angular/material/core';
import { MatDatepicker } from '@angular/material/datepicker';
import { OWTAM_PERIOD_FORMATS } from '../period-date-format';
import { PeriodHelper } from '../period-helper';
import { DateTime } from 'luxon';
import * as _ from 'lodash';

@Component({
  selector: 'owtam-basic-period-picker',
  templateUrl: './basic-period-picker.component.html',
  styleUrls: ['./basic-period-picker.component.scss'],
  providers: [
    // { provide: NG_VALUE_ACCESSOR, useExisting: BasicPeriodPickerComponent, multi: true },
    { provide: MAT_DATE_FORMATS, useValue: OWTAM_PERIOD_FORMATS }
  ],
  encapsulation: ViewEncapsulation.None,
})
export class BasicPeriodPickerComponent implements ControlValueAccessor {

  @Input()
  label: string = "Periodo";

  @Input()
  allowClear: boolean = true;

  @Input()
  required: boolean = false;

  @Input()
  disabled: boolean = false;

  @Input()
  period!: number;

  @Input()
  openCalendarOnClear: boolean = true;

  @Input()
  fullwidth: boolean = true;

  @Input()
  validPeriods: number[] = [];

  inputDate!: DateTime | undefined;

  ngControl!: NgControl;
  constructor(
    @Optional() @Self() ngControl: NgControl
  ) {
    if (ngControl) {
      this.ngControl = ngControl;
      this.ngControl.valueAccessor = this;
    }
  }

  private _initialValidation: boolean = true;
  private _initialIsValid: boolean = false;

  onChange!: (date: number) => void;

  onTouched!: () => void;

  writeValue(period: number): void {
    const value = this._getValidValue(period);
    if (this._initialValidation) {
      this._initialIsValid = _.isEqual(period, value);
    }
    this.period = value;
    this.inputDate = PeriodHelper.periodToDate(this.period);
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState?(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  ngOnInit() {
    // if (this.ngControl.control) {
    //   this.ngControl.control.setValue(this.period);
    //   //  { onlySelf: false, emitModelToViewChange: false, emitViewToModelChange: false, emitEvent: false }
    // }
    console.log('initialIsValid: ', this._initialIsValid);
    if (!this._initialIsValid) {
      this.onChange(this.period);
      this.ngControl.control?.markAsPristine();
    }
    this._initialValidation = false;
  }

  dateFilter = (d: DateTime | null): boolean => {
    return PeriodHelper.periodsFilter(this.validPeriods, d);
  }

  // Evento scatenato alla fine della selezione (anno e mese)
  // Viene utilizzata la classe globale OWGlobal per trasformare le date in IDPeriodo e viceversa
  setMonthAndYear(date: DateTime, datepicker: MatDatepicker<DateTime>) {
    this.inputDate = date;
    this.period = PeriodHelper.dateToPeriod(date);
    this.onChange(this.period);
    datepicker.close();
  }

  clearPeriod(dp: MatDatepicker<DateTime>) {
    this.period = 0;
    this.inputDate = undefined;
    this.onChange(0);
    if (this.openCalendarOnClear) {
      dp.open();
    }
  }

  private _getValidValue(period: number) {
    if (period == 0) {
      period = PeriodHelper.currentPeriod();
    }
    if (this.validPeriods && this.validPeriods.length > 0) {
      if (this.validPeriods.length === 1) {
        period = this.validPeriods[0];
      } else {
        const isValid = this.validPeriods.includes(period);
        if (!isValid) {
          const closest = this.validPeriods
            .map(p => { return { diff: Math.abs(period - p), value: p } })
            .sort((a, b) => a.diff - b.diff)[0].value;
          period = closest;
        }
      }
    }
    return period;
  }
}

period-helper.ts

import { DateTime } from "luxon";

export class PeriodHelper {
    public static dateToPeriod(date: DateTime) {
        if (date) {
            const year = date.year;
            const month = date.month; // Mese inizia da 0, aggiungiamo 1 per ottenere il mese corretto
            const yearMonth = year * 100 + month; // Componiamo il numero yyyyMM
    
            return yearMonth;
        }
        return 0;
    }

    // Da periodo [YYYYMM] a data
    public static periodToDate(period: number): DateTime | undefined {
        if (period) {
            const year = Math.floor(period / 100); // Ottieni l'anno dividendo per 100 e arrotondando verso il basso
            const month = period % 100; // Ottieni il mese prendendo il resto della divisione per 100
            const date = DateTime.fromObject({year, month, day: 1}); // Sottrai 1 al mese perché inizia da 0, quindi il primo mese è 0 (Gennaio)
    
            return date;
        }

        return undefined;
    }

    public static currentPeriod() {
        return this.dateToPeriod(DateTime.now());
    }

    public static periodsFilter(validPeriods: number[], d: DateTime | null): boolean {
        if (d) {
          if (validPeriods && validPeriods.length > 0) {
            const p = PeriodHelper.dateToPeriod(d);
            return validPeriods.includes(p);
          }
        }
        return true;
      }
}

component.ts

validPeriods = [201804, 202205, 202206, 202207, 202401, 202501];

filterForm = new FormGroup({
    //you can try even with PeriodHelper.currentPeriod() as initial value
    period: new FormControl<number>(0, {nonNullable: true}),
  })

component.html

<div [formGroup]="filterForm">
<owtam-basic-period-picker
    [validPeriods]="validPeriods"
    formControlName="period"
    [allowClear]="false"
  >
</div>

I would like to make this validation in the component because I will reuse a lot in my application, so I can't make that for each parent.

Sorry for my bad English

Thanks in advance

0

There are 0 answers