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."