Browse Source

Task/extract file drop area of import activities dialog to reusable component (#8244)

* Extract file drop area

* Update changelog
pull/6986/merge
Thomas Kaul 5 days ago
committed by GitHub
parent
commit
040981e77c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 44
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  3. 107
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  4. 56
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  5. 44
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  6. 36
      libs/ui/src/lib/file-drop-area/file-drop-area.component.html
  7. 45
      libs/ui/src/lib/file-drop-area/file-drop-area.component.scss
  8. 55
      libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts
  9. 130
      libs/ui/src/lib/file-drop-area/file-drop-area.component.ts
  10. 2
      libs/ui/src/lib/file-drop-area/index.ts
  11. 5
      libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts

5
CHANGELOG.md

@ -7,11 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a _Storybook_ story for the file drop area component
### Changed ### Changed
- Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog
- Improved the accessibility of the _Buy me a coffee_ button on the about page - Improved the accessibility of the _Buy me a coffee_ button on the about page
- Improved the accessibility of the entity logo component - Improved the accessibility of the entity logo component
- Extracted the file drop area of the import activities dialog to a reusable component
- Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile
- Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs

44
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,44 +0,0 @@
import { Directive, output } from '@angular/core';
@Directive({
host: {
'(document:paste)': 'onPaste($event)',
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]'
})
export class GfFileDropDirective {
public readonly filesDropped = output<FileList>();
public onDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
public onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
public onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
}
}
public onPaste(event: ClipboardEvent) {
const files = event.clipboardData?.files;
if (files?.length) {
event.preventDefault();
this.filesDropped.emit(files);
}
}
}

