2

I'm working on my first custom control in Angular (16), for a Reactive Form, that contains a slider and an input. If the slider is true, then the input should have a value. If it is not, then the input shouldn't have a value.

Custom component HTML:

<div class="benefit-field">
    <div class="slide-toggle">
        <mat-slide-toggle [checked]="slideEnabled" (change)="updateEnabled($event)">Enabled?</mat-slide-toggle>
    </div>
    <div [ngClass]="{'disabled': !slideEnabled}">
        <mat-form-field>
            <mat-label>{{ typeName }}</mat-label>
            <input type="text" matInput [value]="valueId" [disabled]="!slideEnabled" (change)="onValueChanged($event.target)" />
        </mat-form-field>
    </div>
</div>

Custom component TypeScript:

import { Component, forwardRef, Input } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';

@Component({
  selector: 'app-optional-benefit-field',
  templateUrl: './optional-benefit-field.component.html',
  styleUrls: ['./optional-benefit-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: OptionalBenefitFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: forwardRef(() => OptionalBenefitFieldComponent)
    },
  ]
})
export class OptionalBenefitFieldComponent implements ControlValueAccessor, Validator {
  @Input() typeId: number;
  @Input() typeName: number;

  valueId: number;
  slideEnabled = false;

  onChange = (_valueId) => { /* nothing needs to be done here, let it trickle up */ };
  onTouched = () => { /* nothing needs to be done here, let it trickle up */ };
  touched = false;
  disabled = false;

  writeValue(valueId: number): void {
    if (valueId && valueId > 0) {
      this.slideEnabled = true;
    }
    this.valueId = valueId;
  }

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

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

  markAsTouched(): void {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

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

  updateEnabled(event: MatSlideToggleChange): void {
    this.markAsTouched();
    this.slideEnabled = event.checked;
    if (!this.slideEnabled) {
      this.onChange(null);
    } else {
      this.onChange(this.valueId);
    }
  }

  onValueChanged(input: HTMLInputElement): void {
    if (input.value && (+input.value) > 0) {
      this.onChange(+input.value);
    } else {
      this.onChange(null);
    }
  }

  validate(control: AbstractControl): ValidationErrors | null {
    return (!this.slideEnabled || (this.slideEnabled && control.value))
      ? null
      : { required: true };
  }
}

Parent component HTML:

<app-optional-benefit-field
    [typeId]="..."
    [typeName]="..."
    formControlName="valueId"
    ></app-optional-benefit-field>

When I put the custom control into an invalid state, I can see that validate is returning a { required: true }.

If I look at the parent form, however, .value has the current control's values but .errors is null.

What am I missing in my custom control or parent component for it to properly send/receive the validation error?

1
  • I think you need to associate the custom validator with the custom field in the form control. Typically custom validators are defined as standalone functions and then bound to a control via the FormControl constructor. See the documentation for an example. I think you can still do it as you've written, you might need to make the function static though... new FormControl(null, [OptionalBenefitFieldComponent.validate]). Commented Oct 25, 2024 at 16:51

2 Answers 2

1

The validation of the control are visible, but when you check the error object of the form, it's not updated/ propagated up by the control, it looks like a minor bug in the angular release (version ^19.0.0-next.10), proof.

Stackblitz Demo

If you want you can raise a github issue. The solution is to lower your angular version to the stable release like 18.

Anyway, the code needs to following changes.

When you use the form field module, it must have the NgControl, this can be provided by either ngModel or formControl, I am using formControl.

<mat-form-field>
    <mat-label>{{ typeName }}</mat-label>
    <input type="number" matInput [formControl]="valueId" (input)="onValueChanged($event)"/>
</mat-form-field>

Do not use the [disabled] attribute when working with reactive forms, so I changed it to be disabled programmatically.

