2

I've been following the angular-material docs which looks at creating a custom form field control: https://material.angular.io/guide/creating-a-custom-form-field-control

It conveniently skips over a template and reactive forms full example so i've been scrambling around trying to wire it all up.

I've had a stab at it, with varying success. Although there are other issues, i'd first like to understand how I can get this custom field to recognise when it's invalid so I can perform the <mat-error> you see below (i've removed the *ngIf just so I can see the state of invalid). The {{symbolInput.invalid}} is always false, when in fact it should be true as the field is required!

Custom MatFormFieldControl template use:

<mat-form-field class="symbol">
    <symbol-input
      name="symbol"
      placeholder="Symbol"
      ngModel
      #symbolInput="ngModel"
      [(ngModel)]="symbol"
      required></symbol-input>
    <button
      mat-button matSuffix mat-icon-button
      *ngIf="symbol && (symbol.asset1 || symbol.asset2)"
      aria-label="Clear"
      (click)="clearSymbol()">
      <mat-icon>close</mat-icon>
    </button>
    <mat-error >{{symbolInput.invalid}}</mat-error>
  </mat-form-field>

Custom MatFormFieldControl class:

export interface AssetSymbol {
  asset1: string, asset2: string
}

@Component({
  selector: 'symbol-input',
  templateUrl: './symbol-input.component.html',
  styleUrls: ['./symbol-input.component.css'],
  providers: [{ provide: MatFormFieldControl, useExisting: SymbolInputComponent}]
})
export class SymbolInputComponent implements MatFormFieldControl<AssetSymbol>, OnDestroy {
  static nextId = 0;

  stateChanges = new Subject<void>();
  parts: FormGroup;
  focused = false;
  errorState = false;
  controlType = 'symbol-input';
  onChangeCallback;

  @HostBinding() id = `symbol-input-${SymbolInputComponent.nextId++}`;
  @HostBinding('class.floating')
  get shouldLabelFloat() {
    return this.focused || !this.empty;
  }
  @HostBinding('attr.aria-describedby')
  describedBy = '';
  setDescribedByIds(ids: string[]) {
    this.describedBy = ids.join(' ');
  }

  get empty() {
    let n = this.parts.value;
    return !n.asset1 && !n.asset2;
  }

  @Input()
  get value(): AssetSymbol | null {
    let n = this.parts.value;
    return { asset1: n.asset1, asset2: n.asset2};
  }
  set value(symbol: AssetSymbol | null) {
    symbol = symbol || { asset1: "", asset2: ""};
    this.parts.setValue({asset1: symbol.asset1, asset2: symbol.asset2});
    this.stateChanges.next();
  }
  @Input()
  get placeholder() {
    return this._placeholder;
  }
  set placeholder(plh) {
    this._placeholder = plh;
    this.stateChanges.next();
  }
  private _placeholder: string;
  @Input()
  get required() {
    return this._required;
  }
  set required(req) {
    this._required = coerceBooleanProperty(req);
    this.stateChanges.next();
  }
  private _required = false;
  @Input()
  get disabled() {
    return this._disabled;
  }
  set disabled(dis) {
    this._disabled = coerceBooleanProperty(dis);
    this.stateChanges.next();
  }
  private _disabled = false;

  constructor(
    fb: FormBuilder,
    @Optional() @Self() public ngControl: NgControl,
    private fm: FocusMonitor,
    private elRef: ElementRef<HTMLElement>) {

    this.parts = fb.group({'asset1': '', 'asset2': ''});
    // Setting the value accessor directly (instead of using
    // the providers) to avoid running into a circular import.
    if (this.ngControl != null) this.ngControl.valueAccessor = this;

    fm.monitor(elRef.nativeElement, true).subscribe(origin => {
      this.focused = !!origin;
      this.stateChanges.next();
    });

    this.stateChanges.subscribe(() => {
      this.expandInput(this.value.asset1.length);
      if (this.onChangeCallback) {
        this.onChangeCallback(this.value);
        if (this.required) {
          const symbol = this.value;
          if (!symbol.asset1 || !symbol.asset2) {
            this.errorState = true;
          } else {
            this.errorState = false;
          }
        }
      }
    });
  }

