diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html new file mode 100644 index 000000000..1b67f9d6a --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html @@ -0,0 +1,11 @@ +
+
+
+ + + + +
+
diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.scss b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts new file mode 100644 index 000000000..88f0214fa --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts @@ -0,0 +1,82 @@ +import { TranslatePipe } from '@ngx-translate/core'; +import { MockComponents, MockPipe } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { FilesSelectors } from '../../store'; + +import { ConfirmMoveFileDialogComponent } from './confirm-move-file-dialog.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +describe('ConfirmConfirmMoveFileDialogComponent', () => { + let component: ConfirmMoveFileDialogComponent; + let fixture: ComponentFixture; + + const mockFilesService = { + moveFiles: jest.fn(), + getMoveDialogFiles: jest.fn(), + }; + + beforeEach(async () => { + const dialogRefMock = { + close: jest.fn(), + }; + + const dialogConfigMock = { + data: { files: [], destination: { name: 'files' } }, + }; + + await TestBed.configureTestingModule({ + imports: [ + ConfirmMoveFileDialogComponent, + OSFTestingModule, + ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent), + MockPipe(TranslatePipe), + ], + providers: [ + { provide: DynamicDialogRef, useValue: dialogRefMock }, + { provide: DynamicDialogConfig, useValue: dialogConfigMock }, + { provide: FilesService, useValue: mockFilesService }, + { provide: ToastService, useValue: ToastServiceMock.simple() }, + { provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() }, + provideMockStore({ + signals: [ + { selector: FilesSelectors.getMoveDialogFiles, value: [] }, + { selector: FilesSelectors.getProvider, value: null }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmMoveFileDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with correct properties', () => { + expect(component.config).toBeDefined(); + expect(component.dialogRef).toBeDefined(); + expect(component.files).toBeDefined(); + }); + + it('should get files from store', () => { + expect(component.files()).toEqual([]); + }); +}); diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts new file mode 100644 index 000000000..373a517f2 --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts @@ -0,0 +1,154 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { finalize, forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { FilesSelectors } from '@osf/features/files/store'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FileMenuType } from '@shared/enums/file-menu-type.enum'; +import { FileModel } from '@shared/models/files/file.model'; + +@Component({ + selector: 'osf-move-file-dialog', + imports: [Button, TranslatePipe], + templateUrl: './confirm-move-file-dialog.component.html', + styleUrl: './confirm-move-file-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmMoveFileDialogComponent { + readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + private readonly filesService = inject(FilesService); + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + readonly files = select(FilesSelectors.getMoveDialogFiles); + readonly provider = this.config.data.storageProvider; + + private fileProjectId = this.config.data.resourceId; + protected currentFolder = this.config.data.destination; + + get dragNodeName() { + const filesCount = this.config.data.files.length; + if (filesCount > 1) { + return this.translateService.instant('files.dialogs.moveFile.multipleFiles', { count: filesCount }); + } else { + return this.config.data.files[0]?.name; + } + } + + copyFiles(): void { + return this.copyOrMoveFiles(FileMenuType.Copy); + } + + moveFiles(): void { + return this.copyOrMoveFiles(FileMenuType.Move); + } + + private copyOrMoveFiles(action: FileMenuType): void { + const path = this.currentFolder.path; + if (!path) { + throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError')); + } + const isMoveAction = action === FileMenuType.Move; + + const headerKey = isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader'; + this.config.header = this.translateService.instant(headerKey); + const files: FileModel[] = this.config.data.files; + const totalFiles = files.length; + let completed = 0; + const conflictFiles: { file: FileModel; link: string }[] = []; + + files.forEach((file) => { + const link = file.links.move; + this.filesService + .moveFile(link, path, this.fileProjectId, this.provider(), action) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error) => { + if (error.status === 409) { + conflictFiles.push({ file, link }); + } else { + this.showErrorToast(action, error.error?.message); + } + return of(null); + }), + finalize(() => { + completed++; + if (completed === totalFiles) { + if (conflictFiles.length > 0) { + this.openReplaceMoveDialog(conflictFiles, path, action); + } else { + this.showSuccessToast(action); + this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); + this.completeMove(); + } + } + }) + ) + .subscribe(); + }); + } + + private openReplaceMoveDialog( + conflictFiles: { file: FileModel; link: string }[], + path: string, + action: string + ): void { + this.customConfirmationService.confirmDelete({ + headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single', + messageKey: 'files.dialogs.replaceFile.message', + messageParams: { + name: conflictFiles.map((c) => c.file.name).join(', '), + }, + acceptLabelKey: 'common.buttons.replace', + onConfirm: () => { + const replaceRequests$ = conflictFiles.map(({ link }) => + this.filesService.moveFile(link, path, this.fileProjectId, this.provider(), action, true).pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + ); + forkJoin(replaceRequests$).subscribe({ + next: () => { + this.showSuccessToast(action); + this.completeMove(); + }, + }); + }, + onReject: () => { + const totalFiles = this.config.data.files.length; + if (totalFiles > conflictFiles.length) { + this.showErrorToast(action); + } + this.completeMove(); + }, + }); + } + + private showSuccessToast(action: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showSuccess(`files.dialogs.${messageType}.success`); + } + + private showErrorToast(action: string, errorMessage?: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + } + + private completeMove(): void { + this.dialogRef.close(true); + } +} diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts index 3e5c8e56f..2dfcb0406 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts @@ -3,7 +3,6 @@ import { MockComponents, MockPipe } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component'; @@ -56,14 +55,14 @@ describe('MoveFileDialogComponent', () => { { provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() }, provideMockStore({ signals: [ - { selector: FilesSelectors.getMoveDialogFiles, value: signal([]) }, - { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: signal(0) }, - { selector: FilesSelectors.isMoveDialogFilesLoading, value: signal(false) }, - { selector: FilesSelectors.getMoveDialogCurrentFolder, value: signal(null) }, - { selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) }, - { selector: CurrentResourceSelectors.getResourceWithChildren, value: signal([]) }, - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: signal(false) }, - { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: signal(false) }, + { selector: FilesSelectors.getMoveDialogFiles, value: [] }, + { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 }, + { selector: FilesSelectors.isMoveDialogFilesLoading, value: false }, + { selector: FilesSelectors.getMoveDialogCurrentFolder, value: null }, + { selector: CurrentResourceSelectors.getCurrentResource, value: null }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false }, ], }), ], diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts index e3b33a856..4374dade2 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts @@ -211,7 +211,7 @@ export class MoveFileDialogComponent { if (error.status === 409) { conflictFiles.push({ file, link }); } else { - this.toastService.showError(error.error?.message ?? 'Error'); + this.showErrorToast(action, error.error?.message ?? 'Error'); } return of(null); }), @@ -221,7 +221,7 @@ export class MoveFileDialogComponent { if (conflictFiles.length > 0) { this.openReplaceMoveDialog(conflictFiles, path, action); } else { - this.showToast(action); + this.showSuccessToast(action); this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); this.completeMove(); } @@ -254,7 +254,7 @@ export class MoveFileDialogComponent { forkJoin(replaceRequests$).subscribe({ next: () => { - this.showToast(action); + this.showSuccessToast(action); this.completeMove(); }, }); @@ -262,18 +262,23 @@ export class MoveFileDialogComponent { onReject: () => { const totalFiles = this.config.data.files.length; if (totalFiles > conflictFiles.length) { - this.showToast(action); + this.showErrorToast(action); } this.completeMove(); }, }); } - private showToast(action: string): void { + private showSuccessToast(action: string) { const messageType = action === 'move' ? 'moveFile' : 'copyFile'; this.toastService.showSuccess(`files.dialogs.${messageType}.success`); } + private showErrorToast(action: string, errorMessage?: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + } + private completeMove(): void { this.isFilesUpdating.set(false); this.actions.setCurrentFolder(this.initialFolder); diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 18ac14c55..6c6444787 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -25,6 +25,8 @@
@if (file.previousFolder) { diff --git a/src/app/shared/components/files-tree/files-tree.component.spec.ts b/src/app/shared/components/files-tree/files-tree.component.spec.ts index 4190c6f9d..e868ab604 100644 --- a/src/app/shared/components/files-tree/files-tree.component.spec.ts +++ b/src/app/shared/components/files-tree/files-tree.component.spec.ts @@ -1,5 +1,6 @@ import { MockComponents, MockProvider } from 'ng-mocks'; +import { TreeDragDropService } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { signal } from '@angular/core'; @@ -53,6 +54,7 @@ describe('FilesTreeComponent', () => { MockProvider(ToastService), MockProvider(CustomConfirmationService), MockProvider(DialogService), + TreeDragDropService, ], }).compileComponents(); diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 4b30a486b..d01320220 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -3,7 +3,7 @@ import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { PrimeTemplate } from 'primeng/api'; -import { Tree, TreeNodeSelectEvent, TreeScrollIndexChangeEvent } from 'primeng/tree'; +import { Tree, TreeNodeDropEvent, TreeNodeSelectEvent, TreeScrollIndexChangeEvent } from 'primeng/tree'; import { Clipboard } from '@angular/cdk/clipboard'; import { DatePipe } from '@angular/common'; @@ -27,6 +27,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ConfirmMoveFileDialogComponent } from '@osf/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component'; import { MoveFileDialogComponent } from '@osf/features/files/components/move-file-dialog/move-file-dialog.component'; import { RenameFileDialogComponent } from '@osf/features/files/components/rename-file-dialog/rename-file-dialog.component'; import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; @@ -458,7 +459,39 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.lastSelectedFile = selectedNode; } + onNodeDrop(event: TreeNodeDropEvent) { + const dropFile = event.dropNode as FileModel; + if (dropFile.kind !== FileKind.Folder) { + return; + } + const files = this.selectedFiles(); + const dragFile = event.dragNode as FileModel; + if (!files.includes(dragFile)) { + this.selectFile.emit(dragFile); + files.push(dragFile); + } + this.moveFilesTo(files, dropFile); + } + onNodeUnselect(event: TreeNodeSelectEvent) { this.unselectFile.emit(event.node as FileModel); } + + private moveFilesTo(files: FileModel[], destination: FileModel) { + const isMultiple = files.length > 1; + this.customDialogService + .open(ConfirmMoveFileDialogComponent, { + header: isMultiple ? 'files.dialogs.moveFile.dialogTitleMultiple' : 'files.dialogs.moveFile.dialogTitle', + width: '552px', + data: { + files, + destination, + resourceId: this.resourceId(), + storageProvider: this.storage()?.folder.provider, + }, + }) + .onClose.subscribe(() => { + this.resetFilesProvider.emit(); + }); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 11e832513..0c126478d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1155,13 +1155,17 @@ "moveFile": { "cannotMove": "Cannot move or copy to this folder", "title": "Select Destination", + "dialogTitle": "Move file", + "dialogTitleMultiple": "Move files", "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}} ?", + "multipleFiles": "{{count}} files", "storage": "OSF Storage", "pathError": "Path is not specified!", "success": "Successfully moved.", "noMovePermission": "Cannot move or copy to this file provider", + "error": "Failed to move or copy files, please try again later", "movingHeader": "Moving...", - "copingHeader": "Coping..." + "copingHeader": "Copying..." }, "copyFile": { "success": "File successfully copied."