Feature/extract symbol search to component (#2003) (#2056)

* Extract symbol search to component (#2003)

* Update changelog
pull/2071/head
Arghya Ghosh 12 months ago committed by GitHub
parent f5a50a95de
commit fce3b2084e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Extracted the symbol search to a dedicated component
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
## 1.280.1 - 2023-06-10

@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public currencies: string[] = [];
public currentMarketPrice = null;
public defaultDateFormat: string;
public filteredLookupItems: LookupItem[] = [];
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false;
public platforms: { id: string; name: string }[];
@ -120,10 +118,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [
{
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
},
!!this.data.activity?.SymbolProfile
? {
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
}
: null,
Validators.required
],
tags: [
@ -238,28 +238,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck();
});
this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol'
].valueChanges.pipe(
debounceTime(400),
distinctUntilChanged(),
switchMap((query: string) => {
if (isString(query) && query.length > 1) {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
filteredLookupItemsObservable
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null;
} else {
this.activityForm.controls['dataSource'].setValue(
this.activityForm.controls['searchSymbol'].value.dataSource
);
return filteredLookupItemsObservable;
}
this.updateSymbol();
}
return [];
})
);
this.changeDetectorRef.markForCheck();
});
this.filteredTagsObservable = this.activityForm.controls[
'tags'
@ -393,25 +384,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.tagInput.nativeElement.value = '';
}
public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return (
lookupItem.symbol ===
this.activityForm.controls['searchSymbol'].value.symbol
);
});
if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol);
} else {
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
}
public onCancel() {
this.dialogRef.close();
}
@ -455,13 +427,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dialogRef.close({ activity });
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.activityForm.controls['dataSource'].setValue(
event.option.value.dataSource
);
this.updateSymbol(event.option.value.symbol);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
@ -477,12 +442,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
});
}
private updateSymbol(symbol: string) {
private updateSymbol() {
this.isLoading = true;
this.activityForm.controls['searchSymbol'].setErrors(null);
this.activityForm.controls['searchSymbol'].setValue({ symbol });
this.changeDetectorRef.markForCheck();
this.dataService

@ -48,34 +48,10 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<input
autocapitalize="off"
autocomplete="off"
autocorrect="off"
<gf-symbol-autocomplete
formControlName="searchSymbol"
matInput
[matAutocomplete]="symbolAutocomplete"
(blur)="onBlurSymbol()"
[isLoading]="isLoading"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<mat-option
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="line-height-1"
[value]="lookupItem"
>
<span><b>{{ lookupItem.name }}</b></span>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
}}</small
>
</mat-option>
</mat-autocomplete>
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
</mat-form-field>
</div>
<div

@ -9,9 +9,8 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
@ -21,7 +20,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
imports: [
CommonModule,
FormsModule,
GfSymbolModule,
GfSymbolAutocompleteModule,
GfValueModule,
MatAutocompleteModule,
MatButtonModule,
@ -31,7 +30,6 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
ReactiveFormsModule
],

