Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="flex flex-column gap-2">
<div
[innerHTML]="'files.dialogs.moveFile.message' | translate: { dropNodeName: currentFolder.name, dragNodeName }"
></div>
<div class="flex justify-content-end gap-2 mt-4">
<p-button severity="info" [label]="'common.buttons.cancel' | translate" (onClick)="dialogRef.close()"></p-button>

<p-button [label]="'common.buttons.move' | translate" (onClick)="moveFiles()"></p-button>
<p-button [label]="'common.buttons.copy' | translate" (onClick)="copyFiles()"></p-button>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { TranslatePipe } from '@ngx-translate/core';
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';
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<ConfirmMoveFileDialogComponent>;

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: signal([]) },
{ selector: FilesSelectors.getProvider, value: signal(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([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { select } from '@ngxs/store';

import { TranslatePipe, TranslateService } from '@ngx-translate/core';

import { Button } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { ScrollerModule } from 'primeng/scroller';

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, ScrollerModule],
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 = select(FilesSelectors.getProvider);

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.toastService.showError(error.error?.message ?? 'Error');
}
return of(null);
}),
finalize(() => {
completed++;
if (completed === totalFiles) {
if (conflictFiles.length > 0) {
this.openReplaceMoveDialog(conflictFiles, path, action);
} else {
this.showToast(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.showToast(action);
this.completeMove();
},
});
},
onReject: () => {
const totalFiles = this.config.data.files.length;
if (totalFiles > conflictFiles.length) {
this.showToast(action);
}
this.completeMove();
},
});
}

private showToast(action: string): void {
const messageType = action === 'move' ? 'moveFile' : 'copyFile';
this.toastService.showSuccess(`files.dialogs.${messageType}.success`);
}

private completeMove(): void {
this.dialogRef.close(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
<div class="files-table flex">
<p-tree
[value]="nodes()"
[draggableNodes]="true"
[droppableNodes]="true"
(onScrollIndexChange)="onScrollIndexChange($event)"
[scrollHeight]="scrollHeight()"
[virtualScroll]="true"
Expand All @@ -36,6 +38,7 @@
[selection]="selectedFiles()"
(onNodeSelect)="onNodeSelect($event)"
(onNodeUnselect)="onNodeUnselect($event)"
(onNodeDrop)="onNodeDrop($event)"
>
<ng-template let-file pTemplate="default">
@if (file.previousFolder) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -53,6 +54,7 @@ describe('FilesTreeComponent', () => {
MockProvider(ToastService),
MockProvider(CustomConfirmationService),
MockProvider(DialogService),
TreeDragDropService,
],
}).compileComponents();

Expand Down
37 changes: 36 additions & 1 deletion src/app/shared/components/files-tree/files-tree.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -458,7 +459,41 @@ 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,
foldersStack: structuredClone(this.foldersStack),
initialFolder: structuredClone(this.currentFolder()),
},
})
.onClose.subscribe(() => {
this.resetFilesProvider.emit();
});
}
}
5 changes: 4 additions & 1 deletion src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1155,13 +1155,16 @@
"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 <span class=\"font-bold\">{{dragNodeName}}</span> to <span class=\"font-bold\">{{dropNodeName}}</span> ?",
"multipleFiles": "{{count}} files",
"storage": "OSF Storage",
"pathError": "Path is not specified!",
"success": "Successfully moved.",
"noMovePermission": "Cannot move or copy to this file provider",
"movingHeader": "Moving...",
"copingHeader": "Coping..."
"copingHeader": "Copying..."
},
"copyFile": {
"success": "File successfully copied."
Expand Down