I have implemented a shared/reusable angular material autocomplete, which uses values from a server returned as objects. I can integrate it into other components. However, if I use my component:
<app-positions-input [formControl]="form.controls.position"></app-positions-input>
the form.control.position returns null. But if I integrate like this:
<app-positions-input #positionComponent></app-positions-input>
The positionComponent.control.value returns the selected value. I can't make this reusable autocomplete integrate with the rest of the reactive form. positionComponent is basically this: @ViewChild(PositionsComponent) positionComponent: PositionsComponent; in the parent. But this does not work well, as I can't validate the form, since the control is not part of the reactive form.
import { Component, EventEmitter, OnDestroy, OnInit, Output, forwardRef } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { Observable, Subject } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { PositionService } from '../position.service';
import { MaterialModule } from '@myapp/client/material';
import { AsyncPipe } from '@angular/common';
import { PositionGetDto as GetDto } from '@myapp/dtos';
@Component({
selector: 'app-positions-input',
standalone: true,
imports: [ReactiveFormsModule, MaterialModule, AsyncPipe],
templateUrl: './positions.component.html',
styleUrl: './positions.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PositionsComponent),
multi: true,
}
]
})
export class PositionsComponent implements OnInit, OnDestroy, ControlValueAccessor {
@Output() selected = new EventEmitter<GetDto>();
control = new FormControl<GetDto | undefined>(undefined);
items: GetDto[] = [];
filteredItems: Observable<GetDto[]>;
private ngUnsubscribe = new Subject<void>();
private onChangeCallback: (_: GetDto | undefined) => void = () => {};
private onTouchedCallback: () => void = () => {};
constructor(private service: PositionService) {
this.filteredItems = this.control.valueChanges
.pipe(
// tap((term) => { console.log("Term: %s", term); }),
startWith(''),
map(item => (item ? this._filter(item) : this.items.slice()))
);
}
ngOnInit() {
this.service.getAll().subscribe((items) => {
this.items = items;
});
}
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
writeValue(item: GetDto | undefined): void {
this.control.setValue(item);
}
registerOnChange(fn: any): void {
this.onChangeCallback = fn;
}
registerOnTouched(fn: any): void {
this.onTouchedCallback = fn;
}
setDisabledState?(isDisabled: boolean): void {
//this.disabled = isDisabled;
}
private _filter(term: any): GetDto[] {
let filteredItems: GetDto[];
if (typeof term === 'object' && 'id' in term && 'name' in term) {
filteredItems = this.items;
return filteredItems;
} else if (typeof term === 'string') {
const lowerCaseTerm = term.toLowerCase();
filteredItems = this.items.filter((item) =>
item.name.toLowerCase().includes(lowerCaseTerm)
);
return filteredItems;
}
return this.items;
}
display(item: GetDto): string {
return item ? item.name : '';
}
setValue(item: GetDto | undefined) {
this.selected.emit(item);
this.control.setValue(item);
}
}
<mat-form-field>
<mat-label>Position: </mat-label>
<input matInput aria-label="Position" placeholder="Please put a position" [formControl]="control"
[matAutocomplete]="auto">
<mat-autocomplete #auto="matAutocomplete" [displayWith]="display">
@for (item of filteredItems | async; track item) {
<mat-option [value]="item">{{item.name}}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
I wonder, what am I doing wrong? I was not able to find a suitable reusable/shared autocomplete example.
You need to actually call your
onChangesCallback()with the value you want reflected in the model. So you could add a method like this:And fire it from your template like this (or whatever appropriate event you want to emit the value):
Here's a StackBlitz example.
Also, instead of using your own "unsubscribe subject", you can use the new
takeUntilDestroyed()operator. But even simpler, you can just leave youritemsas an observable to avoid explicitly subscribing:Notice here instead of subscribing to the
service.getAll()then storing the emission to a separateitemsvariable, we can just declare an observable that emits the items, then delcare thefilteredItems$to begin with that.This is much simpler because you don't need
Here's a simplified StackBlitz demo;