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:
onOptionSelected(option: GetDto) {
this.onChangeCallback(option);
}
And fire it from your template like this (or whatever appropriate event you want to emit the value):
<mat-autocomplete (optionSelected)="onOptionSelected($event.option.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 your items
as an observable to avoid explicitly subscribing:
items$: Observable<GetDto[]> = this.service.getAll();
filteredItems$: Observable<GetDto[]> = this.items$.pipe(
switchMap(allItems => this.control.valueChanges.pipe(
startWith(''),
map(term => (term ? this._filter(allItems, term) : allItems.slice()))
))
);
Notice here instead of subscribing to the service.getAll()
then storing the emission to a separate items
variable, we can just declare an observable that emits the items, then delcare the filteredItems$
to begin with that.
This is much simpler because you don't need
Here's a simplified StackBlitz demo;