diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d622252c3..8be58c87ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, briefly coloring changed values green or red, with a live indicator in the header +- Added a _Storybook_ story for the file drop area component ### Changed - 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 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 - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts deleted file mode 100644 index ae772bb500..0000000000 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ /dev/null @@ -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(); - - 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); - } - } -} diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 6ebcc4f2ad..21136af668 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { @@ -23,6 +22,10 @@ import { AssetClass } from '@ghostfolio/prisma/enums'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; +import { + FileFormat, + GfFileDropAreaComponent +} from '@ghostfolio/ui/file-drop-area'; import { DataService } from '@ghostfolio/ui/services'; import { @@ -67,11 +70,7 @@ import { MatTableDataSource } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { - cloudUploadOutline, - informationCircleOutline, - warningOutline -} from 'ionicons/icons'; +import { informationCircleOutline, warningOutline } from 'ionicons/icons'; import { isArray, sortBy } from 'lodash-es'; import ms from 'ms'; import { from, switchMap } from 'rxjs'; @@ -90,7 +89,7 @@ import { GfActivitiesTableComponent, GfDialogFooterComponent, GfDialogHeaderComponent, - GfFileDropDirective, + GfFileDropAreaComponent, IonIcon, MatButtonModule, MatCheckboxModule, @@ -108,14 +107,49 @@ import { templateUrl: 'import-activities-dialog.html' }) export class GfImportActivitiesDialogComponent { - private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI = [ - 'csv', - 'jpeg', - 'jpg', - 'pdf', - 'png' + private static readonly FILE_FORMAT_CSV: FileFormat = { + extensions: ['csv'], + name: 'CSV', + sampleUrl: + 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv' + }; + + 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({ assetProfileIdentifier: new FormControl(null, { validators: [Validators.required] @@ -127,6 +161,7 @@ export class GfImportActivitiesDialogComponent { protected details: any[] = []; protected dialogTitle = $localize`Import Activities`; 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 hasPermissionToEnableGhostfolioAi = hasPermission( @@ -144,6 +179,7 @@ export class GfImportActivitiesDialogComponent { protected isLoading = false; protected isMaxActivitiesOfGhostfolioAiReached = false; + protected readonly language = document.documentElement.lang; protected readonly maxActivitiesOfGhostfolioAi = AI_MAX_ACTIVITIES; protected mode: 'DIVIDEND'; protected pageIndex = 0; @@ -178,7 +214,13 @@ export class GfImportActivitiesDialogComponent { private readonly userService = inject(UserService); 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() { @@ -271,18 +313,18 @@ export class GfImportActivitiesDialogComponent { } } - protected onFilesDropped({ - files, + protected onFileSelected({ + file, stepper }: { - files: FileList; + file: File; stepper: MatStepper; }) { - if (files.length === 0 || this.importStep !== ImportStep.UPLOAD_FILE) { + if (this.importStep !== ImportStep.UPLOAD_FILE) { return; } - this.handleFile({ stepper, file: files[0] }); + this.handleFile({ file, stepper }); } protected onGhostfolioAiChange({ checked }: MatCheckboxChange) { @@ -348,33 +390,6 @@ export class GfImportActivitiesDialogComponent { 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[]) { this.selectedActivities = activities.filter(({ error }) => { return !error; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 75c3ea5458..eea156f9c2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -74,55 +74,13 @@ } @else {
- @if (isLoading) { -
- - Ghostfolio AI is processing the file... -
- } @else { - - } -

- - The following file formats are supported: - CSV - @if (isGhostfolioAiEnabled) { - , JPG, - } @else { - or - } - JSON - @if (isGhostfolioAiEnabled) { - , PDF - or - PNG - } - -

+ @if ( data.deviceType !== 'mobile' && hasPermissionToEnableGhostfolioAi ) { diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss index 1b46f06863..edf449bd69 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss @@ -4,10 +4,6 @@ display: block; .mat-mdc-dialog-content { - a { - color: rgba(var(--palette-primary-500), 1); - } - mat-stepper { @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-progress-spinner { 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) - ); - } - } -} diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html new file mode 100644 index 0000000000..b5c6e19021 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html @@ -0,0 +1,36 @@ +@if (isLoading()) { +
+ + {{ loadingText() }} +
+} @else { + +} +

+ + The following file formats are supported: + @for (fileFormatListPart of fileFormatListParts(); track $index) { + @if (fileFormatListPart.sampleUrl) { + {{ + fileFormatListPart.value + }} + } @else { + {{ fileFormatListPart.value }} + } + } + +

diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.scss b/libs/ui/src/lib/file-drop-area/file-drop-area.component.scss new file mode 100644 index 0000000000..9702ccf4c2 --- /dev/null +++ b/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) + ); + } +} diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts b/libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts new file mode 100644 index 0000000000..eeee334b07 --- /dev/null +++ b/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; + +type Story = StoryObj; + +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 + } +}; diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.ts b/libs/ui/src/lib/file-drop-area/file-drop-area.component.ts new file mode 100644 index 0000000000..f63730e533 --- /dev/null +++ b/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(); + public readonly isLoading = input(false); + public readonly language = input(DEFAULT_LANGUAGE_CODE); + public readonly loadingText = input.required(); + + public readonly fileSelected = output(); + + 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); + } + } +} diff --git a/libs/ui/src/lib/file-drop-area/index.ts b/libs/ui/src/lib/file-drop-area/index.ts new file mode 100644 index 0000000000..b5cf734153 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/index.ts @@ -0,0 +1,2 @@ +export * from './file-drop-area.component'; +export * from './interfaces/interfaces'; diff --git a/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts b/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts new file mode 100644 index 0000000000..695a3de654 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface FileFormat { + extensions: string[]; + name: string; + sampleUrl?: string; +}