Custom Directive to sync inputs with own FormControl implementation

Viewed 255

In our current project we have pretty advanced requirements for our individual FormFields since this is going to be a long one I'll go through the steps one by one:

Environment:
Angular 10+, Angular Material, Reactive Forms

What I want:

Forms go through multiple hands (users A, B and C, A before B before C and so on). Now A enters something in a specific field and sends the form over to B, who sees whether A entered something in a field. A's input is now called the fallbackValue.

Every field that A touched is now to be highlighted as new for B. Preferably by adding a small tag as a matSuffix to the FormField. Now comes the complicated part:

B saves his progress and later reopens the document. Now B should be able to remove anything he added to a control, but if he does so, the fallBackValue entered by A should now fill the input (not necessarily the FormControl but I could live with that). And be styled in a way, so that B sees that this is not a value they entered, but one that one of the previous users entered (I was thinking of something simple like upping the transparency of the text or something).

Example:

Let's pretend A entered "Hello!"

No B sees that Input displaying "Hallo!" and a tag that marks this input as "new"

B wants to overwrite this with "Good day!" now the "new" tag disappears and the input just display "Good day!" like any normal input.

Later B comes back and removes "Good day!", therefore the input defaults to the fallBackValue of "Hallo!" set beforehand by A, which is now displayed slightly different than "Good day!" to signal that it is not a value specified by B.

The information needed to determine the fallbackValue and whether a entry is new I get from the backend (which is a whole other mess I have to figure out, but out of scope for here).

Ideas / What I tried:

I tried to approach this problem by writing my own CustomFormControl, which extends the normal Angular FormControlClass:

export class CustomFormControl extends FormControl {
  isNew: boolean;
  fallBackValue: boolean | string | any[];

  constructor(formState?: any, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null,
              asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null,
              isNew?: boolean, fallBackValue?: boolean | string | any[]) {
    super(formState, validatorOrOpts, asyncValidator);
    this.isNew = isNew;
    this.fallBackValue = fallBackValue;
  }
}

Now idealy I would like to have a functionality like the Angular ´FormControlNameDirective´ that syncs the input element with my CustomFormControl and allows me to access and manipulate the control.

We are currently using ´mat-form-fields´ for everything. I would really like to not reimplement NgControl FormControlDirective and FormControlNameDirective one by one, since that really does not sound 'future proof' to me. And since the behaviour above is needed in roughly 300+ inputs throughout the application I need to find a generally applicable solution.

Any ideas or directions would be amazing. I have been trying to get a grip on this for a few weeks now but however I go about and try to acces the FormControl applied to a FormField I never get my CustomFormControl

1 Answers

As @shhdharmen suggested, using ControlValueAccessor may help in this requirements.

Below is a sample demo of how I would approach this problem.

  1. Define my steps in an array
  fieldStatus = {
    fieldAt0: [],
    fieldAt1: [{ user: 'A', text: 'Hello Today A' }],
    fieldAt2: [
      { user: 'A', text: 'Hello Today By A' },
      { user: 'B', text: 'Hello Today By B' },
    ],
    fieldAt3: [
      { user: 'C', text: 'Hello Today By C' },
      { user: 'B', text: 'Hello Today By B' },
      { user: 'A', text: 'Hello Today By A' },
    ],
    fieldAt4: [
      { user: 'C', text: 'Hello Today By C' },
      { user: 'B', text: 'Hello Today By B' },
      { user: 'A', text: 'Hello Today By A' },
      { user: 'D', text: 'Hello Today By D' },
    ],
  };

With the above we can get the last entry and set this as our fallback

We then need to pass this to our custom input say `

  <app-step-input
    [currentUser]="currentUser"
    [steps]="fieldStatus.fieldAt0"
    formControlName="fieldAt0"
  >
  </app-step-input>

currentUser will hold the identity of the current user

Lets now define the control implementing ControlValueAccessor

import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-step-input',
  templateUrl: './step-input.component.html',
  styleUrls: ['./step-input.component.css'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StepInputComponent),
      multi: true,
    },
  ],
})
export class StepInputComponent implements OnInit, ControlValueAccessor {
  isFallback = false;
  fallBack: { user: string; text: string } = { user: '', text: '' };
  @Input() set steps(steps: any[]) {
    if (steps.length > 0) {
      this.fallBack = steps[steps.length - 1];
      this.currentValue = this.fallBack.text;
      this.isFallback = true;
    }
  }
  @Input() currentUser = '';
  currentValue = '';
  disabled = false;
  onChanges: (param: any) => void;
  onTouched: () => void;
  constructor() {}
  writeValue(obj: any): void {
    if (obj && obj.length > 0) {
      this.currentValue = obj;
    }
  }
  registerOnChange(fn: any): void {
    this.onChanges = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }
  setDisabledState?(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  handleInputChange() {
    this.isFallback = false;
    this.onChanges(this.currentValue);
    this.onTouched();
    // console.log(this.currentValue);
  }

  handleBlur() {
    if (this.currentValue === '') {
      this.currentValue = this.fallBack.text;
      this.isFallback = true;
    }
    console.log(this.fallBack.text);
  }
  handleFocus() {
    if (this.isFallback) {
      this.currentValue = '';
    }
  }
  ngOnInit() {}
}

I am using isFallback to track whether the value is a fallback value and fallback to hold the fallback

the html will simply contain your control, I am using a simple input for this demo

<input
  [(ngModel)]="currentValue"
  (ngModelChange)="handleInputChange()"
  (blur)="handleBlur()"
  (focus)="handleFocus()"
  type="text"
/>
<br />
<small class="tag" *ngIf="fallBack.user === currentUser">SELF </small>
<small class="tag tag-new" *ngIf="isFallback">NEW </small>

Now you should be able to use the control. The control will default to the last step in the steps object

Related