Browse Source

Merge 46ae6acd53 into fa27a05bcf

pull/3708/merge
Daniel Idem 7 months ago
committed by GitHub
parent
commit
110d5e3392
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 60
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  2. 35
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  3. 51
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  4. 35
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  5. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts
  6. 1
      libs/ui/src/lib/tags-selector/index.ts
  7. 32
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  8. 0
      libs/ui/src/lib/tags-selector/tags-selector.component.scss
  9. 106
      libs/ui/src/lib/tags-selector/tags-selector.component.ts

60
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -18,6 +18,7 @@ import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-cre
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { COMMA, ENTER } from '@angular/cdk/keycodes';
@ -27,19 +28,13 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit, OnInit
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { import { MatAutocompleteModule } from '@angular/material/autocomplete';
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -53,8 +48,8 @@ import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Observable, of, Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -70,10 +65,10 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfDialogHeaderModule, GfDialogHeaderModule,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatTabsModule, MatTabsModule,
@ -85,8 +80,6 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup; public activityForm: FormGroup;
public accounts: Account[]; public accounts: Account[];
public assetClass: string; public assetClass: string;
@ -102,7 +95,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendInBaseCurrencyPrecision = 2; public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public firstBuyDate: string; public firstBuyDate: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
@ -319,17 +311,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false }); this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
this.filteredTagsObservable = this.activityForm.controls[
'tags'
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => {
return aTags
? this.filterTags(aTags)
: this.tagsAvailable.slice();
})
);
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.totalItems = transactionCount; this.totalItems = transactionCount;
this.value = value; this.value = value;
@ -437,15 +418,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onAddTag(event: MatAutocompleteSelectedEvent) { public onAddTag(event: Tag[]) {
this.activityForm.get('tags').setValue([ this.activityForm.get('tags').setValue(event);
...(this.activityForm.get('tags').value ?? []),
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
} }
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
@ -480,14 +454,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onRemoveTag(aTag: Tag) {
this.activityForm.get('tags').setValue(
this.activityForm.get('tags').value.filter(({ id }) => {
return id !== aTag.id;
})
);
}
public onUpdateActivity(aActivity: Activity) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], { this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, editDialog: true } queryParams: { activityId: aActivity.id, editDialog: true }
@ -500,14 +466,4 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}
} }

