Skip to content

Commit cde34bc

Browse files
authored
feat(ENG-9544): implement in tree drag and drop support (#750)
* feat(files-tree): implement in tree drag and drop support * fix(translations): fixed typo * test(confirm-move-file-dialog): fixed tests * fix(move-file-dialog): fixed toasts * fix(confirm-move-dialog): fixed pr comments
1 parent 99cd1ae commit cde34bc

File tree

10 files changed

+309
-16
lines changed

10 files changed

+309
-16
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div class="flex flex-column gap-2">
2+
<div
3+
[innerHTML]="'files.dialogs.moveFile.message' | translate: { dropNodeName: currentFolder.name, dragNodeName }"
4+
></div>
5+
<div class="flex justify-content-end gap-2 mt-4">
6+
<p-button severity="info" [label]="'common.buttons.cancel' | translate" (onClick)="dialogRef.close()"></p-button>
7+
8+
<p-button [label]="'common.buttons.move' | translate" (onClick)="moveFiles()"></p-button>
9+
<p-button [label]="'common.buttons.copy' | translate" (onClick)="copyFiles()"></p-button>
10+
</div>
11+
</div>

src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.scss

Whitespace-only changes.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { TranslatePipe } from '@ngx-translate/core';
2+
import { MockComponents, MockPipe } from 'ng-mocks';
3+
4+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
5+
6+
import { ComponentFixture, TestBed } from '@angular/core/testing';
7+
8+
import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component';
9+
import { IconComponent } from '@osf/shared/components/icon/icon.component';
10+
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
11+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
12+
import { FilesService } from '@osf/shared/services/files.service';
13+
import { ToastService } from '@osf/shared/services/toast.service';
14+
15+
import { FilesSelectors } from '../../store';
16+
17+
import { ConfirmMoveFileDialogComponent } from './confirm-move-file-dialog.component';
18+
19+
import { OSFTestingModule } from '@testing/osf.testing.module';
20+
import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock';
21+
import { provideMockStore } from '@testing/providers/store-provider.mock';
22+
import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
23+
24+
describe('ConfirmConfirmMoveFileDialogComponent', () => {
25+
let component: ConfirmMoveFileDialogComponent;
26+
let fixture: ComponentFixture<ConfirmMoveFileDialogComponent>;
27+
28+
const mockFilesService = {
29+
moveFiles: jest.fn(),
30+
getMoveDialogFiles: jest.fn(),
31+
};
32+
33+
beforeEach(async () => {
34+
const dialogRefMock = {
35+
close: jest.fn(),
36+
};
37+
38+
const dialogConfigMock = {
39+
data: { files: [], destination: { name: 'files' } },
40+
};
41+
42+
await TestBed.configureTestingModule({
43+
imports: [
44+
ConfirmMoveFileDialogComponent,
45+
OSFTestingModule,
46+
...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent),
47+
MockPipe(TranslatePipe),
48+
],
49+
providers: [
50+
{ provide: DynamicDialogRef, useValue: dialogRefMock },
51+
{ provide: DynamicDialogConfig, useValue: dialogConfigMock },
52+
{ provide: FilesService, useValue: mockFilesService },
53+
{ provide: ToastService, useValue: ToastServiceMock.simple() },
54+
{ provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() },
55+
provideMockStore({
56+
signals: [
57+
{ selector: FilesSelectors.getMoveDialogFiles, value: [] },
58+
{ selector: FilesSelectors.getProvider, value: null },
59+
],
60+
}),
61+
],
62+
}).compileComponents();
63+
64+
fixture = TestBed.createComponent(ConfirmMoveFileDialogComponent);
65+
component = fixture.componentInstance;
66+
fixture.detectChanges();
67+
});
68+
69+
it('should create', () => {
70+
expect(component).toBeTruthy();
71+
});
72+
73+
it('should initialize with correct properties', () => {
74+
expect(component.config).toBeDefined();
75+
expect(component.dialogRef).toBeDefined();
76+
expect(component.files).toBeDefined();
77+
});
78+
79+
it('should get files from store', () => {
80+
expect(component.files()).toEqual([]);
81+
});
82+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { select } from '@ngxs/store';
2+
3+
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
4+
5+
import { Button } from 'primeng/button';
6+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
7+
8+
import { finalize, forkJoin, of } from 'rxjs';
9+
import { catchError } from 'rxjs/operators';
10+
11+
import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
12+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
13+
14+
import { FilesSelectors } from '@osf/features/files/store';
15+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
16+
import { FilesService } from '@osf/shared/services/files.service';
17+
import { ToastService } from '@osf/shared/services/toast.service';
18+
import { FileMenuType } from '@shared/enums/file-menu-type.enum';
19+
import { FileModel } from '@shared/models/files/file.model';
20+
21+
@Component({
22+
selector: 'osf-move-file-dialog',
23+
imports: [Button, TranslatePipe],
24+
templateUrl: './confirm-move-file-dialog.component.html',
25+
styleUrl: './confirm-move-file-dialog.component.scss',
26+
changeDetection: ChangeDetectionStrategy.OnPush,
27+
})
28+
export class ConfirmMoveFileDialogComponent {
29+
readonly config = inject(DynamicDialogConfig);
30+
readonly dialogRef = inject(DynamicDialogRef);
31+
private readonly filesService = inject(FilesService);
32+
private readonly destroyRef = inject(DestroyRef);
33+
private readonly translateService = inject(TranslateService);
34+
private readonly toastService = inject(ToastService);
35+
private readonly customConfirmationService = inject(CustomConfirmationService);
36+
37+
readonly files = select(FilesSelectors.getMoveDialogFiles);
38+
readonly provider = this.config.data.storageProvider;
39+
40+
private fileProjectId = this.config.data.resourceId;
41+
protected currentFolder = this.config.data.destination;
42+
43+
get dragNodeName() {
44+
const filesCount = this.config.data.files.length;
45+
if (filesCount > 1) {
46+
return this.translateService.instant('files.dialogs.moveFile.multipleFiles', { count: filesCount });
47+
} else {
48+
return this.config.data.files[0]?.name;
49+
}
50+
}
51+
52+
copyFiles(): void {
53+
return this.copyOrMoveFiles(FileMenuType.Copy);
54+
}
55+
56+
moveFiles(): void {
57+
return this.copyOrMoveFiles(FileMenuType.Move);
58+
}
59+
60+
private copyOrMoveFiles(action: FileMenuType): void {
61+
const path = this.currentFolder.path;
62+
if (!path) {
63+
throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError'));
64+
}
65+
const isMoveAction = action === FileMenuType.Move;
66+
67+
const headerKey = isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader';
68+
this.config.header = this.translateService.instant(headerKey);
69+
const files: FileModel[] = this.config.data.files;
70+
const totalFiles = files.length;
71+
let completed = 0;
72+
const conflictFiles: { file: FileModel; link: string }[] = [];
73+
74+
files.forEach((file) => {
75+
const link = file.links.move;
76+
this.filesService
77+
.moveFile(link, path, this.fileProjectId, this.provider(), action)
78+
.pipe(
79+
takeUntilDestroyed(this.destroyRef),
80+
catchError((error) => {
81+
if (error.status === 409) {
82+
conflictFiles.push({ file, link });
83+
} else {
84+
this.showErrorToast(action, error.error?.message);
85+
}
86+
return of(null);
87+
}),
88+
finalize(() => {
89+
completed++;
90+
if (completed === totalFiles) {
91+
if (conflictFiles.length > 0) {
92+
this.openReplaceMoveDialog(conflictFiles, path, action);
93+
} else {
94+
this.showSuccessToast(action);
95+
this.config.header = this.translateService.instant('files.dialogs.moveFile.title');
96+
this.completeMove();
97+
}
98+
}
99+
})
100+
)
101+
.subscribe();
102+
});
103+
}
104+
105+
private openReplaceMoveDialog(
106+
conflictFiles: { file: FileModel; link: string }[],
107+
path: string,
108+
action: string
109+
): void {
110+
this.customConfirmationService.confirmDelete({
111+
headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single',
112+
messageKey: 'files.dialogs.replaceFile.message',
113+
messageParams: {
114+
name: conflictFiles.map((c) => c.file.name).join(', '),
115+
},
116+
acceptLabelKey: 'common.buttons.replace',
117+
onConfirm: () => {
118+
const replaceRequests$ = conflictFiles.map(({ link }) =>
119+
this.filesService.moveFile(link, path, this.fileProjectId, this.provider(), action, true).pipe(
120+
takeUntilDestroyed(this.destroyRef),
121+
catchError(() => of(null))
122+
)
123+
);
124+
forkJoin(replaceRequests$).subscribe({
125+
next: () => {
126+
this.showSuccessToast(action);
127+
this.completeMove();
128+
},
129+
});
130+
},
131+
onReject: () => {
132+
const totalFiles = this.config.data.files.length;
133+
if (totalFiles > conflictFiles.length) {
134+
this.showErrorToast(action);
135+
}
136+
this.completeMove();
137+
},
138+
});
139+
}
140+
141+
private showSuccessToast(action: string) {
142+
const messageType = action === 'move' ? 'moveFile' : 'copyFile';
143+
this.toastService.showSuccess(`files.dialogs.${messageType}.success`);
144+
}
145+
146+
private showErrorToast(action: string, errorMessage?: string) {
147+
const messageType = action === 'move' ? 'moveFile' : 'copyFile';
148+
this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`);
149+
}
150+
151+
private completeMove(): void {
152+
this.dialogRef.close(true);
153+
}
154+
}

src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { MockComponents, MockPipe } from 'ng-mocks';
33

44
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
55

6-
import { signal } from '@angular/core';
76
import { ComponentFixture, TestBed } from '@angular/core/testing';
87

98
import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component';
@@ -56,14 +55,14 @@ describe('MoveFileDialogComponent', () => {
5655
{ provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() },
5756
provideMockStore({
5857
signals: [
59-
{ selector: FilesSelectors.getMoveDialogFiles, value: signal([]) },
60-
{ selector: FilesSelectors.getMoveDialogFilesTotalCount, value: signal(0) },
61-
{ selector: FilesSelectors.isMoveDialogFilesLoading, value: signal(false) },
62-
{ selector: FilesSelectors.getMoveDialogCurrentFolder, value: signal(null) },
63-
{ selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) },
64-
{ selector: CurrentResourceSelectors.getResourceWithChildren, value: signal([]) },
65-
{ selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: signal(false) },
66-
{ selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: signal(false) },
58+
{ selector: FilesSelectors.getMoveDialogFiles, value: [] },
59+
{ selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 },
60+
{ selector: FilesSelectors.isMoveDialogFilesLoading, value: false },
61+
{ selector: FilesSelectors.getMoveDialogCurrentFolder, value: null },
62+
{ selector: CurrentResourceSelectors.getCurrentResource, value: null },
63+
{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [] },
64+
{ selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false },
65+
{ selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false },
6766
],
6867
}),
6968
],

src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export class MoveFileDialogComponent {
211211
if (error.status === 409) {
212212
conflictFiles.push({ file, link });
213213
} else {
214-
this.toastService.showError(error.error?.message ?? 'Error');
214+
this.showErrorToast(action, error.error?.message ?? 'Error');
215215
}
216216
return of(null);
217217
}),
@@ -221,7 +221,7 @@ export class MoveFileDialogComponent {
221221
if (conflictFiles.length > 0) {
222222
this.openReplaceMoveDialog(conflictFiles, path, action);
223223
} else {
224-
this.showToast(action);
224+
this.showSuccessToast(action);
225225
this.config.header = this.translateService.instant('files.dialogs.moveFile.title');
226226
this.completeMove();
227227
}
@@ -254,26 +254,31 @@ export class MoveFileDialogComponent {
254254

255255
forkJoin(replaceRequests$).subscribe({
256256
next: () => {
257-
this.showToast(action);
257+
this.showSuccessToast(action);
258258
this.completeMove();
259259
},
260260
});
261261
},
262262
onReject: () => {
263263
const totalFiles = this.config.data.files.length;
264264
if (totalFiles > conflictFiles.length) {
265-
this.showToast(action);
265+
this.showErrorToast(action);
266266
}
267267
this.completeMove();
268268
},
269269
});
270270
}
271271

272-
private showToast(action: string): void {
272+
private showSuccessToast(action: string) {
273273
const messageType = action === 'move' ? 'moveFile' : 'copyFile';
274274
this.toastService.showSuccess(`files.dialogs.${messageType}.success`);
275275
}
276276

277+
private showErrorToast(action: string, errorMessage?: string) {
278+
const messageType = action === 'move' ? 'moveFile' : 'copyFile';
279+
this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`);
280+
}
281+
277282
private completeMove(): void {
278283
this.isFilesUpdating.set(false);
279284
this.actions.setCurrentFolder(this.initialFolder);

src/app/shared/components/files-tree/files-tree.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
<div class="files-table flex">
2626
<p-tree
2727
[value]="nodes()"
28+
[draggableNodes]="true"
29+
[droppableNodes]="true"
2830
(onScrollIndexChange)="onScrollIndexChange($event)"
2931
[scrollHeight]="scrollHeight()"
3032
[virtualScroll]="true"
@@ -36,6 +38,7 @@
3638
[selection]="selectedFiles()"
3739
(onNodeSelect)="onNodeSelect($event)"
3840
(onNodeUnselect)="onNodeUnselect($event)"
41+
(onNodeDrop)="onNodeDrop($event)"
3942
>
4043
<ng-template let-file pTemplate="default">
4144
@if (file.previousFolder) {

src/app/shared/components/files-tree/files-tree.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MockComponents, MockProvider } from 'ng-mocks';
22

3+
import { TreeDragDropService } from 'primeng/api';
34
import { DialogService } from 'primeng/dynamicdialog';
45

56
import { signal } from '@angular/core';
@@ -53,6 +54,7 @@ describe('FilesTreeComponent', () => {
5354
MockProvider(ToastService),
5455
MockProvider(CustomConfirmationService),
5556
MockProvider(DialogService),
57+
TreeDragDropService,
5658
],
5759
}).compileComponents();
5860

0 commit comments

Comments
 (0)