diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6955d46..e9f9f0fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index d9fdb3fe4..6962d4d40 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { public currencies: string[] = []; public currentMarketPrice = null; public defaultDateFormat: string; - public filteredLookupItems: LookupItem[] = []; - public filteredLookupItemsObservable: Observable = of([]); public filteredTagsObservable: Observable = 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 diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 85511f012..a78b6ba86 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -48,34 +48,10 @@ > Name, symbol or ISIN - - - - {{ lookupItem.name }} -
- {{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency - }} -
-
-
+ implements ControlValueAccessor, DoCheck, MatFormFieldControl, 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(); + 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(); + } + } +} diff --git a/libs/ui/src/lib/symbol-autocomplete/index.ts b/libs/ui/src/lib/symbol-autocomplete/index.ts new file mode 100644 index 000000000..7271d1ca9 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/index.ts @@ -0,0 +1 @@ +export * from './symbol-autocomplete.module'; diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html new file mode 100644 index 000000000..47cb8bcb4 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html @@ -0,0 +1,34 @@ + + + + + + {{ lookupItem.name }} +
+ {{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency }} +
+
+
+ diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss new file mode 100644 index 000000000..71c06f26e --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss @@ -0,0 +1,8 @@ +:host { + display: block; + + .mat-mdc-progress-spinner { + right: 0; + top: calc(50% - 10px); + } +} diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts new file mode 100644 index 000000000..2b6bc45ca --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts @@ -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 + 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 = of([]); + + private unsubscribeSubject = new Subject(); + + 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 }); + } + } +} diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.module.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.module.ts new file mode 100644 index 000000000..d7b1ed2f8 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.module.ts @@ -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 {}