35
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -373,38 +373,11 @@
}" }"
> >
<div class="col"> <div class="col">
<mat-form-field appearance="outline" class="w-100 without-hint"> <gf-tags-selector
<mat-label i18n>Tags</mat-label> [tags]="activityForm.get('tags')?.value"
<mat-chip-grid #tagsChipList> [tagsAvailable]="tagsAvailable"
@for (tag of activityForm.get('tags')?.value; track tag.id) { (tagsChanged)="onAddTag($event)"
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/> />
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div> </div>
</div> </div>

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

@ -8,19 +8,16 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isAfter, isToday } from 'date-fns'; import { isAfter, isToday } from 'date-fns';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; import { EMPTY, Subject, lastValueFrom, of } from 'rxjs';
import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators'; import { catchError, delay, takeUntil } from 'rxjs/operators';
import { DataService } from '../../../../services/data.service'; import { DataService } from '../../../../services/data.service';
import { validateObjectForForm } from '../../../../util/form.util'; import { validateObjectForForm } from '../../../../util/form.util';
@ -35,9 +32,6 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
standalone: false standalone: false
}) })
export class CreateOrUpdateActivityDialog implements OnDestroy { export class CreateOrUpdateActivityDialog implements OnDestroy {
@ViewChild('symbolAutocomplete') symbolAutocomplete;
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass).map((assetClass) => { public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) }; return { id: assetClass, label: translate(assetClass) };
@ -48,7 +42,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public defaultDateFormat: string; public defaultDateFormat: string;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update'; public mode: 'create' | 'update';
@ -284,15 +277,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.filteredTagsObservable = this.activityForm.controls[
'tags'
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => {
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
})
);
this.activityForm this.activityForm
.get('type') .get('type')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntil(this.unsubscribeSubject))
@ -440,27 +424,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
return isAfter(aDate, new Date(0)); return isAfter(aDate, new Date(0));
} }
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []),
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onRemoveTag(aTag: Tag) { public onTagsChanged(event: Tag[]) {
this.activityForm.get('tags').setValue( this.activityForm.get('tags').setValue([...event]);
this.activityForm.get('tags').value.filter(({ id }) => {
return id !== aTag.id;
})
);
} }
public async onSubmit() { public async onSubmit() {
@ -523,16 +492,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}
private updateSymbol() { private updateSymbol() {
this.isLoading = true; this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

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

@ -379,38 +379,11 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }"> <div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
<mat-form-field appearance="outline" class="w-100"> <gf-tags-selector
<mat-label i18n>Tags</mat-label> [tags]="activityForm.get('tags')?.value"
<mat-chip-grid #tagsChipList> [tagsAvailable]="tagsAvailable"
@for (tag of activityForm.get('tags')?.value; track tag.id) { (tagsChanged)="onTagsChanged($event)"
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/> />
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div> </div>
</div> </div>
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>

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

@ -1,5 +1,6 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -24,6 +25,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
FormsModule, FormsModule,
GfAssetProfileIconComponent, GfAssetProfileIconComponent,
GfSymbolAutocompleteComponent, GfSymbolAutocompleteComponent,
GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,

1
libs/ui/src/lib/tags-selector/index.ts

@ -0,0 +1 @@
export * from './tags-selector.component';

32
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -0,0 +1,32 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of tagsSignal(); track tag.id) {
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[(ngModel)]="currentTag"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTags(); track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>

0
libs/ui/src/lib/tags-selector/tags-selector.component.scss

106
libs/ui/src/lib/tags-selector/tags-selector.component.ts

@ -0,0 +1,106 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
effect,
ElementRef,
EventEmitter,
Input,
model,
OnInit,
Output,
signal,
ViewChild
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Tag } from '@prisma/client';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatChipsModule,
MatIconModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-tags-selector',
standalone: true,
styleUrls: ['./tags-selector.component.scss'],
templateUrl: 'tags-selector.component.html'
})
export class GfTagsSelectorComponent implements OnInit {
@Input() tags: Tag[];
@Input() tagsAvailable: Tag[];
@Output() tagsChanged = new EventEmitter<Tag[]>();
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
readonly tagsSignal = signal([{ id: '', name: '' }]);
readonly currentTag = model('');
readonly filteredTags = computed(() => {
const currentTag = this.currentTag().toLowerCase();
const aTags = this.tagsAvailable ?? [{ id: '', name: '' }];
const bTags = this.tagsSignal() ?? [{ id: '', name: '' }];
const cTags = aTags.filter((value) => !bTags.includes(value));
return currentTag
? cTags.filter((tag) => tag.name.toLowerCase().includes(currentTag))
: cTags;
});
public constructor() {
effect(() => {
if (this.tagsSignal()) {
this.tagsChanged.emit(this.tagsSignal());
}
});
}
ngOnInit() {
this.tagsSignal.set(this.tags);
}
public onAddTag(event: MatAutocompleteSelectedEvent) {
const tagId = event.option.value;
const newTag = this.tagsAvailable.find(({ id }) => id === tagId);
if (this.tagsSignal()?.some((el) => el.id === tagId)) {
this.currentTag.set('');
event.option.deselect();
return;
}
this.tagsSignal()
? this.tagsSignal.update((tags) => [...tags, newTag])
: this.tagsSignal.update(() => [newTag]);
this.currentTag.set('');
event.option.deselect();
}
public onRemoveTag(aTag: Tag) {
this.tagsSignal.update((tagsSignal) => {
const index = tagsSignal.indexOf(aTag);
if (index < 0) {
return tagsSignal;
}
tagsSignal.splice(index, 1);
return [...tagsSignal];
});
}
}
Loading…
Cancel
Save