  onContainerClick(event: MouseEvent) {
    if ((event.target as Element).tagName.toLowerCase() != 'input') {
      this.elRef.nativeElement.querySelector('input').focus();
    }
  }

  ngOnDestroy() {
    this.stateChanges.complete();
    this.fm.stopMonitoring(this.elRef.nativeElement);
  }

  onKeyup() {
    this.stateChanges.next();
  }

  static ASSET1_INPUT_SIZE = 2;
  asset1InputSize = SymbolInputComponent.ASSET1_INPUT_SIZE;
  expandInput(currentSize) {
    //const currentSize = (event.target as HTMLInputElement).value.length;
    if (currentSize >= 3) {
      this.asset1InputSize = currentSize;
    } else {
      this.asset1InputSize = SymbolInputComponent.ASSET1_INPUT_SIZE;
    }
  }

  writeValue(value: any) {
    this.value = value;
  }

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

  registerOnTouched(fn: any) {
  }

}

symbol-input.component.html:

<div [formGroup]="parts" >
  <input class="asset asset1" formControlName="asset1" (keyup)="onKeyup()" [size]="asset1InputSize" maxlength="5">
  <span class="input-spacer">&frasl;</span>
  <input class="asset asset2" formControlName="asset2" size="6" maxlength="5">
</div>

Would someone be kind enough to point me in the right direction?

** UPDATED ** symbolInput.invalid flag now gets set after subscribing to this.ngControl.valueChanges and setting this.ngControl.control.setErrors:

constructor(
    fb: FormBuilder,
    @Optional() @Self() public ngControl: NgControl,
    private fm: FocusMonitor,
    private elRef: ElementRef<HTMLElement>) {

    this.parts = fb.group({'asset1': ['',[Validators.required]], 'asset2': ['',[Validators.required]]});

    if (this.ngControl != null) this.ngControl.valueAccessor = this;

    fm.monitor(elRef.nativeElement, true).subscribe(origin => {
      this.focused = !!origin;
      this.stateChanges.next();
    });
    this.ngControl.valueChanges.subscribe(()=>{
      this.expandInput(this.value.asset1.length);
      if (this.required) {
        if (this.parts.invalid) {
          this.errorState = true;
          this.ngControl.control.setErrors({ "invalidSymbol": true });
        } else {
          this.errorState = false;
          this.ngControl.control.setErrors(null);
        }
      }
    });
    this.stateChanges.subscribe(() => {
      if (this.onChangeCallback) {
        this.onChangeCallback(this.value);
      }
    });
  }

Please advise if you think this can be improved.

HGPB
  • 4,346
  • 8
  • 50
  • 86

2 Answers2

1

Your implementation is looking good and you are always getting the invalid as false since you did not add any validation.

You can add the validation for asset1 and asset2 so change the following line

 this.parts = fb.group({'asset1': '', 'asset2': ''});

to

 this.parts = this.fb.group({
      asset1: ['', Validators.required],
      asset2: ['', Validators.required,  Validators.minLength(6)]
 });
Sunil Singh
  • 11,001
  • 2
  • 27
  • 48
  • Thanks. I feel i might have got there by implementing your above solution and subscribing to this.ngControl.valueChanges and setting (when this.required is set) this.ngControl.control.setErrors to get the invalid flag to change. – HGPB Oct 30 '18 at 13:33
0

What I did is the following:

    get errorState(): boolean {
        return (this.model.invalid && this.model.dirty) 
               || (this.ngControl?.invalid && this.ngControl?.dirty);
    }

Where model is my local FormControl and ngControl is the parent control. So it return an error state when my control has an error or the parent is invalid.

Fusion2k
  • 11
  • 1
  • 1