  updateEnabled(event: MatSlideToggleChange): void {
    this.markAsTouched();
    this.slideEnabled = event.checked;
    if (!this.slideEnabled) {
      this.onChange(null);
      this.valueId.disable();
    } else {
      this.onChange(this.valueId.value);
      this.valueId.enable();
    }
    this.valueId.updateValueAndValidity();
  }

I changed the input to be number, since you have written some checks for only numbers.

Apart from this your code works great.

Full Code:

import { Component } from '@angular/core';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { bootstrapApplication } from '@angular/platform-browser';
import { forwardRef, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  AbstractControl,
  ControlValueAccessor,
  FormControl,
  FormGroup,
  FormsModule,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ReactiveFormsModule,
  ValidationErrors,
  Validator,
  Validators,
} from '@angular/forms';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';

@Component({
  selector: 'app-optional-benefit-field',
  imports: [
    MatFormFieldModule,
    MatSlideToggleModule,
    CommonModule,
    MatInputModule,
    FormsModule,
    ReactiveFormsModule,
  ],
  standalone: true,
  template: `
  <div class="benefit-field">
      <div class="slide-toggle">
          <mat-slide-toggle [checked]="slideEnabled" (change)="updateEnabled($event)">Enabled?</mat-slide-toggle>
      </div>
      <div [ngClass]="{'disabled': !slideEnabled}">
          <mat-form-field>
              <mat-label>{{ typeName }}</mat-label>
              <input type="number" matInput [formControl]="valueId" (input)="onValueChanged($event)"/>
          </mat-form-field>
      </div>
  </div>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: OptionalBenefitFieldComponent,
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: forwardRef(() => OptionalBenefitFieldComponent),
    },
  ],
})
export class OptionalBenefitFieldComponent
  implements ControlValueAccessor, Validator
{
  @Input() typeId!: number;
  @Input() typeName!: number;

  valueId: FormControl<number | null> = new FormControl<number | null>({
    value: null,
    disabled: true,
  });
  slideEnabled = false;

  onChange = (_valueId: any) => {
    /* nothing needs to be done here, let it trickle up */
  };
  onTouched = () => {
    /* nothing needs to be done here, let it trickle up */
  };
  touched = false;
  disabled = false;

  writeValue(valueId: number): void {
    if (valueId && valueId > 0) {
      this.slideEnabled = true;
    }
    this.valueId.patchValue(valueId);
  }

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

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

  markAsTouched(): void {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

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

  updateEnabled(event: MatSlideToggleChange): void {
    this.markAsTouched();
    this.slideEnabled = event.checked;
    if (!this.slideEnabled) {
      this.onChange(null);
      this.valueId.disable();
    } else {
      this.onChange(this.valueId.value);
      this.valueId.enable();
    }
    this.valueId.updateValueAndValidity();
  }

  onValueChanged(event: any): void {
    const value = event?.target?.value;
    if (value && +value > 0) {
      this.onChange(+value);
    } else {
      this.onChange(null);
    }
  }

  validate(control: AbstractControl): ValidationErrors | null {
    return !this.slideEnabled || (this.slideEnabled && control.value)
      ? null
      : { required: true };
  }
}

/**
 * @title Basic slide-toggles
 */
@Component({
  selector: 'slide-toggle-overview-example',
  template: `
  <form [formGroup]="form">
    <app-optional-benefit-field
      [typeId]="1234"
      [typeName]="1234"
      formControlName="valueId"
      ></app-optional-benefit-field>
  </form>
  <hr/>
  {{form.errors | json}}
  <hr/>
  {{form.controls.valueId.errors | json}}
  <hr/>
  {{form.value | json}}
  `,
  standalone: true,
  imports: [OptionalBenefitFieldComponent, ReactiveFormsModule, CommonModule],
})
export class SlideToggleOverviewExample {
  form = new FormGroup({
    valueId: new FormControl(null),
  });
}

Stackblitz Demo

Sign up to request clarification or add additional context in comments.

3 Comments

This helps, thanks. We're actually still on 16 (time for Material upgrade has been holding us back, so when you say "It looks like a minor bug in the angular release", what's "it"?
@JamesSkemp The validation of the control are visible, but when you check the error object of the form, it's not updated/ propagated up by the control
Gotcha. That's what I thought you were referring to, but was confused when you referenced 19. So it's a bug in 16-19.next, and likely earlier versions. form.valid is correctly updating, so this gets me what I need to refactor off of. Thanks!
0
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { globalRuleForm } from '../types/global-rule';

export function uniqueMainChildConditionValidator(): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        if (!Array.isArray(control.value)) {
            return null;
        }

        const mainObjects: globalRuleForm[] = control.value;
        
        // Map to track unique conditions for main objects (to avoid duplicate main objects)
        const seenMainConditions = new Map<string, number>();

        for (let i = 0; i < mainObjects.length; i++) {
            const mainObj = mainObjects[i];

            // Handle ChildCondition duplication within the same main object
            if (mainObj.ChildCondition && mainObj.ChildCondition.length > 0) {
                const seenChildConditions = new Set<string>();

                for (const [index, child] of mainObj.ChildCondition.entries()) {
                    const childConditionKey = `${child.Field}-${child.Condition}-${child.ConditionText}`;
                    const parentConditionKey = `${mainObj.Field}-${mainObj.Condition}-${mainObj.ConditionText}`;

                    // Check if the child condition matches the parent condition
                    if (childConditionKey === parentConditionKey) {
                        return { conditionMismatch: `${index} ${i} Child condition in main object ${i + 1} matches the parent condition.` };
                    }

                    // Check for duplicate child conditions
                    if (seenChildConditions.has(childConditionKey)) {
                        return { conditionMismatch: `${index} ${i} Child condition ${childConditionKey} is duplicated in main object ${i + 1}.` };
                    }

                    // Add the child condition to the seen set
                    seenChildConditions.add(childConditionKey);
                }
            }

            // Check for duplicate main object conditions with Logic check
            const mainConditionKey = `${mainObj.Field}-${mainObj.Condition}-${mainObj.ConditionText}`;

            if (seenMainConditions.has(mainConditionKey)) {
                const duplicateIndex = seenMainConditions.get(mainConditionKey);
                return { conditionMismatch: `${i} Main object ${i + 1} has matching properties with main object ${duplicateIndex + 1}.` };
            }

            // Store the main condition key for future comparisons
            seenMainConditions.set(mainConditionKey, i);

            // Check against all other main objects for condition mismatch, considering the Logic property
            for (let j = 0; j < mainObjects.length; j++) {
                if (i !== j) {
                    const otherMainObj = mainObjects[j];

                    // Only compare if neither object has a Logic property
                    if ((!mainObj.Logic && !otherMainObj.Logic) &&
                        mainObj.Field === otherMainObj.Field &&
                        mainObj.Condition === otherMainObj.Condition &&
                        mainObj.ConditionText === otherMainObj.ConditionText
                    ) {
                        return { conditionMismatch: `${j} Main object ${i + 1} has matching properties with main object ${j + 1}.` };
                    }
                }
            }
        }

        return null;
    };
}

1 Comment

Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.