107
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -1,4 +1,3 @@
import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
@ -23,6 +22,10 @@ import { AssetClass } from '@ghostfolio/prisma/enums';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import {
FileFormat,
GfFileDropAreaComponent
} from '@ghostfolio/ui/file-drop-area';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -67,11 +70,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import { informationCircleOutline, warningOutline } from 'ionicons/icons';
cloudUploadOutline,
informationCircleOutline,
warningOutline
} from 'ionicons/icons';
import { isArray, sortBy } from 'lodash-es'; import { isArray, sortBy } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { from, switchMap } from 'rxjs'; import { from, switchMap } from 'rxjs';
@ -90,7 +89,7 @@ import {
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfFileDropDirective, GfFileDropAreaComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
@ -108,14 +107,49 @@ import {
templateUrl: 'import-activities-dialog.html' templateUrl: 'import-activities-dialog.html'
}) })
export class GfImportActivitiesDialogComponent { export class GfImportActivitiesDialogComponent {
private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI = [ private static readonly FILE_FORMAT_CSV: FileFormat = {
'csv', extensions: ['csv'],
'jpeg', name: 'CSV',
'jpg', sampleUrl:
'pdf', 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv'
'png' };
private static readonly FILE_FORMAT_JSON: FileFormat = {
extensions: ['json'],
name: 'JSON',
sampleUrl:
'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json'
};
private static readonly FILE_FORMATS: FileFormat[] = [
GfImportActivitiesDialogComponent.FILE_FORMAT_CSV,
GfImportActivitiesDialogComponent.FILE_FORMAT_JSON
];
private static readonly FILE_FORMATS_OF_GHOSTFOLIO_AI: FileFormat[] = [
GfImportActivitiesDialogComponent.FILE_FORMAT_CSV,
{ extensions: ['jpeg', 'jpg'], name: 'JPG' },
{ extensions: ['pdf'], name: 'PDF' },
{ extensions: ['png'], name: 'PNG' }
]; ];
private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI =
GfImportActivitiesDialogComponent.FILE_FORMATS_OF_GHOSTFOLIO_AI.flatMap(
({ extensions }) => {
return extensions;
}
);
private static readonly FILE_FORMATS_WITH_GHOSTFOLIO_AI = sortBy(
[
...GfImportActivitiesDialogComponent.FILE_FORMATS_OF_GHOSTFOLIO_AI,
GfImportActivitiesDialogComponent.FILE_FORMAT_JSON
],
({ name }) => {
return name;
}
);
protected readonly assetProfileForm = new FormGroup({ protected readonly assetProfileForm = new FormGroup({
assetProfileIdentifier: new FormControl<PortfolioPosition | null>(null, { assetProfileIdentifier: new FormControl<PortfolioPosition | null>(null, {
validators: [Validators.required] validators: [Validators.required]
@ -127,6 +161,7 @@ export class GfImportActivitiesDialogComponent {
protected details: any[] = []; protected details: any[] = [];
protected dialogTitle = $localize`Import Activities`; protected dialogTitle = $localize`Import Activities`;
protected errorMessages: string[] = []; protected errorMessages: string[] = [];
protected readonly ghostfolioAiLoadingText = $localize`Ghostfolio AI is processing the file...`;
protected readonly ghostfolioAiTooltip = $localize`The content of your file will be processed by third-party AI providers. Please remove any personal data before uploading.`; protected readonly ghostfolioAiTooltip = $localize`The content of your file will be processed by third-party AI providers. Please remove any personal data before uploading.`;
protected readonly hasPermissionToEnableGhostfolioAi = hasPermission( protected readonly hasPermissionToEnableGhostfolioAi = hasPermission(
@ -144,6 +179,7 @@ export class GfImportActivitiesDialogComponent {
protected isLoading = false; protected isLoading = false;
protected isMaxActivitiesOfGhostfolioAiReached = false; protected isMaxActivitiesOfGhostfolioAiReached = false;
protected readonly language = document.documentElement.lang;
protected readonly maxActivitiesOfGhostfolioAi = AI_MAX_ACTIVITIES; protected readonly maxActivitiesOfGhostfolioAi = AI_MAX_ACTIVITIES;
protected mode: 'DIVIDEND'; protected mode: 'DIVIDEND';
protected pageIndex = 0; protected pageIndex = 0;
@ -178,7 +214,13 @@ export class GfImportActivitiesDialogComponent {
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
addIcons({ cloudUploadOutline, informationCircleOutline, warningOutline }); addIcons({ informationCircleOutline, warningOutline });
}
protected get fileFormats() {
return this.isGhostfolioAiEnabled
? GfImportActivitiesDialogComponent.FILE_FORMATS_WITH_GHOSTFOLIO_AI
: GfImportActivitiesDialogComponent.FILE_FORMATS;
} }
public ngOnInit() { public ngOnInit() {
@ -271,18 +313,18 @@ export class GfImportActivitiesDialogComponent {
} }
} }
protected onFilesDropped({ protected onFileSelected({
files, file,
stepper stepper
}: { }: {
files: FileList; file: File;
stepper: MatStepper; stepper: MatStepper;
}) { }) {
if (files.length === 0 || this.importStep !== ImportStep.UPLOAD_FILE) { if (this.importStep !== ImportStep.UPLOAD_FILE) {
return; return;
} }
this.handleFile({ stepper, file: files[0] }); this.handleFile({ file, stepper });
} }
protected onGhostfolioAiChange({ checked }: MatCheckboxChange) { protected onGhostfolioAiChange({ checked }: MatCheckboxChange) {
@ -348,33 +390,6 @@ export class GfImportActivitiesDialogComponent {
aStepper.reset(); aStepper.reset();
} }
protected onSelectFile(stepper: MatStepper) {
const input = document.createElement('input');
input.accept = this.isGhostfolioAiEnabled
? [
'application/JSON',
...GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.map(
(fileExtension) => {
return `.${fileExtension}`;
}
)
].join(', ')
: 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
// Getting the file reference
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
this.handleFile({ file, stepper });
}
};
input.click();
}
protected updateSelection(activities: Activity[]) { protected updateSelection(activities: Activity[]) {
this.selectedActivities = activities.filter(({ error }) => { this.selectedActivities = activities.filter(({ error }) => {
return !error; return !error;

56
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -74,55 +74,13 @@
</form> </form>
} @else { } @else {
<div class="d-flex flex-column justify-content-center"> <div class="d-flex flex-column justify-content-center">
@if (isLoading) { <gf-file-drop-area
<div [fileFormats]="fileFormats"
class="align-items-center d-flex drop-area flex-column justify-content-center p-4 text-body-secondary text-center" [isLoading]="isLoading"
> [language]="language"
<mat-spinner [diameter]="20" /> [loadingText]="ghostfolioAiLoadingText"
<span i18n>Ghostfolio AI is processing the file...</span> (fileSelected)="onFileSelected({ stepper, file: $event })"
</div> />
} @else {
<button
class="drop-area p-4 text-body-secondary text-center"
gfFileDrop
(click)="onSelectFile(stepper)"
(filesDropped)="onFilesDropped({ stepper, files: $event })"
>
<div
class="align-items-center d-flex flex-column justify-content-center"
>
<ion-icon class="cloud-icon" name="cloud-upload-outline" />
<span i18n>Choose or drop a file here</span>
</div>
</button>
}
<p class="mb-0 mt-3 text-center">
<small>
<span class="me-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv"
target="_blank"
>CSV</a
>
@if (isGhostfolioAiEnabled) {
<span class="me-1">, JPG,</span>
} @else {
<span class="mx-1" i18n>or</span>
}
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json"
target="_blank"
>JSON</a
>
@if (isGhostfolioAiEnabled) {
<span>, PDF</span>
<span class="mx-1" i18n>or</span>
<span>PNG</span>
}
</small>
</p>
@if ( @if (
data.deviceType !== 'mobile' && hasPermissionToEnableGhostfolioAi data.deviceType !== 'mobile' && hasPermissionToEnableGhostfolioAi
) { ) {

44
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -4,10 +4,6 @@
display: block; display: block;
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
a {
color: rgba(var(--palette-primary-500), 1);
}
mat-stepper { mat-stepper {
@include mat.stepper-overrides( @include mat.stepper-overrides(
( (
@ -25,35 +21,6 @@
} }
} }
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
}
.cloud-icon {
font-size: 2.5rem;
}
.mat-mdc-progress-spinner {
@include mat.progress-spinner-overrides(
(
active-indicator-color: var(--gf-theme-primary-500)
)
);
margin: 0.625rem 0;
}
}
.mat-mdc-form-field { .mat-mdc-form-field {
.mat-mdc-progress-spinner { .mat-mdc-progress-spinner {
right: 1.5rem; right: 1.5rem;
@ -80,14 +47,3 @@
} }
} }
} }
:host-context(.theme-dark) {
.mat-mdc-dialog-content {
.drop-area {
border-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
}

36
libs/ui/src/lib/file-drop-area/file-drop-area.component.html

@ -0,0 +1,36 @@
@if (isLoading()) {
<div
class="align-items-center d-flex drop-area flex-column justify-content-center p-4 text-body-secondary text-center"
>
<mat-spinner [diameter]="20" />
<span>{{ loadingText() }}</span>
</div>
} @else {
<button
class="drop-area p-4 text-body-secondary text-center"
type="button"
(click)="onSelectFile()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(drop)="onDrop($event)"
>
<div class="align-items-center d-flex flex-column justify-content-center">
<ion-icon class="cloud-icon" name="cloud-upload-outline" />
<span i18n>Choose or drop a file here</span>
</div>
</button>
}
<p class="mb-0 mt-3 text-center">
<small>
<span class="me-1" i18n>The following file formats are supported:</span>
@for (fileFormatListPart of fileFormatListParts(); track $index) {
@if (fileFormatListPart.sampleUrl) {
<a target="_blank" [href]="fileFormatListPart.sampleUrl">{{
fileFormatListPart.value
}}</a>
} @else {
<span>{{ fileFormatListPart.value }}</span>
}
}
</small>
</p>

45
libs/ui/src/lib/file-drop-area/file-drop-area.component.scss

@ -0,0 +1,45 @@
@use '@angular/material' as mat;
:host {
a {
color: rgba(var(--palette-primary-500), 1);
}
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
}
.cloud-icon {
font-size: 2.5rem;
}
.mat-mdc-progress-spinner {
@include mat.progress-spinner-overrides(
(
active-indicator-color: var(--gf-theme-primary-500)
)
);
margin: 0.625rem 0;
}
}
}
:host-context(.theme-dark) {
.drop-area {
border-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}

55
libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { GfFileDropAreaComponent } from './file-drop-area.component';
import { FileFormat } from './interfaces/interfaces';
const FILE_FORMAT_CSV: FileFormat = {
extensions: ['csv'],
name: 'CSV',
sampleUrl:
'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv'
};
const FILE_FORMAT_JSON: FileFormat = {
extensions: ['json'],
name: 'JSON',
sampleUrl:
'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json'
};
const FILE_FORMATS_WITH_GHOSTFOLIO_AI: FileFormat[] = [
FILE_FORMAT_CSV,
{ extensions: ['jpeg', 'jpg'], name: 'JPG' },
FILE_FORMAT_JSON,
{ extensions: ['pdf'], name: 'PDF' },
{ extensions: ['png'], name: 'PNG' }
];
export default {
args: {
loadingText: 'Processing the file...'
},
component: GfFileDropAreaComponent,
title: 'File Drop Area'
} as Meta<GfFileDropAreaComponent>;
type Story = StoryObj<GfFileDropAreaComponent>;
export const Default: Story = {
args: {
fileFormats: [FILE_FORMAT_CSV, FILE_FORMAT_JSON]
}
};
export const Loading: Story = {
args: {
fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI,
isLoading: true
}
};
export const WithGhostfolioAi: Story = {
args: {
fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI
}
};

130
libs/ui/src/lib/file-drop-area/file-drop-area.component.ts

@ -0,0 +1,130 @@
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output
} from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { cloudUploadOutline } from 'ionicons/icons';
import { FileFormat } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'(document:paste)': 'onPaste($event)',
class: 'd-flex flex-column'
},
imports: [IonIcon, MatProgressSpinnerModule],
selector: 'gf-file-drop-area',
styleUrls: ['./file-drop-area.component.scss'],
templateUrl: './file-drop-area.component.html'
})
export class GfFileDropAreaComponent {
public readonly fileFormats = input.required<FileFormat[]>();
public readonly isLoading = input(false);
public readonly language = input(DEFAULT_LANGUAGE_CODE);
public readonly loadingText = input.required<string>();
public readonly fileSelected = output<File>();
protected readonly accept = computed(() => {
return this.fileFormats()
.flatMap(({ extensions }) => {
return extensions.map((extension) => {
return `.${extension}`;
});
})
.join(', ');
});
protected readonly fileFormatListParts = computed(() => {
const fileFormats = this.fileFormats();
return new Intl.ListFormat(this.language(), { type: 'disjunction' })
.formatToParts(
fileFormats.map(({ name }) => {
return name;
})
)
.map(({ type, value }) => {
return {
value,
sampleUrl:
type === 'element'
? fileFormats.find(({ name }) => {
return name === value;
})?.sampleUrl
: undefined
};
});
});
public constructor() {
addIcons({ cloudUploadOutline });
}
protected onDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
protected onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
protected onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.emitFirstFile(event.dataTransfer.files);
}
}
protected onPaste(event: ClipboardEvent) {
if (this.isLoading()) {
return;
}
const files = event.clipboardData?.files;
if (files?.length) {
event.preventDefault();
this.emitFirstFile(files);
}
}
protected onSelectFile() {
const fileInputElement = document.createElement('input');
fileInputElement.accept = this.accept();
fileInputElement.type = 'file';
fileInputElement.onchange = (event) => {
const files = (event.target as HTMLInputElement).files;
if (files) {
this.emitFirstFile(files);
}
};
fileInputElement.click();
}
private emitFirstFile(files: FileList) {
const file = files.item(0);
if (file) {
this.fileSelected.emit(file);
}
}
}

2
libs/ui/src/lib/file-drop-area/index.ts

@ -0,0 +1,2 @@
export * from './file-drop-area.component';
export * from './interfaces/interfaces';

5
libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts

@ -0,0 +1,5 @@
export interface FileFormat {
extensions: string[];
name: string;
sampleUrl?: string;
}
Loading…
Cancel
Save