Browse Source

Feature/added symbol-autocomplete to library (#2003)

pull/2056/head
Arghya Ghosh 2 years ago
parent
commit
4b17afe4a1
  1. 6
      CHANGELOG.md
  2. 71
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  3. 28
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  4. 3
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts
  5. 172
      libs/ui/src/lib/abstract-mat-form-field.ts
  6. 1
      libs/ui/src/lib/symbol-autocomplete/index.ts
  7. 32
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  8. 0
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss
  9. 156
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  10. 26
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.module.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Moved symbol-autocomplete to individual component in lib
## 1.280.1 - 2023-06-10
### Added

71
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<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: [
{
!!this.data.activity?.SymbolProfile
? {
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
},
}
: null,
Validators.required
],
tags: [
@ -238,27 +238,18 @@ 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;
});
return filteredLookupItemsObservable;
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(
(value) => {
if (!this.activityForm.controls['searchSymbol'].invalid) {
this.activityForm.controls['dataSource'].setValue(
this.activityForm.controls['searchSymbol'].value.dataSource
);
this.updateSymbol();
} else {
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
}
return [];
})
);
this.filteredTagsObservable = this.activityForm.controls[
@ -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

28
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -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

3
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts

@ -11,9 +11,9 @@ 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 { GfSymbolAutoCompleteModule } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
@NgModule({
@ -21,6 +21,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
imports: [
CommonModule,
FormsModule,
GfSymbolAutoCompleteModule,
GfSymbolModule,
GfValueModule,
MatAutocompleteModule,

172
libs/ui/src/lib/abstract-mat-form-field.ts

@ -0,0 +1,172 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
Component,
DoCheck,
ElementRef,
HostBinding,
HostListener,
Injector,
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 DoCheck, OnDestroy, ControlValueAccessor, MatFormFieldControl<T>
{
private static nextId: number = 0;
@HostBinding()
public id: string = `${this.controlType}-${AbstractMatFormField.nextId++}`;
@HostBinding('attr.aria-describedBy')
public describedBy: string = '';
public focused = false;
public readonly autofilled: boolean;
public errorState: boolean;
public readonly stateChanges = new Subject<void>();
public readonly userAriaDescribedBy: string;
protected onChange?: (value: T) => void;
protected onTouched?: () => void;
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 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 writeValue(value: T): void {
this.value = value;
}
public setDescribedByIds(ids: string[]): void {
this.describedBy = ids.join(' ');
}
public abstract focus(): void;
@HostListener('focusout')
onBlur() {
this.focused = false;
if (this.onTouched) {
this.onTouched();
}
this.stateChanges.next();
}
public onContainerClick(): void {
if (!this.focused) {
this.focus();
}
}
}

1
libs/ui/src/lib/symbol-autocomplete/index.ts

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

32
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -0,0 +1,32 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="symbolAutocomplete"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<mat-option *ngIf="isLoading">
<mat-spinner diameter="50"></mat-spinner>
</mat-option>
<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" matSuffix [diameter]="20"></mat-spinner>-->

0
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss

156
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

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

26
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 {}
Loading…
Cancel
Save