@ -0,0 +1,178 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
Component,
DoCheck,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs';
@Component({
template: ''
})
export abstract class AbstractMatFormField<T>
implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy
{
@HostBinding()
public id = `${this.controlType}-${AbstractMatFormField.nextId++}`;
@HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean;
public errorState: boolean;
public focused = false;
public readonly stateChanges = new Subject<void>();
public readonly userAriaDescribedBy: string;
protected onChange?: (value: T) => void;
protected onTouched?: () => void;
private static nextId: number = 0;
protected constructor(
protected _elementRef: ElementRef,
protected _focusMonitor: FocusMonitor,
public readonly ngControl: NgControl
) {
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
_focusMonitor
.monitor(this._elementRef.nativeElement, true)
.subscribe((origin) => {
this.focused = !!origin;
this.stateChanges.next();
});
}
private _controlType: string;
public get controlType(): string {
return this._controlType;
}
protected set controlType(value: string) {
this._controlType = value;
this.id = `${this._controlType}-${AbstractMatFormField.nextId++}`;
}
private _value: T;
public get value(): T {
return this._value;
}
public set value(value: T) {
this._value = value;
if (this.onChange) {
this.onChange(value);
}
}
public get empty(): boolean {
return !this._value;
}
public _placeholder: string = '';
public get placeholder() {
return this._placeholder;
}
@Input()
public set placeholder(placeholder: string) {
this._placeholder = placeholder;
this.stateChanges.next();
}
public _required: boolean = false;
public get required() {
return this._required;
}
@Input()
public set required(required: any) {
this._required = coerceBooleanProperty(required);
this.stateChanges.next();
}
public _disabled: boolean = false;
public get disabled() {
if (this.ngControl && this.ngControl.disabled !== null) {
return this.ngControl.disabled;
}
return this._disabled;
}
@Input()
public set disabled(disabled: any) {
this._disabled = coerceBooleanProperty(disabled);
if (this.focused) {
this.focused = false;
this.stateChanges.next();
}
}
public abstract focus(): void;
public get shouldLabelFloat(): boolean {
return this.focused || !this.empty;
}
public ngDoCheck(): void {
if (this.ngControl) {
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public ngOnDestroy(): void {
this.stateChanges.complete();
this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
}
public registerOnChange(fn: (_: T) => void): void {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
public setDescribedByIds(ids: string[]): void {
this.describedBy = ids.join(' ');
}
public writeValue(value: T): void {
this.value = value;
}
@HostListener('focusout')
public onBlur() {
this.focused = false;
if (this.onTouched) {
this.onTouched();
}
this.stateChanges.next();
}
public onContainerClick(): void {
if (!this.focused) {
this.focus();
}
}
}

@ -0,0 +1 @@
export * from './symbol-autocomplete.module';

@ -0,0 +1,34 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="symbolAutocomplete"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<ng-container *ngIf="!isLoading">
<mat-option
*ngFor="let lookupItem of filteredLookupItems"
class="line-height-1"
[value]="lookupItem"
>
<span
><b>{{ lookupItem.name }}</b></span
>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency }}</small
>
</mat-option>
</ng-container>
</mat-autocomplete>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
></mat-spinner>

@ -0,0 +1,8 @@
:host {
display: block;
.mat-mdc-progress-spinner {
right: 0;
top: calc(50% - 10px);
}
}

@ -0,0 +1,169 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormControl, NgControl, Validators } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatFormFieldControl } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { isString } from 'lodash';
import { Observable, Subject, of, tap } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap
} from 'rxjs/operators';
import { AbstractMatFormField } from './abstract-mat-form-field';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
},
selector: 'gf-symbol-autocomplete',
styleUrls: ['./symbol-autocomplete.component.scss'],
templateUrl: 'symbol-autocomplete.component.html',
providers: [
{
provide: MatFormFieldControl,
useExisting: SymbolAutocompleteComponent
}
]
})
export class SymbolAutocompleteComponent
extends AbstractMatFormField<LookupItem>
implements OnInit, OnDestroy
{
@Input() public isLoading = false;
@ViewChild(MatInput, { static: false }) private input: MatInput;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
public control = new FormControl();
public filteredLookupItems: LookupItem[] = [];
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dataService: DataService,
public readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'symbol-autocomplete';
}
public ngOnInit() {
super.required = this.ngControl.control?.hasValidator(Validators.required);
if (this.disabled) {
this.control.disable();
}
this.control.valueChanges
.pipe(
debounceTime(400),
distinctUntilChanged(),
filter((query) => {
return isString(query) && query.length > 1;
}),
tap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
}),
switchMap((query: string) => {
return this.dataService.fetchSymbols(query);
})
)
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public displayFn(aLookupItem: LookupItem) {
return aLookupItem?.symbol ?? '';
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public isValueInOptions(value: string) {
return this.filteredLookupItems.some((item) => {
return item.symbol === value;
});
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
this.validateSelection();
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
super.value = {
dataSource: event.option.value.dataSource,
symbol: event.option.value.symbol
} as LookupItem;
}
public set value(value: LookupItem) {
this.control.setValue(value);
super.value = value;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validateRequired() {
const requiredCheck = super.required
? !super.value?.dataSource || !super.value?.symbol
: false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
private validateSelection() {
const error =
!this.isValueInOptions(this.input?.value) ||
this.input?.value !== super.value?.symbol;
if (error) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
}

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
@NgModule({
declarations: [SymbolAutocompleteComponent],
exports: [SymbolAutocompleteComponent],
imports: [
CommonModule,
FormsModule,
GfSymbolModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfSymbolAutocompleteModule {}
Loading…
Cancel
Save