mirror of https://github.com/ghostfolio/ghostfolio
10 changed files with 415 additions and 84 deletions
@ -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(); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1 @@ |
|||||
|
export * from './symbol-autocomplete.module'; |
@ -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,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 }); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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…
Reference in new issue